208 lines
9.3 KiB
TypeScript
208 lines
9.3 KiB
TypeScript
/**
|
|
* @medieval-kit/wooden-hoe
|
|
*
|
|
* Field hoe after the reference photograph: a long ash shaft ending in a
|
|
* squared, chamfered block, a flat iron band wrapped tight around that block,
|
|
* a short flat strap running forward and down from the band, and a dark
|
|
* forged trapezoid blade hung off the strap at roughly 75 degrees to the
|
|
* shaft.
|
|
*
|
|
* FIFTH pass. The fourth swept the old gooseneck past 180 degrees so the
|
|
* blade genuinely hung below the socket, which fixed "scythe" (45 -> 62) but
|
|
* not "hoe": the blade descended almost parallel to the shaft, so from the
|
|
* side the tool read as a paddle on a pole, and the free arc of the neck
|
|
* vaulted over the shaft end with air under it and read as a padlock
|
|
* shackle. The blind critique of that pass said to stop interpreting and
|
|
* model the reference literally. So the gooseneck, the bent lathe neck, the
|
|
* forged collar and the toolSocket ferrule (which covered the top tenth of
|
|
* the shaft and read as a long grey sleeve) are all gone.
|
|
*
|
|
* Point by point against that critique:
|
|
* - The blade sweeps FORWARD off the shaft axis. Its plane sits at about
|
|
* 75 degrees to the shaft and its root edge is about 2.3 shaft diameters
|
|
* clear of the wood, so no side profile has the shaft overlapping the
|
|
* blade, and no solid shares volume with the blade except the strap that
|
|
* deliberately laps onto its back at the root.
|
|
* - The blade holds full thickness over its upper two thirds and thins only
|
|
* in the last third down to a lip, so its side faces actually exist and
|
|
* the cutting edge catches light.
|
|
* - The blade moved from the steel slot to the iron slot with a dark tint.
|
|
* It was the palest element in the render and in the reference it is the
|
|
* darkest; the value order was inverted. Only the cutting edge lightens.
|
|
* - The arch is replaced by band + block + strap, and every iron piece
|
|
* starts inside another solid: the strap begins inside the band slab, the
|
|
* band wraps the block, the block swallows the shaft's top cap (which
|
|
* used to poke out of the socket as a wood sliver).
|
|
*
|
|
* `neckSweep` stays in the config because other files reference the
|
|
* interface, but it is REINTERPRETED: it is now the forward pitch of the
|
|
* whole head in degrees from the shaft axis, clamped to 92..140. Larger
|
|
* folds the blade down toward the shaft, smaller lifts it toward square.
|
|
* There is no arc left for it to sweep.
|
|
*
|
|
* Earlier lessons, still load-bearing. The blade sheet is built with its
|
|
* base AT THE ORIGIN and rotated, then translated, in that order. `curve` in
|
|
* a sheet profile is an absolute rise in metres and must be scaled to the
|
|
* blade. Tints come from createTinter, which returns a new Color each call
|
|
* and floors lightness so a dark lift cannot fall through the palette.
|
|
*
|
|
* Still open: the shaft comes from the shared toolShaft helper, whose taper
|
|
* toward the head is real but slight; the reference tapers more. Core is
|
|
* shared, so that stays as it is.
|
|
*/
|
|
import {
|
|
chamferedBoxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
dishedSheetGeometry,
|
|
jitter,
|
|
mergeColoured,
|
|
taperedBoxGeometry,
|
|
toolShaft,
|
|
type SheetLevel,
|
|
} from '../core/index.ts'
|
|
|
|
export interface WoodenHoeConfig {
|
|
/** Shaft length (metres). */
|
|
readonly length: number
|
|
readonly shaftRadius: number
|
|
/** Width of the cutting edge (metres). */
|
|
readonly bladeWidth: number
|
|
/**
|
|
* Forward pitch of the head (degrees from the shaft axis), clamped to
|
|
* 92..140. At 92 the blade sticks out almost square to the shaft; at 140
|
|
* it folds well down toward it. The blade plane runs at 180 minus this to
|
|
* the shaft, so the default leaves it near 75 degrees, as the reference.
|
|
*/
|
|
readonly neckSweep: number
|
|
/** Dish of the blade. 0 = dead flat, which is close to the reference. */
|
|
readonly dish: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const woodenHoeDefaults: WoodenHoeConfig = {
|
|
length: 1.14,
|
|
shaftRadius: 0.021,
|
|
bladeWidth: 0.23,
|
|
neckSweep: 104,
|
|
dish: 0.8,
|
|
seed: 23,
|
|
}
|
|
|
|
export type WoodenHoeParts = 'shaft' | 'socket' | 'blade'
|
|
|
|
export function createModel(overrides: Partial<WoodenHoeConfig> = {}) {
|
|
return createKitModel<WoodenHoeConfig, 'oak' | 'iron' | 'steel', WoodenHoeParts>({
|
|
id: 'wooden-hoe',
|
|
defaults: woodenHoeDefaults,
|
|
slots: ['oak', 'iron', 'steel'],
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
// Floored: radius and width divide nothing, but they scale everything,
|
|
// and a zero would collapse the head into degenerate triangles.
|
|
const radius = Math.max(0.008, config.shaftRadius)
|
|
const dia = radius * 2
|
|
const bladeW = Math.max(0.05, config.bladeWidth)
|
|
|
|
const shaft = toolShaft({ length: config.length, radius, random })
|
|
const top = shaft.top
|
|
|
|
// --- Squared end block --------------------------------------------------
|
|
// The wood continues past the band as a squared, chamfered block, as in
|
|
// the reference. It starts a diameter INSIDE the round shaft, so the
|
|
// shaft's top cap and its tapered tip are both swallowed whole: the
|
|
// block's half width just exceeds the shaft's top radius.
|
|
const blockHalf = radius * 1.02
|
|
const blockBottom = top - dia * 1.1
|
|
const blockTop = top + dia * 1.2
|
|
const block = chamferedBoxGeometry(
|
|
[blockHalf * 2, blockHalf * 2],
|
|
[blockHalf * 1.84, blockHalf * 1.84],
|
|
blockTop - blockBottom,
|
|
dia * 0.14,
|
|
[0, (blockBottom + blockTop) / 2, 0],
|
|
tint('oakEnd', 0),
|
|
tint('oakEnd', 0.04),
|
|
)
|
|
|
|
// --- Iron band ----------------------------------------------------------
|
|
// A flat band about one shaft diameter tall, wrapped tight on the block.
|
|
// Modelled as a solid slab; the wood passing through it hides the
|
|
// interior, and from outside it reads as the wrapped hoop it stands for.
|
|
const bandHalf = blockHalf + 0.006
|
|
const bandY = top + dia * 0.25
|
|
const band = chamferedBoxGeometry(
|
|
[bandHalf * 2, bandHalf * 2],
|
|
[bandHalf * 2, bandHalf * 2],
|
|
dia * 0.95,
|
|
0.0025,
|
|
[0, bandY, 0],
|
|
tint('iron', 0.03),
|
|
tint('iron', 0.07),
|
|
)
|
|
|
|
// --- Strap (the neck) ---------------------------------------------------
|
|
// A short tapered bar running forward and down from the band; the blade
|
|
// hangs off its end. Direction comes from the head pitch so the whole
|
|
// head follows `neckSweep` as one piece.
|
|
const pitch = (Math.min(140, Math.max(92, config.neckSweep)) * Math.PI) / 180
|
|
const dirY = Math.cos(pitch)
|
|
const dirZ = Math.sin(pitch) // 92..140 degrees keeps this well above 0.6
|
|
|
|
const startY = bandY - dia * 0.1
|
|
const startZ = radius * 0.6 // buried inside the band slab
|
|
const rootZ = dia * 2.3 // blade root, clear of the wood
|
|
const lenToRoot = (rootZ - startZ) / dirZ
|
|
const lap = dia * 0.5 // how far the strap laps onto the blade
|
|
const strapLen = lenToRoot + lap
|
|
const strap = taperedBoxGeometry(
|
|
[dia * 0.52, dia * 0.3],
|
|
[dia * 0.62, dia * 0.32],
|
|
strapLen,
|
|
[0, strapLen / 2, 0],
|
|
tint('iron', -0.04),
|
|
tint('iron', -0.06),
|
|
)
|
|
strap.rotateX(pitch)
|
|
strap.translate(0, startY, startZ)
|
|
|
|
const rootY = startY + dirY * lenToRoot
|
|
|
|
// --- Blade --------------------------------------------------------------
|
|
// A trapezoid widening from a narrow root to the cutting edge, the two
|
|
// edge corners taken off. Thickness is a twenty-fifth of the width, held
|
|
// over the top two thirds and thinned only in the last third to a lip,
|
|
// so the plate shows a side face instead of reading as a sheet. Root
|
|
// dark forged iron, edge worn lighter.
|
|
const bladeLength = bladeW * 0.9
|
|
const halfEdge = bladeW / 2
|
|
const thick = bladeW * 0.04
|
|
const curve = bladeW * 0.045 * config.dish
|
|
const profile: SheetLevel[] = [
|
|
{ y: 0, halfWidth: halfEdge * 0.55, thickness: thick, curve: curve * 0.2 },
|
|
{ y: bladeLength * 0.3, halfWidth: halfEdge * 0.72, thickness: thick, curve: curve * 0.5 },
|
|
{ y: bladeLength * 0.66, halfWidth: halfEdge * 0.88, thickness: thick * 0.94, curve: curve * 0.85 },
|
|
{ y: bladeLength * 0.92, halfWidth: halfEdge, thickness: thick * 0.38, curve },
|
|
{ y: bladeLength, halfWidth: halfEdge * 0.93, thickness: thick * 0.2, curve: curve * 0.97 },
|
|
]
|
|
// The lift looks heavy written down, but the blade's face points at the
|
|
// sky in every standing view and direct sky light lifts it a band or
|
|
// two: at -0.05 it still rendered as the palest part of the tool.
|
|
const blade = dishedSheetGeometry(
|
|
profile, 7, tint('iron', -0.16, 0.4), tint('iron', -0.02, 0.4),
|
|
)
|
|
// A forged blade is not perfectly symmetric.
|
|
blade.rotateY(jitter(random, 0.03))
|
|
// Align to the strap direction, then move to its end. Order matters:
|
|
// rotating after the move would swing the blade around the model origin.
|
|
blade.rotateX(pitch)
|
|
blade.translate(0, rootY, rootZ)
|
|
|
|
return {
|
|
shaft: { slot: 'oak', geometry: mergeColoured([shaft.geometry, block]) },
|
|
socket: { slot: 'iron', geometry: mergeColoured([band, strap]) },
|
|
blade: { slot: 'iron', geometry: mergeColoured([blade]) },
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|