Add the market stall

The piece the kit had been assembled around without having. The basket, the
vegetables, the sack, the crate and the coin pouch all belong on one, and a
market with none of it is a row of props on a floor. It is also the only thing
here with a roof.

The awning SAGS, and that is the whole model. A flat sheet on four posts is a
table with a lid; cloth slung between four points hangs, and the dip is what
says it is cloth before the colour does. It costs nothing:
`dishedSheetGeometry` bends its cross section by `curve`, and one turn about X
maps that bend onto the world's vertical, so the sag is the helper's own
parameter rather than anything hand-built. Per level, because the reference's
front edge dips further than its back — cloth pulled over a ridge and left
loose at the eaves does not hang symmetrically.

Three faults, each of them a thing I had drawn correctly and built wrong:

  - The posts were one height, and the cloth is not one height. It is a
    parabola across its length and deeper at the front than the back, so a
    single post reached some corners and not others: the awning came away as
    its own floating piece 67 mm above the post heads, held up in the drawing
    and by nothing in the geometry. Each post is now sized by the sheet's OWN
    arithmetic — same parabola, same per-level sag, same quarter turn, same
    tilt — rather than by a second version of it that can drift.
  - The legs ran up to the rail's top face and ended level with it. Two upward
    faces in the plane y = 0.745, at all four corners. A leg is housed in a
    rail, not flush with it.
  - The posts stopped at the rail's underside, which put their feet in ITS
    plane — the same fault one storey down. They now run past it and finish as
    a stub below, which is the reference's detail as well as the fix.

And one thing the render corrected rather than the checks: the posts were
inboard, so the counter's boards had posts driven through them a hand's
breadth from each end. That is not how a stall is built and not what the
reference shows — the posts stand at the ends and the top lies between them.
The sag came down from 0.17 of the depth to 0.10 for the same reason: at 0.17
the cloth dropped a tenth of its own span and read as a hammock.

38 models, 32 700 triangles, all checks pass, 0 floating cases, GLB round-trip
clean, Conformant, documentation in step — including the showcase arithmetic,
which every new model moves.
This commit is contained in:
CRTkafa 2026-08-27 03:17:02 +07:00
parent 3a215db997
commit e9f479eead
11 changed files with 639 additions and 10 deletions

View file

@ -0,0 +1,293 @@
/**
* @medieval-kit/market-stall
*
* A plank table on a braced trestle, four posts, and a linen awning over it.
*
* This is the piece the kit was assembled around without having: it is where
* the basket, the vegetables, the sack, the crate and the coin pouch all
* belong, and a market with none of it is a row of props on the floor. It is
* also the only thing here with a roof.
*
* The awning SAGS, and that is the whole model. A flat sheet on four posts is
* a table with a lid; cloth slung between four points hangs, and the dip is
* what says it is cloth before the colour does. It costs nothing here:
* `dishedSheetGeometry` bends its cross section by `curve`, and one turn about
* X maps that bend onto the world's vertical — so the sag is the helper's own
* parameter rather than anything hand-built.
*
* The front edge dips further than the back, which is also the reference and
* is the reason the sag is per-level rather than one number: cloth pulled over
* a ridge and left loose at the eaves does not hang symmetrically.
*/
import { Color, type BufferGeometry } from 'three'
import {
boxGeometry,
createKitModel,
createTinter,
dishedSheetGeometry,
jitter,
mergeColoured,
taperedBoxGeometry,
type SheetLevel,
} from '../core/index.ts'
export interface MarketStallConfig {
/** Along the counter (metres). */
readonly length: number
/** Front to back (metres). */
readonly depth: number
/** Height of the counter top (metres). */
readonly height: number
/** Height of the awning above the ground (metres). */
readonly awning: number
/** How far the cloth hangs between the posts, as a fraction of the depth. */
readonly sag: number
/** Boards in the counter top. */
readonly planks: number
readonly seed: number
}
export const marketStallDefaults: MarketStallConfig = {
length: 1.62,
depth: 0.76,
height: 0.78,
awning: 1.94,
// 0.10 of the depth, not 0.17. At 0.17 the cloth dropped a tenth of its own
// span between the posts and read as a hammock; the reference hangs about
// half that. Cloth stretched over a stall is pulled tight and then sags a
// little, which is a different shape from cloth thrown over it.
sag: 0.1,
planks: 6,
seed: 29,
}
export type MarketStallParts = 'top' | 'trestle' | 'posts' | 'awning'
export function createModel(overrides: Partial<MarketStallConfig> = {}) {
return createKitModel<MarketStallConfig, 'oak' | 'cloth', MarketStallParts>({
id: 'market-stall',
defaults: marketStallDefaults,
slots: ['oak', 'cloth'],
build: ({ config, random }) => {
const tint = createTinter(random)
const L = Math.max(0.5, config.length)
const D = Math.max(0.3, config.depth)
const H = Math.max(0.3, config.height)
const roofY = Math.max(H + 0.25, config.awning)
const planks = Math.max(2, Math.round(config.planks))
const timberSize = D * 0.075
/** Weathered, like the grindstone's frame: a stall lives outdoors. */
const timber = (lift = 0): Color => {
const c = tint('oak', -0.08 + lift, 0.85)
c.offsetHSL(0, -0.09, 0)
return c
}
// --- Counter top ----------------------------------------------------
// Boards with a hair of daylight between them. The gap is what makes it
// a counter rather than a slab, and it is why the planks are separate
// boxes rather than one.
const boardT = timberSize * 0.62
const topY = H - boardT / 2
const pitch = D / planks
// The counter stops SHORT of the posts, which stand at the two ends.
// Run to the full length the boards had posts driven through them a
// hand's breadth from each end, which is not how a stall is built and
// not what the reference shows: the top lies between the posts.
const postSize = timberSize * 0.72
const topL = L - postSize * 2.6
const top: BufferGeometry[] = []
for (let i = 0; i < planks; i += 1) {
top.push(boxGeometry(
[topL, boardT, pitch * 0.93],
[0, topY + jitter(random, boardT * 0.06), -D / 2 + pitch * (i + 0.5)],
timber(jitter(random, 0.05)),
))
}
// --- Trestle --------------------------------------------------------
const trestle: BufferGeometry[] = []
const legX = L / 2 - timberSize * 1.6
const legZ = D / 2 - timberSize * 1.3
const railY = H - boardT - timberSize * 0.5
// Two rails under the top, running the length, which is what the legs
// and the posts both fasten to.
for (const sz of [-1, 1] as const) {
// Full length, so they reach the posts at the ends.
trestle.push(boxGeometry(
[L, timberSize, timberSize * 0.85],
[0, railY, sz * legZ],
timber(-0.02),
))
}
// Legs, splayed a little on the way down so the thing is not a card
// table. Splayed by WIDENING rather than by rotating, which is what lets
// the foot sit flat.
const splay = timberSize * 1.5
for (const sx of [-1, 1] as const) {
for (const sz of [-1, 1] as const) {
trestle.push(taperedBoxGeometry(
[timberSize * 1.15, timberSize * 1.15],
[timberSize, timberSize],
// Up INTO the rail and stopping there. Run to the rail's own top
// face the leg ends level with it — two upward faces in the plane
// y = 0.745, which the checker found at all four corners before
// anything was rendered. A leg is housed in a rail, not flush
// with it.
railY + timberSize * 0.18,
[
sx * (legX + splay * 0.5),
(railY + timberSize * 0.18) / 2,
sz * (legZ + splay * 0.35),
],
timber(-0.05 + jitter(random, 0.04)),
))
}
}
// The braces from the leg up to the rail. They are the reference's most
// recognisable joint and they are also what stops the top racking.
for (const sx of [-1, 1] as const) {
for (const sz of [-1, 1] as const) {
const foot: readonly [number, number, number] = [
sx * (legX + splay * 0.42), railY * 0.42, sz * (legZ + splay * 0.2),
]
const head: readonly [number, number, number] = [
sx * (legX - L * 0.16), railY, sz * legZ,
]
const dx = head[0] - foot[0]
const dy = head[1] - foot[1]
const dz = head[2] - foot[2]
const len = Math.hypot(dx, dy, dz)
const brace = taperedBoxGeometry(
[timberSize * 0.7, timberSize * 0.7],
[timberSize * 0.62, timberSize * 0.62],
len,
[0, len / 2, 0],
timber(-0.03),
)
// Built at the origin and turned, the construction that has never
// gone wrong here. The bearing comes off the two ends, not out of a
// guess: rotateZ then rotateY sends +Y to
// (-sin t cos a, cos t, sin t sin a), so a = atan2(dz, -dx).
brace.rotateZ(Math.acos(Math.max(-1, Math.min(1, dy / len))))
brace.rotateY(Math.atan2(dz, -dx))
trestle.push(brace.translate(foot[0], foot[1], foot[2]))
}
}
// The low stretcher and its cross piece.
const tieY = railY * 0.26
trestle.push(boxGeometry(
[(legX + splay * 0.46) * 2, timberSize * 0.8, timberSize * 0.7],
[0, tieY, 0],
timber(-0.07),
))
for (const sx of [-1, 1] as const) {
trestle.push(boxGeometry(
[timberSize * 0.7, timberSize * 0.7, (legZ + splay * 0.3) * 2],
[sx * (legX + splay * 0.46), tieY, 0],
timber(-0.06),
))
}
// --- Posts ----------------------------------------------------------
// They run from the RAIL, not from the top: bolted to the frame is how
// the reference carries them, and it is also what puts them beyond doubt
// for the support check — a post standing on a plank top would be held up
// by six millimetres of board.
const posts: BufferGeometry[] = []
const postX = L / 2 - postSize * 0.5
// The awning's shape, worked out here because the POSTS need it.
const overhang = timberSize * 1.8
const sag = Math.max(0, config.sag) * D
const halfLength = L / 2 + overhang
const halfDepth = D / 2 + overhang
const tilt = 0.055
/**
* How high the cloth hangs over a point on the ground.
*
* Every post has to reach it, and each of the four reaches a different
* height: the sheet is a parabola across its length and it is deeper at
* the front than the back, so a single post height leaves some of them
* short. Built with one, the cloth came away as its own floating piece
* 67 mm above the post heads — held up in the drawing and by nothing in
* the geometry.
*
* This is the sheet's own arithmetic rather than a second version of it:
* the same parabola, the same per-level sag, the same quarter turn that
* maps the helper's `curve` onto the world's vertical, and the same
* small tilt after it.
*/
const clothY = (x: number, z: number): number => {
const f = (-z + halfDepth) / (2 * halfDepth)
const u = x / halfLength
const curve = sag * (0.72 + f * 0.55)
return roofY + Math.cos(tilt) * curve * u * u + Math.sin(tilt) * z
}
for (const sx of [-1, 1] as const) {
for (const sz of [-1, 1] as const) {
// A shade past the cloth's mid surface, so the head is buried in it.
const head = clothY(sx * postX, sz * legZ) + boardT * 0.15
// Down PAST the rail and finishing as a stub below it, which is both
// the reference's detail and the fix for a real fault: stopped at the
// rail's underside, the post's foot lay in the same plane as it,
// facing the same way — `plane 0,-1,0 | -0.688` at all four corners.
const foot = railY - timberSize * 1.8
posts.push(taperedBoxGeometry(
[postSize, postSize],
[postSize * 0.88, postSize * 0.88],
head - foot,
[sx * postX, (foot + head) / 2, sz * legZ],
timber(0.02 + jitter(random, 0.03)),
))
}
}
// --- Awning ---------------------------------------------------------
/**
* Cloth slung between the four post heads.
*
* `dishedSheetGeometry` builds in the XY plane and bends its cross
* section along Z by `curve`. Turning it a quarter about X maps that
* bend onto the world's vertical, so the parabola the helper already
* draws IS the hang of the cloth: levels run front to back, the half
* width is half the stall's length, and the sag is a parameter rather
* than something modelled.
*
* `curve` lifts the EDGES relative to the middle, which is the right way
* round — the cloth is held at the posts and falls between them.
*/
const levels: SheetLevel[] = []
const steps = 4
for (let i = 0; i < steps; i += 1) {
const f = i / (steps - 1)
levels.push({
y: halfDepth * (f * 2 - 1),
halfWidth: halfLength,
thickness: boardT * 0.2,
// Deeper at the front than the back. Cloth pulled over a ridge and
// left loose at the eaves does not hang symmetrically.
curve: sag * (0.72 + f * 0.55),
})
}
const awning = dishedSheetGeometry(levels, 6, tint('cloth', 0.04, 0.9))
awning.rotateX(-Math.PI / 2)
// A shade of fall front to back, on top of the sag. Same axis as the
// turn above, so it is one angle rather than a second rotation.
awning.rotateX(-tilt)
awning.translate(0, roofY, 0)
return {
top: { slot: 'oak' as const, geometry: mergeColoured(top) },
trestle: { slot: 'oak' as const, geometry: mergeColoured(trestle) },
posts: { slot: 'oak' as const, geometry: mergeColoured(posts) },
awning: { slot: 'cloth' as const, geometry: awning },
}
},
}, overrides)
}