/** * @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 = {}) { return createKitModel< PedestalBasinConfig, 'ceramic' | 'chrome', PedestalBasinParts, Record >({ 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) }