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

@ -22,6 +22,39 @@
* Parts: `hearth` (the stone ring and its bed of embers), `tripod`, and `pot`
* (the cauldron with its bail and the chain it hangs on). The chain belongs to
* the pot rather than to the tripod because it moves with what it carries.
*
* FOURTH PASS, after a blind critique (68, worst axis material: "clay or wood
* before iron"). What it found and what was done, so none of it is re-tried:
*
* - The previous pass DESCRIBED a flipped interior lathe but never called
* `flipGeometry`: the inner lathe faced outward and rendered as a pale
* dome plugging the mouth, which the critic read as a lidless flat disc.
* The interior is now genuinely flipped — wall faces inward, the bottom
* cap flips to face UP and becomes the dark interior floor, dropped about
* a quarter of the pot height below the lip.
* - The previous pass also hand-rolled a `soot()` HSL helper on the `char`
* entry and kept the body in the matte `char` slot; it came out mid-grey,
* LIGHTER than the tripod, and the two darker bands then read as barrel
* hoops on a pale stave body. The body now lives in the IRON slot and is
* tinted `iron` exactly like the legs and chain, so slot metalness and
* palette agree; the two contrasting bands are gone and the one girth
* bead at the widest point carries the SAME colour as the body, so it
* shows as a shading break, not a stripe.
* - The bail ears were horizontal rings floating tangent to the flank (the
* "cross-shaped fitting in mid-air"). They are now solid lug blocks sunk
* into the outer wall just below the lip at exactly opposite points, and
* the bail is a symmetric arc about the pot's axis whose slightly
* over-swept ends terminate INSIDE those blocks.
* - The straight collar between shoulder and rim is deleted: the profile
* now runs lip, immediate bulge, widest just below half height, tuck
* under to a small flat base, with the mouth about 0.9 of the belly.
* - The stone ring showed daylight: bases hovered (translate was fixed at
* 0.4·stoneH regardless of each stone's own height) and the taper opened
* wedge gaps between neighbours. Each stone is now buried by its own
* height, cut longer at the base so neighbours interpenetrate, and the
* ash bed extends UNDER the ring so any remaining gap shows ash, not
* background. The coals ride proud of the ash plane instead of level
* with it, which is what had them clipping against its silhouette.
*/
import { type BufferGeometry } from 'three'
@ -30,6 +63,7 @@ import {
bandGeometry,
createKitModel,
createTinter,
flipGeometry,
jitter,
latheGeometry,
mergeColoured,
@ -96,20 +130,26 @@ export function createModel(overrides: Partial<IronCauldronConfig> = {}) {
for (let i = 0; i < count; i += 1) {
const a = (i / count) * Math.PI * 2 + jitter(random, 0.05)
const arc = (Math.PI * 2 * ringR) / count
// Cut long, like the well's kerb: a flat-faced block on a curve cannot
// close its joint otherwise, and a fire ring with daylight through it
// is not a fire ring.
// Cut LONG at the base, like the well's kerb: a flat-faced block on a
// curve cannot close its joint otherwise, and the taper towards the
// top otherwise opens wedge gaps between neighbours that drop straight
// through to the background.
const h = stoneH * (0.92 + jitter(random, 0.12))
const stone = taperedBoxGeometry(
[arc * (1.06 + jitter(random, 0.08)), stoneH * (1 + jitter(random, 0.1))],
[arc * (0.78 + jitter(random, 0.1)), stoneH * (0.72 + jitter(random, 0.12))],
stoneH * (0.86 + jitter(random, 0.14)),
[arc * (1.18 + jitter(random, 0.08)), stoneH * (1.05 + jitter(random, 0.1))],
[arc * (0.86 + jitter(random, 0.1)), stoneH * (0.74 + jitter(random, 0.12))],
h,
[0, 0, 0],
tint('stone', jitter(random, 0.1)),
)
stone.rotateY(a)
// Buried by each stone's OWN height. The old translate was a fixed
// fraction of the nominal height, so short stones hovered and the
// roughening pass then lifted some bottoms clean off the ground —
// the black bars the critique saw under the front of the ring.
stone.translate(
Math.sin(a) * ringR,
floor + stoneH * 0.4,
floor + h * 0.5 - stoneH * 0.08,
Math.cos(a) * ringR,
)
stones.push(stone)
@ -117,32 +157,37 @@ export function createModel(overrides: Partial<IronCauldronConfig> = {}) {
const hearth = mergeColoured(stones)
roughenGeometry(hearth, stoneH * 0.08, { salt: 19 })
// Ash bed inside the ring, and the embers on it. The bed is char and the
// embers sit slightly proud of it; separating them is what lets the fire
// be put out without the ashes going with it.
const ashR = ringR - stoneH * 0.42
// Ash bed. It runs UNDER the stone ring, not up to its inner face, so a
// gap between two stones shows ash behind it rather than background.
const ashTop = floor + hearthR * 0.055
const ash = latheGeometry(
[
{ y: floor + hearthR * 0.005, radius: ashR },
{ y: floor + hearthR * 0.055, radius: ashR * 0.9 },
{ y: floor + hearthR * 0.005, radius: ringR + stoneH * 0.25 },
{ y: ashTop, radius: ringR - stoneH * 0.05 },
] as Level[],
11, [0, 0, 0], tint('char', 0.04),
)
// The embers sit PROUD of the ash plane — base buried, top clear of it —
// rather than level with it, which is what had them clipping against
// the ash disc's silhouette. Separate from the ash so the fire can be
// put out without the ashes going with it.
const emberPieces: BufferGeometry[] = []
const emberR = ringR - stoneH * 0.7
const emberCount = Math.max(4, Math.round(count * 0.7))
for (let i = 0; i < emberCount; i += 1) {
const a = i * 2.399963
const r = ashR * 0.72 * Math.sqrt((i + 0.4) / emberCount)
const r = emberR * 0.8 * Math.sqrt((i + 0.4) / emberCount)
const size = hearthR * (0.05 + random() * 0.045)
const coalH = size * 0.6
const coal = prismGeometry(
size, size * 0.6, size * 0.7, 5, [0, 0, 0],
size, size * 0.7, coalH, 5, [0, 0, 0],
tint(i % 3 === 0 ? 'emberTip' : 'ember', jitter(random, 0.05), 0.4),
)
coal.rotateY(random() * Math.PI * 2)
coal.translate(
Math.sin(a) * r,
floor + hearthR * 0.06,
ashTop + coalH * 0.2,
Math.cos(a) * r,
)
emberPieces.push(coal)
@ -199,7 +244,7 @@ export function createModel(overrides: Partial<IronCauldronConfig> = {}) {
// the embers -- close enough to look like a cauldron and far enough to
// cook nothing.
const potHang = -H * 0.62
const pot: BufferGeometry[] = []
const potChar: BufferGeometry[] = []
const potIron: BufferGeometry[] = []
// Chain, from the apex ring down to the bail.
@ -227,74 +272,94 @@ export function createModel(overrides: Partial<IronCauldronConfig> = {}) {
potIron.push(ring)
}
// The cauldron itself. The widest point is BELOW the mouth.
// The cauldron itself: lip, immediate bulge, widest just below half
// height, tuck under to a small flat base. No straight collar — the
// collar is what made the profile read as a barrel. One colour for the
// whole vessel, taken off the SAME palette entry as the tripod and the
// chain, because the critique's whole material complaint reduces to the
// pot not being the colour of its own hardware.
const belly = potHang
const cast = tint('iron', -0.02, 0.35)
const profile: Level[] = [
{ y: belly - R * 0.82, radius: R * 0.34 },
{ y: belly - R * 0.78, radius: R * 0.36 },
{ y: belly - R * 0.62, radius: R * 0.66 },
{ y: belly - R * 0.2, radius: R * 0.97 },
{ y: belly + R * 0.1, radius: R },
{ y: belly + R * 0.5, radius: R * 0.86 },
{ y: belly + R * 0.72, radius: R * 0.82 },
{ y: belly - R * 0.38, radius: R * 0.9 },
{ y: belly - R * 0.12, radius: R },
{ y: belly + R * 0.14, radius: R * 0.965 },
{ y: belly + R * 0.42, radius: R * 0.9 },
]
pot.push(latheGeometry(profile, 11, [0, 0, 0], tint('char', 0.05, 0.5), {
colourTop: tint('char', 0.11, 0.5),
potIron.push(latheGeometry(profile, 11, [0, 0, 0], cast, {
colourTop: tint('iron', 0.02, 0.35),
capTop: false,
}))
// The inside, so the mouth is not a hole through to nothing.
pot.push(latheGeometry(
// The interior, FLIPPED: the wall faces inward and the bottom cap flips
// to face up, so from above the mouth is an open cavity with a dark
// floor about a quarter of the pot height below the lip — not a plug.
// It stays in the matte char slot (soot does not gleam), but its colour
// comes off the iron entry: the char entry is warm, and a warm interior
// read as clay or as contents rather than as an empty black pot.
potChar.push(flipGeometry(latheGeometry(
[
{ y: belly - R * 0.55, radius: R * 0.6 },
{ y: belly + R * 0.72, radius: R * 0.76 },
{ y: belly + R * 0.13, radius: R * 0.7 },
{ y: belly + R * 0.46, radius: R * 0.82 },
] as Level[],
11, [0, 0, 0], tint('char', -0.06, 0.4), { capTop: false },
))
// Rim, and the girth band round the belly: cast ridges, and the two lines
// that stop the body reading as a smooth blob.
11, [0, 0, 0], tint('iron', -0.05, 0.3), { capTop: false },
)))
// The rim: an outer ring that closes the annulus between the outer wall
// and the interior wall — both wall tops end inside it. Same colour as
// the body, so it reads as a cast lip, not a stripe.
potIron.push(bandGeometry(
R * 0.86, belly + R * 0.7, R * 0.1, R * 0.06, 11,
tint('iron', -0.04, 0.6),
R * 0.95, belly + R * 0.44, R * 0.09, R * 0.16, 11,
cast, { inner: true },
))
// One girth bead at the widest point, in the body's own colour: a
// shading break, the way the reference shows it. The two contrasting
// dark hoops of the last pass are gone — they were the barrel.
potIron.push(bandGeometry(
R * 1.02, belly + R * 0.06, R * 0.07, R * 0.05, 11,
tint('iron', -0.08, 0.6),
R * 1.04, belly - R * 0.12, R * 0.07, R * 0.06, 11,
cast,
))
// Three feet. Short, splayed, and the reason it can stand in the ashes.
for (let i = 0; i < 3; i += 1) {
const a = (i / 3) * Math.PI * 2 + 0.9
const foot = taperedBoxGeometry(
[R * 0.14, R * 0.14],
[R * 0.15, R * 0.15],
[R * 0.1, R * 0.1],
R * 0.38,
R * 0.4,
[0, 0, 0],
tint('iron', -0.05, 0.6),
)
foot.rotateX(0.22)
foot.rotateX(0.2)
foot.rotateY(a)
foot.translate(
Math.sin(a) * R * 0.52,
belly - R * 0.86,
Math.cos(a) * R * 0.52,
Math.sin(a) * R * 0.5,
belly - R * 0.83,
Math.cos(a) * R * 0.5,
)
potIron.push(foot)
}
// Bail: a real arc, pinned to two lugs. `arcBarGeometry` sweeps from 0 to
// PI in the XY plane, which is already the shape of a handle standing
// over the pot, so no rotation is involved.
const bailR = R * 0.92
// Bail: one symmetric arc about the pot's axis, hung between two solid
// lug blocks sunk into the outer wall at opposite points just below the
// lip. The arc is swept a little past the half circle so both end caps
// terminate INSIDE the lugs; nothing rests on the rim's top plane and
// nothing floats beside the belly.
const lugY = belly + R * 0.29
for (const side of [-1, 1]) {
potIron.push(taperedBoxGeometry(
[R * 0.14, R * 0.13],
[R * 0.11, R * 0.1],
R * 0.24,
[side * R * 0.99, lugY, 0],
tint('iron', 0.02, 0.5),
))
}
potIron.push(arcBarGeometry(
bailR, barT * 0.75, -0.22, Math.PI + 0.22, 9,
[0, belly + R * 0.42, 0],
R * 0.95, barT * 0.75, -0.08, Math.PI + 0.08, 9,
[0, lugY, 0],
tint('iron', 0.06, 0.7),
))
for (const side of [-1, 1]) {
potIron.push(bandGeometry(
R * 0.13, belly + R * 0.5, R * 0.13, R * 0.06, 6,
tint('iron', 0.02, 0.6), { inner: true },
).translate(side * bailR * 0.94, 0, 0))
}
return {
hearth: {
@ -307,9 +372,9 @@ export function createModel(overrides: Partial<IronCauldronConfig> = {}) {
},
tripod: { slot: 'iron' as const, geometry: mergeColoured(iron) },
pot: {
slot: 'char' as const,
geometry: mergeColoured(pot),
extras: [{ slot: 'iron' as const, geometry: mergeColoured(potIron) }],
slot: 'iron' as const,
geometry: mergeColoured(potIron),
extras: [{ slot: 'char' as const, geometry: mergeColoured(potChar) }],
origin: [0, apexY, 0] as const,
},
}