222 lines
9.4 KiB
TypeScript
222 lines
9.4 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)
|
|
// Lifted well above the palette oak. That constant is weathered structural
|
|
// timber, grey and dropped in value, which is right for a fence or a
|
|
// ladder and wrong for this: a woodpile is freshly split, and the split
|
|
// faces have never been outside long enough to go grey. Measured against
|
|
// the reference the pile wants about 0.13 more than the kit default.
|
|
tint.offsetHSL(jitter(random, 0.016), jitter(random, 0.07), 0.07 + 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.3 + 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)
|
|
}
|