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
|
|
@ -9,16 +9,64 @@
|
|||
* pewter. A clear glass would be a period error, and it would also look foreign
|
||||
* next to the rest of the kit.
|
||||
*
|
||||
* The handle turned into a problem. A round rod handle looked like a modern
|
||||
* mug; a real tankard's handle is a FLAT strap of wood or iron, fixed to the
|
||||
* body at two points. The curve comes from `bendGeometry`.
|
||||
* LESSONS FROM THE DEAD VERSIONS, in order:
|
||||
*
|
||||
* 1. A round rod handle looked like a modern mug. A tankard handle is a flat
|
||||
* strap or carved grip fixed to the body at two points.
|
||||
* 2. A flat box strap through `bendGeometry` with a hand-derived offset ended
|
||||
* up buried in the wall: the offset formula did not describe what
|
||||
* `bendGeometry` actually does. Curves are specified by where their ends
|
||||
* land and how far the belly stands off — `arcBarGeometry` takes exactly
|
||||
* that.
|
||||
* 3. Even a correct arc fails if it HUGS the wall. The v3 handle stood 7 mm
|
||||
* off a 47 mm body: no daylight, and the occlusion bake painted it black.
|
||||
* A finger window must stay open between strap and wall.
|
||||
* 4. The body was near straight and slightly flared at the RIM, which is a
|
||||
* bucket's profile, not a tankard's. The reference (and every surviving
|
||||
* coopered mug) is widest at the BASE and narrows to the rim.
|
||||
* 5. Per-LEVEL radius jitter gave one stave a bulge at mid-height that read
|
||||
* as two coincident panels with a shading break between them. Jitter is
|
||||
* now one factor per stave, applied to every level, so each stave is a
|
||||
* single clean board.
|
||||
* 6. The 3.5%-of-step seam gap opened visible dark slots at the rim. The gap
|
||||
* only exists to keep adjacent side faces off a shared plane; 0.6% of a
|
||||
* step (~0.2 mm) does that invisibly.
|
||||
* 7. Two hoops left a bare band of wood under the lower one. Three hoops,
|
||||
* seated where the reference puts them: just under the rim, across the
|
||||
* lower middle, and flush with the bottom edge of the staves. Each hoop is
|
||||
* rotated half a step so its facets stay concentric with the stave facets
|
||||
* instead of crossing them at the seams.
|
||||
*
|
||||
* PASS 2 (blind critique scored v5 at 66; silhouette was the worst axis):
|
||||
*
|
||||
* 8. The -0.15 taper at 1.55 height-to-width still read as a straight can.
|
||||
* Now rim ≈ 0.82 × base (taper -0.22) and the wood column is ~1.3 × the
|
||||
* base diameter (height 0.148): the reference's bucket-like truncated
|
||||
* cone, widest element at the bottom band. The mid-level bulge is gone —
|
||||
* the critic asked for a linear taper and the 1.004 bulge fought it.
|
||||
* 9. The chunky square-section strap on two protruding ears read as a jug
|
||||
* handle on lugs, and the ears collected three modelling errors (lug
|
||||
* through the bottom band, strap tips poking past the lug caps, hard
|
||||
* seams at the wall). The ears are DELETED. The strap is now a SLAB:
|
||||
* arcBarGeometry gives a square section, so the geometry is scaled 2.5×
|
||||
* along the pre-rotation Z (the tangential direction after mounting) —
|
||||
* wide across the body, shallow radially, like the reference. Its ends
|
||||
* are buried in two thin flush PADS that hug the wall (tall one just
|
||||
* under the top band, short foot about a stave-width above the bottom
|
||||
* band), and the apex was pulled in from 0.6 R to 0.45 R off the wall.
|
||||
* 10. The palette's `iron` (0x40464d) is steel-blue and read as cold metal
|
||||
* against the wood. The hoops get a fixed offsetHSL toward the wood's
|
||||
* hue (dark warm oxide, ~21°) applied to the tinted colour — the tinter
|
||||
* cannot shift hue that far and core is shared, so the shift lives here.
|
||||
* Four rivet studs per hoop sit on the band facets (never at a = 0 where
|
||||
* the handle mounts), the reference's most memorable close detail.
|
||||
*/
|
||||
import type { BufferGeometry } from 'three'
|
||||
import { Color, type BufferGeometry } from 'three'
|
||||
|
||||
import {
|
||||
arcBarGeometry,
|
||||
bandGeometry,
|
||||
boxGeometry,
|
||||
chamferedBoxGeometry,
|
||||
createKitModel,
|
||||
createTinter,
|
||||
headGeometry,
|
||||
|
|
@ -33,7 +81,7 @@ export interface OakTankardConfig {
|
|||
readonly height: number
|
||||
/** Rim radius (metres). */
|
||||
readonly radius: number
|
||||
/** Narrowing towards the base. 0 = cylinder. */
|
||||
/** Narrowing towards the base. 0 = cylinder; negative widens the base. */
|
||||
readonly taper: number
|
||||
/** Number of staves. */
|
||||
readonly staveCount: number
|
||||
|
|
@ -45,11 +93,14 @@ export interface OakTankardConfig {
|
|||
}
|
||||
|
||||
export const oakTankardDefaults: OakTankardConfig = {
|
||||
height: 0.162,
|
||||
radius: 0.056,
|
||||
taper: 0.05,
|
||||
// Squat: the wood column is ~1.3 × the base diameter (lesson 8). At 0.17
|
||||
// the tankard was a can; the reference is a bucket.
|
||||
height: 0.148,
|
||||
radius: 0.047,
|
||||
// Negative on purpose: widest at the base, rim ≈ 0.82 × base (lesson 8).
|
||||
taper: -0.22,
|
||||
staveCount: 10,
|
||||
hoopCount: 2,
|
||||
hoopCount: 3,
|
||||
handle: 1,
|
||||
seed: 61,
|
||||
}
|
||||
|
|
@ -63,92 +114,219 @@ export function createModel(overrides: Partial<OakTankardConfig> = {}) {
|
|||
slots: ['oak', 'iron'],
|
||||
build: ({ config, random }) => {
|
||||
const tint = createTinter(random)
|
||||
const half = config.height / 2
|
||||
/**
|
||||
* The tankard's own oak, warmer than the palette entry.
|
||||
*
|
||||
* The palette is pitched for weathered structural timber, which is what
|
||||
* most of the kit is made of, and against their own references the crate,
|
||||
* the barrel and the chest all land within 0.07 on saturation. This one
|
||||
* measured 0.23 under. Its reference is a turned drinking vessel that has
|
||||
* spent its life indoors being handled, not a fence in the rain, and it
|
||||
* is genuinely a richer wood. Same shape of exception as the ladder, which
|
||||
* needs to go the other way and paler.
|
||||
*/
|
||||
const oak = (lift = 0, spread = 1): Color => {
|
||||
const c = tint('oak', lift, spread)
|
||||
const hsl = { h: 0, s: 0, l: 0 }
|
||||
c.getHSL(hsl)
|
||||
c.setHSL(hsl.h, Math.min(1, hsl.s * 1.95), hsl.l * 0.86)
|
||||
return c
|
||||
}
|
||||
// Floored: height and radius sit under divisions and a patched zero
|
||||
// would spray NaN through the occlusion bake (kit trap 2).
|
||||
const height = Math.max(0.04, config.height)
|
||||
const radius = Math.max(0.012, config.radius)
|
||||
const half = height / 2
|
||||
const staves = Math.max(5, Math.round(config.staveCount))
|
||||
const thickness = config.radius * 0.13
|
||||
const bottomRadius = config.radius * (1 - config.taper)
|
||||
const thickness = radius * 0.13
|
||||
const bottomRadius = Math.max(radius * 0.6, radius * (1 - config.taper))
|
||||
// Linear taper (lesson 8): every stave face is a clean trapezoid. The
|
||||
// mid level exists only so radiusAt stays a simple two-piece lerp.
|
||||
const midRadius = (bottomRadius + radius) / 2
|
||||
|
||||
/** Nominal wall radius (at the stave seams) at height y. */
|
||||
const radiusAt = (y: number): number =>
|
||||
y < 0
|
||||
? bottomRadius + ((midRadius - bottomRadius) * (y + half)) / half
|
||||
: midRadius + ((radius - midRadius) * y) / half
|
||||
|
||||
// --- Staves -----------------------------------------------------------
|
||||
// Offset by half a step so a stave FACE (not a seam) sits at a = 0,
|
||||
// where the handle mounts: the pads must land on wood, not on a slit.
|
||||
const stavePieces: BufferGeometry[] = []
|
||||
const step = (Math.PI * 2) / staves
|
||||
// Just enough gap to keep adjacent side faces off a shared plane.
|
||||
// Anything visible from arm's length is too much (lesson 6).
|
||||
const gap = step * 0.006
|
||||
for (let i = 0; i < staves; i += 1) {
|
||||
// A thin gap between staves: adjacent staves were coplanar along their
|
||||
// side faces, and on the barrel that caused z-fighting.
|
||||
const gap = step * 0.035
|
||||
// ONE jitter factor per stave, applied to every level (lesson 5).
|
||||
const k = 1 + jitter(random, 0.008)
|
||||
const levels: Level[] = [
|
||||
{ y: -half, radius: bottomRadius * (1 + jitter(random, 0.012)) },
|
||||
{ y: -half + config.height * 0.5, radius: config.radius * (0.985 + jitter(random, 0.012)) },
|
||||
{ y: half, radius: config.radius * (1 + jitter(random, 0.012)) },
|
||||
{ y: -half, radius: bottomRadius * k },
|
||||
{ y: 0, radius: midRadius * k },
|
||||
{ y: half, radius: radius * k },
|
||||
]
|
||||
const start = (i - 0.5) * step
|
||||
stavePieces.push(staveGeometry(
|
||||
levels, i * step + gap, (i + 1) * step - gap, thickness,
|
||||
tint('oak', jitter(random, 0.06)),
|
||||
levels, start + gap, start + step - gap, thickness,
|
||||
oak(jitter(random, 0.05)),
|
||||
))
|
||||
}
|
||||
|
||||
// --- Base --------------------------------------------------------------
|
||||
// A disc seated inside the staves; its edge stays within them.
|
||||
// A disc seated inside the staves; its edge stays buried in the wall
|
||||
// even at the flattest chord of the jittered stave polygon.
|
||||
const base = headGeometry(
|
||||
bottomRadius - thickness * 0.55, -half + config.height * 0.055,
|
||||
staves, 'up', tint('oakEnd', 0.02), 3, 0.06,
|
||||
bottomRadius - thickness * 0.8, -half + height * 0.055,
|
||||
staves, 'up', oak(0.06), 3, 0.06,
|
||||
)
|
||||
|
||||
// --- Hoops --------------------------------------------------------------
|
||||
// Warm dark iron, not the palette's steel-blue (lesson 10). The tinted
|
||||
// colour is rotated to the wood's side of the wheel; the tinter's own
|
||||
// jitter survives underneath the fixed offset.
|
||||
const hoops = Math.max(0, Math.round(config.hoopCount))
|
||||
const hoopPieces: BufferGeometry[] = []
|
||||
for (let i = 0; i < hoops; i += 1) {
|
||||
const t = hoops === 1 ? 0.5 : 0.13 + (i / (hoops - 1)) * 0.74
|
||||
const y = -half + config.height * t
|
||||
const radius = bottomRadius + (config.radius - bottomRadius) * t
|
||||
hoopPieces.push(bandGeometry(
|
||||
radius + thickness * 0.42, y, config.height * 0.055,
|
||||
thickness * 0.32, staves, tint('iron', jitter(random, 0.05), 0.6),
|
||||
))
|
||||
const hoopHeight = height * 0.078
|
||||
const hoopThickness = radius * 0.09
|
||||
const hoopYs: number[] = []
|
||||
if (hoops === 3) {
|
||||
// Flush with the bottom edge, across the lower middle (below true
|
||||
// centre so the lower zone reads shorter than the upper), and just
|
||||
// under the rim with a bare wood lip above (lesson 7).
|
||||
hoopYs.push(
|
||||
-half + hoopHeight / 2 + 0.0006,
|
||||
-half + height * 0.40,
|
||||
half - height * 0.105,
|
||||
)
|
||||
} else {
|
||||
for (let i = 0; i < hoops; i += 1) {
|
||||
const t = hoops === 1 ? 0.5 : 0.05 + (i / (hoops - 1)) * 0.85
|
||||
hoopYs.push(-half + height * t)
|
||||
}
|
||||
}
|
||||
const hoopPieces: BufferGeometry[] = []
|
||||
const studSize = radius * 0.105
|
||||
for (const y of hoopYs) {
|
||||
const bandR = radiusAt(y) + 0.0026
|
||||
hoopPieces.push(bandGeometry(
|
||||
bandR, y, hoopHeight, hoopThickness, staves,
|
||||
tint('iron', jitter(random, 0.05), 0.6).offsetHSL(-0.53, 0.13, -0.04),
|
||||
))
|
||||
// Rivet studs on the band facets (lesson 10). Facet centres sit at
|
||||
// (i + 0.5)·step here; the later half-step rotation of the whole
|
||||
// hoops part carries them onto the stave centres, so odd i keeps
|
||||
// them clear of the handle's stave at a = 0 and symmetric about it.
|
||||
const facetZ = bandR * Math.cos(step / 2)
|
||||
for (const i of [1, 3, 5, 7]) {
|
||||
const stud = chamferedBoxGeometry(
|
||||
[studSize, studSize], [studSize, studSize], studSize, studSize * 0.28,
|
||||
[0, 0, facetZ + studSize * 0.08],
|
||||
tint('iron', 0.025, 0.5).offsetHSL(-0.53, 0.13, -0.02),
|
||||
)
|
||||
stud.rotateY((i + 0.5) * step)
|
||||
stud.translate(0, y, 0)
|
||||
hoopPieces.push(stud)
|
||||
}
|
||||
}
|
||||
// Half a step aligns the hoop facets with the stave facets; unrotated,
|
||||
// the hoop's flats cross the stave seams and pinch to nothing there.
|
||||
const hoopGeometry = hoopPieces.length > 0
|
||||
? mergeColoured(hoopPieces)
|
||||
: undefined
|
||||
hoopGeometry?.rotateY(step / 2)
|
||||
|
||||
// --- Handle --------------------------------------------------------------
|
||||
// A broad flat slab, not a rod and not a strap on lugs (lessons 1, 9):
|
||||
// wide across the body's tangent, shallow radially, ends buried in two
|
||||
// flush pads that hug the wall.
|
||||
let handle: BufferGeometry | undefined
|
||||
if (config.handle >= 0.5) {
|
||||
// Built as a real arc, not as a bent box.
|
||||
//
|
||||
// The previous version made a flat `boxGeometry` strap and put it
|
||||
// through `bendGeometry`, then pushed it clear of the body by a
|
||||
// hand-derived offset: `(1 - cos(span/2 * k)) / k`. Measured, the
|
||||
// result was a handle whose outermost point stood 7 mm proud of a
|
||||
// body 47 mm in radius, with the rest of it buried in the stave wall.
|
||||
// There was no finger gap at all -- it read as a plank glued to the
|
||||
// side, which is exactly what it was. The offset formula did not
|
||||
// describe what `bendGeometry` actually does to the geometry.
|
||||
//
|
||||
// So this does not compute a correction for a curve it cannot see.
|
||||
// `arcBarGeometry` produces the arc directly, and the arc is specified
|
||||
// by the two things that actually matter: where its ends land, and how
|
||||
// far its belly stands off the body.
|
||||
const span = config.height * 0.72
|
||||
// Ends land INSIDE the stave wall, so the joint is an overlap.
|
||||
const endDepth = config.radius - thickness * 1.1
|
||||
const arcRadius = Math.hypot(span / 2, endDepth)
|
||||
const halfAngle = Math.atan2(span / 2, endDepth)
|
||||
const strap = arcBarGeometry(
|
||||
arcRadius,
|
||||
thickness * 1.5,
|
||||
-halfAngle,
|
||||
halfAngle,
|
||||
7,
|
||||
[0, 0, 0],
|
||||
tint('oak', -0.05),
|
||||
const barD = radius * 0.22 // radial depth of the slab
|
||||
const barW = barD * 2.5 // tangential width (the flat face)
|
||||
/** Outer wall surface at the stave FACE centre (a = 0), not the seam. */
|
||||
const wallZ = (y: number): number => radiusAt(y) * Math.cos(step / 2)
|
||||
|
||||
const topBandBottom = half - height * 0.105 - hoopHeight / 2
|
||||
const bottomBandTop = -half + hoopHeight + 0.0006
|
||||
const staveWidth = (Math.PI * 2 * bottomRadius) / staves
|
||||
|
||||
// Tall pad immediately under the top band; short foot about one
|
||||
// stave-width above the bottom band (the critic's placement, and it
|
||||
// keeps the foot out of the bottom band — v5's clearest error).
|
||||
const padTopH = radius * 0.6
|
||||
const padBottomH = radius * 0.42
|
||||
const yTop = topBandBottom - 0.0012 - padTopH / 2
|
||||
const yBottom = bottomBandTop + staveWidth * 0.9 + padBottomH / 2
|
||||
// The strap ends sit OFF the pad centres — high on the top pad, low
|
||||
// on the foot — so the arms cross the pad faces where the reference's
|
||||
// do, instead of leaving a blocky shelf of pad above the top arm.
|
||||
const yTopEnd = yTop + padTopH * 0.15
|
||||
const yBottomEnd = yBottom - padBottomH * 0.1
|
||||
const yCentre = (yTopEnd + yBottomEnd) / 2
|
||||
// Floored: a degenerate patched height can push the pads past each
|
||||
// other, and this feeds a division and an asin below (trap 2).
|
||||
const spanHalf = Math.max(radius * 0.15, (yTopEnd - yBottomEnd) / 2)
|
||||
|
||||
// One shared colour for strap and pads so the handle reads as one
|
||||
// carved piece, not a strap pinned between blocks. Slightly lighter
|
||||
// than the body; the underside quads live in shadow.
|
||||
const handleColour = oak(0.04)
|
||||
|
||||
// Pads stay just proud of the wall (~half the slab depth): the first
|
||||
// pass of this pad idea stood 7 mm out and the critic-shaped blocks
|
||||
// came straight back. The inner face stays inside the stave solid
|
||||
// without breaking through to the inside of the mug.
|
||||
const padW = barW * 1.05
|
||||
const padD = radius * 0.21
|
||||
const pads = ([[yTop, padTopH], [yBottom, padBottomH]] as const).map(
|
||||
([y, h]) => chamferedBoxGeometry(
|
||||
[padW, padD], [padW, padD], h, 0.0025,
|
||||
[0, y, wallZ(y) - 0.0005],
|
||||
handleColour,
|
||||
),
|
||||
)
|
||||
// The arc is built in the XY plane; -90 degrees about Y carries its
|
||||
// +X into +Z, standing it up beside the body with its span along Y.
|
||||
|
||||
// The arc, specified by where the ends land (buried in the pads and
|
||||
// the wall — anchored to the TOP wall radius, the tighter of the
|
||||
// two under the taper) and where the outer apex sits: 0.45 R off
|
||||
// the wall (lesson 9) — close enough to read as one object, far
|
||||
// enough for daylight (lesson 3).
|
||||
const endZ = (wallZ(yTopEnd) + wallZ(yBottomEnd)) / 2 + 0.0005
|
||||
const apexOuter = wallZ(yCentre) + radius * 0.45
|
||||
const bellyCentre = apexOuter - barD / 2
|
||||
const depth = Math.max(radius * 0.06, bellyCentre - endZ)
|
||||
const arcRadius = (spanHalf * spanHalf + depth * depth) / (2 * depth)
|
||||
const theta = Math.atan2(spanHalf, arcRadius - depth)
|
||||
// THREE segments on purpose: the reference's outer edge is straight
|
||||
// and angular with a hard top corner, not a smooth bow. Three chords
|
||||
// give a near-vertical outer face and two straight diagonal arms.
|
||||
const strap = arcBarGeometry(
|
||||
arcRadius, barD, -theta, theta, 3,
|
||||
[0, 0, 0], handleColour,
|
||||
)
|
||||
// Built in the XY plane with a square section; the pre-rotation Z is
|
||||
// the section direction that becomes tangential to the body, so
|
||||
// scaling it turns the square bar into the reference's flat slab.
|
||||
strap.scale(1, 1, barW / barD)
|
||||
// -90° about Y carries +X into +Z, standing the arc beside the body,
|
||||
// then it is carried out to the wall.
|
||||
strap.rotateY(-Math.PI / 2)
|
||||
handle = mergeColoured([strap])
|
||||
// The body tapers but the arc's two ends share one z: a small lean
|
||||
// about X follows the wall, so the lower arm emerges from its pad at
|
||||
// the same depth as the upper one instead of sinking into the
|
||||
// thicker base first.
|
||||
const lean = Math.asin(Math.min(0.4, Math.max(-0.4,
|
||||
(wallZ(yTopEnd) - wallZ(yBottomEnd)) / (2 * spanHalf))))
|
||||
strap.rotateX(lean)
|
||||
strap.translate(0, yCentre, bellyCentre - arcRadius)
|
||||
handle = mergeColoured([strap, ...pads])
|
||||
}
|
||||
|
||||
return {
|
||||
staves: { slot: 'oak' as const, geometry: mergeColoured(stavePieces) },
|
||||
base: { slot: 'oak' as const, geometry: mergeColoured([base]) },
|
||||
hoops: hoopPieces.length > 0
|
||||
? { slot: 'iron' as const, geometry: mergeColoured(hoopPieces) }
|
||||
hoops: hoopGeometry
|
||||
? { slot: 'iron' as const, geometry: hoopGeometry }
|
||||
: undefined,
|
||||
handle: handle ? { slot: 'oak' as const, geometry: handle } : undefined,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue