263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
/**
|
|
* @medieval-kit/stone-trough
|
|
*
|
|
* A watering trough cut from one block of limestone.
|
|
*
|
|
* The kit could water nobody. It has a well, a bucket and a cauldron, and all
|
|
* three are things you carry water IN; a trough is the thing a village keeps
|
|
* water in, standing in the street outside the smithy where the horses are.
|
|
*
|
|
* It is not the same ROCK as the well. `stone` in this palette was measured
|
|
* off dressed and rubble masonry and is grey, because a wall is quarried and
|
|
* laid face out. One block left in the open for a century reads hue 36 and
|
|
* saturation 0.16 against the wall's 44 and 0.05 — warmer, three times more
|
|
* saturated, lighter. Hence `limestone`, and the rendered block now matches
|
|
* the reference to within a fifth of a degree of hue and 0.004 of lightness.
|
|
*
|
|
* What took three attempts was making it read as CARVED rather than sawn, and
|
|
* the two failures are worth keeping because they are opposite errors:
|
|
*
|
|
* 1. Each wall in two tiers, to give `roughenGeometry` more corners to move.
|
|
* The tiers' corners sit at different positions, so the hash moved them
|
|
* by different amounts and left a hard seam right round the block — a lid
|
|
* on a base.
|
|
* 2. One box per wall, roughened harder to compensate. A box has eight
|
|
* corners and every face is two triangles, so moving those corners folded
|
|
* each wall along its own diagonal. Not stone: creased cardboard.
|
|
*
|
|
* Both tried to get subdivision out of the wrong thing. The walls are
|
|
* `dishedSheetGeometry` — one continuous body with levels along its length,
|
|
* which is what that helper's own docstring says the trough wants. The rim
|
|
* undulates because each level carries its own height, the wall thickens and
|
|
* thins because each carries its own thickness, and there is no join anywhere
|
|
* for a seam to open in.
|
|
*
|
|
* The basin is made the way everything hollow in this kit is made: by not
|
|
* filling it. Four walls and a floor, no boolean, corners overlapping.
|
|
*/
|
|
import { Color, type BufferGeometry } from 'three'
|
|
|
|
import {
|
|
boxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
dishedSheetGeometry,
|
|
jitter,
|
|
mergeColoured,
|
|
roughenGeometry,
|
|
type SheetLevel,
|
|
} from '../core/index.ts'
|
|
|
|
export interface StoneTroughConfig {
|
|
/** Along the trough (metres). */
|
|
readonly length: number
|
|
/** Across it (metres). */
|
|
readonly width: number
|
|
/** Height of the block (metres). */
|
|
readonly height: number
|
|
/** Wall thickness as a fraction of the width. */
|
|
readonly wall: number
|
|
/** How full it stands, as a fraction of the basin's depth. 0 is dry. */
|
|
readonly water: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const stoneTroughDefaults: StoneTroughConfig = {
|
|
// 1.35 by 0.50, not 1.5 by 0.44. The block ran 3.4 long to 1 high and the
|
|
// reference is nearer 2.6, which is the difference between a hewn stone
|
|
// trough and a long low planter. Split between the two dimensions so neither
|
|
// ends up an implausible size on its own.
|
|
length: 1.35,
|
|
width: 0.58,
|
|
height: 0.5,
|
|
// Heavy, but not as heavy as I first made it. A trough is a block with a
|
|
// dish taken out of it rather than a vessel, and at 0.27 the two walls came
|
|
// to more than half the width and the basin shut so far that a three-quarter
|
|
// view showed only its inner face. The reference's opening is about three
|
|
// fifths of the block across, which is this.
|
|
wall: 0.21,
|
|
// Nearly full, and that is the reference rather than a preference: its water
|
|
// stands a couple of centimetres under the rim. It is also the only level at
|
|
// which the water is VISIBLE from anywhere but straight above — at 0.55 the
|
|
// surface sits below the near rim and the trough reads as dry from every
|
|
// angle anyone will look at it from.
|
|
water: 0.85,
|
|
seed: 19,
|
|
}
|
|
|
|
export type StoneTroughParts = 'block' | 'water'
|
|
|
|
export function createModel(overrides: Partial<StoneTroughConfig> = {}) {
|
|
return createKitModel<StoneTroughConfig, 'stone' | 'water', StoneTroughParts>({
|
|
id: 'stone-trough',
|
|
defaults: stoneTroughDefaults,
|
|
slots: ['stone', 'water'],
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const L = Math.max(0.3, config.length)
|
|
const W = Math.max(0.2, config.width)
|
|
const H = Math.max(0.15, config.height)
|
|
const wall = Math.max(0.02, Math.min(0.4, config.wall)) * W
|
|
// The floor is thicker than the walls. A trough wears out from the
|
|
// inside and the bottom is what is left when it does.
|
|
const floor = H * 0.3
|
|
const basin = H - floor
|
|
|
|
/**
|
|
* One wall as a single sheet, laid along +X and centred on the origin.
|
|
*
|
|
* `dishedSheetGeometry` builds in the XY plane: levels run along Y,
|
|
* `halfWidth` spans X, `thickness` spans Z. One turn about Z lays that
|
|
* down so the levels run along the trough and the half-width becomes the
|
|
* wall's HEIGHT — which is the whole trick, because every level can then
|
|
* carry a different height and the rim undulates on its own, with no
|
|
* roughening involved and no seam to tear open.
|
|
*/
|
|
const wallSheet = (len: number, thick: number, tall: number, lift: number): BufferGeometry => {
|
|
const steps = 7
|
|
const levels: SheetLevel[] = []
|
|
for (let i = 0; i < steps; i += 1) {
|
|
levels.push({
|
|
y: len * (i / (steps - 1)),
|
|
halfWidth: (tall / 2) * (1 + jitter(random, 0.035)),
|
|
thickness: thick * (0.88 + random() * 0.24),
|
|
curve: 0,
|
|
})
|
|
}
|
|
const sheet = dishedSheetGeometry(levels, 2, tint('limestone', lift, 0.85))
|
|
sheet.rotateZ(-Math.PI / 2)
|
|
sheet.translate(-len / 2, 0, 0)
|
|
return sheet
|
|
}
|
|
|
|
const stone: BufferGeometry[] = []
|
|
|
|
/**
|
|
* The solid bottom, and the ONLY thing that touches the ground.
|
|
*
|
|
* Built as four walls standing on the floor, every piece had a face on
|
|
* the ground plane, and where the corners overlapped — which is exactly
|
|
* where they are meant to overlap — two downward faces shared the plane
|
|
* y = 0. The checker found it twice, and the second time was after I had
|
|
* "fixed" it by lifting the basin floor, which only moved the pairing
|
|
* from floor-against-wall to wall-against-wall.
|
|
*
|
|
* The answer is not to separate the overlaps but to stop there being
|
|
* four things down there. A trough IS solid below the water line: one
|
|
* block from the ground to the basin, and the walls stand on top of it.
|
|
* One piece on the ground, one bottom face, nothing to pair with.
|
|
*/
|
|
/*
|
|
* Tucked in by less than the roughening displaces, which is the only
|
|
* inset that does not show.
|
|
*
|
|
* At `wall * 0.5` the block sat 35 mm inside the walls on every side, and
|
|
* on a trough 450 mm tall that put a hard horizontal step across the
|
|
* bottom fifth of both long faces. Measured band by band the silhouette
|
|
* ran 0.265 wide over the bottom 90 mm and 0.30 above it. The comment
|
|
* that used to sit here said the base was invisible from above, and it
|
|
* was: what it was not was invisible from the side, which is where a
|
|
* trough is looked at.
|
|
*
|
|
* Now the inset is under the roughening amplitude, so the two surfaces
|
|
* interleave into one weathered face instead of meeting at a ledge, and
|
|
* the tint follows the walls rather than sitting a step darker.
|
|
*/
|
|
const foot = wall * 0.16
|
|
stone.push(boxGeometry(
|
|
[L - foot, floor, W - foot],
|
|
[0, floor / 2, 0],
|
|
new Color(tint('limestone', -0.02, 0.85)),
|
|
))
|
|
|
|
/**
|
|
* The four walls, sunk into that block by DIFFERENT amounts.
|
|
*
|
|
* Sunk, so their undersides are buried in solid stone rather than
|
|
* meeting its top face in a plane. By different amounts, because the
|
|
* long pair and the end pair overlap each other at the corners, and two
|
|
* buried faces in one plane are still two faces in one plane whether
|
|
* anybody can see them or not.
|
|
*
|
|
* The WALLS are flush with the block's nominal outline and the base is
|
|
* tucked in behind them, not the other way round. Standing the walls
|
|
* inset on a full-width base put a sharp ledge right round the bottom and
|
|
* the thing read as a planter on a concrete plinth. Tucked under, the
|
|
* base is invisible from above and the block is one face from rim to
|
|
* ground.
|
|
*
|
|
* Their outer faces cannot land in the base's plane by accident either:
|
|
* each level of a sheet carries its own thickness, so a wall's outer
|
|
* surface is a run of slightly different planes rather than one.
|
|
*/
|
|
const inset = 0
|
|
const longSink = H * 0.06
|
|
const endSink = H * 0.095
|
|
for (const side of [-1, 1] as const) {
|
|
const w = wallSheet(L - inset * 2, wall, basin + longSink, jitter(random, 0.04))
|
|
stone.push(w.translate(
|
|
0,
|
|
floor - longSink + (basin + longSink) / 2,
|
|
(side * (W - wall - inset * 2)) / 2,
|
|
))
|
|
}
|
|
for (const side of [-1, 1] as const) {
|
|
const end = wallSheet(W - wall * 0.5, wall, basin + endSink, 0.03 + jitter(random, 0.04))
|
|
// Built along +X like the others, then turned a quarter about Y, which
|
|
// sends +X to -Z and leaves the height alone.
|
|
end.rotateY(Math.PI / 2)
|
|
stone.push(end.translate(
|
|
(side * (L - wall - inset * 2)) / 2,
|
|
floor - endSink + (basin + endSink) / 2,
|
|
0,
|
|
))
|
|
}
|
|
|
|
const block = mergeColoured(stone)
|
|
// Light, now that the sheets carry the shape themselves. This is grain
|
|
// on a face, not the shape of the block.
|
|
roughenGeometry(block, wall * 0.09, { salt: 41, scaleY: 0.5 })
|
|
|
|
/**
|
|
* Put the block back on the floor.
|
|
*
|
|
* Roughening moves every corner including the ones on the ground, and so
|
|
* does the per-level height jitter, so the block ends up balanced on
|
|
* three corners with daylight under the rest of it. The same treatment
|
|
* the oak's bole gets: flatten everything below the bottom band back to
|
|
* exactly zero, leaving all of the irregularity above that line.
|
|
*/
|
|
const position = block.getAttribute('position')
|
|
for (let i = 0; i < position.count; i += 1) {
|
|
if (position.getY(i) < H * 0.04) position.setY(i, 0)
|
|
}
|
|
position.needsUpdate = true
|
|
block.computeVertexNormals()
|
|
|
|
// --- Water ------------------------------------------------------------
|
|
// A film, not a body. It is a slab rather than a plane because a plane
|
|
// seen edge-on from a low angle vanishes, and a trough is looked into
|
|
// from above and along. Sunk into the floor rather than resting on it,
|
|
// because a surface laid exactly ON another leaves two faces in a plane.
|
|
const level = Math.max(0, Math.min(1, config.water))
|
|
const depth = basin * level
|
|
const sink = H * 0.035
|
|
const water = depth > H * 0.01
|
|
? boxGeometry(
|
|
[L - wall * 1.6, depth + sink, W - wall * 1.6],
|
|
[0, floor - sink + (depth + sink) / 2, 0],
|
|
new Color(tint('water', jitter(random, 0.03), 0.6)),
|
|
)
|
|
: undefined
|
|
|
|
return {
|
|
block: { slot: 'stone' as const, geometry: block },
|
|
// Present and undefined when it is dry, never missing: the kit clears a
|
|
// part's anchor before it checks whether there is anything to put in
|
|
// it, so a key that is absent leaves last build's water hanging there.
|
|
water: water ? { slot: 'water' as const, geometry: water } : undefined,
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|