Two things, both found by pulling on the handcart's thread. THE LADDER. I said three models had parts floating a few millimetres. One did. The other two readings were my own measurement error and worth writing down, because it is the same error as the vertex-sampling ones: I measured each part against the model's BOUNDING BOX minimum, and a part deliberately bedded into the ground drags that datum below the floor, so every correctly placed part beside it appears to float by exactly the bedding depth. The forge's base stones settle 6.8 mm and the well's drystone kerb 9.3 mm, both on purpose, and the bellows and the well frame sit exactly on the floor their own source declares. Nothing wrong with either. The post mill's tail ladder was real: 14.5 mm clear. It already carried two analytic corrections for the same corner, both correct about what they describe, and neither accounting for the chamfer — the corner they compute the drop of has been cut off, so the residual was very nearly the chamfer itself. Rather than add a third derivation to a spot that has now been got wrong three times, the assembled ladder is measured and put on the floor. A bounding box cannot be wrong about this the way arithmetic can, and it stays right when the chamfer or the rail section changes. `restsOn` now covers the mill's trestle and ladder too, and its doc comment carries the bedding caveat, so the next person to reach for it knows the datum is the bounding box and knows what that costs. THE ANIMATED COLUMN. `catalog-table.ts` decided it with `built.action`, which is the VIEWER's hand-registered button and exists for five models. So the generated table published the grindstone, the stone well, the post mill, the cauldron, the forge and the handcart as static — six of the eleven models that carry actions, including three of the most animated things in the kit. The obvious replacement is worse. `core/kit.ts` wraps update as `update: (dt) => options.update?.(dt, runtime)`, so it is a function on every model whether or not that model does anything with it, and switching to it marked all thirty-seven animated. One flag too narrow, its replacement too broad. So the generator now runs each model — a second of frames, then its action if it has one — and asks whether anything moved. That catches what moves by itself. What moves ON DEMAND it cannot catch, because the motion is behind an interface the catalogue never exposed, so `Entry` now carries `actionNames` from the model's own actions. Eleven models, and both documents list them. Worth keeping: this table is generated precisely so it cannot go stale, it says so in its own prose, and it was wrong for months. Generated is not the same as correct — it only moves the mistake into the generator, where nobody looks. Also in the npm README, which is the page npmjs.com renders: `vibe3d init` is now the first line rather than unmentioned, `@types/three` is called out (three ships no declarations, so without it the installed source does not typecheck), the `@/` alias and `allowImportingTsExtensions` have a Project setup block, and the one relative link is gone — npm resolves those against the repository, and this one pointed at a directory the package deliberately does not ship. `homepage` now points at the live site instead of a repository that is private.
558 lines
24 KiB
TypeScript
558 lines
24 KiB
TypeScript
/**
|
|
* @medieval-kit/post-mill
|
|
*
|
|
* A post mill: the medieval windmill, and the largest thing in this kit.
|
|
*
|
|
* The name is the whole design. The entire body — millstones, gearing, miller
|
|
* and all — is balanced on ONE massive vertical post and turns bodily on it, so
|
|
* that the sails can be pointed into whatever wind there is. The miller does
|
|
* that by walking the tail ladder round, which is why the ladder is not a way
|
|
* up but a lever, and why it reaches so far behind the mill.
|
|
*
|
|
* That gives the model its four parts and the order they have to be built in:
|
|
*
|
|
* - `trestle` — two cross-trees laid on the ground, the post standing on
|
|
* them, and four quarter-bars bracing post to cross-tree. Nothing is
|
|
* fastened to the ground: a post mill stands on its own weight, and the
|
|
* cross-trees are what stop it walking off.
|
|
* - `body` — the buck, a boarded timber box with a pitched roof, sitting on
|
|
* the post's crown.
|
|
* - `sails` — four lattice sails on a windshaft that leaves the front gable
|
|
* tilted a little upwards. This part has its own origin at the shaft, so
|
|
* `setTurning` can spin it without moving anything else.
|
|
* - `ladder` — the tail ladder, from the back of the buck down to the ground
|
|
* well behind it.
|
|
*
|
|
* The sails are the reason the triangle count is where it is. A sail is not a
|
|
* blade, it is a LATTICE: a whip with a frame beside it and a run of bars
|
|
* across, which the miller dresses with canvas according to the wind. Drawn as
|
|
* a solid blade the silhouette is wrong in the one place everybody looks, so
|
|
* the bars are modelled and the budget is spent there rather than on the
|
|
* boarding of the buck, which reads perfectly well as vertex colour.
|
|
*/
|
|
import { Color, type BufferGeometry } from 'three'
|
|
|
|
import {
|
|
boxGeometry,
|
|
chamferedBoxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
mergeColoured,
|
|
taperedBoxGeometry,
|
|
} from '../core/index.ts'
|
|
|
|
export interface PostMillConfig {
|
|
/** Height to the ridge of the roof (metres). Sails reach past it. */
|
|
readonly height: number
|
|
/** Sail span, tip to tip (metres). */
|
|
readonly sailSpan: number
|
|
/** Bars across each sail. */
|
|
readonly sailBars: number
|
|
/** Width of the lattice beside each whip, as a fraction of the sail's length. */
|
|
readonly sailWidth: number
|
|
/** Rungs in the tail ladder. */
|
|
readonly ladderRungs: number
|
|
/** Current sail angle (radians). */
|
|
readonly spin: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const postMillDefaults: PostMillConfig = {
|
|
height: 6.2,
|
|
// The lowest sail tip has to clear the ground. At 7.4 against a 6.2 m mill
|
|
// it came down to within 0.27 m of it, which reads as a mill about to plough
|
|
// its own field; the reference leaves roughly a tenth of the height.
|
|
sailSpan: 6.6,
|
|
// The lattice is what says "windmill" at any distance, and nine bars over a
|
|
// 3.3 m sail spaces them like ladder rungs. The reference reads as a fine
|
|
// grid.
|
|
sailBars: 13,
|
|
sailWidth: 0.17,
|
|
ladderRungs: 13,
|
|
spin: 0,
|
|
seed: 43,
|
|
}
|
|
|
|
export type PostMillParts = 'trestle' | 'body' | 'sails' | 'ladder'
|
|
|
|
export interface PostMillActions {
|
|
/** Starts or stops the sails. */
|
|
setTurning(on: boolean): void
|
|
isTurning(): boolean
|
|
/** Puts the sails at a given angle and stops them there. */
|
|
setAngle(radians: number): void
|
|
}
|
|
|
|
export function createModel(overrides: Partial<PostMillConfig> = {}) {
|
|
let turning = false
|
|
let angle = 0
|
|
|
|
return createKitModel<PostMillConfig, 'oak' | 'iron', PostMillParts, PostMillActions>({
|
|
id: 'post-mill',
|
|
defaults: postMillDefaults,
|
|
slots: ['oak', 'iron'],
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const H = config.height
|
|
const half = H / 2
|
|
const floor = -half
|
|
|
|
// Heights are fractions of the mill's own height, so every slider keeps
|
|
// the proportions of the reference rather than stretching one piece.
|
|
const postTop = floor + H * 0.42
|
|
const bodyHeight = H * 0.30
|
|
const bodyBottom = postTop
|
|
const bodyTop = bodyBottom + bodyHeight
|
|
const bodyWidth = H * 0.29 // across the sails' axis
|
|
const bodyDepth = H * 0.34 // along it
|
|
const ridge = bodyTop + H * 0.13
|
|
|
|
// --- Trestle -------------------------------------------------------------
|
|
const timber: BufferGeometry[] = []
|
|
const postSide = H * 0.075
|
|
const treeSpan = H * 0.62
|
|
const treeSide = H * 0.055
|
|
|
|
// Two cross-trees, laid one over the other. They are NOT coplanar: the
|
|
// upper one rides on top of the lower, which is how they actually sit and
|
|
// which keeps their faces out of each other.
|
|
for (const [index, along] of ([0, 1] as const).entries()) {
|
|
const lower = index === 0
|
|
const size: [number, number] = along === 0
|
|
? [treeSpan, treeSide]
|
|
: [treeSide, treeSpan]
|
|
timber.push(chamferedBoxGeometry(
|
|
size,
|
|
[size[0] * 0.97, size[1] * 0.97],
|
|
treeSide,
|
|
treeSide * 0.12,
|
|
[0, floor + (lower ? treeSide * 0.5 : treeSide * 1.4), 0],
|
|
tint('oak', lower ? -0.1 : -0.05),
|
|
))
|
|
}
|
|
|
|
// The post. Slightly tapered, because it is a whole tree and a tree is.
|
|
const postBase = floor + treeSide * 1.1
|
|
const postHeight = postTop - postBase
|
|
timber.push(taperedBoxGeometry(
|
|
[postSide * 1.15, postSide * 1.15],
|
|
[postSide * 0.92, postSide * 0.92],
|
|
postHeight,
|
|
[0, postBase + postHeight / 2, 0],
|
|
tint('oak', -0.02),
|
|
))
|
|
|
|
// Four quarter-bars, one to each arm of the cross-trees. Their feet sit
|
|
// ON the cross-tree and their heads run INTO the post, so both joints are
|
|
// overlaps rather than faces meeting.
|
|
const barFoot = treeSpan * 0.36
|
|
const barHead = postTop - H * 0.075
|
|
const barRise = barHead - (floor + treeSide * 1.6)
|
|
const barLength = Math.hypot(barFoot, barRise)
|
|
for (let i = 0; i < 4; i += 1) {
|
|
const a = (i / 4) * Math.PI * 2
|
|
const bar = chamferedBoxGeometry(
|
|
[H * 0.036, H * 0.05],
|
|
[H * 0.03, H * 0.042],
|
|
barLength * 1.04,
|
|
H * 0.006,
|
|
[0, 0, 0],
|
|
tint('oak', -0.07 + jitter(random, 0.03)),
|
|
)
|
|
// Built upright, leaned outwards, then swung to its arm. Rotating about
|
|
// X leans it in the YZ plane; rotating about Y carries that lean round.
|
|
//
|
|
// The sign is NEGATIVE, and it was not. `rotateX(+t)` carries the bar's
|
|
// top towards +Z and the translate below moves it +Z as well, so the
|
|
// two add and the quarter-bars stood on their heads -- feet gathered at
|
|
// the post, heads splayed out over the cross-trees, which is precisely
|
|
// backwards for a brace whose whole job is to carry the post's load out
|
|
// to the ground. Nothing caught it: the bar joins post to cross-tree
|
|
// either way round, so the mass stays connected and the bounding box is
|
|
// identical. It surfaced in the cauldron, where the same expression
|
|
// built a tripod standing on its apex and the support check finally had
|
|
// something to say.
|
|
bar.rotateX(-Math.atan2(barFoot, barRise))
|
|
bar.rotateY(a)
|
|
bar.translate(
|
|
Math.sin(a) * barFoot * 0.5,
|
|
(floor + treeSide * 1.6 + barHead) / 2,
|
|
Math.cos(a) * barFoot * 0.5,
|
|
)
|
|
timber.push(bar)
|
|
}
|
|
|
|
// Crown: the beam the whole body turns on, laid across the post's head.
|
|
timber.push(chamferedBoxGeometry(
|
|
[bodyWidth * 1.05, H * 0.07],
|
|
[bodyWidth * 0.98, H * 0.06],
|
|
H * 0.05,
|
|
H * 0.006,
|
|
[0, postTop - H * 0.012, 0],
|
|
tint('oak', 0.02),
|
|
))
|
|
|
|
// --- Body ----------------------------------------------------------------
|
|
const shell: BufferGeometry[] = []
|
|
shell.push(chamferedBoxGeometry(
|
|
[bodyWidth, bodyDepth],
|
|
[bodyWidth * 0.99, bodyDepth * 0.99],
|
|
bodyHeight,
|
|
H * 0.008,
|
|
[0, bodyBottom + bodyHeight / 2, 0],
|
|
tint('oak', 0.04),
|
|
tint('oak', -0.03),
|
|
))
|
|
|
|
// Roof: two pitched sheets meeting at a ridge. Built as tapered boxes
|
|
// leaned in rather than as a prism, so the gable ends stay flat and the
|
|
// eaves can overhang the walls, which is what keeps rain off boarding.
|
|
const eave = bodyWidth * 0.62
|
|
const roofRise = ridge - bodyTop
|
|
const slope = Math.hypot(eave, roofRise)
|
|
for (const side of [-1, 1]) {
|
|
// `slope`, not `slope * 2`. Each pitch covers HALF the roof: the slope
|
|
// is already the hypotenuse from ridge to eave. Doubled, the two came
|
|
// out 2.79 m across a building 1.80 m wide, and the roof stopped being
|
|
// a roof and became the largest thing in the silhouette.
|
|
const pitch = chamferedBoxGeometry(
|
|
[slope * 1.03, bodyDepth * 1.08],
|
|
[slope * 1.03, bodyDepth * 1.08],
|
|
H * 0.016,
|
|
H * 0.004,
|
|
[0, 0, 0],
|
|
tint('oak', -0.12),
|
|
)
|
|
// The pitch starts as a slab lying FLAT -- `chamferedBoxGeometry` takes
|
|
// an X-Z footprint and a Y height -- so the tilt is the roof's angle
|
|
// measured from the horizontal, and it is negative on the +X side so
|
|
// the eave drops rather than rises. Both earlier attempts used the
|
|
// complement of this angle, which stood the pitches up like the covers
|
|
// of an open book.
|
|
pitch.rotateZ(-side * Math.atan2(roofRise, eave))
|
|
pitch.translate(side * eave * 0.5, bodyTop + roofRise * 0.5, 0)
|
|
shell.push(pitch)
|
|
}
|
|
// Ridge board, capping the join so the two pitches do not meet in a seam.
|
|
shell.push(chamferedBoxGeometry(
|
|
[H * 0.03, bodyDepth * 1.1],
|
|
[H * 0.024, bodyDepth * 1.1],
|
|
H * 0.022,
|
|
H * 0.004,
|
|
[0, ridge - H * 0.008, 0],
|
|
tint('oak', -0.16),
|
|
))
|
|
|
|
// The door, in the tail wall above the gallery.
|
|
//
|
|
// The ladder has to arrive somewhere. A mill is entered from its tail,
|
|
// through the one wall the sails never sweep past, and the miller comes
|
|
// up the same ladder he turns the mill with -- so a flight of steps
|
|
// ending at blank boarding is the one thing about this model that could
|
|
// not be true.
|
|
//
|
|
// Boarded proud of the wall rather than cut into it: there are no
|
|
// booleans here, and a door of applied planks with a frame round it is
|
|
// how a plank building is actually closed.
|
|
const doorWidth = bodyWidth * 0.42
|
|
const doorHeight = bodyHeight * 0.66
|
|
const doorY = bodyBottom + H * 0.035 + doorHeight / 2
|
|
const doorZ = -bodyDepth / 2
|
|
shell.push(chamferedBoxGeometry(
|
|
[doorWidth, H * 0.018],
|
|
[doorWidth * 0.99, H * 0.016],
|
|
doorHeight,
|
|
H * 0.004,
|
|
[0, doorY, doorZ - H * 0.004],
|
|
tint('oak', -0.14),
|
|
))
|
|
// Two iron-dark ledges across it, and the frame: what stops a plank door
|
|
// being a rectangle drawn on a wall.
|
|
for (const at of [-0.28, 0.3]) {
|
|
shell.push(chamferedBoxGeometry(
|
|
[doorWidth * 1.04, H * 0.012],
|
|
[doorWidth * 1.02, H * 0.01],
|
|
H * 0.022,
|
|
H * 0.003,
|
|
[0, doorY + doorHeight * at, doorZ - H * 0.012],
|
|
tint('oak', -0.22),
|
|
))
|
|
}
|
|
|
|
// The tail gallery: the little platform the ladder arrives at.
|
|
shell.push(chamferedBoxGeometry(
|
|
[bodyWidth * 0.92, H * 0.05],
|
|
[bodyWidth * 0.88, H * 0.045],
|
|
H * 0.018,
|
|
H * 0.004,
|
|
[0, bodyBottom + H * 0.02, -bodyDepth * 0.52],
|
|
tint('oak', -0.06),
|
|
))
|
|
|
|
// --- Sails ---------------------------------------------------------------
|
|
// The windshaft leaves the front gable pointing +Z and tilted up a little,
|
|
// which is what stops the sails striking the body as they come round.
|
|
// The sail disc has to clear the front of the buck.
|
|
//
|
|
// A tilted shaft spreads its sails through Z: at 0.14 rad a 3.5 m sail
|
|
// swings +-0.49 m fore and aft, and with the hub only 0.08 m in front of
|
|
// the gable the whole lower half of the disc passed through the building.
|
|
// Two changes, and both are what a real mill does. The shaft tilts only a
|
|
// few degrees -- just enough to keep the sails off the body and to take
|
|
// some of their weight onto the thrust bearing -- and it projects a good
|
|
// way out, so the sweep happens clear of the wall.
|
|
const shaftTilt = 0.075
|
|
// Far enough forward and no further. The clearance the disc needs is
|
|
// sailLength * sin(tilt), about 0.26 m here; pushing the hub out to 0.86
|
|
// of the body's depth left the sails hanging most of a metre off the
|
|
// gable with nothing but the shaft between them, where the reference has
|
|
// them sweeping close past the boarding.
|
|
const hubZ = bodyDepth * 0.72
|
|
const hubY = bodyBottom + bodyHeight * 0.72
|
|
const sailLength = config.sailSpan / 2
|
|
const hubRadius = H * 0.035
|
|
const barT = H * 0.014
|
|
const latticeWidth = sailLength * config.sailWidth
|
|
const bars = Math.max(2, Math.round(config.sailBars))
|
|
|
|
const rig: BufferGeometry[] = []
|
|
const ironWork: BufferGeometry[] = []
|
|
// Windshaft: iron-banded oak, poking out of the gable ALONG Z.
|
|
//
|
|
// `taperedBoxGeometry` takes its third argument as a Y height, so written
|
|
// without a rotation this was a short vertical post standing inside the
|
|
// buck rather than a shaft projecting from the front of it. Hidden inside
|
|
// the body it went unseen through every render; it only surfaced when the
|
|
// iron bands were added and turned out to be coaxial with it, which the
|
|
// z-fight check reported at once.
|
|
// Long enough to reach back into the gable it comes out of.
|
|
const shaftLength = H * 0.20
|
|
const shaft = taperedBoxGeometry(
|
|
[hubRadius * 2.1, hubRadius * 2.1],
|
|
[hubRadius * 1.5, hubRadius * 1.5],
|
|
shaftLength,
|
|
[0, 0, 0],
|
|
tint('oak', -0.04),
|
|
)
|
|
shaft.rotateX(Math.PI / 2)
|
|
shaft.translate(0, 0, -shaftLength * 0.32)
|
|
rig.push(shaft)
|
|
// The bands themselves, and they are not decoration. Four sails pull on
|
|
// one shaft from four directions; what holds the whips to it is iron
|
|
// strapping, and this model declared an `iron` slot while using it
|
|
// nowhere -- the shaft was described in the comment above as iron-banded
|
|
// and then painted oak like everything else.
|
|
for (const at of [-0.02, 0.03]) {
|
|
const band = taperedBoxGeometry(
|
|
[hubRadius * 2.4, hubRadius * 2.4],
|
|
[hubRadius * 2.3, hubRadius * 2.3],
|
|
H * 0.014,
|
|
[0, 0, 0],
|
|
tint('iron', 0.02, 0.7),
|
|
)
|
|
band.rotateX(Math.PI / 2)
|
|
band.translate(0, 0, H * at)
|
|
ironWork.push(band)
|
|
}
|
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
const a = (i / 4) * Math.PI * 2
|
|
const sail: BufferGeometry[] = []
|
|
const tone = tint('oak', -0.05 + jitter(random, 0.04))
|
|
|
|
// Whip: the spar the whole sail hangs on, running out from the hub.
|
|
sail.push(boxGeometry(
|
|
[barT * 1.3, sailLength - hubRadius, barT * 1.6],
|
|
[0, hubRadius + (sailLength - hubRadius) / 2, 0],
|
|
new Color(tone),
|
|
))
|
|
// The outer rail of the frame, parallel to the whip.
|
|
sail.push(boxGeometry(
|
|
[barT * 0.9, (sailLength - hubRadius) * 0.9, barT * 1.1],
|
|
[latticeWidth, hubRadius + (sailLength - hubRadius) * 0.5, 0],
|
|
new Color(tint('oak', -0.1)),
|
|
))
|
|
// And the bars across. These are the sail: a solid blade would read as
|
|
// a propeller, and the lattice is what says "windmill" at any distance.
|
|
for (let b = 0; b < bars; b += 1) {
|
|
const t = (b + 0.6) / bars
|
|
sail.push(boxGeometry(
|
|
[latticeWidth * 1.12, barT * 0.7, barT * 0.9],
|
|
[latticeWidth * 0.5, hubRadius + (sailLength - hubRadius) * t, 0],
|
|
new Color(tint('oak', -0.02 + jitter(random, 0.05))),
|
|
))
|
|
}
|
|
|
|
const merged = mergeColoured(sail)
|
|
// Cant: each sail is twisted a few degrees about its own length so it
|
|
// presents a face to the wind. Without it the four read as a flat cross.
|
|
merged.rotateY(0.18)
|
|
merged.rotateZ(a)
|
|
// Opposite pairs sit at different depths along the shaft.
|
|
//
|
|
// This is how the sails are actually carried: TWO stocks pass through
|
|
// the windshaft at right angles to each other, one behind the other,
|
|
// and each stock carries a sail at both of its ends. Built all in one
|
|
// plane the four converge on the hub in perfect symmetry, and the
|
|
// z-fight check found their faces meeting there -- correctly, because
|
|
// four timbers cannot occupy one crossing.
|
|
merged.translate(0, 0, (i % 2 === 0 ? -1 : 1) * barT * 2.1)
|
|
rig.push(merged)
|
|
}
|
|
|
|
const sails = mergeColoured(rig)
|
|
const shaftBands = mergeColoured(ironWork)
|
|
shaftBands.rotateX(-shaftTilt)
|
|
// The rig is authored around the hub and the part's origin goes there, so
|
|
// `setTurning` spins it about the shaft instead of about the model.
|
|
sails.rotateX(-shaftTilt)
|
|
|
|
// --- Ladder --------------------------------------------------------------
|
|
// Long and shallow: it is the lever the mill is turned with, not a stair.
|
|
const steps: BufferGeometry[] = []
|
|
// Long and shallow. The tail ladder is the lever the mill is turned by,
|
|
// so it reaches well behind: at 0.42 of the height it came down at 47
|
|
// degrees, which is a staircase. The reference lands much further back.
|
|
const footZ = -bodyDepth * 0.5 - H * 0.62
|
|
const headZ = -bodyDepth * 0.52
|
|
const headY = bodyBottom + H * 0.02
|
|
const run = headZ - footZ
|
|
const rise = headY - floor
|
|
const railLength = Math.hypot(run, rise)
|
|
const lean = Math.atan2(run, rise)
|
|
const railGap = bodyWidth * 0.34
|
|
|
|
// The whole ladder is lifted by one offset, rails and rungs together.
|
|
//
|
|
// The foot has to land ON the ground, level with the cross-trees, and two
|
|
// things push it under. The rail is built 3% long so it can bury its head
|
|
// in the gallery, and half of that overrun hangs off the bottom; and once
|
|
// tilted, the lowest CORNER of its end face drops below that face's
|
|
// centre by half the rail's depth times sin(lean). Left uncorrected the
|
|
// ladder sat 0.10 m under the trestle, so the mill's floor was its stair
|
|
// and the whole thing hovered above whatever it was placed on.
|
|
//
|
|
// Correcting only the rails is worse than not correcting at all. The
|
|
// second term displaces them PERPENDICULAR to their own axis by very
|
|
// nearly a rung's half-depth, so the rungs -- placed on the original
|
|
// centre line -- came away from the rails entirely. The support audit
|
|
// caught that at the small end of the height slider as loose rungs
|
|
// floating beside the ladder.
|
|
const railDepth = H * 0.038
|
|
const liftY = railLength * 0.015 * Math.cos(lean) + (railDepth / 2) * Math.sin(lean)
|
|
const liftZ = -railLength * 0.015 * Math.sin(lean)
|
|
|
|
for (const side of [-1, 1]) {
|
|
const rail = chamferedBoxGeometry(
|
|
[H * 0.022, railDepth],
|
|
[H * 0.018, H * 0.032],
|
|
railLength * 1.03,
|
|
H * 0.004,
|
|
[0, 0, 0],
|
|
tint('oak', -0.08),
|
|
)
|
|
// +lean, not -lean. `run` is positive -- the ladder's head sits at a
|
|
// LARGER z than its foot -- and rotateX(+t) carries +Y towards +Z, so
|
|
// the negative sign laid the rails along the opposite diagonal to the
|
|
// one the rungs are placed on. The two crossed near the middle and
|
|
// diverged towards the ends, which is exactly what the support audit
|
|
// reported: the middle rungs held, the outer ones floated. The bounding
|
|
// box looked right the whole time, because the translation put the
|
|
// ladder where it belonged and only its internals were wrong.
|
|
rail.rotateX(lean)
|
|
rail.translate(
|
|
side * railGap,
|
|
(floor + headY) / 2 + liftY,
|
|
(footZ + headZ) / 2 + liftZ,
|
|
)
|
|
steps.push(rail)
|
|
}
|
|
|
|
const rungs = Math.max(2, Math.round(config.ladderRungs))
|
|
for (let i = 0; i < rungs; i += 1) {
|
|
const t = (i + 0.5) / rungs
|
|
// Spanning PAST both rails, so each rung is housed in them rather than
|
|
// ending at their inner faces.
|
|
// The rung is DEEPER than the rail it passes through (0.048 against
|
|
// 0.038), so its faces stand proud on both sides.
|
|
//
|
|
// Made thinner than the rail, a rung that "passes through" it has its
|
|
// whole surface buried inside the rail's hollow interior, touching
|
|
// nothing: these are surfaces, not solids. At the default height the
|
|
// voxel grid was coarse enough to bridge the gap and the support check
|
|
// passed; shrink the mill to the bottom of the height slider and the
|
|
// finer grid separated three of the five rungs from the ladder. The
|
|
// check was right both times -- the joint was never real.
|
|
const rung = boxGeometry(
|
|
[railGap * 2 + H * 0.03, H * 0.02, H * 0.048],
|
|
[0, 0, 0],
|
|
new Color(tint('oak', -0.03 + jitter(random, 0.04))),
|
|
)
|
|
rung.rotateX(lean)
|
|
rung.translate(0, floor + rise * t + liftY, footZ + run * t + liftZ)
|
|
steps.push(rung)
|
|
}
|
|
|
|
/**
|
|
* Then MEASURE the foot and put it on the ground, rather than reasoning
|
|
* about where it ended up.
|
|
*
|
|
* `liftY` above is two analytic corrections to the same corner, and both
|
|
* are right about the thing they describe. What neither accounts for is
|
|
* the chamfer: the rail is a chamfered box, so the corner they compute
|
|
* the drop of has been cut off, and the ladder came to rest 14.5 mm high
|
|
* — very nearly the chamfer itself. That is the third arithmetic mistake
|
|
* at this one corner; the comments above are the first two.
|
|
*
|
|
* A bounding box cannot be wrong about this the way a derivation can, and
|
|
* it stays right when the chamfer, the taper or the rail section change.
|
|
* The head is buried in the gallery by a 3% overrun that is several times
|
|
* this shift, so moving the assembly does not pull it out.
|
|
*/
|
|
const ladder = mergeColoured(steps)
|
|
ladder.computeBoundingBox()
|
|
ladder.translate(0, floor - ladder.boundingBox!.min.y, 0)
|
|
|
|
return {
|
|
trestle: { slot: 'oak' as const, geometry: mergeColoured(timber) },
|
|
body: { slot: 'oak' as const, geometry: mergeColoured(shell) },
|
|
sails: {
|
|
slot: 'oak' as const,
|
|
geometry: sails,
|
|
// The bands belong here, not with the trestle. They are authored
|
|
// around the hub, so in any other part's frame they land at the
|
|
// model's origin and hang in the air -- which is where the support
|
|
// check found them. They also turn with the shaft, because in a real
|
|
// mill the shaft is what the sails are keyed to.
|
|
extras: [{ slot: 'iron' as const, geometry: shaftBands }],
|
|
origin: [0, hubY, hubZ] as const,
|
|
},
|
|
ladder: { slot: 'oak' as const, geometry: ladder },
|
|
}
|
|
},
|
|
|
|
actions: ({ parts, getConfig }) => {
|
|
angle = getConfig().spin
|
|
parts.sails.anchor.rotation.z = angle
|
|
return {
|
|
setTurning: (on) => { turning = on },
|
|
isTurning: () => turning,
|
|
setAngle: (radians) => {
|
|
turning = false
|
|
angle = radians
|
|
parts.sails.anchor.rotation.z = angle
|
|
},
|
|
}
|
|
},
|
|
|
|
update: (deltaSeconds, { parts }) => {
|
|
if (!turning) return
|
|
// A working post mill turns slowly — on the order of ten revolutions a
|
|
// minute at the sail tips, not the blur people draw.
|
|
angle += deltaSeconds * 1.05
|
|
parts.sails.anchor.rotation.z = angle
|
|
},
|
|
}, overrides)
|
|
}
|