363 lines
17 KiB
TypeScript
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)
|
|
}
|