medieval-kit/contemporary-props/models/pedestal-basin/model.ts

363 lines
17 KiB
TypeScript

/**
* @contemporary-props/pedestal-basin
*
* The first object in the kit that is not round, and the first where the
* ceramic slot carries the whole model.
*
* The catalogue budgets this row at "two lathes making an hourglass", and that
* is the one thing it cannot be. Every basin in use today is a D in plan: a
* semicircular front, straight sides, and a flat back that goes against a
* wall. A lathe cannot make a flat back, and the flat back is not a detail —
* it is what gives the object a FRONT, and an object with a front reads as
* plumbing where a circular one reads as a birdbath. So the row introduces
* `planSweepGeometry` and `dPlan` instead, and both are in core because the
* cistern, the bath and the back-to-wall pan all want the same curve. The
* extrusion helper the catalogue reserves for row 11 is a different thing
* again: that one runs a fixed section along a straight line.
*
* Measured off the reference against a 0.85 m rim height, which is the height
* a basin is actually fitted at:
*
* width 0.72 of the height
* plan depth 0.78 of the width
* bowl body 0.28 of the height, the rest is pedestal
* pedestal 0.28 of the width at the neck, flaring to 0.31 at the foot
* rim shelf 0.15 of the local half-extent, so it is wider at the back
* than at the sides exactly as the photograph's is
*
* The bowl is ONE closed profile. It runs from the outer rim edge down the
* outside, in under the bowl, up the inside to the dish floor, out across it
* and back over the rim to where it started, so the solid seals itself with no
* caps at either end. That is also what stops it doing what the vase did
* before it was bored: a single-sided shell with an open top shows you the
* inside of its own far wall, which is back-facing, which is culled, which
* looks like the object has a bite taken out of it.
*/
import { type BufferGeometry } from 'three'
import {
bakeOcclusion,
createKitModel,
createTinter,
dPlan,
latheGeometry,
mergeColoured,
planSweepGeometry,
smoothNormals,
type PlanLevel,
} from '../core/index.ts'
export interface PedestalBasinConfig {
/** Height to the rim (metres). */
readonly height: number
/** Width of the bowl across the front (metres). */
readonly width: number
/** Sides around the plan. */
readonly segments: number
readonly seed: number
}
export const pedestalBasinDefaults: PedestalBasinConfig = {
// The fitted height for a basin rim, and it is a standard rather than a
// choice: 0.85 m is what the regulations put it at and what every one in a
// house is set to.
height: 0.85,
width: 0.58,
segments: 56,
seed: 12,
}
export type PedestalBasinParts = 'bowl' | 'pedestal' | 'waste'
export function createModel(overrides: Partial<PedestalBasinConfig> = {}) {
return createKitModel<
PedestalBasinConfig, 'ceramic' | 'chrome', PedestalBasinParts, Record<string, never>
>({
id: 'pedestal-basin',
defaults: pedestalBasinDefaults,
slots: ['ceramic', 'chrome'],
build: ({ config, random }) => {
const tint = createTinter(random)
const H = Math.min(1.05, Math.max(0.6, config.height))
const W = Math.min(0.9, Math.max(0.35, config.width))
const segments = Math.max(16, Math.round(config.segments))
const halfW = W / 2
const halfD = W * 0.39
const bowlH = H * 0.28
const rimY = H
const bowlBase = rimY - bowlH
// Glaze is nearly white and very slightly warm, and the deviation has to
// stay small: two vases' worth of mottle on a surface this large reads
// as dirt rather than as glaze.
const glaze = tint('ceramic', 0.02, 0.35)
const glazeShade = tint('ceramic', -0.05, 0.35)
/*
* The back exponent, and it is 4 rather than the helper's default 8.
*
* At 8 the back corner turns through most of its angle in about twenty
* degrees of plan, and a ring of 44 points puts three of them in it: the
* corner came out as a pair of broad diagonal facets, which is what the
* critic saw from behind. Four keeps a back that is flat enough to sit
* against plaster -- 0.84 of full depth at the diagonal against a
* circle's 0.71 -- and spreads the turn over enough of the ring for the
* segments to describe it.
*/
const plan = dPlan(segments, 4)
/* --------------------------------------------------------------- bowl */
/**
* The closed profile, read anticlockwise in section.
*
* Two things are going on in every level. `scale` is the multiplier on
* the plan, so 1 is the rim's outer edge; the shelf is the step from 1.00
* to 0.85 at the top, and because it is a FRACTION of the local plan
* extent it comes out 44 mm at the sides and 68 mm at the back, which is
* the relationship the reference has and would have taken two numbers to
* write any other way.
*
* `shift` is what keeps the BACK FLAT. A basin narrows on three sides as
* it descends and does not move at all on the fourth, because the fourth
* is against a wall for its whole height. Scaling alone pulls that plane
* forward with the rest and the bowl ends up floating off the plaster;
* `wall()` pins it by pushing each ring back by exactly what the scale
* took off. Every level below the rim goes through it.
*/
const rimShift = -W * 0.021
const wall = (y: number, scale: number): PlanLevel =>
({ y, scale, shift: halfD * (1 - scale) })
/**
* The interior is NOT pinned to the wall, and that is the whole reason
* the shelf exists.
*
* Pinning both the outer body and the bore to the same back plane makes
* the rim's back width exactly zero -- the two rings meet there -- and
* the tap hole then sits over a gap instead of over ceramic. So the bore
* is a separate shape that happens to live inside the body: its own two
* scales, and one shift that pushes it forward so the shelf comes out
* 67 mm at the back and 43 at the front, which is the reference's.
*/
const dish = (y: number, sx: number, sz: number): PlanLevel =>
({ y, scale: sx, scaleZ: sz, shift: rimShift })
/*
* Written rim-first because that is the order it is easiest to read and
* to measure, and then REVERSED before it is swept.
*
* The direction matters and it is the same rule `latheGeometry` has
* always had: a profile is walked from the bottom upward, because that
* is what puts the outward normal on the outside. Walked the other way
* every face on the body points inward, back-face culling removes the
* near wall, and what the render shows is the inside of the far one --
* which arrives looking like a folded panel with a hole in it, not like
* a winding fault, and costs an afternoon.
*/
const rimFirst: PlanLevel[] = [
{ y: rimY, scale: 1 },
// down the outside: a short vertical face, then the belly, which does
// not close to a point -- it comes down BROAD and the pedestal tucks
// under it. Narrowing it to a neck instead left the pedestal's
// shoulders sticking out from under the bowl in a step.
wall(rimY - bowlH * 0.1, 0.995),
wall(rimY - bowlH * 0.34, 0.95),
wall(rimY - bowlH * 0.58, 0.86),
wall(rimY - bowlH * 0.78, 0.78),
wall(rimY - bowlH * 0.92, 0.7),
/*
* Where the skirt STOPS, and it is fixed by arithmetic rather than
* taste.
*
* A wall-pinned ring's front edge sits at halfD (1 - 2 s), so it
* crosses the centre line at s = 0.5 and every ring below that is
* BEHIND the middle of the basin. The waste is at the dish's centre,
* a little forward of the middle. Ending the skirt at 0.47 therefore
* asked the underside to climb from a ring behind the waste to the
* waste itself, which means travelling FORWARD as it rises: the shell
* folded over on itself and the render showed a creased panel with a
* hole in it under the bowl. Nothing about the underside was wrong.
* The skirt had already swept past the point it had to reach.
*
* At 0.58 the front edge is 36 mm forward of centre, the waste is
* inside that, and every ring of the underside can move backward and
* inward the whole way up, which is what a shell does.
*/
wall(bowlBase, 0.62),
wall(bowlBase - bowlH * 0.04, 0.58),
// The underside: back, in and up to the waste, monotonic in all three.
{ y: bowlBase + bowlH * 0.02, scale: 0.44, scaleZ: 0.4, shift: halfD * 0.34 },
{ y: bowlBase + bowlH * 0.12, scale: 0.28, scaleZ: 0.25, shift: halfD * 0.2 },
// The last ring of the underside is CONCENTRIC with the floor of the
// dish and a little larger, so the shell closes with an even wall all
// round. Left on its own walk toward the wall it finished off-centre
// from the floor above it, and the two met in a pinched point that
// showed under the bowl from behind.
{ y: bowlBase + bowlH * 0.22, scale: 0.32, scaleZ: 0.28, shift: rimShift },
// the floor of the dish, and it is FLAT and broad
//
// Tapered to a near point instead, there was nowhere to put a waste:
// the grating came out wider than the floor it sat in and vanished
// into the wall of the bowl. A basin's floor is about a fifth of its
// width across, which is what a 90 mm waste needs to sit in.
dish(rimY - bowlH * 0.72, 0.28, 0.24),
// and out across the dish, which is shallow: a basin you wash your
// hands in is 130 mm deep, not a bucket
dish(rimY - bowlH * 0.68, 0.4, 0.35),
dish(rimY - bowlH * 0.6, 0.52, 0.45),
dish(rimY - bowlH * 0.42, 0.66, 0.575),
dish(rimY - bowlH * 0.2, 0.79, 0.7),
dish(rimY - bowlH * 0.05, 0.843, 0.752),
dish(rimY, 0.848, 0.757),
// over the rim and back to the start, which closes the solid
{ y: rimY, scale: 1 },
]
const bowl = [...rimFirst].reverse()
const bowlPieces: BufferGeometry[] = [
planSweepGeometry(plan, bowl, [halfW, halfD], [0, 0, 0], glaze, {
colourTop: glaze,
}),
]
/* -------------------------------------------------------- the fittings */
/**
* The tap hole, the overflow and the waste, and NONE of them may be a
* tube with an open end.
*
* All three were built as short cylinders with `capTop: false`, on the
* reasoning that a hole is a shape you look into. It is not, in a
* renderer: an open tube shows you its own inside, its inside faces away
* from you, back-face culling removes it, and what is left is whatever
* was behind the basin. Looking down into the bowl the waste was a
* ragged black tear and the tap hole was nothing at all.
*
* What reads as a hole is a DARK SURFACE YOU CAN SEE -- a capped disc,
* sitting a hair proud so nothing can bury it, with a chamfer round it
* so its edge catches light the way a rolled rim does. Every one of the
* three is now that, and the only difference between them is which way
* they face.
*/
const dark = tint('ceramic', -0.7, 0.12)
/** A dark disc facing +Y, to be turned wherever it is needed. */
const eye = (radius: number, colour = dark): BufferGeometry => latheGeometry([
{ y: 0, radius: radius * 0.86 },
{ y: radius * 0.16, radius },
{ y: radius * 0.22, radius },
], 16, [0, 0, 0], colour, { capBottom: false, capTop: true })
const tapR = W * 0.038
// Halfway across the back shelf: between the bore's back edge and the
// body's, both of which are written above rather than guessed at here.
const boreBack = rimShift + halfD * 0.757
bowlPieces.push(
eye(tapR).translate(0, rimY - tapR * 0.1, (boreBack + halfD) / 2),
)
// The overflow is in the bore's back wall a little under the rim, which
// is a point on the same profile: the ring at 0.8 of the bowl height.
const overflowR = W * 0.023
const overflowY = rimY - bowlH * 0.2
bowlPieces.push(
eye(overflowR)
// MINUS a quarter turn, so it faces INTO the bowl. Turned the other
// way it faces the ceramic behind it and there is nothing to see
// from any angle, which is what the critic reported.
.rotateX(-Math.PI / 2)
.translate(0, overflowY, rimShift + halfD * 0.7 - overflowR * 0.1),
)
/* ----------------------------------------------------------- pedestal */
/**
* The pedestal, and it is NOT under the middle of the bowl.
*
* Its back face is flush with the basin's back face, because both are
* touching the same wall; that puts its centre well behind the bowl's,
* and its front face at roughly the bowl's centre line, which is where
* the reference's is. Centring it under the bowl instead leaves a gap
* behind it that a wall would have to be built into.
*
* It is very slightly waisted and flares at the foot, which is the only
* thing keeping a 0.6 m column from reading as a length of pipe.
*/
const pedHalfW = W * 0.15
// Depth is capped by the skirt, not chosen: the skirt's front edge at
// scale s sits at halfD (1 - 2 s), so anything deeper than s * halfD
// reaches past it and the pedestal's top corner shows under the bowl.
// At the skirt's 0.47 that is 0.183 W, and this is just inside it.
const pedHalfD = W * 0.175
/*
* How far up into the bowl the pedestal goes, and it is a MEASUREMENT
* off the profile above rather than a nice-looking number.
*
* The binding direction is Z, not X. The pedestal's back is on the wall
* plane and so is the bowl's, so what has to be covered is its FRONT
* face at 0.01 W, and the bowl's front at scale s sits at 0.39 W (1-2s):
* it only clears the pedestal once s passes 0.49. At 0.16 of the bowl
* height the ring had closed to 0.29 and the pedestal stood up inside
* the bowl in plain sight. The bowl's outer surface ends at 0.52, so
* this is the one height the joint can be at, and it is not a choice.
*/
const pedTop = bowlBase
const pedestal: PlanLevel[] = [
// A rolled foot rather than a cut end: three rings instead of one, so
// the base has a profile in silhouette instead of an angular corner.
{ y: 0, scale: 0.94 },
{ y: H * 0.004, scale: 0.985 },
{ y: H * 0.014, scale: 1 },
{ y: H * 0.03, scale: 0.982 },
{ y: H * 0.055, scale: 0.94 },
{ y: H * 0.3, scale: 0.925 },
{ y: pedTop - H * 0.05, scale: 0.95 },
{ y: pedTop, scale: 0.99 },
]
const pedestalPieces: BufferGeometry[] = [
planSweepGeometry(plan, pedestal, [pedHalfW, pedHalfD],
[0, 0, halfD - pedHalfD], glazeShade,
{ colourTop: glaze, capBottom: true, capTop: true }),
]
/* -------------------------------------------------------------- waste */
// The chrome grating in the floor of the dish. One disc, and it is the
// only thing in the model that is not glaze.
/*
* The waste, sunk into the floor of the dish rather than sitting on it.
*
* Every fitting on this model failed the same way first time: modelled
* proud, in a colour a shade off the glaze, and therefore invisible in
* a render of a white object under a white light. A drain is a hole with
* a bright ring round it, so this is a dark disc a few millimetres down
* inside a chrome rim, and the darkness is doing the work.
*/
const wasteY = rimY - bowlH * 0.72
const wasteR = W * 0.062
/*
* A chrome ring with a dark centre, and both of them CAPPED.
*
* Built as two open tubes this was the worst of the three: it is looked
* straight down into, so both open ends faced the camera and both were
* culled. What arrived was a torn black shape in the middle of a white
* dish, which is the one thing on this model anybody notices.
*/
const waste = mergeColoured([
latheGeometry([
{ y: wasteY - 0.004, radius: wasteR * 0.92 },
{ y: wasteY + 0.0015, radius: wasteR },
{ y: wasteY + 0.0032, radius: wasteR * 0.94 },
], 20, [0, 0, rimShift], tint('chrome', -0.02, 0.4),
{ capBottom: false, capTop: true }),
eye(wasteR * 0.62, tint('chrome', -0.62, 0.15))
.translate(0, wasteY + 0.0028, rimShift),
])
bakeOcclusion(bowlPieces, { strength: 0.4 })
return {
bowl: { slot: 'ceramic' as const, geometry: smoothNormals(mergeColoured(bowlPieces), 50) },
pedestal: { slot: 'ceramic' as const, geometry: smoothNormals(mergeColoured(pedestalPieces), 50) },
waste: { slot: 'chrome' as const, geometry: smoothNormals(waste, 35) },
}
},
}, overrides)
}