medieval-kit/my-registry/models/wooden-shovel/model.ts
CRT 7e14d9b314 Make the wooden tools wooden, and give the spade its handle
Three faults across two tools, and two of them come down to a model being
named after a material it was not made of.

The pitchfork had three steel tines, 0.24 m on a 1.5 m shaft -- 16% of the
tool against roughly 22% in the reference -- fanned out like a cultivator.
A hay fork has four or five, they are the working length of the thing, and
they stay close to parallel so a forkful stays on them. They are also
wood: the model is called wooden-pitchfork and a hay fork really was cut
and steamed from a branch, because iron is heavy at the end of a long shaft
and hay needs no edge. The socket stays iron, since that is the ferrule.

The shovel's blade was a sheet of polished steel. The strap that runs up
behind it has been modelled as an iron shoe all along -- a shoe for a board
that was being rendered as steel. A medieval digging shovel is a shaped
board with iron along its lip, so the blade moves to oak and the shoe
becomes the only metal on the head.

And it had no handle: the shaft simply ended in a swelled knob. A digging
spade is worked with both hands with the lower one round a crossbar, which
is what separates a spade from a stick with a plate on the end, and it sits
right at the end of the silhouette where the eye lands. The bar crosses the
grip swell rather than butting onto it.

Two notes for later. The wider tine spread pushed the fork past its size
guard at 0.35 m across; 30-40 cm is the head of a real hay fork, so the
limit moved rather than the geometry. And `createTinter` returns one Color
and mutates it while `steelTint` and `ironTint` allocate per call -- the
new code copies out of the first before the next call, but the two helpers
disagreeing about this is a trap in itself.

27 models, 18599 triangles, all checks pass, 0 floating cases, build clean.
2026-08-23 11:21:37 +07:00

183 lines
7.4 KiB
TypeScript

/**
* @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.
*
* 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.
*
* 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.
*
* 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.
*/
import { Color, type BufferGeometry } from 'three'
import {
chamferedBoxGeometry,
createKitModel,
createTinter,
dishedSheetGeometry,
ironTint,
mergeColoured,
prismGeometry,
toolShaft,
toolSocket,
type SheetLevel,
} from '../core/index.ts'
export interface WoodenShovelConfig {
readonly length: number
readonly shaftRadius: number
/** The widest point of the blade (metres). */
readonly bladeWidth: number
/** Blade length, as a fraction of the total length. */
readonly bladeLength: number
/** Depth of the scoop: how far the edges rise above the middle. 0 = flat sheet. */
readonly dish: number
/** Tilt of the blade relative to the shaft (degrees). */
readonly bladeAngle: number
readonly seed: number
}
export const woodenShovelDefaults: WoodenShovelConfig = {
length: 1.16,
shaftRadius: 0.022,
bladeWidth: 0.27,
bladeLength: 0.27,
dish: 0.17,
bladeAngle: 9,
seed: 31,
}
export type WoodenShovelParts = 'shaft' | 'socket' | 'blade'
export function createModel(overrides: Partial<WoodenShovelConfig> = {}) {
return createKitModel<WoodenShovelConfig, 'oak' | 'iron', WoodenShovelParts>({
id: 'wooden-shovel',
defaults: woodenShovelDefaults,
slots: ['oak', 'iron'],
build: ({ config, random }) => {
const tint = createTinter(random)
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
// 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 },
]
// 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))
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)
// 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),
)
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)
}
return {
shaft: { slot: 'oak', geometry: mergeColoured([shaft.geometry, 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.
slot: 'oak',
geometry: blade,
extras: [{ slot: 'iron', geometry: strap }],
},
}
},
}, overrides)
}