Files
houseplan-card/src/wall-thickness.ts
T
2026-08-13 18:55:49 +00:00

2313 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Wall thickness — pure geometry (docs/WALL-THICKNESS.md).
*
* Thickness is a rendering layer keyed by a segment identity that survives
* resize. Wall bodies grow ±½ from the centreline; fills, glow, sun and
* displayed m² use the inner (inset) contour. Wall-length rulers stay on the
* centreline.
*/
import { union, difference, intersection } from 'polyclip-ts';
import { polygonArea, roomPoly, roomEdges, sharedBoundary, paperRoomShapes } from './logic';
export interface WallEntry {
key: string;
cm: number;
/** Optional exact interval endpoints in config coordinates (new writes). */
a?: number[];
b?: number[];
}
export const WALL_MIN_CM = 1;
export const WALL_MAX_CM = 100;
/** Default thickness offered in the Draw toolbar (docs/WALL-THICKNESS.md §6). */
export const DRAW_WALL_DEFAULT_CM = 15;
/** Below this screen depth the diagonal hatch becomes visual noise. */
export const WALL_HATCH_MIN_PX = 3;
/** Mitre spikes longer than this × thickness fall back to a bevel. */
export const MITRE_LIMIT = 4;
// ------------------------------- units --------------------------------------
/** Shared full/static render policy for the thin-on-screen fallback. */
export function wallBodyNeedsSolid(depthUnits: number, pxPerUnit: number): boolean {
return Number.isFinite(depthUnits) && depthUnits > 0
&& Number.isFinite(pxPerUnit) && pxPerUnit > 0
&& depthUnits * pxPerUnit < WALL_HATCH_MIN_PX;
}
export function clampWallCm(cm: number): number {
if (!Number.isFinite(cm)) return WALL_MIN_CM;
return Math.max(WALL_MIN_CM, Math.min(WALL_MAX_CM, cm));
}
/** Config cm → the thickness field (cm, or inches when HA is imperial). */
export function cmToField(cm: number, imperial: boolean): string {
if (!Number.isFinite(cm) || cm <= 0) return '';
if (imperial) return String(Math.round((cm / 2.54) * 100) / 100);
return String(Math.round(cm * 100) / 100);
}
/**
* Field value → cm. Empty / non-finite / ≤0 means "remove thickness"
* (returns null). Imperial field is inches.
*/
export function fieldToCm(raw: string | number, imperial: boolean): number | null {
const v = typeof raw === 'number' ? raw : parseFloat(String(raw).trim().replace(',', '.'));
if (!Number.isFinite(v) || v <= 0) return null;
const cm = imperial ? v * 2.54 : v;
return clampWallCm(cm);
}
/** Real cm → length in the same units as the room polygon (via cell_cm). */
export function wallCmToUnits(cm: number, cellCm: number, gridPitch: number): number {
const c = Number(cellCm) > 0 ? Number(cellCm) : 5;
return (clampWallCm(cm) / c) * gridPitch;
}
// ------------------------------- segment key --------------------------------
function q(v: number, pitch: number): number {
if (!(pitch > 0) || !Number.isFinite(v)) return v;
return Math.round(v / pitch) * pitch;
}
/**
* Direction of a wall, modulo 180° (a wall is the same from either end),
* as a unit vector with a stable sign (prefer +x, then +y).
*/
export function wallDir(a: number[], b: number[]): [number, number] {
let dx = b[0] - a[0], dy = b[1] - a[1];
const L = Math.hypot(dx, dy);
if (L < 1e-12) return [1, 0];
dx /= L; dy /= L;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) {
dx = -dx; dy = -dy;
}
return [dx, dy];
}
/**
* Segment key: quantised midpoint + direction. Same wall from either end,
* survives whole-grid moves when re-keyed by the resize commit.
*/
export function wallKey(a: number[], b: number[], pitch: number): string {
const mx = q((a[0] + b[0]) / 2, pitch);
const my = q((a[1] + b[1]) / 2, pitch);
const [dx, dy] = wallDir(a, b);
// angle bucket: round to ~0.1° so float noise does not fork keys
let ang = Math.atan2(dy, dx);
if (ang < 0) ang += Math.PI;
const aq = Math.round(ang * 1800) / 1800; // π rad ≈ 3.14 → 0.1° steps
const prec = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
return `${mx.toFixed(prec)},${my.toFixed(prec)}@${aq.toFixed(4)}`;
}
/**
* Scale applied to endpoints before keying. Render-space edges use
* `coordScale = NORM_W` with `pitch = GRID_STEP_N` so keys match the
* normalised config; config-space edges use `coordScale = 1`.
*/
function keyOf(a: number[], b: number[], pitch: number, scale: number): string {
if (scale === 1) return wallKey(a, b, pitch);
return wallKey([a[0] / scale, a[1] / scale], [b[0] / scale, b[1] / scale], pitch);
}
/** Exact stored interval in the caller's coordinate space, when available. */
function entrySpan(w: WallEntry, coordScale: number): [number[], number[]] | null {
if (!Array.isArray(w.a) || !Array.isArray(w.b) || w.a.length < 2 || w.b.length < 2) return null;
const nums = [Number(w.a[0]), Number(w.a[1]), Number(w.b[0]), Number(w.b[1])];
if (!nums.every(Number.isFinite)) return null;
const scale = coordScale > 0 ? coordScale : 1;
return [[nums[0] * scale, nums[1] * scale], [nums[2] * scale, nums[3] * scale]];
}
/** Persist an interval with both its compatible key and lossless endpoints. */
function wallEntry(a: number[], b: number[], cm: number, pitch: number, coordScale: number): WallEntry {
const scale = coordScale > 0 ? coordScale : 1;
return {
key: keyOf(a, b, pitch, scale),
cm: clampWallCm(cm),
a: [a[0] / scale, a[1] / scale],
b: [b[0] / scale, b[1] / scale],
};
}
/** One parsed key: midpoint in the caller's coordinate space + angle bucket. */
interface ParsedKey {
w: WallEntry;
x: number;
y: number;
ang: number;
}
function parseKeys(walls: WallEntry[], coordScale: number): ParsedKey[] {
const scale = coordScale > 0 ? coordScale : 1;
const out: ParsedKey[] = [];
for (const w of walls) {
const at = w.key.lastIndexOf('@');
if (at < 0) continue;
const [sx, sy] = w.key.slice(0, at).split(',').map(Number);
const aq = Number(w.key.slice(at + 1));
if (![sx, sy, aq].every(Number.isFinite)) continue;
out.push({ w, x: sx * scale, y: sy * scale, ang: aq });
}
return out;
}
/** Direction of a segment as a 0..π bucket, matching the key's angle field. */
function segAngle(a: number[], b: number[]): number {
const [dx, dy] = wallDir(a, b);
let ang = Math.atan2(dy, dx);
if (ang < 0) ang += Math.PI;
return ang;
}
function angleClose(x: number, y: number): boolean {
let d = Math.abs(x - y);
if (d > Math.PI / 2) d = Math.PI - d;
return d < 0.02; // ~1°
}
/**
* Match within half a grid step on the midpoint (direction must agree).
*
* AUD-159B6-01: this used to also accept a key whose midpoint merely LAY
* SOMEWHERE on the queried segment, so 30 cm set on a 4-unit shared stretch
* was reported for the whole 10-unit edge that contains it and the thickness
* visibly leaked past the physical wall. A key now identifies ONE stretch;
* callers query atomic intervals (see wallIntervals) and old whole-edge keys
* are resolved separately, per parent edge, in cmsForPoly().
*/
export function lookupWall(
walls: WallEntry[] | null | undefined,
a: number[], b: number[],
pitch: number,
coordScale = 1,
): WallEntry | null {
if (!walls?.length) return null;
const want = keyOf(a, b, pitch, coordScale);
const hit = walls.find((w) => w.key === want);
if (hit) return hit;
// tolerant fallback: same direction bucket, midpoint within half pitch (norm)
const scale = coordScale > 0 ? coordScale : 1;
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
const ang = segAngle(a, b);
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
for (const e of parseKeys(walls, scale)) {
if (!angleClose(e.ang, ang)) continue;
if (Math.hypot(e.x - mx, e.y - my) <= tol) return e.w;
}
return null;
}
export function thicknessCmAt(
walls: WallEntry[] | null | undefined,
a: number[], b: number[],
pitch: number,
coordScale = 1,
): number {
const e = lookupWall(walls, a, b, pitch, coordScale);
return e && e.cm > 0 ? clampWallCm(e.cm) : 0;
}
/**
* Drop entries whose key matches no current wall stretch.
*
* "Stretch" means an ATOMIC interval (AUD-159B6-01): whole polygon edges,
* shared overlaps AND the pieces an open span cuts an edge into — the last of
* which is where a legitimately split thickness lives, so leaving them out
* would delete the solid remainder of a partially opened wall on the next save.
*/
export function degradeWalls(
walls: WallEntry[] | null | undefined,
rooms: any[],
pitch: number,
coordScale = 1,
openCuts: number[][] = [],
): WallEntry[] {
if (!walls?.length) return [];
const live = new Set<string>();
const edges = roomEdges(rooms);
for (const seg of edges) {
live.add(keyOf([seg[0], seg[1]], [seg[2], seg[3]], pitch, coordScale));
}
// partial shared overlaps are keyed by their own mid — keep those too
const list = rooms || [];
const eps = Math.max(pitch * coordScale * 0.02, 1e-9);
for (let i = 0; i < list.length; i++) {
const pa = roomPoly(list[i]);
if (!pa) continue;
for (let j = i + 1; j < list.length; j++) {
const pb = roomPoly(list[j]);
if (!pb) continue;
for (const sg of sharedBoundary(pa, pb, eps)) {
live.add(keyOf([sg[0], sg[1]], [sg[2], sg[3]], pitch, coordScale));
}
}
}
for (const room of list) {
if (!room?.id) continue;
const at = atomicPolyForRoom(list, room.id, openCuts, pitch, coordScale, walls);
if (!at) continue;
for (let i = 0; i < at.poly.length; i++) {
live.add(keyOf(at.poly[i], at.poly[(i + 1) % at.poly.length], pitch, coordScale));
}
}
const exactStillLive = (w: WallEntry): boolean => {
const span = entrySpan(w, coordScale);
if (!span) return false;
const [a, b] = span;
const dx = b[0] - a[0], dy = b[1] - a[1];
const L = Math.hypot(dx, dy);
if (L <= eps) return false;
const onCurrentEdge = edges.some((sg) => {
const ea = [sg[0], sg[1]], eb = [sg[2], sg[3]];
return angleClose(segAngle(a, b), segAngle(ea, eb))
&& distToSeg(a[0], a[1], ea[0], ea[1], eb[0], eb[1]) <= eps
&& distToSeg(b[0], b[1], ea[0], ea[1], eb[0], eb[1]) <= eps;
});
if (!onCurrentEdge) return false;
// A stored solid interval must not straddle a newly virtual piece.
const overlapsCut = (openCuts || []).some((c) => {
const ca = [c[0], c[1]], cb = [c[2], c[3]];
if (!angleClose(segAngle(a, b), segAngle(ca, cb))) return false;
const lineDist = (p: number[]) => Math.abs((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / L;
if (lineDist(ca) > eps || lineDist(cb) > eps) return false;
const L2 = L * L;
const t0 = ((ca[0] - a[0]) * dx + (ca[1] - a[1]) * dy) / L2;
const t1 = ((cb[0] - a[0]) * dx + (cb[1] - a[1]) * dy) / L2;
return Math.min(1, Math.max(t0, t1)) - Math.max(0, Math.min(t0, t1)) > eps / L;
});
return !overlapsCut;
};
return walls.filter((w) => (live.has(w.key) || exactStillLive(w))
&& w.cm >= WALL_MIN_CM && w.cm <= WALL_MAX_CM);
}
/**
* Wall direction vs opening angle (both mod 180°). Used so a T-junction
* opening does not bind to the perpendicular receiving wall.
*/
export function wallAngleMatches(
a: number[], b: number[],
openingAngleDeg: number,
tolDeg = 8,
): boolean {
const [dx, dy] = wallDir(a, b);
let wang = Math.atan2(dy, dx);
if (wang < 0) wang += Math.PI;
let oang = ((openingAngleDeg * Math.PI) / 180) % Math.PI;
if (oang < 0) oang += Math.PI;
let d = Math.abs(wang - oang);
if (d > Math.PI / 2) d = Math.PI - d;
return d <= (tolDeg * Math.PI) / 180;
}
/**
* After an edge drag: rewrite keys whose old span mid/dir map to a moved
* stretch. `oldSpans` / `newSpans` are parallel lists of [a,b] endpoints.
*
* A stored key may name either the whole polygon edge or one atomic remainder
* left by a partial shared/open stretch. The latter has a different midpoint,
* so an exact whole-edge key map is insufficient: project every unmatched key
* onto the old edge and carry that relative point onto the new one.
*/
export function rekeyWallsAfterMove(
walls: WallEntry[] | null | undefined,
oldSpans: [number[], number[]][],
newSpans: [number[], number[]][],
pitch: number,
coordScale = 1,
): WallEntry[] {
if (!walls?.length) return [];
if (oldSpans.length !== newSpans.length) return walls.slice();
const map = new Map<string, string>();
for (let i = 0; i < oldSpans.length; i++) {
const [oa, ob] = oldSpans[i];
const [na, nb] = newSpans[i];
const ok = keyOf(oa, ob, pitch, coordScale);
const nk = keyOf(na, nb, pitch, coordScale);
if (ok !== nk) map.set(ok, nk);
}
const scale = coordScale > 0 ? coordScale : 1;
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
const used = new Set<string>();
const out: WallEntry[] = [];
for (const w of walls) {
// Exact endpoints are authoritative for new entries. Never move only their
// compatibility key while leaving a/b behind on the old wall.
let nk = '';
let moved: [number[], number[]] | null = null;
const exact = entrySpan(w, scale);
if (exact) {
for (let i = 0; i < oldSpans.length; i++) {
const [oa, ob] = oldSpans[i];
const [na, nb] = newSpans[i];
if (!angleClose(segAngle(exact[0], exact[1]), segAngle(oa, ob))) continue;
if (distToSeg(exact[0][0], exact[0][1], oa[0], oa[1], ob[0], ob[1]) > tol
|| distToSeg(exact[1][0], exact[1][1], oa[0], oa[1], ob[0], ob[1]) > tol) continue;
const dx = ob[0] - oa[0], dy = ob[1] - oa[1];
const L2 = dx * dx + dy * dy;
if (L2 < 1e-18) continue;
const movePoint = (p: number[]): number[] => {
const t = Math.max(0, Math.min(1, ((p[0] - oa[0]) * dx + (p[1] - oa[1]) * dy) / L2));
return [na[0] + (nb[0] - na[0]) * t, na[1] + (nb[1] - na[1]) * t];
};
moved = [movePoint(exact[0]), movePoint(exact[1])];
nk = keyOf(moved[0], moved[1], pitch, scale);
break;
}
}
if (!exact) nk = map.get(w.key) || '';
if (!nk) {
const parsed = parseKeys([w], scale)[0];
if (parsed) {
for (let i = 0; i < oldSpans.length; i++) {
const [oa, ob] = oldSpans[i];
const [na, nb] = newSpans[i];
if (!angleClose(parsed.ang, segAngle(oa, ob))) continue;
const dx = ob[0] - oa[0], dy = ob[1] - oa[1];
const L2 = dx * dx + dy * dy;
if (L2 < 1e-18) continue;
const t = ((parsed.x - oa[0]) * dx + (parsed.y - oa[1]) * dy) / L2;
if (t < -1e-6 || t > 1 + 1e-6) continue;
if (distToSeg(parsed.x, parsed.y, oa[0], oa[1], ob[0], ob[1]) > tol) continue;
const mx = na[0] + (nb[0] - na[0]) * Math.max(0, Math.min(1, t));
const my = na[1] + (nb[1] - na[1]) * Math.max(0, Math.min(1, t));
const [ux, uy] = wallDir(na, nb);
const arm = Math.max(pitch * scale, 1e-6);
nk = keyOf(
[mx - ux * arm, my - uy * arm],
[mx + ux * arm, my + uy * arm],
pitch, scale,
);
break;
}
}
}
if (!nk) nk = w.key;
if (used.has(nk)) continue;
used.add(nk);
out.push(moved
? wallEntry(moved[0], moved[1], w.cm, pitch, scale)
: { ...w, key: nk, cm: clampWallCm(w.cm) });
}
return out;
}
/** Upsert or remove a wall entry by endpoints. */
export function setWallThickness(
walls: WallEntry[] | null | undefined,
a: number[], b: number[],
cm: number | null,
pitch: number,
coordScale = 1,
): WallEntry[] {
const key = keyOf(a, b, pitch, coordScale);
const base = (walls || []).filter((w) => w.key !== key);
if (cm == null || cm < WALL_MIN_CM) return base;
return [...base, wallEntry(a, b, cm, pitch, coordScale)];
}
/**
* Every atomic stretch of one room that may carry a thickness (open ones are
* excluded). The unit of a wall is the interval, not the polygon edge.
*/
export function solidIntervalsForRoom(
rooms: any[],
roomId: string,
openCuts: number[][],
pitch: number,
coordScale = 1,
wallBreaks: WallEntry[] | null | undefined = [],
): Array<{ a: number[]; b: number[] }> {
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale, wallBreaks);
if (!at) return [];
const out: Array<{ a: number[]; b: number[] }> = [];
for (let i = 0; i < at.poly.length; i++) {
const a = at.poly[i], b = at.poly[(i + 1) % at.poly.length];
if (edgeIsOpen(a, b, openCuts, pitch, coordScale)) continue;
out.push({ a, b });
}
return out;
}
/**
* Apply one thickness to every atomic stretch of a room that is allowed to
* carry one (skips open-boundary stretches listed in `openCuts`).
*/
export function setWallThicknessForRoom(
walls: WallEntry[] | null | undefined,
rooms: any[],
roomId: string,
cm: number | null,
pitch: number,
openCuts: number[][] = [],
coordScale = 1,
): WallEntry[] {
let out = walls ? walls.slice() : [];
for (const iv of solidIntervalsForRoom(rooms, roomId, openCuts, pitch, coordScale, out)) {
out = setWallThickness(out, iv.a, iv.b, cm, pitch, coordScale);
}
return out;
}
/**
* After drawing a new room: set session thickness on stretches that do not yet
* have one. Shared stretches that already carry a neighbour's cm are left
* alone (docs/WALL-THICKNESS.md — one physical wall, one thickness).
*/
export function applyWallThicknessToNewRoom(
walls: WallEntry[] | null | undefined,
rooms: any[],
roomId: string,
cm: number | null,
pitch: number,
openCuts: number[][] = [],
coordScale = 1,
): WallEntry[] {
if (cm == null || cm < WALL_MIN_CM) return walls ? walls.slice() : [];
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale, walls);
if (!at) return walls ? walls.slice() : [];
// effective cm per interval — a neighbour's thickness counts even when it is
// still stored under a pre-atomic whole-edge key
const cms = cmsForPoly(walls, at, pitch, coordScale);
let out = walls ? walls.slice() : [];
for (let i = 0; i < at.poly.length; i++) {
const a = at.poly[i], b = at.poly[(i + 1) % at.poly.length];
if (edgeIsOpen(a, b, openCuts, pitch, coordScale)) continue;
if (cms[i] > 0) continue;
out = setWallThickness(out, a, b, cm, pitch, coordScale);
}
return out;
}
/**
* SVG path for the thick-wall preview while drawing a room outline.
* Closed contours use outset−inset; open polylines use per-segment quads.
*/
export function drawWallPreviewD(
pts: number[][],
halfDepth: number,
closed: boolean,
): string {
if (!(halfDepth > 0) || !pts || pts.length < 2) return '';
if (closed && pts.length >= 3) {
let poly = pts;
const last = pts[pts.length - 1];
if (pts.length >= 4
&& Math.hypot(pts[0][0] - last[0], pts[0][1] - last[1]) < 1e-9) {
poly = pts.slice(0, -1);
}
if (poly.length >= 3) {
const offs = poly.map(() => halfDepth);
const outset = outsetContour(poly, offs);
const inset = insetContour(poly, offs);
if (outset && inset) {
return `${polyToPath(outset)} ${polyToPath(reversePoly(inset))}`;
}
}
}
let d = '';
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
const dx = b[0] - a[0], dy = b[1] - a[1];
const L = Math.hypot(dx, dy);
if (L < 1e-9) continue;
const ux = dx / L, uy = dy / L;
const nx = -uy, ny = ux;
const h = halfDepth;
const quad = [
[a[0] + nx * h, a[1] + ny * h],
[b[0] + nx * h, b[1] + ny * h],
[b[0] - nx * h, b[1] - ny * h],
[a[0] - nx * h, a[1] - ny * h],
];
d += (d ? ' ' : '') + polyToPath(quad);
}
return d;
}
/**
* Is this stretch virtual? Interval-exact (AUD-159B6-01): the midpoint must sit
* ON the cut, not merely near the cut's own midpoint. Atomic intervals never
* straddle a cut end (they are split there), so the test is unambiguous —
* a partial open span no longer has to cover the parent edge's midpoint to
* count, and no longer opens the parts it does not cover.
*/
function edgeIsOpen(a: number[], b: number[], cuts: number[][], pitch: number, coordScale = 1): boolean {
if (!cuts.length) return false;
const eps = openEps(pitch, coordScale);
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
const [dx, dy] = wallDir(a, b);
for (const c of cuts) {
const [ex, ey] = wallDir([c[0], c[1]], [c[2], c[3]]);
if (Math.abs(dx * ey - dy * ex) > 0.05) continue; // not collinear
if (distToSeg(mx, my, c[0], c[1], c[2], c[3]) <= eps) return true;
}
return false;
}
/** Collinearity / on-segment tolerance for interval work (plan units). */
function openEps(pitch: number, coordScale: number): number {
return Math.max(pitch * (coordScale > 0 ? coordScale : 1) * 0.04, 1e-9);
}
// ------------------------------- inset / rings ------------------------------
function signedArea(poly: number[][]): number {
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
s += a[0] * b[1] - b[0] * a[1];
}
return s / 2;
}
/** Inward unit normal for edge i (into the polygon). */
export function inwardNormal(poly: number[][], i: number): [number, number] {
const a = poly[i], b = poly[(i + 1) % poly.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const L = Math.hypot(dx, dy) || 1;
// left normal of edge direction; flip if it points outward
let nx = -dy / L, ny = dx / L;
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const probe = [mid[0] + nx * 1e-3, mid[1] + ny * 1e-3];
// winding-agnostic: a probe that leaves the poly means we had the outward normal
if (!pointInPoly(probe, poly)) {
nx = -nx; ny = -ny;
}
// if area is negative (CW), left normal already points inward for standard math —
// pointInPoly check above handles both.
void signedArea;
return [nx, ny];
}
function pointInPoly(p: number[], poly: number[][]): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i][0], yi = poly[i][1], xj = poly[j][0], yj = poly[j][1];
if ((yi > p[1]) !== (yj > p[1]) &&
p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi + 0) + xi) inside = !inside;
}
return inside;
}
/** Same direction, no turn: the joint of two pieces of ONE straight wall. */
function collinearJoint(uA: number[], uB: number[]): boolean {
const cross = uA[0] * uB[1] - uA[1] * uB[0];
const dot = uA[0] * uB[0] + uA[1] * uB[1];
return Math.abs(cross) < 1e-9 && dot > 0;
}
function lineIntersect(
p: number[], r: number[],
q: number[], s: number[],
): number[] | null {
// p + t r = q + u s
const rxs = r[0] * s[1] - r[1] * s[0];
if (Math.abs(rxs) < 1e-12) return null;
const qp = [q[0] - p[0], q[1] - p[1]];
const t = (qp[0] * s[1] - qp[1] * s[0]) / rxs;
return [p[0] + t * r[0], p[1] + t * r[1]];
}
/**
* Inset a polygon by a per-edge inward distance (same units as poly).
* Zero-offset edges stay on the original. Mitre joins; bevel when the mitre
* would spike longer than MITRE_LIMIT × max(adjacent offsets).
*/
export function insetContour(poly: number[][], offsets: number[]): number[][] | null {
const n = poly?.length || 0;
if (n < 3 || offsets.length !== n) return null;
if (offsets.every((o) => !(o > 0))) return poly.map((p) => [p[0], p[1]]);
const out: number[][] = [];
for (let i = 0; i < n; i++) {
const iPrev = (i - 1 + n) % n;
const a0 = poly[iPrev], a1 = poly[i];
const b0 = poly[i], b1 = poly[(i + 1) % n];
const oA = Math.max(0, offsets[iPrev]);
const oB = Math.max(0, offsets[i]);
const [nAx, nAy] = inwardNormal(poly, iPrev);
const [nBx, nBy] = inwardNormal(poly, i);
const dA = [a1[0] - a0[0], a1[1] - a0[1]];
const dB = [b1[0] - b0[0], b1[1] - b0[1]];
const LA = Math.hypot(dA[0], dA[1]) || 1;
const LB = Math.hypot(dB[0], dB[1]) || 1;
const uA = [dA[0] / LA, dA[1] / LA];
const uB = [dB[0] / LB, dB[1] / LB];
const pA = [a0[0] + nAx * oA, a0[1] + nAy * oA];
const pB = [b0[0] + nBx * oB, b0[1] + nBy * oB];
if (!(oA > 0) && !(oB > 0)) {
out.push([poly[i][0], poly[i][1]]);
continue;
}
// AUD-159B6-01: atomic intervals put COLLINEAR neighbours in one outline.
// Two parallel offset lines never intersect, so the mitre branch below would
// fall through to a bevel that skips the zero side and slants the wall face.
// Equal offsets collapse to one point, different ones step across.
if (collinearJoint(uA, uB)) {
const v = poly[i];
const pa = [v[0] + nAx * oA, v[1] + nAy * oA];
const pb = [v[0] + nBx * oB, v[1] + nBy * oB];
out.push(pa);
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
continue;
}
const hit = lineIntersect(pA, uA, pB, uB);
const maxO = Math.max(oA, oB, 1e-9);
if (hit) {
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
if (dist <= MITRE_LIMIT * maxO) {
out.push(hit);
continue;
}
}
// bevel: two points, each edge's offset line stopped at the vertex offset
if (oA > 0) out.push([poly[i][0] + nAx * oA, poly[i][1] + nAy * oA]);
if (oB > 0) out.push([poly[i][0] + nBx * oB, poly[i][1] + nBy * oB]);
if (!(oA > 0) && !(oB > 0)) out.push([poly[i][0], poly[i][1]]);
}
return out.length >= 3 ? out : null;
}
export type WallKind = 'shared' | 'outer';
export interface WallBodyPath {
/** SVG path `d` for the wall ring (evenodd: outer + reverse inset). */
d: string;
key: string;
kind: WallKind;
cm: number;
/** Screen-thickness hint in plan units (full wall depth). */
depthUnits: number;
}
function polyToPath(poly: number[][], close = true): string {
if (!poly.length) return '';
let d = `M ${poly[0][0]} ${poly[0][1]}`;
for (let i = 1; i < poly.length; i++) d += ` L ${poly[i][0]} ${poly[i][1]}`;
if (close) d += ' Z';
return d;
}
function reversePoly(poly: number[][]): number[][] {
return poly.slice().reverse();
}
// --------------------------- atomic intervals -------------------------------
//
// docs/WALL-THICKNESS.md §2. A room edge is NOT the unit of a wall: a single
// polygon edge can be shared with a neighbour over part of its length, carry a
// virtual (open) stretch in the middle, and be an outer wall for the rest.
// Every geometry step below therefore works on ATOMIC INTERVALS — the pieces
// an edge is cut into by every shared-boundary end and every open-span end.
// Both the stored key and the rendered ring follow those pieces (AUD-159B6-01).
/** Room outline with every atomic breakpoint inserted as a vertex. */
export interface AtomicPoly {
/** Subdivided outline (superset of the room polygon's vertices). */
poly: number[][];
/** For sub-edge i: index of the original polygon edge it belongs to. */
parent: number[];
/** The untouched room polygon. */
orig: number[][];
}
export function atomicPolyForRoom(
rooms: any[],
roomId: string,
openCuts: number[][],
pitch: number,
coordScale = 1,
wallBreaks: WallEntry[] | null | undefined = [],
): AtomicPoly | null {
const room = (rooms || []).find((r) => r?.id === roomId);
const orig = roomPoly(room);
if (!orig || orig.length < 3) return null;
const eps = openEps(pitch, coordScale);
const breaks: number[][] = [];
for (const other of rooms || []) {
if (!other || other.id === roomId) continue;
const op = roomPoly(other);
if (!op) continue;
for (const sg of sharedBoundary(orig, op, eps)) {
breaks.push([sg[0], sg[1]], [sg[2], sg[3]]);
}
}
for (const c of openCuts || []) breaks.push([c[0], c[1]], [c[2], c[3]]);
// A closed virtual span may have been the only geometric breakpoint between
// two real intervals of different thickness. New wall entries retain their
// exact endpoints so deleting that span cannot erase the thickness boundary.
for (const w of wallBreaks || []) {
const span = entrySpan(w, coordScale);
if (span) breaks.push(span[0], span[1]);
}
const poly: number[][] = [];
const parent: number[] = [];
for (let i = 0; i < orig.length; i++) {
const a = orig[i], b = orig[(i + 1) % orig.length];
poly.push([a[0], a[1]]);
parent.push(i);
const L = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (L < eps * 2 || !breaks.length) continue;
const gap = Math.min(0.499, (eps * 2) / L);
const ts: number[] = [];
for (const p of breaks) {
if (distToSeg(p[0], p[1], a[0], a[1], b[0], b[1]) > eps) continue;
const t = ((p[0] - a[0]) * (b[0] - a[0]) + (p[1] - a[1]) * (b[1] - a[1])) / (L * L);
if (t <= gap || t >= 1 - gap) continue;
if (ts.some((u) => Math.abs(u - t) * L <= eps * 2)) continue;
ts.push(t);
}
ts.sort((x, y) => x - y);
for (const t of ts) {
poly.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
parent.push(i);
}
}
return { poly, parent, orig };
}
/** Shared-boundary stretches of one room against every other (plan units). */
function sharedSegsOf(rooms: any[], roomId: string, eps: number): number[][] {
const room = (rooms || []).find((r) => r?.id === roomId);
const poly = roomPoly(room);
if (!poly) return [];
const out: number[][] = [];
for (const other of rooms || []) {
if (!other || other.id === roomId) continue;
const op = roomPoly(other);
if (!op) continue;
for (const sg of sharedBoundary(poly, op, eps)) out.push(sg);
}
return out;
}
function kindsForPoly(
poly: number[][],
shared: number[][],
openCuts: number[][],
pitch: number,
coordScale: number,
): Array<WallKind | null> {
const eps = openEps(pitch, coordScale);
const out: Array<WallKind | null> = [];
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
if (edgeIsOpen(a, b, openCuts, pitch, coordScale)) { out.push(null); continue; }
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
const onShared = shared.some((sg) => distToSeg(mx, my, sg[0], sg[1], sg[2], sg[3]) <= eps);
out.push(onShared ? 'shared' : 'outer');
}
return out;
}
/**
* Classify each ATOMIC interval of a room: shared with a neighbour, or outer.
* Open (virtual) stretches are reported as kind null (no thickness allowed).
* Indices align with `atomicPolyForRoom(...).poly`.
*/
export function edgeKinds(
rooms: any[],
roomId: string,
openCuts: number[][],
pitch: number,
coordScale = 1,
): Array<WallKind | null> {
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale);
if (!at) return [];
const shared = sharedSegsOf(rooms, roomId, openEps(pitch, coordScale));
return kindsForPoly(at.poly, shared, openCuts, pitch, coordScale);
}
/**
* Effective thickness (cm) per atomic interval.
*
* An interval first looks for its OWN key. What is left over is matched against
* keys written before the split — a pre-atomic key describes the whole parent
* edge, so its cm goes to the intervals of that edge nobody claimed. Without
* that, an existing plan would silently lose thickness the moment a neighbour
* or an open span cuts one of its walls in two.
*/
function cmsForPoly(
walls: WallEntry[] | null | undefined,
at: AtomicPoly,
pitch: number,
coordScale: number,
): number[] {
const n = at.poly.length;
const cms = new Array<number>(n).fill(0);
if (!walls?.length) return cms;
const claimed = new Set<string>();
const orphans: number[] = [];
for (let i = 0; i < n; i++) {
const a = at.poly[i], b = at.poly[(i + 1) % n];
const hit = lookupWall(walls, a, b, pitch, coordScale);
if (hit && hit.cm > 0) {
cms[i] = clampWallCm(hit.cm);
claimed.add(hit.key);
} else {
orphans.push(i);
}
}
if (!orphans.length) return cms;
const scale = coordScale > 0 ? coordScale : 1;
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
const parsed = parseKeys(walls, scale).filter((e) => e.w.cm > 0);
// An exact run materialised before Split may cover only part of the new
// polygon parent edge. Resolve those lossless spans against each orphaned
// atomic child first: [0..6] must cover new child [4..6], but never [6..10].
for (let oi = orphans.length - 1; oi >= 0; oi--) {
const i = orphans[oi];
const a = at.poly[i], b = at.poly[(i + 1) % n];
const ang = segAngle(a, b);
let best: { cm: number; extra: number } | null = null;
for (const e of parsed) {
const span = entrySpan(e.w, scale);
if (!span || !angleClose(segAngle(span[0], span[1]), ang)) continue;
if (distToSeg(a[0], a[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol
|| distToSeg(b[0], b[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol) continue;
const childLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
const spanLen = Math.hypot(span[1][0] - span[0][0], span[1][1] - span[0][1]);
const extra = Math.max(0, spanLen - childLen);
if (!best || extra < best.extra) best = { cm: clampWallCm(e.w.cm), extra };
}
if (!best) continue;
cms[i] = best.cm;
orphans.splice(oi, 1);
}
const byParent = new Map<number, number[]>();
for (const i of orphans) {
const p = at.parent[i];
const list = byParent.get(p);
if (list) list.push(i);
else byParent.set(p, [i]);
}
for (const [pi, idxs] of byParent) {
const a = at.orig[pi], b = at.orig[(pi + 1) % at.orig.length];
const ang = segAngle(a, b);
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
let best: { cm: number; d: number; exact: boolean } | null = null;
const parentLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
for (const e of parsed) {
if (claimed.has(e.w.key)) continue;
if (!angleClose(e.ang, ang)) continue;
const span = entrySpan(e.w, scale);
let exact = false;
let d = 0;
if (span) {
// Normalisation may compact one equal-thickness run through several
// collinear room sides. Its midpoint can then lie outside a shorter
// child side, but the lossless endpoints still prove that the run
// covers that side. Require BOTH endpoints so a partial wall cannot
// leak into the rest of its parent edge (AUD-159B6-01).
if (!angleClose(segAngle(span[0], span[1]), ang)) continue;
if (distToSeg(a[0], a[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol
|| distToSeg(b[0], b[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol) continue;
exact = true;
d = Math.max(0, Math.hypot(span[1][0] - span[0][0], span[1][1] - span[0][1]) - parentLen);
} else {
if (distToSeg(e.x, e.y, a[0], a[1], b[0], b[1]) > tol) continue;
d = Math.hypot(e.x - mx, e.y - my);
}
if (!best || (exact && !best.exact) || (exact === best.exact && d < best.d)) {
best = { cm: clampWallCm(e.w.cm), d, exact };
}
}
if (!best) continue;
for (const i of idxs) cms[i] = best.cm;
}
return cms;
}
/** One atomic wall stretch of one room, with everything a caller may need. */
export interface WallInterval {
roomId: string;
a: number[];
b: number[];
key: string;
kind: WallKind | null;
cm: number;
open: boolean;
/** Half depth in plan units (0 when there is no thickness). */
half: number;
}
/** Per-room atomic geometry: subdivided outline + kinds + cms + half offsets. */
export interface RoomWallProfile extends AtomicPoly {
kinds: Array<WallKind | null>;
cms: number[];
offsets: number[];
}
export function roomWallProfile(
rooms: any[],
roomId: string,
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): RoomWallProfile | null {
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale, walls);
if (!at) return null;
const shared = sharedSegsOf(rooms, roomId, openEps(pitch, coordScale));
const kinds = kindsForPoly(at.poly, shared, openCuts, pitch, coordScale);
const cms = cmsForPoly(walls, at, pitch, coordScale);
const offsets = cms.map((cm, i) => (
kinds[i] && cm > 0 ? wallCmToUnits(cm, cellCm, gridPitch) / 2 : 0
));
return { ...at, kinds, cms, offsets };
}
/** Every atomic wall stretch of every room (render/plan units). */
export function wallIntervals(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): WallInterval[] {
const out: WallInterval[] = [];
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr) continue;
for (let i = 0; i < pr.poly.length; i++) {
const a = pr.poly[i], b = pr.poly[(i + 1) % pr.poly.length];
out.push({
roomId: room.id,
a: [a[0], a[1]],
b: [b[0], b[1]],
key: keyOf(a, b, pitch, coordScale),
kind: pr.kinds[i],
cm: pr.kinds[i] ? pr.cms[i] : 0,
open: pr.kinds[i] === null,
half: pr.offsets[i],
});
}
}
return out;
}
/**
* Upgrade the effective current profile to lossless interval endpoints before
* a room-outline mutation. Legacy entries contain only midpoint + direction,
* which is enough while the original edge still exists but cannot tell two
* child edges apart after Split. Materialising first preserves the resolved
* value without broadening the legacy midpoint fallback to unrelated walls.
*/
export function materializeWallIntervals(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): WallEntry[] {
// Rebuild from the effective profile instead of retaining midpoint-only
// legacy rows beside their lossless replacements. Keeping both lets the
// tolerant lookup match a stale collinear stretch between these two calls.
let out: WallEntry[] = [];
const resolved = wallIntervals(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
for (const iv of resolved) {
if (iv.open || !(iv.cm > 0)) continue;
out = setWallThickness(out, iv.a, iv.b, iv.cm, pitch, coordScale);
}
return out;
}
/**
* Rewrite `walls` so every entry names a maximal equal-thickness interval of
* the CURRENT geometry, and no entry survives under an open span. Atomic
* entries compact across every consecutive solid run; a thickness change or
* virtual gap remains an exact stored breakpoint.
*
* This is the single place where the spec invariant "an open span and a
* positive thickness never share a key" is enforced: opening a stretch splits
* the parent key and drops the piece under the span, closing it merges the
* pieces back and inherits the cm of whatever stayed solid.
*/
export function normalizeWallIntervals(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): WallEntry[] {
if (!walls?.length) return [];
const atomic: WallInterval[] = [];
const atomicKeys = new Set<string>();
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
if (iv.open || !(iv.cm > 0) || atomicKeys.has(iv.key)) continue;
atomicKeys.add(iv.key);
atomic.push(iv);
}
// Compact every maximal solid run of one thickness. This still restores one
// whole-edge entry when all children agree, but retains an exact breakpoint
// when neighbouring real intervals intentionally have different thicknesses.
const parents: Array<{ a: number[]; b: number[]; key: string; cm: number; len: number }> = [];
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr) continue;
for (let pi = 0; pi < pr.orig.length; pi++) {
const children: number[] = [];
for (let i = 0; i < pr.parent.length; i++) {
if (pr.parent[i] === pi) children.push(i);
}
if (!children.length) continue;
for (let at = 0; at < children.length;) {
const first = children[at];
const cm = pr.cms[first];
if (!(cm > 0) || pr.kinds[first] === null) { at++; continue; }
let end = at;
while (end + 1 < children.length) {
const next = children[end + 1];
if (pr.kinds[next] === null || pr.cms[next] !== cm) break;
end++;
}
const last = children[end];
const a = pr.poly[first], b = pr.poly[(last + 1) % pr.poly.length];
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (len > 0) parents.push({
a: [a[0], a[1]], b: [b[0], b[1]],
key: keyOf(a, b, pitch, coordScale), cm, len,
});
at = end + 1;
}
}
}
parents.sort((a, b) => b.len - a.len || a.key.localeCompare(b.key));
const out: WallEntry[] = [];
const seen = new Set<string>();
const covered = new Set<string>();
const tol = openEps(pitch, coordScale) * 4;
for (const parent of parents) {
const matches = atomic.filter((iv) => (
!covered.has(iv.key) && iv.cm === parent.cm &&
angleClose(segAngle(iv.a, iv.b), segAngle(parent.a, parent.b)) &&
distToSeg(iv.a[0], iv.a[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol &&
distToSeg(iv.b[0], iv.b[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol
));
if (!matches.length) continue;
for (const iv of matches) covered.add(iv.key);
if (seen.has(parent.key)) continue;
seen.add(parent.key);
out.push(wallEntry(parent.a, parent.b, parent.cm, pitch, coordScale));
}
for (const iv of atomic) {
if (covered.has(iv.key) || seen.has(iv.key)) continue;
seen.add(iv.key);
out.push(wallEntry(iv.a, iv.b, iv.cm, pitch, coordScale));
}
return out;
}
/** Effective thickness of the atomic interval that covers a segment's middle. */
export function intervalCmAt(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
seg: number[],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): number {
const eps = openEps(pitch, coordScale);
const mx = (seg[0] + seg[2]) / 2, my = (seg[1] + seg[3]) / 2;
const ang = segAngle([seg[0], seg[1]], [seg[2], seg[3]]);
let best: { cm: number; d: number } | null = null;
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
if (!angleClose(segAngle(iv.a, iv.b), ang)) continue;
const d = distToSeg(mx, my, iv.a[0], iv.a[1], iv.b[0], iv.b[1]);
if (d > eps * 4) continue;
if (!best || d < best.d) best = { cm: iv.cm, d };
}
return best?.cm || 0;
}
function distToSeg(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
const abx = bx - ax, aby = by - ay;
const L2 = abx * abx + aby * aby;
if (L2 < 1e-18) return Math.hypot(px - ax, py - ay);
let t = ((px - ax) * abx + (py - ay) * aby) / L2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (ax + abx * t), py - (ay + aby * t));
}
/**
* Per-room half-depth offsets (plan units) for inset/outset: every thick edge
* (shared or outer) → half; open/none → 0. docs/WALL-THICKNESS.md §2.
*/
export function insetOffsetsForRoom(
rooms: any[],
roomId: string,
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): number[] {
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
return pr ? pr.offsets : [];
}
/** Alias — half offsets drive both inset and outset. */
export const halfOffsetsForRoom = insetOffsetsForRoom;
/**
* Inner (clean-floor) contour of a room: inset by half wall thickness.
* Returns the original poly when there is no thickness.
*/
export function innerContourForRoom(
rooms: any[],
roomId: string,
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): number[][] | null {
const room = (rooms || []).find((r) => r?.id === roomId);
const poly = roomPoly(room);
if (!poly || poly.length < 3) return null;
if (!walls?.length) return poly.map((p) => [p[0], p[1]]);
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || !pr.offsets.some((o) => o > 0)) return poly.map((p) => [p[0], p[1]]);
return insetContour(pr.poly, pr.offsets) || poly.map((p) => [p[0], p[1]]);
}
function closedRing(poly: number[][]): number[][][] {
const ring = poly.map((p) => [p[0], p[1]]);
ring.push([poly[0][0], poly[0][1]]);
return [ring];
}
interface ExteriorEnvelopeGeometry {
/** Union of room centrelines. Shared Split edges disappear from this shape. */
centre: any;
/** Wall shell generated only from the surviving exterior boundary. */
shell: any;
}
/** Open every ring of a polyclip MultiPolygon and drop its closing duplicate. */
function geometryRings(geom: any): number[][][] {
const out: number[][][] = [];
for (const polygon of Array.isArray(geom) ? geom : []) {
if (!Array.isArray(polygon)) continue;
for (const raw of polygon) {
if (!Array.isArray(raw) || raw.length < 4) continue;
const ring = raw.slice(0, -1).map((p: number[]) => [p[0], p[1]]);
if (ring.length >= 3) out.push(ring);
}
}
return out;
}
function pointOnSegment(p: number[], a: number[], b: number[], eps: number): boolean {
if (distToSeg(p[0], p[1], a[0], a[1], b[0], b[1]) > eps) return false;
const dx = b[0] - a[0], dy = b[1] - a[1];
const dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * dy;
const len2 = dx * dx + dy * dy;
return dot >= -eps && dot <= len2 + eps;
}
/**
* Split a boolean-union boundary at every stored exterior interval endpoint.
* Polyclip is allowed to collapse a collinear child-room vertex; retaining the
* interval breakpoints is what preserves unequal wall depths on the two sides.
*/
function exteriorBoundaryProfile(
ring: number[][],
outer: WallInterval[],
eps: number,
): { poly: number[][]; offsets: number[] } | null {
const poly: number[][] = [];
const offsets: number[] = [];
for (let i = 0; i < ring.length; i++) {
const a = ring[i], b = ring[(i + 1) % ring.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > eps * eps)) continue;
const cuts = [0, 1];
for (const iv of outer) {
for (const p of [iv.a, iv.b]) {
if (!pointOnSegment(p, a, b, eps)) continue;
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
if (t > eps && t < 1 - eps) cuts.push(t);
}
}
cuts.sort((x, y) => x - y);
const unique = cuts.filter((t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > eps);
for (let at = 0; at < unique.length - 1; at++) {
const t0 = unique[at], t1 = unique[at + 1];
const p = [a[0] + dx * t0, a[1] + dy * t0];
const mid = [a[0] + dx * (t0 + t1) / 2, a[1] + dy * (t0 + t1) / 2];
let half = 0;
for (const iv of outer) {
if (pointOnSegment(mid, iv.a, iv.b, eps)) half = Math.max(half, iv.half);
}
poly.push(p);
offsets.push(half);
}
}
return poly.length >= 3 && offsets.length === poly.length ? { poly, offsets } : null;
}
/**
* Exterior masonry is derived from the union of room centrelines, not from
* each room independently. A Split edge therefore vanishes before mitres are
* built and cannot turn its artificial child corner into part of the facade.
*/
function exteriorEnvelopeGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale: number,
): ExteriorEnvelopeGeometry | null {
const polys = (rooms || []).map(roomPoly).filter((p): p is number[][] => !!p && p.length >= 3);
if (!polys.length) return null;
let centre: any = union(closedRing(polys[0]) as any);
for (let i = 1; i < polys.length; i++) centre = union(centre, closedRing(polys[i]) as any);
const outer = wallIntervals(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
).filter((iv) => iv.kind === 'outer' && iv.half > 0);
const eps = openEps(pitch, coordScale) * 4;
let shell: any = null;
for (const ring of geometryRings(centre)) {
const profile = exteriorBoundaryProfile(ring, outer, eps);
if (!profile || !profile.offsets.some((o) => o > 0)) continue;
const outset = outsetContour(profile.poly, profile.offsets);
const inset = insetContour(profile.poly, profile.offsets);
if (!outset || !inset) continue;
const piece = difference(closedRing(outset) as any, closedRing(inset) as any);
shell = shell ? union(shell, piece) : piece;
}
return { centre, shell: shell || [] };
}
function polyclipToPathD(geom: any): string {
if (!geom) return '';
let d = '';
// polyclip Geom: MultiPolygon = Polygon[]; Polygon = Ring[] where ring[0]
// is the outer and ring[1..] are holes. We must emit EVERY ring so evenodd
// fill punches the floor out of the wall body (otherwise a single-room
// outset fills solid — the whole room looks like hatch).
for (const poly of geom as any[]) {
if (!Array.isArray(poly)) continue;
for (const ring of poly) {
if (!Array.isArray(ring) || ring.length < 4) continue;
const pts = ring.slice(0, ring.length - 1);
if (pts.length < 3) continue;
d += (d ? ' ' : '') + polyToPath(pts.map((p: number[]) => [p[0], p[1]]));
}
}
return d;
}
/**
* Mitre patches at an endpoint where a virtual stretch meets real walls that
* belong to different room contours.
*
* The normal per-room rings can only join adjacent thick edges of ONE room.
* At a virtual T, the two real arms may be owned by two point-touching rooms;
* each ring then ends with a butt cap and their union leaves a stair-step at
* the outer corner. The patch is the missing offset-line parallelogram. It is
* restricted to open-span endpoints, so ordinary corners keep the existing
* contour/mitre/bevel implementation unchanged.
*/
function virtualJunctionPatches(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale: number,
): number[][][] {
if (!walls?.length || !openCuts?.length) return [];
const eps = openEps(pitch, coordScale) * 4;
const unique = new Map<string, WallInterval>();
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
if (iv.open || !(iv.half > 0) || unique.has(iv.key)) continue;
unique.set(iv.key, iv);
}
const intervals = [...unique.values()];
if (intervals.length < 2) return [];
const nodes: number[][] = [];
for (const cut of openCuts) {
for (const p of [[cut[0], cut[1]], [cut[2], cut[3]]]) {
if (!nodes.some((q) => Math.hypot(q[0] - p[0], q[1] - p[1]) <= eps)) nodes.push(p);
}
}
const out: number[][][] = [];
const awayFrom = (iv: WallInterval, v: number[]): number[] | null => {
let dx = 0, dy = 0;
if (Math.hypot(iv.a[0] - v[0], iv.a[1] - v[1]) <= eps) {
dx = iv.b[0] - iv.a[0]; dy = iv.b[1] - iv.a[1];
} else if (Math.hypot(iv.b[0] - v[0], iv.b[1] - v[1]) <= eps) {
dx = iv.a[0] - iv.b[0]; dy = iv.a[1] - iv.b[1];
} else {
return null;
}
const L = Math.hypot(dx, dy);
return L > eps ? [dx / L, dy / L] : null;
};
for (const v of nodes) {
const touching = intervals
.map((iv) => ({ iv, u: awayFrom(iv, v) }))
.filter((x): x is { iv: WallInterval; u: number[] } => !!x.u);
for (let i = 0; i < touching.length; i++) {
for (let j = i + 1; j < touching.length; j++) {
const a = touching[i], b = touching[j];
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
const sin = Math.abs(cross);
if (sin < 1e-3) continue; // one straight wall, no corner to fill
const da = b.iv.half / sin;
const db = a.iv.half / sin;
const pa = [v[0] - a.u[0] * da, v[1] - a.u[1] * da];
const pb = [v[0] - b.u[0] * db, v[1] - b.u[1] * db];
const far = [pa[0] + pb[0] - v[0], pa[1] + pb[1] - v[1]];
const maxHalf = Math.max(a.iv.half, b.iv.half, 1e-9);
if (Math.hypot(far[0] - v[0], far[1] - v[1]) > MITRE_LIMIT * maxHalf) continue;
out.push(cross > 0 ? [v.slice(), pa, far, pb] : [v.slice(), pb, far, pa]);
}
}
}
return out;
}
/**
* One evenodd ring path per room: outset(half) − inset(half). Shared walls
* meet as two half-rings; callers may union them via wallBodiesUnionPath.
*/
export function wallBodyRings(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): WallBodyPath[] {
if (!walls?.length) return [];
const out: WallBodyPath[] = [];
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || pr.poly.length < 3 || !pr.offsets.some((o) => o > 0)) continue;
const outset = outsetContour(pr.poly, pr.offsets);
const inset = insetContour(pr.poly, pr.offsets);
if (!outset || !inset) continue;
const d = `${polyToPath(outset)} ${polyToPath(reversePoly(inset))}`;
let key = '';
let kind: WallKind = 'outer';
let cm = 0;
let depth = 0;
for (let i = 0; i < pr.poly.length; i++) {
if (!(pr.offsets[i] > 0)) continue;
const a = pr.poly[i], b = pr.poly[(i + 1) % pr.poly.length];
key = keyOf(a, b, pitch, coordScale);
kind = pr.kinds[i] || 'outer';
cm = pr.cms[i];
depth = wallCmToUnits(cm, cellCm, gridPitch);
break;
}
out.push({ d, key, kind, cm, depthUnits: depth });
}
return out;
}
/**
* Seamless wall hatch: union of each room's own outset-minus-inset wall ring,
* with opening slots cut as holes. One continuous body across L and T joins.
*
* Do not rewrite this as `(union outsets) - (union insets)`: subtraction does
* not distribute over union. In a nested/complex layout the clean floor of one
* room would then erase a wall owned by another room, leaving half-depth strips
* and tiny holes at junctions.
*/
/**
* The masonry itself, as polygons: room wall rings joined at their mitres,
* with opening slots cut through. Drawing uses it as one path; the light model
* uses the same geometry as its occluders, so a wall blocks light exactly
* where the plan shows a wall — with its real thickness, and with a doorway
* that is a real gap between two jamb faces. A successful empty operation is
* returned as an empty geometry; null means the boolean pass itself failed,
* so drawing callers may distinguish it from "nothing solid" and fall back.
*/
export function wallBodiesGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
extraBodies: number[][][] = [],
): { geom: any; paperGeom: any; depthUnits: number } | null {
if (!walls?.length && !extraBodies.length) return null;
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
let maxDepth = 0;
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || pr.poly.length < 3 || !pr.offsets.some((o) => o > 0)) continue;
for (const o of pr.offsets) if (o > 0) maxDepth = Math.max(maxDepth, o * 2);
const outC = outsetContour(pr.poly, pr.offsets);
const inC = insetContour(pr.poly, pr.offsets);
if (!outC) continue;
roomRings.push({ outset: outC, inset: inC });
}
for (const body of extraBodies) {
const xs = body.map((p) => p[0]), ys = body.map((p) => p[1]);
if (xs.length) {
const bboxDepth = Math.min(
Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
// A 96-gon is a circle and its bbox is the diameter. Four-point bodies
// are partitions/square columns; their shortest edge is the real depth,
// whereas a rotated bbox exaggerates it and incorrectly enables hatch.
const edgeDepth = Math.min(...body.map((p, i) => {
const q = body[(i + 1) % body.length];
return Math.hypot(q[0] - p[0], q[1] - p[1]);
}));
maxDepth = Math.max(maxDepth, body.length > 16 ? bboxDepth : edgeDepth);
}
}
const junctions = virtualJunctionPatches(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const openingIndex = openings.length
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
: null;
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
// Paper and masonry share this one structural pass. Renderers cache the
// returned pair, so a live HA state update never repeats exterior topology.
const paperGeom = exterior
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
: [];
const bodyOf = (ring: typeof roomRings[number]): any => {
const outset: any = closedRing(ring.outset);
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
};
let body: any = null;
for (const ring of roomRings) {
try {
const piece = bodyOf(ring);
body = body ? union(body, piece) : piece;
} catch {
// An acute child contour may be invalid for boolean subtraction. The
// interval pass below still supplies its physical wall without letting
// the artificial mitre back into the exterior envelope.
}
}
// Per-room rings preserve established L/T/nested joins. Atomic quads are
// also included so a rejected acute child ring cannot remove a divider or
// an interior half-wall. Clipping them to the centre union gives a hard
// facade boundary; the canonical exterior shell is added afterwards.
if (exterior) {
for (const edge of wallEdgeBodies(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
)) {
try {
const piece = intersection(closedRing(edge.quad) as any, exterior.centre);
body = body ? union(body, piece) : piece;
} catch {
// A valid per-room ring may already own this interval. If neither
// representation is usable the final result fails closed below.
}
}
}
// The room-ring subtraction above cannot infer a mitre between real arms
// owned by different contours at a virtual T. Add only those missing
// junction pieces, then let physical openings cut through them as usual.
for (const patch of junctions)
body = body ? union(body, closedRing(patch) as any) : closedRing(patch);
if (body && exterior) body = intersection(body, exterior.centre);
if (exterior?.shell?.length)
body = body ? union(body, exterior.shell) : exterior.shell;
// cut opening tunnels (axis-aligned to opening angle)
for (const o of openings) {
if (!(o.length > 0)) continue;
const association = resolveOpeningWallAssociation(openingIndex!, o, true);
if (!association.negative && !association.positive) continue;
const rad = (o.angle * Math.PI) / 180;
const ux = Math.cos(rad), uy = Math.sin(rad);
const nx = -uy, ny = ux;
const half = o.length / 2;
const pad = Math.max(maxDepth, pitch * coordScale) * 1.25;
const slot = [
[o.x - ux * half - nx * pad, o.y - uy * half - ny * pad],
[o.x + ux * half - nx * pad, o.y + uy * half - ny * pad],
[o.x + ux * half + nx * pad, o.y + uy * half + ny * pad],
[o.x - ux * half + nx * pad, o.y - uy * half + ny * pad],
];
if (body) body = difference(body, closedRing(slot) as any);
}
// Independent bodies are physical but own no openings. Unioning here (not
// before the loop above) preserves them under coincident room openings.
for (const extra of extraBodies) {
if (extra.length < 3) continue;
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
}
return { geom: body || [], paperGeom, depthUnits: maxDepth };
} catch {
return null;
}
}
export function wallBodiesUnionPath(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
/** Independent physical bodies are unioned only after room openings are cut,
* so a door/window/gate can never punch a coincident partition or column. */
extraBodies: number[][][] = [],
): { d: string; paperD: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
if (!walls?.length && !extraBodies.length) return null;
const united = wallBodiesGeometry(
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
);
const d = united ? polyclipToPathD(united.geom) : '';
const paperD = united ? polyclipToPathD(united.paperGeom) : '';
if (united && d) return { d, paperD, depthUnits: united.depthUnits, fillRule: 'evenodd' };
if (united) return null; // successful empty result: do not resurrect raw rings
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
// that creates an exterior tooth at a corner Split, so resurrecting it after
// a boolean failure would make malformed input violate the facade invariant.
return null;
}
/**
* Per-edge wall quads for styling hooks and thick-cut suppression — one body
* per unique wall key. Shared and outer walls both grow ±½ from the
* centreline (docs/WALL-THICKNESS.md §2). Production hatch uses
* wallBodiesUnionPath; these quads remain for hooks / stroke cuts.
*/
export interface WallEdgeBody {
key: string;
kind: WallKind;
cm: number;
/** Quad corners (4 points), CCW. */
quad: number[][];
a: number[];
b: number[];
depthUnits: number;
}
export function wallEdgeBodies(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): WallEdgeBody[] {
if (!walls?.length) return [];
const seen = new Set<string>();
const out: WallEdgeBody[] = [];
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr) continue;
const poly = pr.poly;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const kind = pr.kinds[i];
if (!kind) continue;
const cm = pr.cms[i];
if (!(cm > 0)) continue;
const key = keyOf(a, b, pitch, coordScale);
if (seen.has(key)) continue;
seen.add(key);
const depth = wallCmToUnits(cm, cellCm, gridPitch);
const [inx, iny] = inwardNormal(poly, i);
const ox = -inx, oy = -iny;
const h = depth / 2;
const quad: number[][] = [
[a[0] + ox * h, a[1] + oy * h],
[b[0] + ox * h, b[1] + oy * h],
[b[0] + inx * h, b[1] + iny * h],
[a[0] + inx * h, a[1] + iny * h],
];
out.push({ key, kind, cm, quad, a: [a[0], a[1]], b: [b[0], b[1]], depthUnits: depth });
}
}
return out;
}
/** SVG path for an edge body, with optional opening slots cut (evenodd holes). */
export function wallEdgePathD(
body: WallEdgeBody,
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
): string {
let d = polyToPath(body.quad);
const [dx, dy] = wallDir(body.a, body.b);
const ux = dx, uy = dy;
// normal across the wall (from a toward inward of first room estimate = perp)
const nx = -uy, ny = ux;
for (const o of openings) {
if (!wallAngleMatches(body.a, body.b, o.angle)) continue;
// only openings whose centre lies on (or very near) this span's centreline
const dist = distToSeg(o.x, o.y, body.a[0], body.a[1], body.b[0], body.b[1]);
if (dist > Math.max(body.depthUnits * 0.55, 1e-3)) continue;
const half = o.length / 2;
// slot covers full depth of this quad
const pad = body.depthUnits; // generous across
// project opening onto wall direction
const cx = o.x, cy = o.y;
const slot = [
[cx - ux * half - nx * pad, cy - uy * half - ny * pad],
[cx + ux * half - nx * pad, cy + uy * half - ny * pad],
[cx + ux * half + nx * pad, cy + uy * half + ny * pad],
[cx - ux * half + nx * pad, cy - uy * half + ny * pad],
];
d += ` ${polyToPath(reversePoly(slot))}`;
}
return d;
}
/**
* Outward paper growth offsets per edge (plan units): half-thickness under
* every thick wall (outer and shared) so the scene background never shows
* through the outer half-out.
*/
export function paperOutwardOffsets(
rooms: any[],
roomId: string,
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): number[] {
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
return pr ? pr.offsets : [];
}
/**
* Expand a polygon outward by per-edge offsets (mirror of inset with flipped
* normals). Used for paper under shared thick walls.
*/
export function outsetContour(poly: number[][], offsets: number[]): number[][] | null {
const n = poly?.length || 0;
if (n < 3 || offsets.length !== n) return null;
if (offsets.every((o) => !(o > 0))) return poly.map((p) => [p[0], p[1]]);
// outset = inset of the reversed winding with same offsets, then reverse back
const rev = reversePoly(poly);
const revOff = offsets.slice().reverse();
// shift so revOff[i] applies to edge rev[i]→rev[i+1] which was poly edge
// after reverse: edge i of rev was edge (n-1-i) of original... careful.
// Simpler: negate inward normals by using inset on poly with negative?
// Build by flipping offset direction manually:
const out: number[][] = [];
for (let i = 0; i < n; i++) {
const iPrev = (i - 1 + n) % n;
const oA = Math.max(0, offsets[iPrev]);
const oB = Math.max(0, offsets[i]);
const [nAx, nAy] = inwardNormal(poly, iPrev);
const [nBx, nBy] = inwardNormal(poly, i);
// outward = -inward
const a0 = poly[iPrev], a1 = poly[i];
const b0 = poly[i], b1 = poly[(i + 1) % n];
const dA = [a1[0] - a0[0], a1[1] - a0[1]];
const dB = [b1[0] - b0[0], b1[1] - b0[1]];
const LA = Math.hypot(dA[0], dA[1]) || 1;
const LB = Math.hypot(dB[0], dB[1]) || 1;
const uA = [dA[0] / LA, dA[1] / LA];
const uB = [dB[0] / LB, dB[1] / LB];
const pA = [a0[0] - nAx * oA, a0[1] - nAy * oA];
const pB = [b0[0] - nBx * oB, b0[1] - nBy * oB];
if (!(oA > 0) && !(oB > 0)) {
out.push([poly[i][0], poly[i][1]]);
continue;
}
if (collinearJoint(uA, uB)) {
const v = poly[i];
const pa = [v[0] - nAx * oA, v[1] - nAy * oA];
const pb = [v[0] - nBx * oB, v[1] - nBy * oB];
out.push(pa);
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
continue;
}
const hit = lineIntersect(pA, uA, pB, uB);
const maxO = Math.max(oA, oB, 1e-9);
if (hit) {
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
if (dist <= MITRE_LIMIT * maxO) {
out.push(hit);
continue;
}
}
if (oA > 0) out.push([poly[i][0] - nAx * oA, poly[i][1] - nAy * oA]);
if (oB > 0) out.push([poly[i][0] - nBx * oB, poly[i][1] - nBy * oB]);
}
void rev; void revOff;
return out.length >= 3 ? out : null;
}
/**
* Paper shapes grown under thick shared walls. Falls back to exact room
* contours when there is no thickness (byte-compatible with paperRoomShapes).
*/
export function paperRoomShapesWithWalls(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): Array<
| { path: string }
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
if (!walls?.length) return paperRoomShapes(rooms);
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (exterior) {
const paper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
const path = polyclipToPathD(paper);
if (path) return [{ path }];
}
} catch {
// Safe fallback below: exact room centrelines never reproduce the known
// exterior Split spike, even when boolean offsetting rejected bad input.
}
return paperRoomShapes(rooms);
}
interface OpeningWallEdge {
roomId: string;
a: number[];
b: number[];
inward: [number, number];
cm: number;
half: number;
area: number;
key: string;
}
/**
* Immutable wall index shared by opening symbols, wall cuts and tunnel fills.
* Building atomic room profiles is the expensive O(rooms²) part; callers that
* resolve several openings build this once and reuse it for every opening.
*/
export interface OpeningWallIndex {
edges: OpeningWallEdge[];
adjacencyEps: number;
}
export function openingWallIndex(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): OpeningWallIndex {
const edges: OpeningWallEdge[] = [];
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(
rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (!pr) continue;
const area = Math.abs(polygonArea(pr.poly));
for (let i = 0; i < pr.poly.length; i++) {
// A virtual interval has no opening, inner face or physical tunnel.
if (!pr.kinds[i]) continue;
const a = pr.poly[i], b = pr.poly[(i + 1) % pr.poly.length];
edges.push({
roomId: room.id,
a, b,
inward: inwardNormal(pr.poly, i),
cm: pr.cms[i],
half: pr.offsets[i],
area,
key: keyOf(a, b, pitch, coordScale),
});
}
}
return { edges, adjacencyEps: openEps(pitch, coordScale) };
}
export interface OpeningWallPiece {
x0: number;
x1: number;
half: number;
cm: number;
key: string;
/** Canonical unit direction of the physical wall, independent of room winding. */
axis: [number, number];
}
export interface OpeningWallSide {
roomId: string;
side: -1 | 1;
/** First matching room edge in config order. Symbols historically use that
* order to choose their default face on an inherently ambiguous shared wall;
* tunnel ownership still uses compareOpeningSides below. */
order: number;
pieces: OpeningWallPiece[];
faceDistance: number;
area: number;
coverage: number;
full: boolean;
}
export interface OpeningWallAssociation {
negative: OpeningWallSide | null;
positive: OpeningWallSide | null;
}
function tunnelCoverage(pieces: OpeningWallPiece[], lo: number, hi: number, eps: number): {
coverage: number; full: boolean;
} {
const spans = pieces
.map((p) => [Math.max(lo, p.x0), Math.min(hi, p.x1)] as [number, number])
.filter((p) => p[1] - p[0] > eps)
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (!spans.length) return { coverage: 0, full: false };
let start = spans[0][0], end = spans[0][1], coverage = 0;
let full = start <= lo + eps;
for (let i = 1; i < spans.length; i++) {
const [a, b] = spans[i];
if (a <= end + eps) {
end = Math.max(end, b);
continue;
}
coverage += end - start;
full = false;
start = a; end = b;
}
coverage += end - start;
full = full && end >= hi - eps;
return { coverage, full };
}
function compareOpeningSides(a: OpeningWallSide, b: OpeningWallSide): number {
return Number(b.full) - Number(a.full)
|| a.faceDistance - b.faceDistance
|| a.area - b.area
|| a.roomId.localeCompare(b.roomId);
}
/**
* Resolve adjacent room sides for one opening against a prebuilt wall index.
* A candidate must be genuinely collinear/adjacent (4% of one grid pitch), not
* merely the closest parallel wall within a whole cell. This keeps detached
* rooms and double-wall air gaps from becoming a phantom second room.
*/
export function resolveOpeningWallAssociation(
index: OpeningWallIndex,
opening: { x: number; y: number; angle: number; length: number },
physicalOnly = false,
): OpeningWallAssociation {
const x = Number(opening?.x), y = Number(opening?.y);
const angle = Number(opening?.angle), length = Number(opening?.length);
if (![x, y, angle, length].every(Number.isFinite) || !(length > 0)) {
return { negative: null, positive: null };
}
const rad = angle * Math.PI / 180;
const ux = Math.cos(rad), uy = Math.sin(rad);
const nx = -uy, ny = ux;
const openingHalf = length / 2;
const eps = Math.max(1e-9, index.adjacencyEps);
const candidates = new Map<string, OpeningWallSide>();
let candidateOrder = 0;
for (const edge of index.edges) {
if (physicalOnly && !(edge.half > 0)) continue;
if (!wallAngleMatches(edge.a, edge.b, angle)) continue;
const [edgeUx, edgeUy] = wallDir(edge.a, edge.b);
// Adjacency is perpendicular distance to the wall line. Long legacy
// openings may have their centre just beyond an endpoint while still
// overlapping the real span; the projection clip below decides that part.
const lineDistance = Math.abs((x - edge.a[0]) * edgeUy - (y - edge.a[1]) * edgeUx);
if (lineDistance > eps) continue;
const ta = (edge.a[0] - x) * ux + (edge.a[1] - y) * uy;
const tb = (edge.b[0] - x) * ux + (edge.b[1] - y) * uy;
const x0 = Math.max(-openingHalf, Math.min(ta, tb));
const x1 = Math.min(openingHalf, Math.max(ta, tb));
if (x1 - x0 <= eps) continue;
const side = (edge.inward[0] * nx + edge.inward[1] * ny >= 0 ? 1 : -1) as -1 | 1;
// Signed centreline position matters: an edge just across the axis has an
// inner face closer by that offset, not farther by abs(offset) + half.
const mx = (edge.a[0] + edge.b[0]) / 2;
const my = (edge.a[1] + edge.b[1]) / 2;
const centreY = (mx - x) * nx + (my - y) * ny;
const faceDistance = Math.abs(centreY + side * edge.half);
const key = `${side}|${edge.roomId}`;
const piece: OpeningWallPiece = {
x0, x1, half: edge.half, cm: edge.cm, key: edge.key, axis: [edgeUx, edgeUy],
};
const previous = candidates.get(key);
if (previous) {
previous.pieces.push(piece);
previous.faceDistance = Math.min(previous.faceDistance, faceDistance);
} else {
candidates.set(key, {
roomId: edge.roomId, side, order: candidateOrder++, pieces: [piece], faceDistance,
area: edge.area, coverage: 0, full: false,
});
}
}
for (const candidate of candidates.values()) {
const coverage = tunnelCoverage(candidate.pieces, -openingHalf, openingHalf, eps);
candidate.coverage = coverage.coverage;
candidate.full = coverage.full;
}
const pick = (side: -1 | 1): OpeningWallSide | null => {
const list = [...candidates.values()].filter((candidate) => (
candidate.side === side && candidate.coverage > eps
));
list.sort(compareOpeningSides);
return list[0] || null;
};
return { negative: pick(-1), positive: pick(1) };
}
function centrePiece(side: OpeningWallSide): OpeningWallPiece {
return [...side.pieces].sort((a, b) => {
const da = a.x0 <= 0 && a.x1 >= 0 ? 0 : Math.min(Math.abs(a.x0), Math.abs(a.x1));
const db = b.x0 <= 0 && b.x1 >= 0 ? 0 : Math.min(Math.abs(b.x0), Math.abs(b.x1));
return da - db || (b.x1 - b.x0) - (a.x1 - a.x0) || a.key.localeCompare(b.key);
})[0];
}
/**
* Half-depth from the centreline toward the selected face of an opening.
* The exact same association resolver is used by wall cuts and tunnel fills;
* invalid angle/distance fallbacks can no longer move a symbol into a slot
* which the other renderers do not recognise.
*/
export function openingInnerFaceOffset(
rooms: any[],
opening: { x: number; y: number; angle: number; length: number; flip_v?: boolean },
walls: WallEntry[] | null | undefined,
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
openCuts: number[][] = [],
): { ox: number; oy: number; cm: number; side: -1 | 1 } {
const index = openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
return openingInnerFaceOffsetFromIndex(index, opening);
}
/** Cheap per-opening face resolution against a cached atomic wall index. */
export function openingInnerFaceOffsetFromIndex(
index: OpeningWallIndex,
opening: { x: number; y: number; angle: number; length: number; flip_v?: boolean },
): { ox: number; oy: number; cm: number; side: -1 | 1 } {
const association = resolveOpeningWallAssociation(index, opening);
const available = [association.negative, association.positive]
.filter((side): side is OpeningWallSide => !!side)
// Preserve the pre-index symbol behaviour: on a shared wall the first room
// in model order owns the unflipped face. This is separate from tunnel
// ownership, whose geometric tie-breaks must remain order-independent.
.sort((a, b) => a.order - b.order);
if (!available.length) return { ox: 0, oy: 0, cm: 0, side: -1 };
const natural = available[0];
const selectedSide = (opening.flip_v ? -natural.side : natural.side) as -1 | 1;
const selected = (selectedSide === -1 ? association.negative : association.positive) || natural;
const piece = centrePiece(selected);
if (!(piece.half > 0) || !(piece.cm > 0)) return { ox: 0, oy: 0, cm: 0, side: selectedSide };
const rad = opening.angle * Math.PI / 180;
const nx = -Math.sin(rad), ny = Math.cos(rad);
return {
ox: nx * selectedSide * piece.half,
oy: ny * selectedSide * piece.half,
cm: piece.cm,
side: selectedSide,
};
}
/** One half of a room-coloured opening tunnel, in opening-local coordinates. */
export interface OpeningTunnelFace {
side: -1 | 1;
roomId: string;
/** One SVG path containing one contour per disconnected physical span. */
d: string;
}
/** Pure geometry consumed by the full-card opening-tunnel renderer. */
export interface OpeningTunnelGeometry {
faces: OpeningTunnelFace[];
/** Local Y bounds. The wall centreline is always y=0. */
minY: number;
maxY: number;
wallKey: string;
}
/** @internal Exported so the non-overlapping union profile has a direct mutation guard. */
export function tunnelFacePath(side: -1 | 1, pieces: OpeningWallPiece[]): string {
const eps = 1e-9;
const valid = pieces.filter((piece) => (
Number.isFinite(piece.x0) && Number.isFinite(piece.x1)
&& Number.isFinite(piece.half) && piece.x1 > piece.x0 && piece.half > 0
));
if (!valid.length) return '';
// Turn overlapping atomic wall intervals into a non-overlapping depth
// profile. A profile slab uses the deepest physical body covering that X;
// this is the exact union of all candidate rectangles and never extends an
// opening past either jamb.
const rawBreaks = valid.flatMap((piece) => [piece.x0, piece.x1]).sort((a, b) => a - b);
const breaks: number[] = [];
for (const value of rawBreaks) {
const tail = breaks[breaks.length - 1];
if (tail === undefined || value > tail + eps) breaks.push(value);
}
const profile: Array<{ x0: number; x1: number; half: number }> = [];
for (let i = 0; i + 1 < breaks.length; i++) {
const x0 = breaks[i], x1 = breaks[i + 1];
if (!(x1 > x0 + eps)) continue;
const mid = (x0 + x1) / 2;
const half = valid.reduce((depth, piece) => (
mid >= piece.x0 - eps && mid <= piece.x1 + eps ? Math.max(depth, piece.half) : depth
), 0);
if (!(half > 0)) continue;
const tail = profile[profile.length - 1];
if (tail && x0 <= tail.x1 + eps && Math.abs(half - tail.half) <= eps) {
tail.x1 = x1;
} else {
profile.push({ x0, x1, half });
}
}
// Build one simple outline for every connected span. Thickness changes are
// vertices on its outer envelope, not shared edges between translucent SVG
// rectangles, so neither antialiasing seams nor double-alpha bands exist.
const components: Array<Array<{ x0: number; x1: number; half: number }>> = [];
for (const slab of profile) {
const component = components[components.length - 1];
const tail = component?.[component.length - 1];
if (tail && slab.x0 <= tail.x1 + eps) {
slab.x0 = tail.x1;
component.push(slab);
} else {
components.push([slab]);
}
}
return components.map((component) => {
const first = component[0], last = component[component.length - 1];
// Both half-faces are subpaths of one nonzero-filled path. Give them a
// real device-pixel overlap at ordinary wall depths: a 0.1 px overlap was
// still rasterised as a faint centre seam by Chromium. Because winding is
// identical this remains one alpha application, not a double-fill band.
const seam = Math.min(Math.min(...component.map((slab) => slab.half)) * 0.25, 0.75);
const axisY = -side * seam;
const commands: string[] = [];
if (side === 1) {
commands.push(`M ${first.x0} ${axisY} L ${last.x1} ${axisY}`);
for (let i = component.length - 1; i >= 0; i--) {
const slab = component[i];
commands.push(`L ${slab.x1} ${slab.half} L ${slab.x0} ${slab.half}`);
}
} else {
// Keep the same winding direction as the positive face. The two faces
// overlap only around y=0; matching winding makes that overlap solid
// under the nonzero fill rule instead of cancelling into a hairline.
commands.push(`M ${last.x1} ${axisY} L ${first.x0} ${axisY}`);
for (const slab of component)
commands.push(`L ${slab.x0} ${-slab.half} L ${slab.x1} ${-slab.half}`);
}
commands.push('Z');
return commands.join(' ');
}).join(' ');
}
type TunnelOccupancy = Map<string, Array<[number, number]>>;
function reserveTunnelPieces(
opening: { x: number; y: number; angle: number },
side: -1 | 1,
pieces: OpeningWallPiece[],
occupied?: TunnelOccupancy,
): OpeningWallPiece[] {
if (!occupied) return pieces;
const openingRad = opening.angle * Math.PI / 180;
const openingUx = Math.cos(openingRad), openingUy = Math.sin(openingRad);
const out: OpeningWallPiece[] = [];
const eps = 1e-9;
for (const piece of pieces) {
const [ux, uy] = piece.axis;
const direction = openingUx * ux + openingUy * uy;
if (Math.abs(direction) <= eps) continue;
const centre = opening.x * ux + opening.y * uy;
const g0 = centre + direction * piece.x0;
const g1 = centre + direction * piece.x1;
const lo = Math.min(g0, g1), hi = Math.max(g0, g1);
const occupancyKey = `${piece.key}|${side}`;
const previous = occupied.get(occupancyKey) || [];
let fragments: Array<[number, number]> = [[lo, hi]];
for (const [usedLo, usedHi] of previous) {
const next: Array<[number, number]> = [];
for (const [a, b] of fragments) {
if (usedHi <= a + eps || usedLo >= b - eps) next.push([a, b]);
else {
if (usedLo > a + eps) next.push([a, Math.min(b, usedLo)]);
if (usedHi < b - eps) next.push([Math.max(a, usedHi), b]);
}
}
fragments = next;
if (!fragments.length) break;
}
for (const [a, b] of fragments) {
const lx0 = (a - centre) / direction;
const lx1 = (b - centre) / direction;
out.push({ ...piece, x0: Math.min(lx0, lx1), x1: Math.max(lx0, lx1) });
}
const merged = [...previous, [lo, hi] as [number, number]]
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const compact: Array<[number, number]> = [];
for (const span of merged) {
const tail = compact[compact.length - 1];
if (tail && span[0] <= tail[1] + eps) tail[1] = Math.max(tail[1], span[1]);
else compact.push([span[0], span[1]]);
}
occupied.set(occupancyKey, compact);
}
return out;
}
function openingTunnelGeometryFromIndex(
index: OpeningWallIndex,
opening: { x: number; y: number; angle: number; length: number },
occupied?: TunnelOccupancy,
): OpeningTunnelGeometry | null {
const association = resolveOpeningWallAssociation(index, opening, true);
const negative = association.negative, positive = association.positive;
if (!negative && !positive) return null;
let chosen: Array<{ candidate: OpeningWallSide; side: -1 | 1 }>;
if (negative && positive) {
chosen = [{ candidate: negative, side: -1 }, { candidate: positive, side: 1 }];
} else {
const only = (negative || positive)!;
chosen = [{ candidate: only, side: -1 }, { candidate: only, side: 1 }];
}
const renderedPieces = chosen.map(({ candidate, side }) => ({
candidate,
side,
pieces: reserveTunnelPieces(opening, side, candidate.pieces, occupied),
}));
const faces = renderedPieces.map(({ candidate, side, pieces }) => ({
side, roomId: candidate.roomId, d: tunnelFacePath(side, pieces),
}));
const allPieces = renderedPieces.flatMap(({ pieces }) => pieces);
if (!allPieces.length) return null;
const maxHalf = Math.max(...allPieces.map((piece) => piece.half));
const wallKey = [...new Set(allPieces.map((piece) => piece.key))].sort().join('|');
return { faces, minY: -maxHalf, maxY: maxHalf, wallKey };
}
/**
* Resolve the physical tunnel and its adjacent rooms without reading card or
* HA state. The opening-local X axis follows `angle`; local y=0 is the wall
* centreline. Atomic room-wall profiles provide exact mixed-thickness clips,
* so a legacy opening near a breakpoint cannot paint beyond the real body.
*
* A single adjacent room (outer wall) owns both halves. Two adjacent rooms own
* one half each. Draft walls, virtual spans and zero-thickness intervals never
* produce a face because they have no eligible room-wall interval.
*/
export function openingTunnelGeometry(
rooms: any[],
opening: { x: number; y: number; angle: number; length: number },
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): OpeningTunnelGeometry | null {
if (![pitch, cellCm, gridPitch, coordScale].every(Number.isFinite)
|| !(pitch > 0) || !(cellCm > 0) || !(gridPitch > 0) || !(coordScale > 0)
|| !walls?.length) return null;
const index = openingWallIndex(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
return openingTunnelGeometryFromIndex(index, opening);
}
/** Resolve every opening while paying the atomic room-profile cost once. */
export function openingTunnelGeometries(
rooms: any[],
openings: Array<{ x: number; y: number; angle: number; length: number }>,
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): Array<OpeningTunnelGeometry | null> {
if (![pitch, cellCm, gridPitch, coordScale].every(Number.isFinite)
|| !(pitch > 0) || !(cellCm > 0) || !(gridPitch > 0) || !(coordScale > 0)
|| !walls?.length) return openings.map(() => null);
const index = openingWallIndex(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
return openingTunnelGeometriesFromIndex(index, openings);
}
/** Cheap batch resolution against a cached atomic wall index. */
export function openingTunnelGeometriesFromIndex(
index: OpeningWallIndex,
openings: Array<{ x: number; y: number; angle: number; length: number }>,
): Array<OpeningTunnelGeometry | null> {
const occupied: TunnelOccupancy = new Map();
return openings.map((opening) => openingTunnelGeometryFromIndex(index, opening, occupied));
}