From 1dc0b9d04aae7f7899e319dcadecae2e6653ba36 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 18:47:29 +0000 Subject: [PATCH] 3D: the inrichting as bodies, per form The 3D view carried walls, slabs and stairs; a fitted-out plan showed as empty rooms. core/furnishing3d.ts derives the prisms each furnishing stands as, built per form from the same stored dimensions and the same fractions the 2D mark is drawn from: a cabinet's plinth, its carcass set back from the front panels and the blad over it; a worktop's bowls sunk through it; a bath's rim around its tub; a table's top on its legs; a rack's posts and shelves. Parts are laid out in the furnishing's own frame and mapped through worldPoint(), so rotation and mirroring follow the placed object exactly as the drawing does. The mesh maps a part's material to a colour, as it does for everything else in the scene. Where a piece starts is now stated once: furnishingZ0() puts it on the floor, or at OVERHEAD_Z0_MM for the wall cabinet and the afzuigkap that hang. The IFC export reads that instead of its own copy of the rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ws8AeAGzQdbr38WsByyBfw --- CLAUDE.md | 18 + scripts/site/pages.ts | 8 +- src/core/furnishing3d.ts | 668 +++++++++++++++++++++++++++++++++++++ src/io/ifc.ts | 25 +- src/render3d/mesh.ts | 43 ++- tests/furnishing3d.test.ts | 270 +++++++++++++++ 6 files changed, 1006 insertions(+), 26 deletions(-) create mode 100644 src/core/furnishing3d.ts create mode 100644 tests/furnishing3d.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index b1fa0a1..7a20fd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,6 +232,21 @@ topology says about itself that does not add up (a dangling port, a link that ne one storey, disagreeing disciplines or service data). It reports; it never enforces, and never repairs. +**`furnishingSolids()`** — the prisms one piece of inrichting stands as, per form: a cabinet's +plinth, its carcass set back from the front panels, and the blad over it; a bath's rim around its +tub; a worktop's bowls sunk through it; a table's top on its legs. Every part is laid out from the +same stored dimensions and the same fractions the 2D mark is drawn from, so the body and the mark +cannot disagree about which way a piece faces or where its bowl sits, and is mapped through +`worldPoint()` so rotation and mirroring follow the placed object. + +**A piece stands where its own form puts it, not where its height field says it ends.** Most forms +fill `[z0, z0 + furnishingHeight()]`, but a base cabinet's stored height is its CARCASS: it stands on +a 150 plinth and carries a 40 blad, which is the 910 worktop height a Dutch kitchen is set out to +(see [furnishing.ts](src/model/furnishing.ts)). The IFC export states the nominal box instead — the +same difference as between a stair's stepped body here and the single flight solid it exports as. +What both read from one place is `furnishingZ0()`: on the floor, or `OVERHEAD_Z0_MM` for the wall +cabinet and the afzuigkap that hang. + ## Adding a symbol Symbols live in `src/render/symbols/.ts` and are aggregated by @@ -459,6 +474,9 @@ before changing one: into a distribution system per groep. No fittings, no risers as elements, no port connectivity — the document holds none of it — and a cable run's section is a placeholder, not a measurement. +- The 3D fit-out is massing, not joinery: a front is one panel in the recess rather than a hung + door, a bowl is a recess rather than a moulded basin, and nothing is cut against the fabric — a + unit drawn through a wall renders through it, the way the plan draws it. - The permit sheet is bouwkundig and carries no services at all. - Wall surface counts the two faces of a wall plus the reveals through it. A reveal is measured over the structural thickness only — cladding makes it deeper, but that is facade work — and a floor diff --git a/scripts/site/pages.ts b/scripts/site/pages.ts index 1e36838..79516f5 100644 --- a/scripts/site/pages.ts +++ b/scripts/site/pages.ts @@ -487,8 +487,9 @@ regels, zonder dat er iets bijgehouden hoeft te worden.

3D-weergave

3 zet de 3D-weergave aan: het hele gebouw, rechtstreeks opgetrokken uit de getekende -muren — met deur- en raamopeningen, vloerplaten met vides en trapgaten, trappen trede voor trede en -elke verdieping op haar eigen peil. Slepen draait het beeld, scrollen zoomt, Shift+slepen verschuift; +muren — met deur- en raamopeningen, vloerplaten met vides en trapgaten, trappen trede voor trede, de +inrichting per stuk (kasten op hun plint en onder hun blad, sanitair met kuip en bak, meubels op hun +poten) en elke verdieping op haar eigen peil. Slepen draait het beeld, scrollen zoomt, Shift+slepen verschuift; F brengt alles in beeld en Esc of nogmaals 3 keert terug naar de plattegrond. Net als de ruimtes is het beeld afgeleid: er staat niets driedimensionaals in het document.

@@ -795,7 +796,8 @@ walls: a room divided in two shows as two rows, with nothing to keep in step.

3D view

3 switches to the 3D view: the whole building, extruded directly from the drawn walls — door and window openings cut out, floor slabs with their voids and stairwells, stairs tread by -tread, every storey at its own level. Dragging orbits, scrolling zooms, Shift+drag pans; F fits everything +tread, the fit-out piece by piece (cabinets on their plinths and under their worktops, fixtures with +their tubs and bowls, furniture on its legs), every storey at its own level. Dragging orbits, scrolling zooms, Shift+drag pans; F fits everything and Esc or 3 again returns to the plan. Like the rooms, the view is derived: nothing three-dimensional is stored in the document.

diff --git a/src/core/furnishing3d.ts b/src/core/furnishing3d.ts new file mode 100644 index 0000000..9655a78 --- /dev/null +++ b/src/core/furnishing3d.ts @@ -0,0 +1,668 @@ +// Derived 3D geometry for the inrichting: the prisms one placed furnishing +// contributes to the scene mesh, built per form so a bath reads as a tub, a +// kitchen run as carcasses under a blad, and a table as a top on legs. +// +// The plan mark is the reference. Every part below is laid out from the same +// stored width, depth and height the 2D mark is drawn from, and from the same +// fractions (src/render/furnishing/) where the mark states one -- a bath's rim, +// a basin's inset, a sofa's back -- so the body and the mark cannot disagree +// about which way a piece faces or where its bowl sits. +// +// Footprints are built in the furnishing's own frame (the frame furnishingBox() +// uses: a wall-mounted piece runs y from 0 at the wall into the room, a +// free-standing one is centred) and mapped through worldPoint(), so rotation +// and mirroring follow the placed object exactly as the drawing does. +// +// z is mm above the furnishing's own storey floor: 0 for a piece that stands on +// it, OVERHEAD_Z0_MM for one that hangs. Mesh winding is normalized by the +// caller; polygon orientation is not significant here. +import { + Furnishing, applianceMark, bedPlaces, furnishingBasins, furnishingDrawers, + furnishingFront, furnishingHeight, furnishingKind, furnishingOverhead, rackBays, showerTray, + toiletCistern, +} from "../model/furnishing"; +import { Vec, v, clipHalfPlane, polygonArea } from "../geometry/vec"; +import { worldPoint } from "./placed"; +import { + cabinetFront, cabinetOutline, furnishingBox, FRONT_THICKNESS, WORKTOP_OVERHANG, +} from "./furnishing"; + +/** + * What a part is made of. The mesh maps these to colours; nothing here states a + * finish. Casework is board and timber, worktop the blad over it, appliance the + * steel of a toestel, sanitary the porcelain of a fixture, soft a mattress or + * upholstery. + */ +export type FitoutMaterial = "casework" | "worktop" | "appliance" | "sanitary" | "soft"; + +/** One vertical prism of a furnishing: a footprint over a z-band. */ +export interface FurnishingPart { + poly: Vec[]; + /** Rings cut out of the footprint -- a tub inside its rim, a bowl in a blad. */ + holes?: Vec[][]; + z0: number; + z1: number; + material: FitoutMaterial; +} + +/** + * Where overhead fit-out starts, mm above the floor: the ordinary underside of + * a bovenkast hung over a worktop, and the height an afzuigkap goes to. A + * furnishing carries no stored mounting height (see model/furnishing.ts), so + * the one figure is stated here and read by the 3D mesh and the IFC export + * alike. + */ +export const OVERHEAD_Z0_MM = 1400; + +/** Where a furnishing's body starts: on the floor, unless it hangs. */ +export const furnishingZ0 = (f: Furnishing): number => + furnishingOverhead(f) ? OVERHEAD_Z0_MM : 0; + +/** Plinth under a cabinet run: the recess a kitchen's toe kick is set out to. */ +const PLINTH_MM = 150; +const PLINTH_SETBACK_MM = 50; +/** Blad over a carcass, and the shadow gap around a front panel. */ +const WORKTOP_MM = 40; +const REVEAL_MM = 4; +/** How deep a bowl sits below the surface it is set in. */ +const SINK_DEPTH_MM = 200; +const BASIN_DEPTH_MM = 140; +/** Plate stock: a shelf, a tray floor, the bottom of a bowl. */ +const PLATE_MM = 25; +/** Square section of a leg, an upright or a shower rail, mm. */ +const POST_MM = 60; +/** Footprints under this area (mm²), and z-bands under this height (mm), are + * dropped rather than emitted. */ +const AREA_EPS = 1; +const H_EPS = 1e-6; +/** Chord step for flattening a bowl's or a round table's arcs, radians. */ +const ARC_CHORD = Math.PI / 16; + +/** A part before it is placed: the same fields, in the furnishing's own frame. */ +type LocalPart = FurnishingPart; + +/** + * The prisms one furnishing contributes to the scene mesh, in world + * millimetres. Degenerate footprints and empty z-bands are dropped rather than + * emitted. + * + * The z-band a piece occupies is its own: most forms fill [z0, z0 + height], + * but a cabinet stands on its plinth and carries its blad, so a 720 carcass + * reaches the 910 worktop height a Dutch kitchen is set out to (see + * model/furnishing.ts). The IFC export states the nominal box instead; that is + * the same difference as between a stair's stepped body here and the single + * flight solid it exports as. + */ +export function furnishingSolids(f: Furnishing): FurnishingPart[] { + const base = furnishingZ0(f); + const out: FurnishingPart[] = []; + for (const p of localParts(f)) { + if (!(p.z1 - p.z0 > H_EPS)) continue; + if (p.poly.length < 3 || Math.abs(polygonArea(p.poly)) <= AREA_EPS) continue; + const holes = (p.holes ?? []) + .filter(h => h.length >= 3 && Math.abs(polygonArea(h)) > AREA_EPS) + .map(h => h.map(q => worldPoint(f, q))); + out.push({ + poly: p.poly.map(q => worldPoint(f, q)), + ...(holes.length > 0 ? { holes } : {}), + z0: base + p.z0, z1: base + p.z1, material: p.material, + }); + } + return out; +} + +function localParts(f: Furnishing): LocalPart[] { + switch (f.form) { + case "cabinet": return cabinetParts(f); + case "appliance": return applianceParts(f); + case "counter": return counterParts(f); + case "toilet": return toiletParts(f); + case "urinal": return urinalParts(f); + case "urinal-trough": return troughParts(f); + case "bidet": return bidetParts(f); + case "basin": return basinParts(f); + case "basin-trough": return basinTroughParts(f); + case "bath": return bathParts(f); + case "shower": return showerParts(f); + case "shower-head": return showerHeadParts(f); + case "bed": return bedParts(f); + case "seat": return seatParts(f); + case "table": return tableParts(f); + case "table-round": return tableRoundParts(f); + case "desk": return deskParts(f); + case "rack": return rackParts(f); + } +} + +/* ── shapes in the furnishing's own frame ─────────────────────────────────── */ + +const rect = (x0: number, y0: number, x1: number, y1: number): Vec[] => + [v(x0, y0), v(x1, y0), v(x1, y1), v(x0, y1)]; + +/** A rectangle pulled in by `d` on every side; empty when nothing is left. */ +function shrink(x0: number, y0: number, x1: number, y1: number, d: number): Vec[] { + if (x1 - x0 <= 2 * d || y1 - y0 <= 2 * d) return []; + return rect(x0 + d, y0 + d, x1 - d, y1 - d); +} + +/** A convex footprint pulled in by `d` on every edge, by clipping against each + * edge's inward offset -- a corner unit's pentagon insets like its box. */ +function insetPoly(poly: Vec[], d: number): Vec[] { + const ccw = polygonArea(poly) > 0; + let out = poly; + for (let i = 0; i < poly.length && out.length >= 3; i++) { + const p = poly[i]!, q = poly[(i + 1) % poly.length]!; + const ex = q.x - p.x, ey = q.y - p.y; + const l = Math.hypot(ex, ey); + if (l < H_EPS) continue; + // Inward is left of travel on a positively wound ring under y-down. + const nx = (ccw ? -ey : ey) / l, ny = (ccw ? ex : -ex) / l; + out = clipHalfPlane(out, v(p.x + nx * d, p.y + ny * d), v(nx, ny)); + } + return out.length >= 3 ? out : []; +} + +/** An ellipse as a polygon, flattened at ARC_CHORD. */ +function ellipse(cx: number, cy: number, rx: number, ry: number): Vec[] { + const steps = Math.max(8, Math.ceil((Math.PI * 2) / ARC_CHORD)); + const out: Vec[] = []; + for (let i = 0; i < steps; i++) { + const a = (i / steps) * Math.PI * 2; + out.push(v(cx + Math.cos(a) * rx, cy + Math.sin(a) * ry)); + } + return out; +} + +/** A rectangle with its corners rounded to `r`, as a polygon -- the plan's + * rounded() mark, flattened. */ +function roundedRect(x0: number, y0: number, x1: number, y1: number, r: number): Vec[] { + const rr = Math.max(0, Math.min(r, (x1 - x0) / 2, (y1 - y0) / 2)); + if (rr <= H_EPS) return rect(x0, y0, x1, y1); + const steps = Math.max(2, Math.ceil(Math.PI / 2 / ARC_CHORD)); + const out: Vec[] = []; + const corners: Array<[number, number, number]> = [ + [x1 - rr, y1 - rr, 0], [x0 + rr, y1 - rr, Math.PI / 2], + [x0 + rr, y0 + rr, Math.PI], [x1 - rr, y0 + rr, Math.PI * 1.5], + ]; + for (const [cx, cy, a0] of corners) { + for (let i = 0; i <= steps; i++) { + const a = a0 + (i / steps) * (Math.PI / 2); + out.push(v(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr)); + } + } + return out; +} + +/* ── cabinetry ────────────────────────────────────────────────────────────── */ + +/** + * A cabinet: the plinth it stands on, the carcass set back from its front, the + * front panels with a shadow gap around each, and the blad where the unit + * carries one. A wall unit hangs and has no plinth; an open unit shows its + * shelves in the recess the front would otherwise fill. + */ +function cabinetParts(f: Furnishing): LocalPart[] { + const outline = cabinetOutline(f); + const h = furnishingHeight(f); + const hangs = furnishingKind(f) === "wall"; + const plinth = hangs ? 0 : PLINTH_MM; + const top = plinth + h; + const out: LocalPart[] = []; + + if (plinth > 0) { + const foot = insetPoly(outline, PLINTH_SETBACK_MM); + if (foot.length >= 3) out.push({ poly: foot, z0: 0, z1: plinth, material: "casework" }); + } + + const front = furnishingFront(f); + const open = front === "open"; + // The carcass stops short of the front so the panels -- or, on an open unit, + // the shelves -- stand in their own plane; without the setback the two + // coincide and the faces z-fight. + const body = setBackFromFront(f, outline, FRONT_THICKNESS); + out.push({ poly: body.length >= 3 ? body : outline, z0: plinth, z1: top, material: "casework" }); + + if (open) { + // Shelves read the unit as open: a plate at each level, standing in the + // depth of the front panel the unit does not have. + const levels = Math.max(1, Math.round(h / 400) - 1); + for (let i = 1; i <= levels; i++) { + const z = plinth + (h * i) / (levels + 1); + out.push({ poly: frontBand(f, 0, 1, 0), z0: z, z1: z + PLATE_MM, material: "casework" }); + } + } else { + for (const p of frontPanels(f, plinth, top)) out.push(p); + } + + if (f.worktop) { + out.push({ + poly: pushFront(f, outline, WORKTOP_OVERHANG), + z0: top, z1: top + WORKTOP_MM, material: "worktop", + }); + } + return out; +} + +/** The unit's open face as a local segment, and the unit normal pointing out + * of it. cabinetFront() states the face; the normal is its left under y-down, + * which for both a straight and a corner unit points into the room. */ +function frontAxis(f: Furnishing): { p: Vec; q: Vec; n: Vec; len: number } { + const [p, q] = cabinetFront(f); + const ex = q.x - p.x, ey = q.y - p.y; + const l = Math.hypot(ex, ey) || 1; + return { p, q, n: v(-ey / l, ex / l), len: l }; +} + +/** The footprint with everything within `d` of the front plane cut away. */ +function setBackFromFront(f: Furnishing, poly: Vec[], d: number): Vec[] { + const a = frontAxis(f); + return clipHalfPlane(poly, v(a.p.x - a.n.x * d, a.p.y - a.n.y * d), v(-a.n.x, -a.n.y)); +} + +/** The footprint with its front edge pushed out by `d`: the blad's oversail. */ +function pushFront(f: Furnishing, poly: Vec[], d: number): Vec[] { + const a = frontAxis(f); + const onFront = (q: Vec): boolean => + Math.abs((q.x - a.p.x) * a.n.x + (q.y - a.p.y) * a.n.y) <= 1e-6; + return poly.map(q => (onFront(q) ? v(q.x + a.n.x * d, q.y + a.n.y * d) : q)); +} + +/** + * A band across the front, from `t0` to `t1` along the open face and `inset` + * from either end: where a panel or a shelf stands. The band runs back into the + * carcass by the front's own thickness. + */ +function frontBand(f: Furnishing, t0: number, t1: number, inset: number): Vec[] { + const a = frontAxis(f); + const s0 = t0 + inset / a.len, s1 = t1 - inset / a.len; + if (!(s1 > s0)) return []; + const at = (t: number): Vec => v(a.p.x + (a.q.x - a.p.x) * t, a.p.y + (a.q.y - a.p.y) * t); + const b0 = at(s0), b1 = at(s1); + const dx = -a.n.x * FRONT_THICKNESS, dy = -a.n.y * FRONT_THICKNESS; + return [b0, b1, v(b1.x + dx, b1.y + dy), v(b0.x + dx, b0.y + dy)]; +} + +/** The leaves the front is divided into: one door, a pair, or a bank of + * drawers, each set in by a shadow gap. A sliding front reads as its pair of + * panels. */ +function frontPanels(f: Furnishing, z0: number, z1: number): LocalPart[] { + const front = furnishingFront(f); + const out: LocalPart[] = []; + const panel = (t0: number, t1: number, a: number, b: number): void => { + const poly = frontBand(f, t0, t1, REVEAL_MM); + if (poly.length >= 3 && b - a > H_EPS) out.push({ poly, z0: a, z1: b, material: "casework" }); + }; + if (front === "drawers") { + const n = furnishingDrawers(f); + const each = (z1 - z0) / n; + for (let i = 0; i < n; i++) { + panel(0, 1, z0 + i * each + REVEAL_MM, z0 + (i + 1) * each - REVEAL_MM); + } + return out; + } + const leaves = front === "door" ? 1 : 2; + for (let i = 0; i < leaves; i++) { + panel(i / leaves, (i + 1) / leaves, z0 + REVEAL_MM, z1 - REVEAL_MM); + } + return out; +} + +/* ── kitchen ──────────────────────────────────────────────────────────────── */ + +/** + * A fixed appliance: the toestel's box. An afzuigkap is the exception -- it + * hangs, so it is the canopy with its chimney against the wall rather than a + * block on the floor. + */ +function applianceParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + if (applianceMark(f) !== "hood") { + return [{ poly: rect(b.x0, b.y0, b.x1, b.y1), z0: 0, z1: h, material: "appliance" }]; + } + const canopy = Math.min(h * 0.25, 200); + const flue = rect(-f.width * 0.15, b.y0, f.width * 0.15, b.y0 + f.depth * 0.3); + return [ + { poly: rect(b.x0, b.y0, b.x1, b.y1), z0: 0, z1: canopy, material: "appliance" }, + { poly: flue, z0: canopy, z1: h, material: "appliance" }, + ]; +} + +/** + * The aanrecht: carcass under a blad, with the bowls sunk through both. The + * bowl footprints are counterMark()'s, so the run's drainer stays on the hand + * the drawing puts it. + */ +function counterParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + const box = rect(b.x0, b.y0, b.x1, b.y1); + const bowls = counterBowls(f); + const deck = Math.max(PLINTH_MM, h - WORKTOP_MM); + const out: LocalPart[] = [ + { poly: insetPoly(box, PLINTH_SETBACK_MM), z0: 0, z1: PLINTH_MM, material: "casework" }, + { poly: box, holes: bowls, z0: PLINTH_MM, z1: deck, material: "casework" }, + { poly: box, holes: bowls, z0: deck, z1: h, material: "worktop" }, + ]; + for (const bowl of bowls) { + const floor = Math.max(0, deck - SINK_DEPTH_MM); + out.push({ poly: bowl, z0: floor, z1: floor + PLATE_MM, material: "sanitary" }); + } + return out; +} + +/** The bowls in a worktop run, in local mm -- counterMark()'s own layout. */ +function counterBowls(f: Furnishing): Vec[][] { + const w = f.width, d = f.depth; + const b = furnishingBox(f); + const n = furnishingBasins(f); + const inset = Math.min(d * 0.16, w * 0.1); + const bowlD = d - 2 * inset; + const bowlW = Math.min(bowlD * 1.05, (w - inset * (n + 1)) / n); + const out: Vec[][] = []; + for (let i = 0; i < n; i++) { + const x0 = b.x0 + inset + i * (bowlW + inset); + if (bowlW > 0 && bowlD > 0) out.push(rect(x0, b.y0 + inset, x0 + bowlW, b.y0 + inset + bowlD)); + } + return out; +} + +/* ── sanitair ─────────────────────────────────────────────────────────────── */ + +/** + * A toilet: the cistern against the wall and the bowl in front of it, at the + * depths toiletMark() divides the fixture into. An ingebouwde stortbak is a + * duct the bowl hangs off; an exposed one is the close-coupled box behind it. + * Grab rails are the invalidentoilet's clearance made visible. + */ +function toiletParts(f: Furnishing): LocalPart[] { + const w = f.width, d = f.depth; + const b = furnishingBox(f); + const h = furnishingHeight(f); + const concealed = toiletCistern(f) === "concealed"; + const band = d * (concealed ? 0.194 : 0.277); + const bowlW = Math.min(w * 0.9, 400); + const out: LocalPart[] = [ + { + poly: rect(b.x0, b.y0, b.x1, b.y0 + band), + z0: 0, z1: concealed ? 1050 : Math.max(h, 800), material: "sanitary", + }, + { + poly: roundedRect(-bowlW / 2, b.y0 + band, bowlW / 2, b.y1, bowlW / 2), + // A wall-hung pan on a duct floats; a close-coupled one stands. + z0: concealed ? Math.max(0, h - 350) : 0, z1: h, material: "sanitary", + }, + ]; + if (f.rails) { + const x = w / 2 - w * 0.045; + for (const sx of [-x, x]) { + out.push({ + poly: rect(sx - POST_MM / 2, b.y0 + d * 0.08, sx + POST_MM / 2, b.y1 - d * 0.067), + z0: 700, z1: 700 + POST_MM, material: "sanitary", + }); + } + } + return out; +} + +/** Wandurinoir: the bowl hung off the wall, flat at the wall and semicircular + * into the room -- urinalMark()'s outline. */ +function urinalParts(f: Furnishing): LocalPart[] { + const w = f.width, d = f.depth; + const h = furnishingHeight(f); + const flat = d * 0.265; + const r = Math.min(w / 2, d - flat); + const poly: Vec[] = [v(-r, 0), v(r, 0), v(r, flat)]; + const steps = Math.max(4, Math.ceil(Math.PI / ARC_CHORD)); + for (let i = 1; i < steps; i++) { + const a = (i / steps) * Math.PI; + poly.push(v(Math.cos(a) * r, flat + Math.sin(a) * r)); + } + poly.push(v(-r, flat)); + return [{ poly, z0: Math.max(0, h - 500), z1: h, material: "sanitary" }]; +} + +/** Standurinoir: the trough, hollow inside its rim. */ +function troughParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + const box = rect(b.x0, b.y0, b.x1, b.y1); + const inner = shrink(b.x0, b.y0, b.x1, b.y1, 80); + return [ + { poly: box, ...(inner.length ? { holes: [inner] } : {}), z0: 0, z1: h, material: "sanitary" }, + { poly: inner, z0: 0, z1: Math.max(PLATE_MM, h * 0.4), material: "sanitary" }, + ]; +} + +/** Bidet: the rounded pedestal bidetMark() draws. */ +function bidetParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + return [{ + poly: roundedRect(b.x0, b.y0, b.x1, b.y1, f.width * 0.395), + z0: 0, z1: furnishingHeight(f), material: "sanitary", + }]; +} + +/** Wastafel: the top with its bowls sunk into it, hung off the wall. */ +function basinParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + const n = furnishingBasins(f); + const each = f.width / n; + const inset = Math.min(60, each / 6, f.depth / 6); + const bowls: Vec[][] = []; + for (let i = 0; i < n; i++) { + const x0 = b.x0 + i * each; + const bowl = shrink(x0, b.y0, x0 + each, b.y1, inset); + if (bowl.length) bowls.push(bowl); + } + return basinDeck(rect(b.x0, b.y0, b.x1, b.y1), bowls, h, BASIN_DEPTH_MM); +} + +/** Wastafel, meervoudig: one trough over the length of the run. */ +function basinTroughParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + const bowl = shrink(b.x0, b.y0, b.x1, b.y1, Math.min(80, f.depth / 6)); + return basinDeck(rect(b.x0, b.y0, b.x1, b.y1), bowl.length ? [bowl] : [], h, BASIN_DEPTH_MM + 60); +} + +/** The slab a basin's bowls are sunk through, and the floor of each bowl. */ +function basinDeck(top: Vec[], bowls: Vec[][], h: number, depth: number): LocalPart[] { + const deckBottom = Math.max(0, h - depth - PLATE_MM); + const out: LocalPart[] = [{ + poly: top, ...(bowls.length ? { holes: bowls } : {}), + z0: Math.max(0, h - PLATE_MM * 2), z1: h, material: "sanitary", + }]; + for (const bowl of bowls) { + out.push({ poly: bowl, z0: deckBottom, z1: deckBottom + PLATE_MM, material: "sanitary" }); + } + return out; +} + +/** Bad: the rim, and the tub sunk inside it -- bathMark()'s own rim and inner + * radius, so the body is the mark given depth. */ +function bathParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + const rim = Math.min(80, f.depth * 0.107); + const inner = roundedRect( + b.x0 + rim, b.y0 + rim, b.x1 - rim, b.y1 - rim, + Math.min(150, (f.depth - 2 * rim) / 2), + ); + const floor = Math.max(0, h - 400); + return [ + { poly: rect(b.x0, b.y0, b.x1, b.y1), holes: [inner], z0: 0, z1: h, material: "sanitary" }, + { poly: inner, z0: 0, z1: floor + PLATE_MM, material: "sanitary" }, + ]; +} + +/** Douche: the wet area, and where there is a tray, its rim around a sunk + * floor. Without one the fixture is the bare area it drains. */ +function showerParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + const box = rect(b.x0, b.y0, b.x1, b.y1); + if (showerTray(f) === "none") { + return [{ poly: box, z0: 0, z1: Math.max(PLATE_MM, h), material: "sanitary" }]; + } + const rim = Math.min(60, f.width * 0.067, f.depth * 0.067); + const inner = shrink(b.x0, b.y0, b.x1, b.y1, rim); + return [ + { poly: box, ...(inner.length ? { holes: [inner] } : {}), z0: 0, z1: h, material: "sanitary" }, + { poly: inner, z0: 0, z1: Math.max(PLATE_MM, h * 0.4), material: "sanitary" }, + ]; +} + +/** Douche: the head on its rail, at the height the fixture states. */ +function showerHeadParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + const r = Math.min(f.width, f.depth) * 0.225; + const cy = b.y0 + f.depth / 2; + return [ + { + poly: rect(-POST_MM / 2, b.y0, POST_MM / 2, b.y0 + POST_MM), + z0: Math.max(0, h - 1100), z1: h, material: "sanitary", + }, + { + poly: rect(-POST_MM / 3, b.y0, POST_MM / 3, cy), + z0: Math.max(0, h - 60), z1: Math.max(0, h - 20), material: "sanitary", + }, + { poly: ellipse(0, cy, r, r), z0: Math.max(0, h - 60), z1: h, material: "sanitary" }, + ]; +} + +/* ── meubels ──────────────────────────────────────────────────────────────── */ + +/** Bed: the base, the mattress on it and a pillow per place at the head -- + * bedMark()'s layout, with the head at -y as the mark draws it. */ +function bedParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const w = f.width, d = f.depth; + const h = furnishingHeight(f); + const mattress = Math.min(160, h * 0.4); + const pillowH = Math.min(60, mattress / 2); + const baseTop = Math.max(0, h - mattress); + const out: LocalPart[] = [ + { poly: shrink(b.x0, b.y0, b.x1, b.y1, 20), z0: 0, z1: baseTop, material: "casework" }, + { poly: rect(b.x0, b.y0, b.x1, b.y1), z0: baseTop, z1: h - pillowH, material: "soft" }, + ]; + const places = bedPlaces(f); + const gap = w * (places === 2 ? 0.056 : 0.111); + const pillowW = (w - gap * (places + 1)) / places; + const pillowD = d * 0.175; + for (let i = 0; i < places; i++) { + const x0 = b.x0 + gap + i * (pillowW + gap); + out.push({ + poly: rect(x0, b.y0 + d * 0.03, x0 + pillowW, b.y0 + d * 0.03 + pillowD), + z0: h - pillowH, z1: h, material: "soft", + }); + } + return out; +} + +/** Zitmeubel: the back along the far side, an arm at each end and the seat + * between them -- seatMark()'s divisions, given height. */ +function seatParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const w = f.width, d = f.depth; + const h = furnishingHeight(f); + const backY = b.y0 + d * 0.222; + const armX = Math.max(0, w / 2 - Math.min(200, w * 0.1)); + return [ + { poly: rect(b.x0, b.y0, b.x1, backY), z0: 0, z1: h, material: "soft" }, + { poly: rect(b.x0, backY, -armX, b.y1), z0: 0, z1: h * 0.7, material: "soft" }, + { poly: rect(armX, backY, b.x1, b.y1), z0: 0, z1: h * 0.7, material: "soft" }, + { poly: rect(-armX, backY, armX, b.y1), z0: 0, z1: h * 0.55, material: "soft" }, + ]; +} + +/** Tafel: the top on four legs. */ +function tableParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + const topBottom = Math.max(0, h - WORKTOP_MM); + const out: LocalPart[] = [ + { poly: rect(b.x0, b.y0, b.x1, b.y1), z0: topBottom, z1: h, material: "casework" }, + ]; + for (const p of legs(b.x0, b.y0, b.x1, b.y1, POST_MM)) { + out.push({ poly: p, z0: 0, z1: topBottom, material: "casework" }); + } + return out; +} + +/** Ronde tafel: the top on a central column and its foot. */ +function tableRoundParts(f: Furnishing): LocalPart[] { + const h = furnishingHeight(f); + const rx = f.width / 2, ry = f.depth / 2; + const topBottom = Math.max(0, h - WORKTOP_MM); + const unit = Math.min(rx, ry); + return [ + { poly: ellipse(0, 0, rx, ry), z0: topBottom, z1: h, material: "casework" }, + { poly: ellipse(0, 0, unit * 0.18, unit * 0.18), z0: 0, z1: topBottom, material: "casework" }, + { poly: ellipse(0, 0, unit * 0.5, unit * 0.5), z0: 0, z1: PLATE_MM, material: "casework" }, + ]; +} + +/** Bureau: the top, the pedestal under it at the hand the piece is mirrored + * to, and a leg at the open end. */ +function deskParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const w = f.width, d = f.depth; + const h = furnishingHeight(f); + const topBottom = Math.max(0, h - WORKTOP_MM); + const out: LocalPart[] = [ + { poly: rect(b.x0, b.y0, b.x1, b.y1), z0: topBottom, z1: h, material: "casework" }, + { + poly: rect(w * 0.036, -d * 0.286, w * 0.393, d * 0.286), + z0: 0, z1: topBottom, material: "casework", + }, + ]; + for (const y of [b.y0, b.y1 - POST_MM]) { + out.push({ + poly: rect(b.x0 + POST_MM, y, b.x0 + 2 * POST_MM, y + POST_MM), + z0: 0, z1: topBottom, material: "casework", + }); + } + return out; +} + +/** Stellage: an upright at each bay division, front and back, and a shelf at + * each level. rackBays() reads the bay count off the width. */ +function rackParts(f: Furnishing): LocalPart[] { + const b = furnishingBox(f); + const h = furnishingHeight(f); + const bays = rackBays(f); + const out: LocalPart[] = []; + for (let i = 0; i <= bays; i++) { + const x = b.x0 + (f.width * i) / bays; + const x0 = Math.min(Math.max(b.x0, x - POST_MM / 2), b.x1 - POST_MM); + for (const y of [b.y0, b.y1 - POST_MM]) { + out.push({ poly: rect(x0, y, x0 + POST_MM, y + POST_MM), z0: 0, z1: h, material: "casework" }); + } + } + const levels = Math.max(2, Math.round(h / 400)); + for (let i = 1; i <= levels; i++) { + const z = (h * i) / levels; + out.push({ + poly: rect(b.x0, b.y0, b.x1, b.y1), + z0: Math.max(0, z - PLATE_MM), z1: z, material: "casework", + }); + } + return out; +} + +/** The four corner legs of a box footprint. */ +function legs(x0: number, y0: number, x1: number, y1: number, size: number): Vec[][] { + const inset = size; + if (x1 - x0 < 2 * (inset + size) || y1 - y0 < 2 * (inset + size)) { + return [rect(x0, y0, x1, y1)]; + } + const out: Vec[][] = []; + for (const x of [x0 + inset, x1 - inset - size]) { + for (const y of [y0 + inset, y1 - inset - size]) out.push(rect(x, y, x + size, y + size)); + } + return out; +} + diff --git a/src/io/ifc.ts b/src/io/ifc.ts index c01904d..1fcac64 100644 --- a/src/io/ifc.ts +++ b/src/io/ifc.ts @@ -70,8 +70,9 @@ import { floorSolids, videHole } from "../core/solids"; import { detectRooms, roomSize, sizeLabel, Room, roomArea } from "../core/rooms"; import { resolveStair, stairBox } from "../core/stair"; import { StairKind, stairParams } from "../model/stair"; -import { furnishingHeight, furnishingClass, furnishingKind, type Furnishing, type FurnishingClass } from "../model/furnishing"; +import { furnishingHeight, furnishingClass, type FurnishingClass } from "../model/furnishing"; import { furnishingBox } from "../core/furnishing"; +import { furnishingZ0 } from "../core/furnishing3d"; import { getSymbol, SymbolDef, SymbolCategory } from "../render/symbols"; import { Placed, LocalBox, worldPoint, symbolFootprintCorners } from "../core/placed"; import { Vec, v, add, sub, scale, norm, perp, len, mid, pointInPolygon } from "../geometry/vec"; @@ -668,19 +669,6 @@ export function toIfc(doc: PlanDoc, nowMs = Date.now()): string { * real door or window's actual thickness (out of scope for this export). */ const FILLER_DEPTH_MM = 50; - /** Where overhead fit-out starts, mm above the floor — the ordinary - * underside of a bovenkast hung over a worktop, and the height an - * afzuigkap goes to. A furnishing carries no stored mounting height (see - * model/furnishing.ts); out of scope to make this one authored rather than - * a constant. */ - const WALL_CABINET_Z0_MM = 1400; - - /** Where a furnishing's box starts: on the floor, unless it hangs. */ - const furnishingZ0 = (fn: Furnishing): number => - (fn.form === "cabinet" && furnishingKind(fn) === "wall") - || (fn.form === "appliance" && fn.mark === "hood") - ? WALL_CABINET_Z0_MM : 0; - /** Nominal box height for a symbol's placeholder extrusion, mm. States * where a symbol sits, not a manufacturer's actual product height. */ const SYMBOL_NOMINAL_HEIGHT_MM = 500; @@ -1059,10 +1047,11 @@ export function toIfc(doc: PlanDoc, nowMs = Date.now()): string { // ── furnishings: one element each, class from the trade ───────────────── // // Box geometry over the piece's own vertical range: most stand on the - // floor, a wall cabinet hangs at WALL_CABINET_Z0_MM. No carcass, front, - // worktop, hinge or bowl is modelled — this states where the piece is and - // how tall it stands, the way core/furnishing.ts's own furnishingBox() - // does for the plan drawing. + // floor, a piece that hangs starts at core/furnishing3d.ts's + // OVERHEAD_Z0_MM, the one figure the 3D view places it at too. No carcass, + // front, worktop, hinge or bowl is modelled — this states where the piece + // is and how tall it stands, the way core/furnishing.ts's own + // furnishingBox() does for the plan drawing. for (const fn of furnishingsOf(floor)) { const z0 = furnishingZ0(fn); const shape = bodyShape([extrudedSolid(boxQuad(fn, furnishingBox(fn)), z0, z0 + furnishingHeight(fn))]); diff --git a/src/render3d/mesh.ts b/src/render3d/mesh.ts index bf7fbe1..d28415b 100644 --- a/src/render3d/mesh.ts +++ b/src/render3d/mesh.ts @@ -5,9 +5,10 @@ // z is height above Peil (mm, positive up); a renderer maps axes itself. Pure // and uncached like the rest of the derived geometry — callers cache against // the store revision. -import { PlanDoc, Floor, Id, floorElevation, floorHeight, stairsOf } from "../model/doc"; +import { PlanDoc, Floor, Id, floorElevation, floorHeight, stairsOf, furnishingsOf } from "../model/doc"; import { floorSolids, FloorSolids } from "../core/solids"; import { stairSteps, StairStep } from "../core/stair3d"; +import { furnishingSolids, type FitoutMaterial } from "../core/furnishing3d"; import { Vec, v, dist, polygonArea, mid, norm, add, sub, scale, clipHalfPlane } from "../geometry/vec"; import { triangulatePolygon, triangulateWithHoles } from "./triangulate"; @@ -49,6 +50,27 @@ export const DOOR_COLOR: Rgb = [0.78, 0.71, 0.6]; export const GLASS_COLOR: Rgb = [0.875, 0.91, 0.933]; /** Sandwich-panel wall bodies: the 2D panelFill's warm band, opaque. */ export const PANEL_COLOR: Rgb = [0.906, 0.882, 0.827]; +/** Fit-out casework: cabinet carcasses, fronts, table tops, shelving. */ +export const CASEWORK_COLOR: Rgb = [0.74, 0.65, 0.52]; +/** The blad over a run: darker than the casework it rests on. */ +export const WORKTOP_COLOR: Rgb = [0.42, 0.41, 0.4]; +/** A fixed appliance: steel, cool against the timber beside it. */ +export const APPLIANCE_COLOR: Rgb = [0.6, 0.63, 0.66]; +/** Sanitary fixtures: porcelain, the lightest thing in the scene. */ +export const SANITARY_COLOR: Rgb = [0.97, 0.97, 0.96]; +/** Mattresses and upholstery. */ +export const SOFT_COLOR: Rgb = [0.68, 0.7, 0.74]; + +/** What each fit-out material is drawn in. core/furnishing3d.ts says what a + * part is made of; the colour is this module's, as it is for everything else + * in the scene. */ +const FITOUT_COLORS: Record = { + casework: CASEWORK_COLOR, + worktop: WORKTOP_COLOR, + appliance: APPLIANCE_COLOR, + sanitary: SANITARY_COLOR, + soft: SOFT_COLOR, +}; /** Door leaf thickness, mm. */ const DOOR_LEAF_MM = 40; @@ -100,10 +122,11 @@ interface MeshAcc { * The building as one triangle soup: per storey, wall prisms (with the bands a * window's borstwering and an opening's lintel put back) and the junction * wedges between them, door leaves and window panes in the voids, the slab and - * terrace plate with their holes, and each stair as the steps core/stair3d.ts - * derives from its parameters. A wall a flight crosses is cut to a shadow gap - * under the steps within the crossing interval (STAIR_CLEAR_MM). Spaces are - * room volumes, not built fabric, and are not rendered. + * terrace plate with their holes, each stair as the steps core/stair3d.ts + * derives from its parameters, and the inrichting as the parts + * core/furnishing3d.ts derives per form. A wall a flight crosses is cut to a + * shadow gap under the steps within the crossing interval (STAIR_CLEAR_MM). + * Spaces are room volumes, not built fabric, and are not rendered. * * `hiddenFloors` drops whole storeys by floor id — the 3D view's per-storey * toggle. A hidden storey also withholds its plate, so the storey below is @@ -193,6 +216,16 @@ export function buildSceneMesh(doc: PlanDoc, hiddenFloors?: ReadonlySet): Me emitPrism(acc, step.poly, [], elev + step.z0, elev + seat(step.z1), STAIR_COLOR); } } + + // The inrichting: each piece as the parts its own form stands as. Nothing + // cuts a piece against the fabric, so a unit drawn through a wall renders + // through it. + for (const fn of furnishingsOf(f)) { + for (const part of furnishingSolids(fn)) { + emitPrism(acc, part.poly, part.holes ?? [], elev + part.z0, elev + part.z1, + FITOUT_COLORS[part.material]); + } + } } return { diff --git a/tests/furnishing3d.test.ts b/tests/furnishing3d.test.ts new file mode 100644 index 0000000..6795f10 --- /dev/null +++ b/tests/furnishing3d.test.ts @@ -0,0 +1,270 @@ +// Fit-out solids: the prisms each furnishing form contributes to the 3D scene, +// where they stand, and that the scene mesh carries them at their storey's +// elevation. +import { + Furnishing, FurnishingForm, FURNISHING_FORMS, FURNISHING_PRESETS, furnishingDefaults, + furnishingOverhead, +} from "../src/model/furnishing"; +import { furnishingSolids, furnishingZ0, FurnishingPart, OVERHEAD_Z0_MM } from "../src/core/furnishing3d"; +import { furnishingBox, WORKTOP_OVERHANG } from "../src/core/furnishing"; +import { localPoint } from "../src/core/placed"; +import { buildSceneMesh, CASEWORK_COLOR, WORKTOP_COLOR, SANITARY_COLOR } from "../src/render3d/mesh"; +import { polygonArea, v } from "../src/geometry/vec"; +import { emptyDoc, newId, floorElevation, type Floor, type PlanDoc } from "../src/model/doc"; + +let failures = 0; +function check(name: string, cond: boolean, detail = ""): void { + if (!cond) { failures++; console.error(`FAIL ${name} ${detail}`); } + else console.log(`ok ${name}`); +} +function near(a: number, b: number, tol = 1): boolean { return Math.abs(a - b) <= tol; } +function nearRel(a: number, b: number, rel = 1e-6): boolean { + return Math.abs(a - b) <= Math.max(1, Math.abs(b) * rel); +} + +const mk = (form: FurnishingForm, over: Partial = {}): Furnishing => { + const d = furnishingDefaults(form); + return { + id: newId("fit"), form, x: 0, y: 0, rotation: 0, + width: d.width, depth: d.depth, height: d.height, ...over, + }; +}; + +/** Footprint area net of the rings cut out of it, mm². */ +const netArea = (p: FurnishingPart): number => + Math.abs(polygonArea(p.poly)) - (p.holes ?? []).reduce((s, h) => s + Math.abs(polygonArea(h)), 0); + +/** The overall z-band a piece's parts occupy. */ +const extentOf = (f: Furnishing): { z0: number; z1: number } => { + const parts = furnishingSolids(f); + return { + z0: Math.min(...parts.map(p => p.z0)), + z1: Math.max(...parts.map(p => p.z1)), + }; +}; + +const volumeOfParts = (parts: FurnishingPart[]): number => + parts.reduce((s, p) => s + netArea(p) * (p.z1 - p.z0), 0); + +// ── every form yields a body ──────────────────────────────────────────────── + +for (const form of FURNISHING_FORMS) { + const f = mk(form); + const parts = furnishingSolids(f); + check(`${form} yields at least one prism`, parts.length > 0); + check(`${form} prisms have real footprints and bands`, + parts.every(p => p.poly.length >= 3 && Math.abs(polygonArea(p.poly)) > 1 && p.z1 > p.z0)); + check(`${form} stands on or above the floor`, parts.every(p => p.z0 >= -1e-9)); + check(`${form} holes are real rings inside their part`, + parts.every(p => (p.holes ?? []).every(h => h.length >= 3 + && Math.abs(polygonArea(h)) > 1 + && Math.abs(polygonArea(h)) < Math.abs(polygonArea(p.poly))))); +} + +// Every preset the palette offers is placeable, so every preset has a body. +for (const p of FURNISHING_PRESETS) { + const f = mk(p.form, { width: p.width, depth: p.depth, height: p.height, kind: p.kind, + front: p.front, corner: p.corner, worktop: p.worktop, mark: p.mark, cistern: p.cistern, + rails: p.rails, basins: p.basins, tray: p.tray }); + check(`preset ${p.id} yields a body`, furnishingSolids(f).length > 0); +} + +// ── the plan footprint bounds the body ────────────────────────────────────── +// +// A part may not wander outside the footprint the plan draws, save for the +// blad's stated oversail. Checked in the piece's own frame, so it covers the +// rotated and mirrored cases too. +for (const form of FURNISHING_FORMS) { + for (const over of [{}, { rotation: 0.7, x: 1234, y: -567 }, { rotation: -2.1, mirrored: true }]) { + const f = mk(form, { worktop: form === "cabinet", ...over }); + const b = furnishingBox(f); + const slack = form === "cabinet" ? WORKTOP_OVERHANG + 1e-6 : 1e-6; + const inside = furnishingSolids(f).every(p => [p.poly, ...(p.holes ?? [])].every(ring => + ring.every(q => { + const l = localPoint(f, q); + return l.x >= b.x0 - slack && l.x <= b.x1 + slack + && l.y >= b.y0 - slack && l.y <= b.y1 + slack; + }))); + check(`${form} stays inside its footprint (${JSON.stringify(over)})`, inside); + } +} + +// A placed piece is its own body moved: turning and mirroring go through +// worldPoint(), so the parts are congruent to the unplaced ones. +{ + const at = { x: 4000, y: -2500, rotation: 1.1, mirrored: true }; + const flat = mk("bath"); + const placed = mk("bath", at); + const a = furnishingSolids(flat), z = furnishingSolids(placed); + check("placing a furnishing keeps its part count", a.length === z.length); + check("placing a furnishing keeps every footprint area", + a.every((p, i) => nearRel(Math.abs(polygonArea(p.poly)), Math.abs(polygonArea(z[i]!.poly))))); + check("placing a furnishing keeps every z-band", + a.every((p, i) => near(p.z0, z[i]!.z0, 1e-6) && near(p.z1, z[i]!.z1, 1e-6))); +} + +// ── where a piece starts ──────────────────────────────────────────────────── + +for (const form of FURNISHING_FORMS) { + const f = mk(form); + check(`${form} starts on the floor unless it hangs`, + furnishingZ0(f) === (furnishingOverhead(f) ? OVERHEAD_Z0_MM : 0)); +} + +{ + const wall = mk("cabinet", { kind: "wall", height: 700, depth: 350 }); + const hood = mk("appliance", { mark: "hood", height: 850, depth: 500 }); + check("a wall cabinet hangs at OVERHEAD_Z0_MM", + furnishingZ0(wall) === OVERHEAD_Z0_MM && furnishingSolids(wall).every(p => p.z0 >= OVERHEAD_Z0_MM)); + check("an afzuigkap hangs at the same height", + furnishingSolids(hood).every(p => p.z0 >= OVERHEAD_Z0_MM)); + const e = extentOf(wall); + check("a wall cabinet's carcass reaches its own height above the hang", + near(e.z0, OVERHEAD_Z0_MM) && near(e.z1, OVERHEAD_Z0_MM + 700), JSON.stringify(e)); + check("a wall unit carries no plinth", + furnishingSolids(wall).every(p => p.z0 >= OVERHEAD_Z0_MM - 1e-9)); +} + +// ── the cabinet stack: plinth, carcass, front, blad ───────────────────────── +{ + const base = mk("cabinet", { kind: "base", width: 600, depth: 600, height: 720, worktop: true }); + const parts = furnishingSolids(base); + const e = extentOf(base); + check("a base unit stands on its plinth and carries its blad", + near(e.z0, 0) && near(e.z1, 150 + 720 + 40), JSON.stringify(e)); + const plinth = parts.find(p => p.z0 === 0)!; + const carcass = parts.find(p => near(p.z0, 150) && p.material === "casework")!; + check("the plinth is set back from the carcass", + Math.abs(polygonArea(plinth.poly)) < Math.abs(polygonArea(carcass.poly)), + `${Math.abs(polygonArea(plinth.poly))} vs ${Math.abs(polygonArea(carcass.poly))}`); + const blad = parts.filter(p => p.material === "worktop"); + check("exactly one blad, over the carcass", blad.length === 1 && near(blad[0]!.z0, 870)); + check("the blad oversails the carcass", + Math.abs(polygonArea(blad[0]!.poly)) > Math.abs(polygonArea(carcass.poly))); + check("the blad's oversail is the plan's overhang", + near(Math.abs(polygonArea(blad[0]!.poly)) - Math.abs(polygonArea(carcass.poly)), + 600 * (WORKTOP_OVERHANG + 20)), String(Math.abs(polygonArea(blad[0]!.poly)))); + + const drawers = furnishingSolids( + mk("cabinet", { kind: "base", front: "drawers", drawers: 4, worktop: true })); + const doors = furnishingSolids(mk("cabinet", { kind: "base", front: "door", worktop: true })); + const open = furnishingSolids(mk("cabinet", { kind: "base", front: "open", worktop: true })); + check("a bank of drawers is one panel per drawer", drawers.length === doors.length + 3, + `${drawers.length} vs ${doors.length}`); + // What stands in the recess left in front of the carcass: a leaf over the + // whole opening on a closed unit, a plate per shelf on an open one. + const inRecess = (ps: FurnishingPart[]): FurnishingPart[] => + ps.filter(p => p.material === "casework" && Math.abs(polygonArea(p.poly)) < 0.2 * 600 * 580); + check("a door fills the recess in front of the carcass", + inRecess(doors).length === 1 && inRecess(doors)[0]!.z1 - inRecess(doors)[0]!.z0 > 700, + JSON.stringify(inRecess(doors).map(p => p.z1 - p.z0))); + check("an open unit shows shelf plates instead", + inRecess(open).length >= 1 && inRecess(open).every(p => p.z1 - p.z0 < 100), + JSON.stringify(inRecess(open).map(p => p.z1 - p.z0))); + const dbl = furnishingSolids(mk("cabinet", { kind: "base", front: "double", worktop: true })); + check("a pair of doors is two panels", dbl.length === doors.length + 1); + + const corner = mk("cabinet", { kind: "base", corner: true, width: 900, depth: 900, worktop: true }); + const cp = furnishingSolids(corner); + check("a corner unit's carcass is the pentagon the plan draws", + cp.some(p => p.material === "casework" && p.poly.length === 5)); +} + +// ── fixtures are hollow where the plan draws them hollow ──────────────────── +{ + const bath = mk("bath", { width: 1700, depth: 750, height: 600 }); + const parts = furnishingSolids(bath); + const rim = parts.find(p => (p.holes ?? []).length > 0); + check("a bath's rim is cut by its tub", rim !== undefined); + check("the tub is open above its floor", + volumeOfParts(parts) < 1700 * 750 * 600, + `${volumeOfParts(parts)} vs ${1700 * 750 * 600}`); + + const counter = mk("counter", { width: 1200, depth: 600, height: 910, basins: 2 }); + const cparts = furnishingSolids(counter); + const deck = cparts.find(p => p.material === "worktop")!; + check("both bowls are sunk through the blad", (deck.holes ?? []).length === 2); + check("each bowl has a floor below the blad", + cparts.filter(p => p.material === "sanitary").length === 2 + && cparts.filter(p => p.material === "sanitary").every(p => p.z1 < deck.z0)); + + const basin = mk("basin", { width: 1200, depth: 450, height: 850, basins: 2 }); + check("a wastafel hangs its top at its stated height", + near(extentOf(basin).z1, 850)); + const shower = mk("shower", { tray: "tray", width: 900, depth: 900, height: 100 }); + check("a douchebak is a rim around a sunk floor", + furnishingSolids(shower).some(p => (p.holes ?? []).length === 1)); +} + +// ── the scene mesh carries the fit-out ────────────────────────────────────── + +/** Signed volume of the triangle soup by the divergence theorem, mm³, + * restricted to triangles of one colour. */ +function meshVolume(m: ReturnType, rgb: readonly [number, number, number]): number { + let s = 0; + const P = m.positions, C = m.colors; + for (let i = 0; i + 8 < P.length; i += 9) { + if (Math.abs(C[i]! - rgb[0]) > 1e-3 || Math.abs(C[i + 1]! - rgb[1]) > 1e-3 + || Math.abs(C[i + 2]! - rgb[2]) > 1e-3) continue; + const ax = P[i]!, ay = P[i + 1]!, az = P[i + 2]!; + const bx = P[i + 3]!, by = P[i + 4]!, bz = P[i + 5]!; + const cx = P[i + 6]!, cy = P[i + 7]!, cz = P[i + 8]!; + s += (ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx)) / 6; + } + return s; +} + +/** A closed 4000x3000 rectangle of walls, so the storey has fabric to carry. */ +function rectFloor(f: Floor): void { + const pts = [v(0, 0), v(4000, 0), v(4000, 3000), v(0, 3000)]; + const ids = pts.map(p => { const id = newId("n"); f.nodes.push({ id, x: p.x, y: p.y }); return id; }); + for (let i = 0; i < 4; i++) { + f.walls.push({ id: newId("w"), a: ids[i]!, b: ids[(i + 1) % 4]!, thickness: 100, bulge: 0, openings: [] }); + } +} + +{ + const doc: PlanDoc = emptyDoc(); + const ground = doc.floors[0]!; + rectFloor(ground); + const cabinet = mk("cabinet", { x: 1000, y: 200, kind: "base", worktop: true, rotation: 0.4 }); + const bath = mk("bath", { x: 3000, y: 2000 }); + ground.furnishings = [cabinet, bath]; + + const mesh = buildSceneMesh(doc); + const parts = [...furnishingSolids(cabinet), ...furnishingSolids(bath)]; + const expectCasework = volumeOfParts(parts.filter(p => p.material === "casework")); + const expectWorktop = volumeOfParts(parts.filter(p => p.material === "worktop")); + const expectSanitary = volumeOfParts(parts.filter(p => p.material === "sanitary")); + check("the mesh carries the casework volume", + nearRel(meshVolume(mesh, CASEWORK_COLOR), expectCasework, 1e-4), + `${meshVolume(mesh, CASEWORK_COLOR)} vs ${expectCasework}`); + check("the mesh carries the blad volume", + nearRel(meshVolume(mesh, WORKTOP_COLOR), expectWorktop, 1e-4)); + check("the mesh carries the fixture volume, tub excluded", + nearRel(meshVolume(mesh, SANITARY_COLOR), expectSanitary, 1e-4), + `${meshVolume(mesh, SANITARY_COLOR)} vs ${expectSanitary}`); + + // A hidden storey withholds its fit-out with everything else on it. + const hidden = buildSceneMesh(doc, new Set([ground.id])); + check("a hidden storey shows no fit-out", near(meshVolume(hidden, CASEWORK_COLOR), 0, 1e-3)); + + // On an upper storey the same piece rides at that storey's elevation. + const upper: Floor = { id: newId("f"), name: "1e", nodes: [], walls: [], symbols: [], + furnishings: [mk("cabinet", { x: 1000, y: 1000, kind: "base", worktop: true })] }; + rectFloor(upper); + doc.floors.push(upper); + const two = buildSceneMesh(doc); + const elev = floorElevation(doc, 1); + check("an upper storey's fit-out is lifted to its elevation", elev > 0 + && nearRel(meshVolume(two, CASEWORK_COLOR), + meshVolume(mesh, CASEWORK_COLOR) + + volumeOfParts(furnishingSolids(upper.furnishings![0]!).filter(p => p.material === "casework")), + 1e-4)); + const zTop = Math.max(...Array.from({ length: two.positions.length / 3 }, + (_, i) => two.positions[i * 3 + 2]!)); + check("the upper storey's body reaches above the ground storey's", zTop > elev); +} + +console.log(failures === 0 ? "ALL FURNISHING3D TESTS PASSED" : `${failures} FAILURES`); +process.exit(failures === 0 ? 0 : 1);