106 lines
3.6 KiB
TypeScript
106 lines
3.6 KiB
TypeScript
/**
|
|
* @medieval-kit/wooden-stool
|
|
*
|
|
* A three-legged stool. Three legs are no accident: on uneven ground three legs
|
|
* always touch, a fourth one rocks — that is why village furniture has three.
|
|
*
|
|
* Its function in a scene: to say "somebody sits here". Not a prop on its own
|
|
* but the sign of a human presence.
|
|
*/
|
|
import { Color } from 'three'
|
|
|
|
import {
|
|
chamferedBoxGeometry,
|
|
createKitModel,
|
|
jitter,
|
|
MEDIEVAL_PALETTE,
|
|
mergeColoured,
|
|
prismGeometry,
|
|
} from '../core/index.ts'
|
|
|
|
export interface WoodenStoolConfig {
|
|
readonly height: number
|
|
/** Radius of the seat (metres). */
|
|
readonly seatRadius: number
|
|
readonly legCount: number
|
|
/** How far the legs splay outwards. 0 = upright. */
|
|
readonly splay: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const woodenStoolDefaults: WoodenStoolConfig = {
|
|
height: 0.32,
|
|
seatRadius: 0.17,
|
|
legCount: 3,
|
|
splay: 0.32,
|
|
seed: 17,
|
|
}
|
|
|
|
export type WoodenStoolParts = 'seat' | 'legs'
|
|
|
|
export function createModel(overrides: Partial<WoodenStoolConfig> = {}) {
|
|
return createKitModel<WoodenStoolConfig, 'oak', WoodenStoolParts>({
|
|
id: 'wooden-stool',
|
|
defaults: woodenStoolDefaults,
|
|
slots: ['oak'],
|
|
build: ({ config, random }) => {
|
|
const tint = new Color()
|
|
const half = config.height / 2
|
|
const seatThickness = config.seatRadius * 0.22
|
|
|
|
// Seat: a thick wooden disc — that is, a short cylinder. Its edge narrows
|
|
// slightly downwards, which gives the impression of being hewn from a log.
|
|
tint.copy(MEDIEVAL_PALETTE.oakEnd)
|
|
tint.offsetHSL(jitter(random, 0.01), jitter(random, 0.04), jitter(random, 0.04))
|
|
const seatTop = half
|
|
const seat = prismGeometry(
|
|
config.seatRadius * 0.97,
|
|
config.seatRadius,
|
|
seatThickness,
|
|
12,
|
|
[0, seatTop - seatThickness / 2, 0],
|
|
tint,
|
|
)
|
|
|
|
const legs = []
|
|
const count = Math.max(3, config.legCount)
|
|
const legLength = config.height - seatThickness
|
|
for (let i = 0; i < count; i += 1) {
|
|
const angle = (i / count) * Math.PI * 2 + jitter(random, 0.06)
|
|
tint.copy(MEDIEVAL_PALETTE.oak)
|
|
tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), jitter(random, 0.06))
|
|
// 12 percent of the seat DIAMETER, not 7. The reference legs are
|
|
// chunky turned posts and these were sticks; the critic and a ruler
|
|
// agreed to within a percent.
|
|
const thick = config.seatRadius * 0.24
|
|
|
|
// Leg: a stick hanging below the origin, tapering downwards. Order is
|
|
// critical: lean first, then MOVE OUT TO THE RADIUS, then rotate. Without
|
|
// the move-to-radius step all three legs stack on the axis when splay=0.
|
|
const leg = chamferedBoxGeometry(
|
|
// Wider at the FOOT than at the seat, which is the way round a stool
|
|
// leg is turned: the mass belongs low. It was tapering the other way.
|
|
[thick, thick],
|
|
[thick * 0.82, thick * 0.82],
|
|
legLength,
|
|
// A heavy chamfer, because the reference feet are domed rather than cut
|
|
// off square, and at this scale a big chamfer is a dome.
|
|
thick * 0.34,
|
|
[0, -legLength / 2, 0],
|
|
tint,
|
|
)
|
|
leg.rotateZ(config.splay)
|
|
leg.translate(config.seatRadius * 0.6, 0, 0)
|
|
leg.rotateY(angle)
|
|
// Go INTO the seat disc: the top end is invisible and aligns with no plane.
|
|
leg.translate(0, seatTop - seatThickness * 0.35, 0)
|
|
legs.push(leg)
|
|
}
|
|
|
|
return {
|
|
seat: { slot: 'oak', geometry: seat },
|
|
legs: { slot: 'oak', geometry: mergeColoured(legs) },
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|