The kit could water nobody. It had 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.
Two additions to the core, both earned by measurement rather than by wanting
them:
- `limestone`, a palette key. `stone` 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. Both are right and neither replaces the other. The rendered block
now matches its reference to within a fifth of a degree of hue and 0.004
of lightness.
- `water`, a slot, and it is far less green than instinct says. Sampled
inside the reference's basin the film reads hue 39.5 — which is the hue of
the stone UNDERNEATH it, not a colour of its own. It darkens and slightly
saturates whatever it lies on and does that through the material's
opacity. Painted the pond green I was about to reach for, the composite
would have missed the reference by a hundred degrees.
Making it read as CARVED rather than sawn took three goes, and the two failures
are opposite errors worth keeping:
1. Each wall in two tiers, to give `roughenGeometry` more corners to move.
The tiers' corners sit at different positions, the hash moved them by
different amounts, and the render showed a lid on a base with a hard seam
right round it.
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 were trying to get subdivision out of the wrong thing. The walls are
`dishedSheetGeometry`, whose own docstring says the trough wants it: one
continuous body with levels along its length, so 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 for a seam to open in.
The z-fight checker earned its keep twice on the same plane, `0,-1,0 | 0`.
Four walls standing on the ground all have a face down there, and where the
corners overlap — which is where they are MEANT to overlap — two of those
faces share it. Lifting the basin floor only moved the pairing from
floor-against-wall to wall-against-wall. The answer was to stop there being
four things down there at all: a trough is solid below the water line, so it
is one block from the ground to the basin with the walls sunk into it, by
different amounts, because two buried faces in one plane are still two faces
in one plane.
Last, two defaults that the render corrected rather than the reference. The
walls at 0.27 of the width shut the basin so far that a three-quarter view
showed only its inner face, against the reference's opening of about three
fifths — now 0.21. And the water at 0.55 of the basin's depth sits below the
near rim and the trough reads as dry from every angle anyone will look at it
from; the reference stands a couple of centimetres under the rim, which is
0.85.
36 models, 31 748 triangles, fourteen material slots, all checks pass, 0
floating cases, GLB round-trip clean, Conformant, documentation in step.
243 lines
10 KiB
TypeScript
243 lines
10 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 = {
|
|
length: 1.5,
|
|
width: 0.58,
|
|
height: 0.44,
|
|
// 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.
|
|
*/
|
|
const foot = wall * 0.5
|
|
stone.push(boxGeometry(
|
|
[L - foot, floor, W - foot],
|
|
[0, floor / 2, 0],
|
|
new Color(tint('limestone', -0.05, 0.7)),
|
|
))
|
|
|
|
/**
|
|
* 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)
|
|
}
|