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:
parent
3f57165d06
commit
9b4ce16c59
82 changed files with 9725 additions and 5064 deletions
|
|
@ -1,41 +1,64 @@
|
|||
/**
|
||||
* @medieval-kit/wooden-hoe
|
||||
*
|
||||
* Gooseneck field hoe: an ash shaft, a forged iron neck curving forward and
|
||||
* down from the tip of the shaft, and a dished blade at the end of it.
|
||||
* 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.
|
||||
*
|
||||
* THIRD attempt, and both of the earlier ones missed the same thing: the
|
||||
* GOOSENECK. What makes a hoe a hoe is not the blade, it is the curved neck
|
||||
* that carries the blade FORWARD off the axis of the shaft. Without it what
|
||||
* you get is a flat sheet balanced on top of a post — in the render it read
|
||||
* exactly as "lectern", "music stand", "road sign". The neck also adds negative
|
||||
* space to the silhouette: that gap between shaft and blade is what tells the
|
||||
* object apart from a distance.
|
||||
* 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.
|
||||
*
|
||||
* In the second attempt I tried to curve the blade with `bendGeometry` and
|
||||
* wrote in the comment "this single detail solves the real problem in the
|
||||
* silhouette". Measuring showed that was wrong: because the blade was built
|
||||
* CENTRED on y=0 the bend was symmetric, both ends went the same way and the
|
||||
* middle stayed where it was. On a 0.235 m blade the Z range DROPPED from
|
||||
* 0.0337 to 0.0327, i.e. the curve was not visible in the silhouette at all.
|
||||
* With the same blade built base-at-origin the run-out is 44 mm. Now both the
|
||||
* neck and the blade are started from the origin.
|
||||
* 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 type { BufferGeometry } from 'three'
|
||||
|
||||
import {
|
||||
bendGeometry,
|
||||
chamferedBoxGeometry,
|
||||
createKitModel,
|
||||
createTinter,
|
||||
dishedSheetGeometry,
|
||||
ironTint,
|
||||
steelTint,
|
||||
jitter,
|
||||
latheGeometry,
|
||||
mergeColoured,
|
||||
taperedBoxGeometry,
|
||||
toolShaft,
|
||||
toolSocket,
|
||||
type Level,
|
||||
type SheetLevel,
|
||||
} from '../core/index.ts'
|
||||
|
||||
|
|
@ -43,11 +66,16 @@ export interface WoodenHoeConfig {
|
|||
/** Shaft length (metres). */
|
||||
readonly length: number
|
||||
readonly shaftRadius: number
|
||||
/** Width of the blade (metres). */
|
||||
/** Width of the cutting edge (metres). */
|
||||
readonly bladeWidth: number
|
||||
/** Total sweep of the gooseneck (degrees). 0 = straight neck, no longer a hoe. */
|
||||
/**
|
||||
* 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 = flat sheet. */
|
||||
/** Dish of the blade. 0 = dead flat, which is close to the reference. */
|
||||
readonly dish: number
|
||||
readonly seed: number
|
||||
}
|
||||
|
|
@ -56,19 +84,8 @@ export const woodenHoeDefaults: WoodenHoeConfig = {
|
|||
length: 1.14,
|
||||
shaftRadius: 0.021,
|
||||
bladeWidth: 0.23,
|
||||
// 112 stands. I lowered it to 95 after reading a render in which the blade
|
||||
// happened to sit edge-on to the camera, and mistook foreshortening for the
|
||||
// wrong angle.
|
||||
//
|
||||
// It is also worth recording that the reference generated for this model is
|
||||
// a plain strap hoe, where this one is deliberately a GOOSENECK -- a curved
|
||||
// forged neck carrying the blade ahead of the shaft axis, as the
|
||||
// description says. Both are period-correct, and bending the model towards
|
||||
// the photograph on that point would be changing the design rather than
|
||||
// fixing a fault. The blade's depth and wedge are another matter: a hoe
|
||||
// blade is the heavy end of the tool whichever neck it hangs from.
|
||||
neckSweep: 112,
|
||||
dish: 1,
|
||||
neckSweep: 104,
|
||||
dish: 0.8,
|
||||
seed: 23,
|
||||
}
|
||||
|
||||
|
|
@ -80,109 +97,111 @@ export function createModel(overrides: Partial<WoodenHoeConfig> = {}) {
|
|||
defaults: woodenHoeDefaults,
|
||||
slots: ['oak', 'iron', 'steel'],
|
||||
build: ({ config, random }) => {
|
||||
const shaft = toolShaft({ length: config.length, radius: config.shaftRadius, random })
|
||||
const socketLength = config.length * 0.075
|
||||
const socket = toolSocket({
|
||||
y: shaft.top - socketLength * 0.42,
|
||||
shaftRadius: shaft.topRadius,
|
||||
length: socketLength,
|
||||
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)
|
||||
|
||||
// --- Gooseneck ----------------------------------------------------------
|
||||
// It is built with its base AT THE ORIGIN and bent from there; centred on
|
||||
// y=0 it would bend symmetrically and nothing would happen.
|
||||
//
|
||||
// `latheGeometry` was chosen because it has intermediate levels: bending
|
||||
// a two-level box gives you a warped box, not an arc.
|
||||
const neckLength = config.length * 0.17
|
||||
const bar = config.shaftRadius * 0.85
|
||||
const sweep = (config.neckSweep * Math.PI) / 180
|
||||
const curvature = sweep / neckLength
|
||||
const shaft = toolShaft({ length: config.length, radius, random })
|
||||
const top = shaft.top
|
||||
|
||||
const neckLevels: Level[] = Array.from({ length: 7 }, (_, i) => {
|
||||
const t = i / 6
|
||||
return { y: neckLength * t, radius: bar * (1.05 - 0.28 * t) }
|
||||
})
|
||||
const neck = latheGeometry(neckLevels, 5, [0, 0, 0], ironTint(random, -0.02), {
|
||||
colourTop: ironTint(random, 0.04),
|
||||
})
|
||||
// A forged neck is not round but FLAT: it is hammered out crosswise. The
|
||||
// scaling is BEFORE the bend and only in X — scaling in Z would ruin the
|
||||
// plane of the arc.
|
||||
neck.scale(1.75, 1, 0.62)
|
||||
bendGeometry(neck, curvature)
|
||||
neck.translate(0, shaft.top - neckLength * 0.12, 0)
|
||||
// --- 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),
|
||||
)
|
||||
|
||||
// The TIP of the neck and the tangent there come out of the arc mapping
|
||||
// itself — computed instead of placed by eye, so that when `neckSweep`
|
||||
// changes the blade follows on its own.
|
||||
const tipY = shaft.top - neckLength * 0.12 + Math.sin(sweep) / curvature
|
||||
const tipZ = (1 - Math.cos(sweep)) / curvature
|
||||
// --- 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),
|
||||
)
|
||||
|
||||
// --- Blade ---------------------------------------------------------------
|
||||
// Continues from the tip of the neck. Built base-at-origin so that the
|
||||
// dish is actually visible.
|
||||
// 0.17 of the tool, not 0.115. A field hoe's blade is the heavy end of
|
||||
// it -- in the reference it is close to a fifth of the whole length --
|
||||
// and at 131 mm ours read as a tab riveted to a stick.
|
||||
const bladeLength = config.length * 0.15
|
||||
const thick = config.length * 0.028
|
||||
const thin = config.length * 0.006
|
||||
// A dished sheet, not a bent box.
|
||||
//
|
||||
// The blade was a `chamferedBoxGeometry` put through `bendGeometry`.
|
||||
// That has exactly four levels in Y, and once the blade was made deep
|
||||
// enough to look right the bend had to work a 50-degree arc across those
|
||||
// four -- the cutting edge came out visibly faceted, a row of steps
|
||||
// instead of a curve. `dishedSheetGeometry` exists for this: it produces
|
||||
// one seamless concave surface whose cross-section is curved at every
|
||||
// level, which is what the shovel's blade is built from.
|
||||
//
|
||||
// The hoe is a wedge: narrow where the neck carries it, wide where it
|
||||
// meets the ground. That taper is most of what identifies the
|
||||
// silhouette, and it was nearly parallel-sided before.
|
||||
const halfEdge = config.bladeWidth / 2
|
||||
// `curve` is an absolute rise in metres, not a ratio, so it has to be
|
||||
// scaled to the blade. Written as `-0.34 * dish` it asked for a 340 mm
|
||||
// rise across a 230 mm blade -- seven times its own half-width -- and
|
||||
// the sheet came out as a pair of wings. The shovel gets this right:
|
||||
// `bladeWidth * dish`, with its own dish slider running 0 to 0.22. This
|
||||
// one's runs 0 to 2, so the coefficient differs; the quantity does not.
|
||||
const curve = config.bladeWidth * 0.13 * config.dish
|
||||
// --- 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.34, thickness: thick, curve: curve * 0.15 },
|
||||
{ y: bladeLength * 0.18, halfWidth: halfEdge * 0.6, thickness: thick * 0.9, curve: curve * 0.5 },
|
||||
{ y: bladeLength * 0.52, halfWidth: halfEdge * 0.86, thickness: thick * 0.6, curve: curve * 0.9 },
|
||||
{ y: bladeLength * 0.86, halfWidth: halfEdge, thickness: thin * 1.6, curve },
|
||||
{ y: bladeLength, halfWidth: halfEdge * 0.99, thickness: thin, curve: curve * 0.95 },
|
||||
{ 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, steelTint(random, -0.04), steelTint(random, 0.05),
|
||||
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.04))
|
||||
// Align to the tangent at the neck tip, then move it there — order is critical.
|
||||
blade.rotateX(sweep)
|
||||
blade.translate(0, tipY, tipZ)
|
||||
|
||||
// Collar: the forged thickening where the neck meets the blade. The only
|
||||
// detail that answers how the two pieces hold on to each other.
|
||||
const collar = latheGeometry([
|
||||
{ y: -bar * 0.9, radius: bar * 1.05 },
|
||||
{ y: 0, radius: bar * 1.5 },
|
||||
{ y: bar * 1.1, radius: bar * 1.15 },
|
||||
], 6, [0, 0, 0], ironTint(random, 0.06))
|
||||
collar.scale(1.7, 1, 0.7)
|
||||
collar.rotateX(sweep)
|
||||
collar.translate(0, tipY, tipZ)
|
||||
|
||||
const ironwork: BufferGeometry = mergeColoured([socket, neck, collar])
|
||||
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: shaft.geometry },
|
||||
socket: { slot: 'iron', geometry: ironwork },
|
||||
blade: { slot: 'steel', geometry: mergeColoured([blade]) },
|
||||
shaft: { slot: 'oak', geometry: mergeColoured([shaft.geometry, block]) },
|
||||
socket: { slot: 'iron', geometry: mergeColoured([band, strap]) },
|
||||
blade: { slot: 'iron', geometry: mergeColoured([blade]) },
|
||||
}
|
||||
},
|
||||
}, overrides)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue