medieval-kit/my-registry/models/log-pile/model.ts
CRT 1888616315 Land the spilled coins, plane the bench's tenons, and sharpen the support check
Three defects reported from looking at the models, and the first of them
says something about the check that was supposed to catch it.

Every third coin was lifted to "land on top of the previous one" while its
position stayed independently scattered, so it was almost never above
anything and simply hovered about 3 mm off the ground. A stacking coin now
takes the previous coin's PLACE as well as its height. The tilted ones were
lifted by half their thickness plus their circumradius; a coin is a
nine-sided prism and can come to rest on a face rather than a corner, so
the lift uses the apothem and its own radius, erring towards bedding into
the ground rather than floating above it.

The support check missed all of that, and the reason is worth writing down:
its voxel is the model's extent over the resolution, so at 64 a 0.19 m coin
pouch gets a 3 mm voxel -- exactly the size of the gap it existed to find. A
check coarser than the defect it hunts is worth very little. At 112 the
whole suite still runs in fifteen seconds.

Raising it immediately turned up a real fault nobody had seen. The log
pile's sawn ends were cut to exactly the body's end radius and placed
exactly at its rim, so disc and lathe met along a seam of no width: they
touched and never overlapped, and there was nothing joining them but two
edges in the same place. The caps are now three percent wider and seated
slightly inside, which is two millimetres here and true of a real log
anyway, since bark wears back from a cut face.

And the bench's through-tenons stood 42 mm proud of the seat -- a block of
oak the size of a thumb sticking straight up out of the surface you are
meant to sit on. A real through-tenon is wedged and then planed back, so
what shows is end grain and a hairline of shoulder. Three millimetres
proud, ten housed below.

28 models, 20 547 triangles, all checks pass at resolution 112, 0 floating
cases, build clean.
2026-08-23 13:23:09 +07:00

217 lines
9 KiB
TypeScript

/**
* @medieval-kit/log-pile
*
* A pile of cut firewood. One of the cheapest models in the kit and one of the
* highest in scene value: put it at the foot of a wall and that spot instantly
* becomes "somewhere people live".
*
* Three things make a pile a pile:
* - The log end (the grain cross-section) is far lighter than the bark. Those
* pale circles are the first thing you see when you look at the pile.
* - No log matches its neighbour in diameter, length, angle or roll.
* - And most importantly: every log RESTS ON THE ONES BELOW IT.
*
* That last one was wrong in two separate attempts. First I used a fixed row
* height, then I computed it "from the thickest log" — in both cases the thick
* logs sank into the ones underneath and the thin ones hung in mid-air. The
* right answer is to solve the real contact height for each log: the point
* where two circles are tangent. `restingHeight` below does exactly that, and
* the pile now settles by itself — whatever radii come along.
*/
import { Color, type BufferGeometry } from 'three'
import {
MEDIEVAL_PALETTE,
createKitModel,
headGeometry,
jitter,
latheGeometry,
mergeColoured,
type Level,
} from '../core/index.ts'
export interface LogPileConfig {
/** How many rows. */
readonly rows: number
/** Number of logs in the bottom row. */
readonly perRow: number
/** Log length (metres). */
readonly logLength: number
/** Average log radius (metres). */
readonly logRadius: number
/** Thickness variety. 0 = all the same diameter. */
readonly variation: number
/** Pyramid-shaped (1) or a straight stack (0). */
readonly taperRows: number
readonly seed: number
}
export const logPileDefaults: LogPileConfig = {
// Denser than it was. Twelve logs read as "some firewood"; a stack is the
// thing that makes a wall look lived-against, and that needs enough logs
// for the ends to form a pattern rather than a row.
rows: 4,
perRow: 7,
logLength: 0.62,
logRadius: 0.065,
variation: 0.22,
taperRows: 1,
seed: 41,
}
export type LogPileParts = 'bark' | 'ends'
interface Placed {
readonly x: number
readonly y: number
readonly r: number
}
/**
* The height at which a log of radius `r` at position `x` will come to rest.
*
* Computes the height at which it is tangent to each log below it and takes the
* highest; if it touches none of them it rests on the ground. Two circles being
* tangent means the distance between their centres equals the sum of the radii,
* so the vertical distance is √((r₁+r₂)² − Δx²).
*/
function restingHeight(x: number, r: number, below: readonly Placed[], ground: number): number {
let y = ground + r
for (const other of below) {
const dx = Math.abs(x - other.x)
const reach = r + other.r
if (dx >= reach) continue
y = Math.max(y, other.y + Math.sqrt(reach * reach - dx * dx))
}
return y
}
export function createModel(overrides: Partial<LogPileConfig> = {}) {
return createKitModel<LogPileConfig, 'oak', LogPileParts>({
id: 'log-pile',
defaults: logPileDefaults,
slots: ['oak'],
build: ({ config, random }) => {
const tint = new Color()
const barkTint = (): Color => {
tint.copy(MEDIEVAL_PALETTE.oak)
tint.offsetHSL(jitter(random, 0.016), jitter(random, 0.07), -0.06 + jitter(random, 0.07))
return tint
}
const endTint = (): Color => {
tint.copy(MEDIEVAL_PALETTE.oakEnd)
// +0.07 did not deliver what the file header promises. A sawn end is
// dramatically paler than bark -- in a reference photograph the pale
// discs are the first thing the eye lands on -- and at this offset the
// ends were merely a slightly different brown.
tint.offsetHSL(jitter(random, 0.012), -0.06 + jitter(random, 0.04), 0.19 + jitter(random, 0.05))
return tint
}
const bark: BufferGeometry[] = []
const ends: BufferGeometry[] = []
const rows = Math.max(1, config.rows)
const spread = Math.max(0, Math.min(0.6, config.variation))
let below: Placed[] = []
for (let row = 0; row < rows; row += 1) {
const inRow = Math.max(1, config.perRow - (config.taperRows >= 0.5 ? row : 0))
const radii = Array.from({ length: inRow },
() => config.logRadius * (1 - spread + random() * spread * 2))
// Horizontally, neighbouring logs are tangent: the gap is the sum of
// the two radii. A fixed gap drove the thick ones into each other.
const gaps = radii.slice(0, -1).map((r, i) => r + radii[i + 1]!)
const rowWidth = gaps.reduce((sum, w) => sum + w, 0)
// Upper rows are offset so they drop into the GROOVE of the row below;
// the offset direction alternates, otherwise the pile leans one way.
const shift = (row % 2 === 1 ? 1 : -1) * config.logRadius * 0.5
+ jitter(random, config.logRadius * 0.1)
const placed: Placed[] = []
let cursor = -rowWidth / 2 + shift
for (let i = 0; i < inRow; i += 1) {
const radius = radii[i]!
const x = cursor
if (i < gaps.length) cursor += gaps[i]!
placed.push({ x, y: restingHeight(x, radius, below, 0), r: radius })
}
for (const log of placed) {
const length = config.logLength * (0.86 + random() * 0.28)
// Body: the ends are left uncapped, the grain cross-section is a
// separate pair of discs. That lets bark and end take very different
// colours.
//
// CRITICAL: it must NEVER EXCEED `log.r` anywhere. Previously the
// ends were `log.r * (1 ± 0.05)`, i.e. they fattened by up to 5%;
// since the layout is computed from `log.r`, neighbours intersected
// by up to 10% at their ends. A log now only ever TAPERS IN — which
// closes the bug and matches a real log, which narrows towards the
// end anyway.
const taperA = 1 - random() * 0.1
const taperB = 1 - random() * 0.1
const profile: Level[] = [
{ y: -length / 2, radius: log.r * taperA },
{ y: 0, radius: log.r },
{ y: length / 2, radius: log.r * taperB },
]
const body = latheGeometry(profile, 7, [0, 0, 0], barkTint(), {
capTop: false,
capBottom: false,
})
const grain = endTint()
// The sawn end stands a shade PROUD of the bark, and it has to.
//
// Cut to exactly the body's end radius, the disc and the lathe's open
// rim meet along a seam of no width at all: they touch and never
// overlap. Raising the support check's resolution showed that up at
// once -- the ends came away from the logs they belong to, because
// there was never anything joining them, only two edges in the same
// place. Three percent of the radius is two millimetres here, which
// nobody will see, and it is true of a real log anyway: bark wears
// back from a cut face.
// Set a little INSIDE the end as well as wider than it, so the rim
// passes through the wall rather than sitting level with its edge.
// Widening alone left one cap in five still meeting the body edge to
// edge, which is a joint only in the sense that two things are in the
// same place.
const seat = length * 0.02
const capA = headGeometry(profile.at(-1)!.radius * 1.03, length / 2 - seat, 7, 'up', grain, 3, 0.07)
const capB = headGeometry(profile[0]!.radius * 1.03, -length / 2 + seat, 7, 'down', grain, 3, 0.07)
// Each log is rolled randomly about its own axis: with every facet at
// the same angle the pile looks mechanical, and on top of that
// adjacent logs kept parallel faces that could z-fight.
const roll = random() * Math.PI * 2
const tilt = jitter(random, 0.04)
for (const [target, geometry] of [[bark, body], [ends, capA], [ends, capB]] as const) {
geometry.rotateY(roll)
// About X, NOT about Z.
//
// `latheGeometry` builds along Y. Rotating +90 degrees about Z
// sends +Y to -X, so every log lay along the X axis -- which is
// the very axis the layout spaces them along. Each log was 0.62 m
// long and its neighbour's centre was 0.13 m away, so every log
// ran straight through about five of its neighbours. The tangent
// solve above was correct the whole time; it was solving for an
// orientation the geometry did not have. Rotating about X sends
// +Y to +Z, which is the axis `restingHeight` assumes.
geometry.rotateX(Math.PI / 2)
geometry.rotateY(tilt)
geometry.translate(log.x, log.y, jitter(random, config.logLength * 0.015))
target.push(geometry)
}
}
below = placed
}
return {
bark: { slot: 'oak', geometry: mergeColoured(bark) },
ends: { slot: 'oak', geometry: mergeColoured(ends) },
}
},
}, overrides)
}