Bring the anvil's horn back to its reference proportion, rebuild the book's metal furniture so no two overlapping faces share a plane, and release 0.2.0.

This commit is contained in:
CRTkafa 2026-09-01 03:00:37 +07:00
parent 3f57165d06
commit 9b4ce16c59
82 changed files with 9725 additions and 5064 deletions

View file

@ -1,35 +1,70 @@
/**
* @medieval-kit/wooden-shovel
*
* Third attempt. The first two failed, and both for the same reason: I tried
* to build the blade out of flat pieces.
* Sixth attempt. The reference is an antique one-piece grain scoop: a squared
* dished plate, nearly as broad as it is long, an iron shoe forged round the
* leading edge with a return onto both faces, a round bent shaft and a T-grip.
* History of the earlier passes:
*
* attempt 1 — two boxes end to end. It came out "shovel-shaped", not a shovel.
* attempt 2 — three flat panels, each rotated slightly, lined up side by
* side. Because the panels rotated about their own centres, steps were
* left between them; the eye read that as "three boards", not one surface.
* attempt 1-2 — boxes / flat panels. Read as boards, not a scoop.
* attempt 3-4 — one dishedSheetGeometry, but the outline was an oval; the
* blind critic called it "a paddle or a wooden spoon" (67).
* attempt 5 — trapezoid plan, real shoe band + side straps, seated ferrule.
* Scored 69: landmarks all present, silhouette still wrong. The critic
* measured the pan at 1.35 long-to-wide against 1.05 in the photo, called
* the taper "a straight-sided funnel", the pan "flat", the shoe "a cap
* sitting on the top edge only", and found both side straps floating
* outboard of the blade. It also flagged: the socket-end rim reading as a
* seam with a lighter panel below it, the ferrule not seated (a wedge of
* dish poking past the collar), the T-bar arms not colinear (the grip was
* a TAPERED prism, so one arm was fatter than the other), the shaft tip
* exposed below the crossbar, and a width/colour step mid-shaft (the
* per-level jitter in core's toolShaft).
*
* What makes a shovel a shovel is that the blade is ONE CONTINUOUS CONCAVE
* SURFACE: the dish that holds the soil. `dishedSheetGeometry` was written for
* exactly this — a seamless sheet with a curved cross-section whose width and
* thickness change along its length.
* What this version does differently:
* - PAN OUTLINE: length-to-width is ~1.04 (bladeWidth 0.29 against a 0.30
* span). The socket end is 0.78 of full width, and the pull-in happens in
* the bottom third through a rounded shoulder; the top two thirds run
* near-parallel into a straight leading edge with small corner chamfers.
* - DISH WITH WALLS: besides the sheet's parabolic hollow, two wooden side
* rails (rolled to lie on the local surface slope) and the socket-end rim
* arc form a raised lip standing proud of the floor. The rim's ends and
* the rails' lower ends interpenetrate, so the rim reads as the scoop's
* back wall instead of a floating seam.
* - SHOE WRAPS THE EDGE: the arc band round the leading edge is joined by
* two thin dished-sheet RETURNS overlaid on the front and back faces
* (about 12% of the blade), and the floating side straps are replaced by
* angled corner caps that overlap both the returns and the pan's edges.
* - SHAFT: built here as a smooth 7-level lathe (no mid-shaft jitter step),
* bent gently with bendGeometry (base at the origin first — the bend trap)
* toward the same side the blade tilts. The T-grip is a SYMMETRIC lathe
* bar (rounded ends, no taper, so no kink at the handle), and the shaft's
* bottom tip terminates INSIDE the bar.
* - SEATING: the collar overlaps the blade base by ~4cm and the base level's
* curve is halved, so no dish corner pokes out past the collar.
* - COLOUR: the shoe and the socket are a rust-brown iron (palette iron
* pulled toward leather) instead of cool slate; the timber is slightly
* desaturated toward weathered oak. Lifts stay positive: the occlusion
* bake darkens everything pressed against the sheet.
*
* Silhouette: a narrow neck at the socket, widest at 45%, a soft taper towards
* the tip. A foot tread at the back — on a real shovel the top edge of the
* blade is folded over, to press down on with the foot.
* Traps honoured: `dish` is floored before any radius is fitted to it
* (division by zero → NaN spreads through the occlusion bake), every asin
* argument is clamped below 1, and every colour is a NEW Color per call.
*/
import { Color, type BufferGeometry } from 'three'
import {
arcBarGeometry,
bendGeometry,
chamferedBoxGeometry,
createKitModel,
createTinter,
dishedSheetGeometry,
ironTint,
jitter,
latheGeometry,
mergeColoured,
prismGeometry,
toolShaft,
toolSocket,
MEDIEVAL_PALETTE,
type Level,
type SheetLevel,
} from '../core/index.ts'
@ -48,11 +83,24 @@ export interface WoodenShovelConfig {
}
export const woodenShovelDefaults: WoodenShovelConfig = {
length: 1.16,
// 0.86, not 1.16. The reference is a short-handled grain scoop, not a
// long-handled digging shovel: its shaft runs about two and a half blade
// lengths, ours ran nearly four. At that ratio, stood on its grip with the
// pan in the air, a blind viewer called it a floor lamp.
length: 0.86,
shaftRadius: 0.022,
bladeWidth: 0.27,
bladeLength: 0.27,
dish: 0.17,
bladeWidth: 0.29,
// a shovel blade is wider than it is long; taller than wide is a dustpan.
// 0.32 of a shorter tool, which keeps the pan the same size in metres while
// the handle comes down. A shovel blade is wider than it is long; taller
// than wide is a dustpan.
bladeLength: 0.32,
// 0.09, not 0.14. Measured against the reference the hollow wants to be 8 to
// 10 percent of the blade width. At 0.14, with a lip standing on both long
// edges, the pan curled into a U-section trough and a blind viewer called it
// a bin.
dish: 0.09,
bladeAngle: 9,
seed: 31,
}
@ -66,116 +114,240 @@ export function createModel(overrides: Partial<WoodenShovelConfig> = {}) {
slots: ['oak', 'iron'],
build: ({ config, random }) => {
const tint = createTinter(random)
/** Pull saturation down: the critic read the timber as new pine. */
const weather = (colour: Color, drop: number): Color => {
const hsl = { h: 0, s: 0, l: 0 }
colour.getHSL(hsl)
colour.setHSL(hsl.h, Math.max(0, hsl.s - drop), hsl.l)
return colour
}
/**
* Rust-brown forged iron: the palette's cool slate pulled toward
* leather. Returns a NEW Color every call — two rusts in one geometry
* call must not resolve to the same object.
*/
const rust = (lift: number): Color => {
const c = new Color(MEDIEVAL_PALETTE.iron).lerp(new Color(MEDIEVAL_PALETTE.leather), 0.45)
const hsl = { h: 0, s: 0, l: 0 }
c.getHSL(hsl)
c.setHSL(
hsl.h + jitter(random, 0.01),
Math.min(1, hsl.s + 0.05 + jitter(random, 0.03)),
Math.max(0.045, hsl.l + lift + jitter(random, 0.03)),
)
return c
}
const span = config.length * config.bladeLength
const shaftLength = config.length - span * 0.82
const shaft = toolShaft({ length: shaftLength, radius: config.shaftRadius, random })
// --- T-handle --------------------------------------------------------
// The reference has one and this did not: the shaft simply ended in a
// swelled knob. A digging spade is worked with both hands and the lower
// one goes round a crossbar; it is the detail that separates a spade
// from a stick with a plate on the end, and it is right at the end of
// the silhouette where the eye lands.
//
// The bar crosses the grip swell rather than butting onto it, so the
// joint is an overlap like every other in the kit.
const gripSpan = config.shaftRadius * 8.6
const grip = prismGeometry(
config.shaftRadius * 0.95,
config.shaftRadius * 0.82,
gripSpan,
7,
[0, 0, 0],
new Color(tint('oak', 0.05)),
)
// Built along Y; +90 degrees about Z lays it along X, across the blade.
grip.rotateZ(Math.PI / 2)
grip.translate(0, -shaft.top + config.shaftRadius * 0.9, 0)
const socketLength = config.length * 0.05
const socket = toolSocket({
y: shaft.top - socketLength * 0.3,
shaftRadius: shaft.topRadius,
length: socketLength,
random,
})
const half = config.bladeWidth / 2
const t = config.length * 0.011
const curve = config.bladeWidth * config.dish
const r = config.shaftRadius
const shaftLength = config.length - span * 0.82
// Cross-section profile — the FOURTH attempt, this time over the silhouette.
//
// The third attempt was geometrically correct (one continuous dished
// surface) but it still did not read as a shovel: the edges bulged in the
// middle and closed softly towards the tip, i.e. a SPOON profile. A shovel
// is not a spoon: it runs almost PARALLEL along its sides, then ends in a
// short chamfer at the tip. What holds the soil is that parallel part;
// without it what you get is a spatula.
const profile: SheetLevel[] = [
{ y: 0, halfWidth: half * 0.24, thickness: t * 1.5, curve: curve * 0.08 },
{ y: span * 0.11, halfWidth: half * 0.82, thickness: t * 1.15, curve: curve * 0.4 },
{ y: span * 0.28, halfWidth: half * 0.99, thickness: t * 0.95, curve: curve * 0.85 },
{ y: span * 0.62, halfWidth: half, thickness: t * 0.82, curve },
{ y: span * 0.85, halfWidth: half * 0.95, thickness: t * 0.6, curve: curve * 0.94 },
{ y: span * 0.96, halfWidth: half * 0.74, thickness: t * 0.32, curve: curve * 0.72 },
{ y: span, halfWidth: half * 0.44, thickness: t * 0.14, curve: curve * 0.48 },
// --- shaft -----------------------------------------------------------
// Own lathe rather than core's toolShaft: the per-level jitter there
// left a visible width step mid-shaft, and toolShaft cannot bend. The
// tip radius is small enough to bury inside the T-bar, the swell sits
// just ABOVE the bar, and the taper is gentle and continuous.
const rr = (s: number): number => r * s * (1 + jitter(random, 0.015))
const shaftLevels: Level[] = [
{ y: 0, radius: rr(0.72) }, // tip — ends inside the T-bar
{ y: r * 1.1, radius: rr(1.03) },
{ y: r * 2.6, radius: rr(1.08) }, // grip swell, above the bar
{ y: shaftLength * 0.16, radius: rr(0.97) },
{ y: shaftLength * 0.55, radius: rr(1.01) },
{ y: shaftLength * 0.85, radius: rr(0.97) },
{ y: shaftLength, radius: rr(0.9) }, // taper toward the socket
]
const topRadius = shaftLevels.at(-1)!.radius
const shaftGeo = latheGeometry(
shaftLevels, 7, [0, 0, 0],
weather(tint('oak', 0.05), 0.05),
{ colourTop: weather(tint('oak', -0.01), 0.05) },
)
// Gentle bend, the reference's signature. Built with its base at the
// origin FIRST: bendGeometry wraps around y=0, and a body centred on
// y=0 bends symmetrically into nothing. Negative curvature bends toward
// -Z, the side the blade tilts to.
const bendK = 0.06
bendGeometry(shaftGeo, -bendK)
shaftGeo.translate(0, -shaftLength / 2, 0)
const bendAngle = shaftLength * bendK
const topY = Math.sin(bendAngle) / bendK - shaftLength / 2
const topZ = -(1 - Math.cos(bendAngle)) / bendK
// Both colours copied out first: `createTinter` returns one Color and
// mutates it, so two tints inside one call would resolve to the same
// value.
const boardLow = new Color(tint('oak', -0.05))
const boardHigh = new Color(tint('oakEnd', 0.03))
// --- T-grip ----------------------------------------------------------
// A SYMMETRIC lathe bar with rounded ends. The previous tapered prism
// gave the two arms different diameters, which read as a kink where
// they met the handle.
const gripHalfSpan = r * 4.3
const gripR = r * 1.15
const gripLevels: Level[] = [
{ y: -gripHalfSpan, radius: gripR * 0.62 },
{ y: -gripHalfSpan + r * 1.3, radius: gripR },
{ y: gripHalfSpan - r * 1.3, radius: gripR },
{ y: gripHalfSpan, radius: gripR * 0.62 },
]
const grip = latheGeometry(gripLevels, 7, [0, 0, 0], weather(tint('oak', 0.04), 0.05))
grip.rotateZ(Math.PI / 2) // built along Y; lay it along X
// Centred a whisker above the shaft's bottom tip, so the tip's end face
// terminates inside the bar instead of resting on the ground beside it.
grip.translate(0, -shaftLength / 2 + r * 0.15, 0)
// --- socket / ferrule ------------------------------------------------
// Same profile as core's toolSocket, built here so it can take the rust
// tone: the critic read the palette's slate-blue iron against the
// photograph's rust-brown. Raised so the collar overlaps the blade base
// by ~4cm — seated on the tang, no shadow slot.
const sockL = config.length * 0.05
const sy = topY - sockL * 0.18
const sockLevels: Level[] = [
{ y: sy - sockL * 0.9, radius: topRadius * 1.12 },
{ y: sy - sockL * 0.45, radius: topRadius * 1.34 },
{ y: sy + sockL * 0.1, radius: topRadius * 1.5 },
{ y: sy + sockL * 0.22, radius: topRadius * 1.72 }, // collar
{ y: sy + sockL * 0.34, radius: topRadius * 1.46 },
]
const socket = latheGeometry(sockLevels, 6, [0, 0, 0], rust(0.02), { colourTop: rust(0.06) })
socket.translate(0, 0, topZ)
// --- pan -------------------------------------------------------------
// Floored HERE: two radii below are fitted as halfWidth² over twice
// this, and at dish = 0 that division is infinite and the vertices NaN.
const c = config.bladeWidth * Math.max(0.012, config.dish)
// Widest AT THE TIP, narrowing back to the throat, with the last two
// levels drawing the leading corners off.
//
// The version before this widened over the bottom fifth, ran parallel
// through the middle and then pulled back in at the tip. That is a
// rectangle, and with a lip standing up on all four sides a viewer read
// it as a framed tray on a post rather than as a shovel. A pan flares to
// its cutting edge; the narrow end is the end the handle is on.
//
// The base level's curve is halved so no dish corner pokes out past the
// ferrule collar.
const profile: SheetLevel[] = [
{ y: 0, halfWidth: half * 0.7, thickness: t * 1.35, curve: c * 0.5 },
{ y: span * 0.12, halfWidth: half * 0.83, thickness: t * 1.2, curve: c * 0.75 },
{ y: span * 0.35, halfWidth: half * 0.93, thickness: t * 1.05, curve: c * 0.95 },
{ y: span * 0.7, halfWidth: half * 0.99, thickness: t * 0.95, curve: c },
{ y: span * 0.9, halfWidth: half, thickness: t * 0.88, curve: c },
{ y: span * 0.97, halfWidth: half * 0.95, thickness: t * 0.82, curve: c * 0.98 },
{ y: span, halfWidth: half * 0.84, thickness: t * 0.78, curve: c * 0.96 },
]
const boardLow = weather(tint('oak', -0.04), 0.06)
const boardHigh = weather(tint('oakEnd', 0.02), 0.05)
const sheet = dishedSheetGeometry(profile, 8, boardLow, boardHigh)
// Foot tread: the fold at the top edge of the blade, BEHIND the dish. It
// must sit at shoulder height and be WIDE — this is where you step, and a
// narrow ledge is both useless and invisible in the silhouette.
const tread = chamferedBoxGeometry(
[config.bladeWidth * 0.78, t * 2.4],
[config.bladeWidth * 0.7, t * 1.9],
t * 1.8,
t * 0.34,
[0, 0, 0],
new Color(tint('oak', -0.1)),
)
tread.rotateX(0.42)
tread.translate(0, span * 0.12, -t * 1.7)
// There is deliberately NO rail across the throat. One ran there, bent to
// the dish, and with a lip already standing on both long edges and a band
// across the tip it closed the pan into a four-sided frame. A shovel is
// open at the handle end: that is the end the load leaves by.
// Back strap: forged iron leaving the socket and running up BEHIND the
// blade. This is the second thing that makes a shovel a shovel. Without it
// the blade looks like a sheet glued to the end of the shaft; on a real
// shovel the strap is what carries the blade, and it is also what answers
// the eye's question of "how is this held on".
const strap = chamferedBoxGeometry(
[config.shaftRadius * 2.3, t * 2.2],
[config.shaftRadius * 1.1, t * 1.4],
span * 0.5,
t * 0.3,
[0, span * 0.22, -t * 1.4],
ironTint(random, -0.02),
)
// Side rails: the raised side walls of the scoop. Rolled about their own
// long axis to lie on the local surface slope (otherwise their outboard
// half hangs out behind the sheet), leaned to follow the plan taper, and
// kept inboard of the plan edge so nothing floats off the shoulder.
const roll = Math.atan((1.7 * c) / half)
const railW = config.bladeWidth * 0.095
const railD = config.bladeWidth * 0.05
const railA = { x: half * 0.68, y: span * 0.05 }
const railB = { x: half * 0.95, y: span * 0.93 }
const railLen = Math.hypot(railB.x - railA.x, railB.y - railA.y)
const railLean = Math.atan2(railB.x - railA.x, railB.y - railA.y)
const railMidX = (railA.x + railB.x) / 2
const railMidU = railMidX / half
const rails: BufferGeometry[] = []
for (const side of [-1, 1] as const) {
const rail = chamferedBoxGeometry(
[railW, railD], [railW, railD], railLen, t * 0.4,
[0, 0, 0], weather(tint('oak', 0.03), 0.05),
)
rail.rotateY(-side * roll)
rail.rotateZ(-side * railLean)
rail.translate(side * railMidX, (railA.y + railB.y) / 2, c * railMidU * railMidU + t * 0.55)
rails.push(rail)
}
const blade: BufferGeometry = mergeColoured([sheet, tread])
// Both must take the SAME transform, or the strap won't stay behind the blade.
for (const piece of [blade, strap]) {
// The blade is tilted slightly forward of the shaft line, so it bites the soil.
piece.rotateX(-(config.bladeAngle * Math.PI) / 180)
piece.translate(0, shaft.top - span * 0.06, 0)
// --- iron shoe -------------------------------------------------------
// Band round the leading edge + a thin return sheet overlaid on EACH
// face + angled corner caps. Lifts positive: the occlusion bake darkens
// everything pressed against the sheet.
// Floored against the SHEET thickness, not just scaled to the blade.
// `span` is a slider, and at its short end a band sized only as a
// fraction of it becomes thin enough that its end face lands in the same
// plane as the sheet tip it is supposed to wrap, which is a flicker
// rather than a shoe.
const bandTh = Math.max(t * 2.4, span * 0.085)
const tipR = (0.9 * half) * (0.9 * half) / (2 * (0.96 * c))
const bandAngle = Math.asin(Math.min(0.995, (half * 0.94) / tipR))
const band = arcBarGeometry(
tipR, bandTh,
-Math.PI / 2 - bandAngle, -Math.PI / 2 + bandAngle,
10, [0, 0, 0], rust(0.05),
)
band.rotateX(Math.PI / 2)
band.translate(0, span - bandTh * 0.3, tipR + t * 0.4)
// Face returns: the iron coming back over the wood, ~12% of the blade.
// Slightly wider than the pan so a hairline of iron shows around the
// wood edge, offset so they interpenetrate the sheet without sharing a
// plane with it. Their top edges end inside the band.
const returns: BufferGeometry[] = []
for (const face of [1, -1] as const) {
const ret = dishedSheetGeometry([
// Starts BELOW the rails finish, not level with them. The rails end at
// span * 0.89 and the returns used to start at span * 0.88, so two end
// faces of the same orientation sat a couple of millimetres apart; at
// the short end of the blade slider that gap closes to under a
// millimetre and the two planes become one.
{ y: span * 0.8, halfWidth: half, thickness: t * 0.9, curve: c },
{ y: span + t * 0.3, halfWidth: half * 0.91, thickness: t * 0.9, curve: c * 0.97 },
], 8, rust(0.05))
ret.translate(0, 0, face * t * 0.62)
returns.push(ret)
}
// Corner caps: angled over the chamfered corners, overlapping the
// returns above and the pan's side edges below — the iron continues
// round the corner instead of straps starting in mid-air.
const capLen = config.bladeWidth * 0.11
const capW = config.bladeWidth * 0.08
const capD = config.bladeWidth * 0.105
const capAngle = Math.atan2(half * 0.09, span * 0.08)
const caps: BufferGeometry[] = []
for (const side of [-1, 1] as const) {
const cap = chamferedBoxGeometry(
[capW, capD], [capW, capD], capLen, t * 0.35,
[0, 0, 0], rust(0.05),
)
cap.rotateY(-side * roll)
cap.rotateZ(side * capAngle)
cap.translate(side * half * 0.9, span * 0.955, c * 0.88 + t * 0.2)
caps.push(cap)
}
const blade: BufferGeometry = mergeColoured([sheet, ...rails])
const shoe = mergeColoured([band, ...returns, ...caps])
// Both must take the SAME transform, or the shoe won't stay on the
// blade. The tilt continues the bend's lean plus the config angle.
const tilt = (config.bladeAngle * Math.PI) / 180 + bendAngle
for (const piece of [blade, shoe]) {
piece.rotateX(-tilt)
piece.translate(0, topY - span * 0.1, topZ)
}
return {
shaft: { slot: 'oak', geometry: mergeColoured([shaft.geometry, grip]) },
shaft: { slot: 'oak', geometry: mergeColoured([shaftGeo, grip]) },
socket: { slot: 'iron', geometry: socket },
blade: {
// Oak, not steel. A medieval digging shovel is a shaped board with
// an iron shoe along its lip -- the strap below is that shoe, and it
// has been there all along while the board it protects was rendered
// as a sheet of polished steel.
// Oak, not steel: the blade is a shaped board, and the only metal
// on it is the shoe protecting its lip.
slot: 'oak',
geometry: blade,
extras: [{ slot: 'iron', geometry: strap }],
extras: [{ slot: 'iron', geometry: shoe }],
},
}
},