medieval-kit/my-registry/models/stone-well/model.ts
CRT ad6aed30dd Add the stone well, and stone to the palette
A village well: a drystone kerb, a timber frame over it, a windlass with
its collars and crank, and a rope and bucket that lower down the shaft.
`setDepth` and `setWinding` drive the bucket; its part origin is the
roller's axis, so winding is a translation and costs nothing per frame.

The kit had no masonry at all, which for a medieval catalogue is a hole
rather than an omission -- a well, a trough, a millstone, a boundary wall
and a hearth are all stone before they are anything else. `stone` is the
twelfth slot. Its colour sits close to weathered oak on purpose: what
separates the two at a glance is not hue but that stone scatters light
completely flat, and roughness is not something vertex colour can carry,
which is exactly the argument the slot table makes for keeping iron and
steel apart.

Four faults on the way in, and three of them are ones this session has seen
before in other models.

The blocks were written footprint-first as [wall, arc]. `rotateY` carries a
footprint's +Z out along the radius and its +X round the circumference, so
every block sat across the ring rather than along it -- each one sticking a
block's length out of a wall a block's width thick. The kerb came out as
rubble thrown at a circle.

The rope started at the roller's axis, which put its whole surface inside
the barrel's hollow interior touching nothing. Then it started in the eye
of the wound coil, which is also a hole. It has to begin within the coil's
material, between 1.0 and 1.34 of the roller radius, so that it crosses the
coil's outer face on the way down.

The actions set `anchor.position.y = 0`, which destroys what `origin` had
just done and dropped the whole rope and bucket by the height of the
roller. Everything the actions touch is now an offset from where the origin
put the anchor.

And the masonry was over the triangle budget at 2 784, almost 1 900 of
which were chamfers on forty-four blocks -- edges the roughen pass softens
anyway, and stone is the one material here where a sharp arris is not
wrong. Plain tapered boxes bring the model to 1 376.

Two things about the wall itself, both from looking at it. Varying each
block by a tenth in arc and thickness put every stone at its own radius and
its own width; a dry wall is irregular the way a hand-cut stone is
irregular, which is a few percent. And blocks cut to exactly their share of
the arc cannot close: a flat face on a curve leaves a wedge of daylight at
every joint, so they are cut long and bite into each other -- safe here
because each is rotated to its own angle and no two adjoining faces are
parallel.

29 models, 22 043 triangles, all checks pass, 0 floating cases, GLB
round-trip clean, Conformant, build clean.
2026-08-23 14:18:08 +07:00

387 lines
16 KiB
TypeScript

/**
* @medieval-kit/stone-well
*
* A village well: a drystone kerb, a timber frame over it, and a windlass to
* wind the bucket up on.
*
* The kerb is the point of the thing and it is worth saying why. A well is a
* hole; what makes it an OBJECT is the ring of stone built round its mouth, and
* that ring is there for a reason — it keeps the surface water, and whatever the
* surface water is carrying, from running back down into the drinking water. So
* the courses are laid dry, staggered, and stand well proud of the ground.
*
* Four parts:
*
* - `kerb` — the stone ring. Every block is placed individually, because a
* turned cylinder with lines drawn on it reads as a pot.
* - `frame` — two uprights standing OUTSIDE the kerb, and the head beam
* across them with their tenons showing through it.
* - `windlass`— the roller between the uprights, its end collars, and the
* crank. The rope is wound on the middle of the roller.
* - `bucket` — rope and bucket, hung from the roller. Its origin is the
* roller's axis, so `setDepth` lowers it down the shaft without
* moving anything else.
*
* This is the model that brought `stone` into the palette. The kit had no
* masonry at all, which for a medieval catalogue is a hole rather than an
* omission.
*/
import { Color, type BufferGeometry } from 'three'
import {
bandGeometry,
boxGeometry,
chamferedBoxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
roughenGeometry,
staveGeometry,
taperedBoxGeometry,
type Level,
} from '../core/index.ts'
export interface StoneWellConfig {
/** Outer radius of the stone kerb (metres). */
readonly radius: number
/** Height of the kerb above the ground (metres). */
readonly wallHeight: number
/** Courses of stone. */
readonly courses: number
/** Blocks in each course. */
readonly blocks: number
/** Height from the ground to the top of the head beam (metres). */
readonly frameHeight: number
/** How far down the bucket hangs. 0 = at the roller, 1 = at the water. */
readonly depth: number
readonly seed: number
}
export const stoneWellDefaults: StoneWellConfig = {
radius: 0.62,
wallHeight: 0.66,
courses: 4,
blocks: 11,
frameHeight: 1.95,
depth: 0.34,
seed: 17,
}
export type StoneWellParts = 'kerb' | 'frame' | 'windlass' | 'bucket'
export interface StoneWellActions {
/** Sends the bucket down (1) or brings it up (0). */
setDepth(t: number): void
depth(): number
/** Winds continuously until stopped. Positive winds up. */
setWinding(speed: number): void
}
export function createModel(overrides: Partial<StoneWellConfig> = {}) {
let depth = 0
let winding = 0
let travel = 0
/**
* Where the part's own origin put the anchor.
*
* `origin` places the anchor at the roller's axis at build time, and the
* geometry is authored around it. Writing an absolute `position.y` here
* therefore DESTROYS that placement -- which is what the first version did,
* setting it to 0 and dropping the whole rope and bucket by the height of the
* roller, so the rope started a hand's width below the barrel it hangs from.
* Everything the actions do is now an offset from this.
*/
let restY = 0
return createKitModel<StoneWellConfig, 'stone' | 'oak' | 'iron' | 'cloth', StoneWellParts, StoneWellActions>({
id: 'stone-well',
defaults: stoneWellDefaults,
slots: ['stone', 'oak', 'iron', 'cloth'],
build: ({ config, random }) => {
const tint = createTinter(random)
const H = config.frameHeight
const floor = -H / 2
const R = config.radius
const wall = R * 0.34
// --- Kerb ---------------------------------------------------------------
const masonry: BufferGeometry[] = []
const courses = Math.max(1, Math.round(config.courses))
const blocks = Math.max(5, Math.round(config.blocks))
const courseH = config.wallHeight / courses
const midR = R - wall / 2
for (let c = 0; c < courses; c += 1) {
// Every other course is offset by half a block: a joint running
// straight up through four courses is a crack waiting to happen, and a
// waller knows it. It is also what stops the ring reading as a stack of
// identical rings.
const phase = (c % 2) * 0.5
for (let b = 0; b < blocks; b += 1) {
const a = ((b + phase) / blocks) * Math.PI * 2
const arc = (Math.PI * 2 * midR) / blocks
// Plain tapered boxes, not chamfered ones. A chamfer is 44 triangles
// against 12, and at forty-four blocks that is 1 900 of them spent on
// edges the `roughenGeometry` pass below softens anyway. Stone is the
// one material in this kit where a sharp arris is not wrong.
// ARC first, WALL second. `rotateY(a)` carries the footprint's +Z out
// along the radius and its +X round the circumference, so writing the
// wall thickness first put every block across the ring instead of
// along it: each one stuck a block's length out of a wall a block's
// width thick, and the kerb came out as rubble thrown at a circle.
// The variation is small on purpose. At +-10% on the arc and +-9% on
// the thickness, every block sat at its own radius and its own width:
// the ring came out as rubble rather than as courses, and a waller
// who laid it like that would be looking for other work. A dry wall
// is irregular in the way a hand-cut stone is irregular, which is a
// few percent, not a tenth.
// Blocks are cut a little LONGER than their share of the arc, and
// courses a little taller than their share of the height, so
// neighbours bite into each other.
//
// A flat-faced box on a curve cannot close the joint at its outer
// corner: the chord is shorter than the arc, so anything sized to its
// exact share leaves a wedge of daylight at every joint and the wall
// reads as a ring of separate stones. Letting them overlap is the
// same rule the rest of the kit follows for joints, and it is safe
// here because each block is rotated to its own angle, so no two
// adjoining faces are parallel.
const block = taperedBoxGeometry(
[arc * (1.04 + jitter(random, 0.03)), wall * (0.99 + jitter(random, 0.04))],
[arc * (1.02 + jitter(random, 0.03)), wall * (0.95 + jitter(random, 0.04))],
courseH * (1.03 + jitter(random, 0.025)),
[0, 0, 0],
tint('stone', jitter(random, 0.09)),
)
// Built facing +Z and swung round: rotating first and placing second
// is what keeps each block square to the ring rather than to the axes.
block.rotateY(a)
block.translate(
Math.sin(a) * midR,
floor + courseH * (c + 0.5),
Math.cos(a) * midR,
)
masonry.push(block)
}
}
// Rough dressing. Small: the blocks are already irregular in size, and
// displacing them further only opens the dry joints between them.
const kerb = mergeColoured(masonry)
roughenGeometry(kerb, wall * 0.03, { salt: 13 })
// --- Frame --------------------------------------------------------------
const timber: BufferGeometry[] = []
const postSide = R * 0.24
const postX = R + postSide * 0.55
const beamH = R * 0.2
const beamY = floor + H - beamH / 2
// The uprights run PAST the beam and show as tenons on top of it.
const postTop = floor + H + beamH * 0.34
for (const side of [-1, 1]) {
timber.push(chamferedBoxGeometry(
[postSide * 1.06, postSide * 1.06],
[postSide * 0.94, postSide * 0.94],
postTop - floor,
postSide * 0.09,
[side * postX, (floor + postTop) / 2, 0],
tint('oak', -0.03 + jitter(random, 0.04)),
))
}
timber.push(chamferedBoxGeometry(
[postX * 2 + postSide * 2.2, beamH * 0.86],
[postX * 2 + postSide * 1.9, beamH * 0.78],
beamH,
beamH * 0.09,
[0, beamY, 0],
tint('oak', 0.03),
))
// --- Windlass -----------------------------------------------------------
// The roller sits well below the beam: the rope has to come off it and
// fall clear down the middle of the well, and a roller tucked under the
// beam would foul it.
const rollerY = floor + H * 0.62
const rollerR = R * 0.15
const rollerHalf = postX - postSide * 0.25
const gear: BufferGeometry[] = []
const iron: BufferGeometry[] = []
const rollerProfile: Level[] = [
{ y: -rollerHalf, radius: rollerR * 0.86 },
{ y: -rollerHalf * 0.88, radius: rollerR },
{ y: rollerHalf * 0.88, radius: rollerR },
{ y: rollerHalf, radius: rollerR * 0.86 },
]
const roller = latheGeometry(rollerProfile, 9, [0, 0, 0], tint('oak', 0.02, 1.2))
// Built along Y like every lathe here; +90 about Z lays it along X,
// spanning the gap between the uprights.
roller.rotateZ(Math.PI / 2)
roller.translate(0, rollerY, 0)
gear.push(roller)
// End collars: the raised shoulders that keep the rope from wandering off
// the barrel and into the bearing.
for (const side of [-1, 1]) {
const collar = bandGeometry(
rollerR * 1.5, 0, rollerR * 0.55, rollerR * 0.5, 9,
tint('oak', -0.06, 1.2), { inner: true },
)
collar.rotateZ(Math.PI / 2)
collar.translate(side * rollerHalf * 0.62, rollerY, 0)
gear.push(collar)
}
// The rope wound on the barrel, between the collars.
const coil = bandGeometry(
rollerR * 1.34, 0, rollerR * 1.05, rollerR * 0.34, 9,
tint('cloth', -0.04, 1.3), { inner: true },
)
coil.rotateZ(Math.PI / 2)
coil.translate(0, rollerY, 0)
gear.push(coil)
// Crank: an iron elbow through the upright with a wooden grip on the end.
const crankOut = postX + postSide * 0.9
iron.push(taperedBoxGeometry(
[rollerR * 0.5, rollerR * 0.5],
[rollerR * 0.42, rollerR * 0.42],
postSide * 2.4,
[0, 0, 0],
tint('iron', 0.02, 0.7),
).rotateZ(Math.PI / 2).translate(postX + postSide * 0.5, rollerY, 0))
iron.push(boxGeometry(
[rollerR * 0.46, rollerR * 2.6, rollerR * 0.46],
[crankOut, rollerY - rollerR * 1.2, 0],
new Color(tint('iron', -0.02, 0.7)),
))
gear.push(taperedBoxGeometry(
[rollerR * 0.72, rollerR * 0.72],
[rollerR * 0.58, rollerR * 0.58],
rollerR * 2.1,
[0, 0, 0],
tint('oak', 0.05),
).rotateZ(Math.PI / 2).translate(crankOut + rollerR * 0.9, rollerY - rollerR * 2.2, 0))
// --- Rope and bucket ----------------------------------------------------
// Authored hanging from the ROLLER'S AXIS, which is where the part's
// origin goes, so lowering the bucket is one rotation-free translation.
const bucketR = R * 0.3
const bucketH = bucketR * 1.5
const fall = H * 0.34 * Math.min(1, Math.max(0, config.depth))
const hanging: BufferGeometry[] = []
// The rope from barrel to bail. It has to stretch with the drop, so its
// length is derived rather than fixed.
// The rope leaves the COIL, not the axis.
//
// Starting it at y = 0 put its whole surface inside the roller's hollow
// interior, touching nothing -- these are surfaces, not solids, so a rope
// threaded up the middle of a barrel is joined to it only in the sense
// that it is inside it. The support check called the bucket floating and
// it was right. Beginning just inside the coil's inner face means the
// rope crosses the coil's outer face on its way down.
// Inside the coil's MATERIAL, which is the band from 1.0 to 1.34 of the
// roller radius -- not at 0.95, which is the hole through the middle of
// it. Starting there the rope hung in the ring's eye touching nothing;
// starting at 1.15 it begins within the wound rope and crosses its outer
// face on the way down.
const ropeTop = -rollerR * 1.15
const ropeLength = Math.max(rollerR * 1.6, fall) - ropeTop
hanging.push(latheGeometry(
[
{ y: ropeTop - ropeLength, radius: rollerR * 0.11 },
{ y: ropeTop, radius: rollerR * 0.12 },
] as Level[],
6, [0, 0, 0], tint('cloth', -0.08, 1.3),
{ capTop: false, capBottom: false },
))
// Bail: the iron loop the bucket hangs by.
const bail = bandGeometry(
bucketR * 0.94, 0, rollerR * 0.16, rollerR * 0.13, 9,
tint('iron', 0.04, 0.7), { inner: true },
)
bail.rotateX(Math.PI / 2)
bail.translate(0, ropeTop - ropeLength - bucketR * 0.5, 0)
hanging.push(bail)
// A small stave bucket, built the way the kit's bucket is: separate
// boards, not a turned cup.
const staves = 11
const step = (Math.PI * 2) / staves
const bucketY = ropeTop - ropeLength - bucketR * 0.5 - bucketH * 0.52
for (let i = 0; i < staves; i += 1) {
hanging.push(staveGeometry(
[
{ y: bucketY - bucketH / 2, radius: bucketR * 0.82 },
{ y: bucketY + bucketH / 2, radius: bucketR },
] as Level[],
i * step + step * 0.06,
(i + 1) * step - step * 0.06,
bucketR * 0.14,
tint('oak', jitter(random, 0.06)),
))
}
for (const at of [-0.34, 0.32]) {
const hoop = bandGeometry(
bucketR * (at < 0 ? 0.88 : 1.01), bucketY + bucketH * at,
bucketH * 0.1, bucketR * 0.05, staves,
tint('iron', 0.03, 0.7),
)
hanging.push(hoop)
}
hanging.push(latheGeometry(
[
{ y: bucketY - bucketH * 0.44, radius: bucketR * 0.78 },
{ y: bucketY - bucketH * 0.38, radius: bucketR * 0.8 },
] as Level[],
staves, [0, 0, 0], tint('oak', -0.1),
))
return {
kerb: { slot: 'stone' as const, geometry: kerb },
frame: { slot: 'oak' as const, geometry: mergeColoured(timber) },
windlass: {
slot: 'oak' as const,
geometry: mergeColoured(gear),
extras: [{ slot: 'iron' as const, geometry: mergeColoured(iron) }],
},
bucket: {
slot: 'oak' as const,
geometry: mergeColoured(hanging),
origin: [0, rollerY, 0] as const,
},
}
},
actions: ({ parts, getConfig }) => {
depth = getConfig().depth
travel = 0
restY = parts.bucket.anchor.position.y
return {
setDepth: (t) => {
winding = 0
depth = Math.min(1, Math.max(0, t))
travel = 0
},
depth: () => depth,
setWinding: (speed) => { winding = speed },
}
},
update: (deltaSeconds, { parts, getConfig }) => {
if (winding === 0) return
// Winding moves the bucket by TRANSLATION rather than by rebuilding the
// rope, so the action costs nothing per frame. The rope's own length is
// fixed by `depth` at build time; between rebuilds the bucket slides on
// it, which is exactly what a bucket on a rope does.
const H = getConfig().frameHeight
travel = Math.min(H * 0.34, Math.max(-H * 0.2, travel + winding * deltaSeconds * H * 0.18))
parts.bucket.anchor.position.y = restY + travel
},
}, overrides)
}