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 8a0749e..fc134f2 100644 --- a/scripts/site/pages.ts +++ b/scripts/site/pages.ts @@ -508,8 +508,10 @@ 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, kolommen, balken en leuningen, 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, kolommen, balken en leuningen, +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.

@@ -836,7 +838,9 @@ 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, columns, beams and -railings, stairs tread by tread, every storey at its own level. Dragging orbits, scrolling zooms, Shift+drag pans; F fits everything +railings, stairs tread by 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 d9b757d..01ed4d5 100644 --- a/src/io/ifc.ts +++ b/src/io/ifc.ts @@ -71,8 +71,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"; @@ -669,19 +670,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; @@ -1107,10 +1095,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 8e39866..f571d28 100644 --- a/src/render3d/mesh.ts +++ b/src/render3d/mesh.ts @@ -5,10 +5,11 @@ // 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 { structureSolids } from "../core/structure"; 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"; @@ -52,6 +53,28 @@ export const GLASS_COLOR: Rgb = [0.875, 0.91, 0.933]; export const PANEL_COLOR: Rgb = [0.906, 0.882, 0.827]; /** Steel structure: a cool mid grey, told from the masonry it carries. */ export const STEEL_COLOR: Rgb = [0.62, 0.64, 0.67]; +/** 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: darker than STEEL_COLOR, so a toestel in a run is told + * from a column standing in it. */ +export const APPLIANCE_COLOR: Rgb = [0.52, 0.55, 0.58]; +/** 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; @@ -103,10 +126,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 @@ -142,6 +166,17 @@ export function buildSceneMesh(doc: PlanDoc, hiddenFloors?: ReadonlySet): Me const color = s.material === "steel" ? STEEL_COLOR : s.material === "timber" ? DOOR_COLOR : WALL_COLOR; emitPrism(acc, s.poly, [], elev + s.z0, elev + seat(s.z1), color); } + + // The inrichting stands on its own for the same reason: each piece as the + // parts its own form stands as, on a storey with or without walls. 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]); + } + } if (!fs) continue; // Steps are derived once per stair: the stairs are drawn from them, and 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);