Files
houseplan-card/src/wall-thickness.ts
T
2026-08-24 01:47:22 +03:00

3635 lines
138 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;
/**
* Hatch density is a physical quantity, not a coordinate one (#230).
*
* The pattern step used to be a constant 8 units while wall thickness converts
* through `cell_cm`, so the same 15 cm wall carried 7.8 stripes on a 1 cm grid
* and 0.3 on a 25 cm one — a 25× spread for identical data. The step now
* follows the plan's centimetres: 8 units at the reference `cell_cm: 5`, which
* is 9.6 cm, and that distance holds at every grid scale.
*/
export const HATCH_REFERENCE_CELL_CM = 5;
export const HATCH_BASE_STEP_UNITS = 8;
/** Below this the stripes fuse into a fill even at maximum zoom (cell ≈ 80). */
export const HATCH_MIN_STEP_UNITS = 0.5;
/** Above this further thickening no longer reads (cell ≈ 0.5). */
export const HATCH_MAX_STEP_UNITS = 80;
/** A step thinner than this on screen is noise, not hatching. */
export const HATCH_MIN_STEP_PX = 2;
/** Mitre spikes longer than this × thickness fall back to a bevel. */
export const MITRE_LIMIT = 4;
/** Multi-ray joins stay inside this × the largest incident half-depth (#249). */
export const MULTI_WALL_JOIN_LIMIT = 1.25;
/** Normalized dot-product tolerance for a physically orthogonal ray pair. */
export const MULTI_WALL_ORTHOGONAL_DOT_EPSILON = 1e-9;
export interface MultiWallNodeRaySupport {
/** Physical half-depth owned by this finite co-directional interval. */
halfDepth: number;
/** Distance from the canonical node to the interval's real endpoint. */
length: number;
}
export interface MultiWallNodeRay {
/** Unit direction from the canonical node toward the interval's other end. */
u: [number, number];
/** Largest incident half-depth at the node; used by the join formula. */
halfDepth: number;
/** Furthest real endpoint in this direction. */
length: number;
/** Non-dominated finite strips whose union is the physical ray support. */
supports: MultiWallNodeRaySupport[];
}
export interface MultiWallNode {
point: [number, number];
rays: MultiWallNodeRay[];
halfDepth: number;
limit: number;
}
/** Scale-relative lookup shared by every contour producer in one structural pass. */
export interface MultiWallNodeMap {
epsilon: number;
coordinateScale: number;
nodes: MultiWallNode[];
/** Spatial buckets keep vertex lookup linear instead of scanning all nodes. */
index: Map<string, MultiWallNode[]>;
}
/** One finite physical wall centreline with its already-converted half depth. */
export interface LinearWallSegment {
a: number[];
b: number[];
halfDepth: number;
}
// ------------------------------- 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;
}
/**
* Pattern step in plan units for a given grid scale (#230, spec §8.1).
*
* At the reference scale this returns exactly the historical 8, so plans on
* `cell_cm: 5` — every golden fixture among them — render byte for byte as
* before.
*/
export function wallHatchStepUnits(cellCm: number): number {
const c = Number(cellCm) > 0 ? Number(cellCm) : HATCH_REFERENCE_CELL_CM;
if (c === HATCH_REFERENCE_CELL_CM) return HATCH_BASE_STEP_UNITS;
const step = HATCH_BASE_STEP_UNITS * (HATCH_REFERENCE_CELL_CM / c);
return Math.min(HATCH_MAX_STEP_UNITS, Math.max(HATCH_MIN_STEP_UNITS, step));
}
/**
* Stripes too close together on screen: fill the body instead (#230, §8.4).
*
* Companion to `wallBodyNeedsSolid`, which watches the body's depth. Now that
* the step is physical it no longer shrinks with zoom, so the far end of the
* zoom range needs its own guard.
*/
export function wallHatchNeedsSolid(stepUnits: number, pxPerUnit: number): boolean {
return Number.isFinite(stepUnits) && stepUnits > 0
&& Number.isFinite(pxPerUnit) && pxPerUnit > 0
&& stepUnits * pxPerUnit < HATCH_MIN_STEP_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;
}
/** Storage-noise tolerance used only to stabilise wall identity near a node. */
function keyEpsilon(pitch: number): number {
return Math.max(Math.abs(pitch) * 1e-6, 1e-9);
}
/**
* Treat a coordinate already within storage precision of a grid node as that
* exact node. Arbitrary off-grid geometry remains off-grid: this is identity
* canonicalisation, not an implicit geometry snap.
*/
function canonicalKeyCoordinate(v: number, pitch: number): number {
if (!(pitch > 0) || !Number.isFinite(v)) return v;
const snapped = q(v, pitch);
return Math.abs(snapped - v) <= keyEpsilon(pitch) ? snapped : v;
}
/**
* 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 ca = [canonicalKeyCoordinate(a[0], pitch), canonicalKeyCoordinate(a[1], pitch)];
const cb = [canonicalKeyCoordinate(b[0], pitch), canonicalKeyCoordinate(b[1], pitch)];
const mx = q((ca[0] + cb[0]) / 2, pitch);
const my = q((ca[1] + cb[1]) / 2, pitch);
const [dx, dy] = wallDir(ca, cb);
// 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;
// A lossless entry can prove that it names this exact physical stretch even
// when an older midpoint key landed on the other side of a rounding tie.
// This is deliberately same-span only: parent containment remains the
// separate exactCoveringWall/cmsForPoly compatibility contract.
const scale = coordScale > 0 ? coordScale : 1;
const exactEps = keyEpsilon(pitch) * scale;
const closePoint = (x: number[], y: number[]): boolean => (
Math.abs(x[0] - y[0]) <= exactEps && Math.abs(x[1] - y[1]) <= exactEps
);
for (const wall of walls) {
const span = entrySpan(wall, scale);
if (!span) continue;
if ((closePoint(span[0], a) && closePoint(span[1], b))
|| (closePoint(span[0], b) && closePoint(span[1], a))) return wall;
}
// tolerant fallback: same direction bucket, midpoint within half pitch (norm)
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);
if (e && e.cm > 0) return clampWallCm(e.cm);
const exact = exactCoveringWall(walls, a, b, pitch, coordScale);
return exact ? clampWallCm(exact.cm) : 0;
}
/**
* Lossless parent-run fallback for an atomic child query.
*
* `lookupWall` intentionally keeps the narrow "one key = one stretch"
* contract (AUD-159B6-01). Closing a virtual span, however, asks about the
* atomic solid children around it while persisted exact endpoints may describe
* their longer parent run. Exact endpoints can prove containment without
* broadening the ambiguous legacy key-only fallback.
*/
function exactCoveringWall(
walls: WallEntry[] | null | undefined,
a: number[], b: number[],
pitch: number,
coordScale: number,
): WallEntry | null {
if (!walls?.length) return null;
const scale = coordScale > 0 ? coordScale : 1;
const queryLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (queryLen < 1e-12) return null;
const queryAngle = segAngle(a, b);
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
let best: { wall: WallEntry; extra: number; stable: string } | null = null;
for (const wall of walls) {
if (!(wall.cm > 0)) continue;
const span = entrySpan(wall, scale);
if (!span) continue;
const spanLen = Math.hypot(span[1][0] - span[0][0], span[1][1] - span[0][1]);
if (spanLen < 1e-12 || !angleClose(segAngle(span[0], span[1]), queryAngle)) 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;
// A shorter span may sit within endpoint tolerance but cannot prove that
// it covers the query. Keep that tolerance scale-relative, like lookup.
if (spanLen + tol < queryLen) continue;
const extra = Math.max(0, spanLen - queryLen);
const stable = `${wall.key}|${clampWallCm(wall.cm)}|${span.flat().join(',')}`;
if (!best || extra < best.extra - 1e-12
|| (Math.abs(extra - best.extra) <= 1e-12 && stable < best.stable)) {
best = { wall, extra, stable };
}
}
return best?.wall || null;
}
/**
* 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 scale = coordScale > 0 ? coordScale : 1;
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
const exactEps = Math.max(pitch * scale * 1e-6, 1e-9);
type Move = {
oa: number[]; ob: number[]; na: number[]; nb: number[];
dx: number; dy: number; len2: number;
};
const moves: Move[] = [];
const keyMoves = new Map<string, Set<string>>();
for (let i = 0; i < oldSpans.length; i++) {
const [oa, ob] = oldSpans[i];
const [na, nb] = newSpans[i];
if (![oa?.[0], oa?.[1], ob?.[0], ob?.[1], na?.[0], na?.[1], nb?.[0], nb?.[1]]
.every(Number.isFinite)) continue;
const dx = ob[0] - oa[0], dy = ob[1] - oa[1];
const len2 = dx * dx + dy * dy;
if (len2 < 1e-18) continue;
// Unchanged polygon edges are context, not split points. Including them
// would atomise every long wall on every preview even when no part moved.
if (Math.max(Math.hypot(na[0] - oa[0], na[1] - oa[1]),
Math.hypot(nb[0] - ob[0], nb[1] - ob[1])) <= exactEps) continue;
moves.push({ oa, ob, na, nb, dx, dy, len2 });
const ok = keyOf(oa, ob, pitch, coordScale);
const nk = keyOf(na, nb, pitch, coordScale);
if (ok !== nk) {
const targets = keyMoves.get(ok) || new Set<string>();
targets.add(nk);
keyMoves.set(ok, targets);
}
}
if (!moves.length) return walls.slice();
const pointAt = (a: number[], b: number[], t: number): number[] => [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
];
const mapPoint = (p: number[], move: Move): number[] => {
const t = Math.max(0, Math.min(1,
((p[0] - move.oa[0]) * move.dx + (p[1] - move.oa[1]) * move.dy) / move.len2));
return pointAt(move.na, move.nb, t);
};
const closePoint = (a: number[], b: number[]): boolean =>
Math.hypot(a[0] - b[0], a[1] - b[1]) <= exactEps;
const canonicalSpan = (a: number[], b: number[]): [number[], number[]] => {
const [ux, uy] = wallDir(a, b);
return (b[0] - a[0]) * ux + (b[1] - a[1]) * uy >= 0
? [[...a], [...b]] : [[...b], [...a]];
};
const out: WallEntry[] = [];
const exactOut: { entry: WallEntry; span: [number[], number[]] }[] = [];
const pushExact = (a: number[], b: number[], cm: number): void => {
if (Math.hypot(b[0] - a[0], b[1] - a[1]) <= exactEps) return;
const [ca, cb] = canonicalSpan(a, b);
const value = clampWallCm(cm);
const duplicate = exactOut.some((candidate) => candidate.entry.cm === value
&& closePoint(candidate.span[0], ca) && closePoint(candidate.span[1], cb));
if (duplicate) return;
const entry = wallEntry(ca, cb, value, pitch, scale);
out.push(entry);
exactOut.push({ entry, span: [ca, cb] });
};
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. A record
// may be longer than the moved room edge, so partition it at every overlap
// boundary and transform the covered atoms from this immutable source.
const exact = entrySpan(w, scale);
if (exact) {
const [wa, wb] = canonicalSpan(exact[0], exact[1]);
const wx = wb[0] - wa[0], wy = wb[1] - wa[1];
const wallLen2 = wx * wx + wy * wy;
const wallLen = Math.sqrt(wallLen2);
if (wallLen <= exactEps) {
out.push({ ...w, cm: clampWallCm(w.cm) });
continue;
}
type Overlap = { lo: number; hi: number; move: Move };
const overlaps: Overlap[] = [];
for (const move of moves) {
if (!angleClose(segAngle(wa, wb), segAngle(move.oa, move.ob))) continue;
const lineDistance = (p: number[]): number =>
Math.abs((p[0] - wa[0]) * wy - (p[1] - wa[1]) * wx) / wallLen;
if (lineDistance(move.oa) > tol || lineDistance(move.ob) > tol) continue;
const ta = ((move.oa[0] - wa[0]) * wx + (move.oa[1] - wa[1]) * wy) / wallLen2;
const tb = ((move.ob[0] - wa[0]) * wx + (move.ob[1] - wa[1]) * wy) / wallLen2;
const lo = Math.max(0, Math.min(ta, tb));
const hi = Math.min(1, Math.max(ta, tb));
if ((hi - lo) * wallLen > exactEps) overlaps.push({ lo, hi, move });
}
if (!overlaps.length) {
pushExact(wa, wb, w.cm);
continue;
}
const bounds = [0, 1, ...overlaps.flatMap(({ lo, hi }) => [lo, hi])]
.sort((a, b) => a - b)
.filter((value, index, list) => index === 0
|| Math.abs(value - list[index - 1]) * wallLen > exactEps);
for (let i = 0; i + 1 < bounds.length; i++) {
const lo = bounds[i], hi = bounds[i + 1];
if ((hi - lo) * wallLen <= exactEps) continue;
const a = pointAt(wa, wb, lo), b = pointAt(wa, wb, hi);
const mid = (lo + hi) / 2;
const candidates = overlaps.filter((overlap) =>
mid >= overlap.lo - 1e-12 && mid <= overlap.hi + 1e-12);
if (!candidates.length) {
pushExact(a, b, w.cm);
continue;
}
const first: [number[], number[]] = [
mapPoint(a, candidates[0].move), mapPoint(b, candidates[0].move),
];
const conflict = candidates.slice(1).some((candidate) => {
const ca = mapPoint(a, candidate.move), cb = mapPoint(b, candidate.move);
return !closePoint(first[0], ca) || !closePoint(first[1], cb);
});
// Conflicting room transforms are invalid planner input. Preserve the
// source atom rather than selecting by array order or losing masonry.
if (conflict) pushExact(a, b, w.cm);
else pushExact(first[0], first[1], w.cm);
}
continue;
}
// Legacy entries carry only a midpoint/direction key, so they cannot be
// split without inventing a length. Move an unambiguous whole-edge key or
// projected midpoint, and never deduplicate them merely by key.
let nk = '';
const direct = keyMoves.get(w.key);
if (direct?.size === 1) nk = [...direct][0];
if (!nk) {
const parsed = parseKeys([w], scale)[0];
if (parsed) {
const targets = new Set<string>();
for (const move of moves) {
if (!angleClose(parsed.ang, segAngle(move.oa, move.ob))) continue;
const t = ((parsed.x - move.oa[0]) * move.dx
+ (parsed.y - move.oa[1]) * move.dy) / move.len2;
if (t < -1e-6 || t > 1 + 1e-6) continue;
if (distToSeg(parsed.x, parsed.y,
move.oa[0], move.oa[1], move.ob[0], move.ob[1]) > tol) continue;
const at = mapPoint([parsed.x, parsed.y], move);
const [ux, uy] = wallDir(move.na, move.nb);
const arm = Math.max(pitch * scale, 1e-6);
targets.add(keyOf(
[at[0] - ux * arm, at[1] - uy * arm],
[at[0] + ux * arm, at[1] + uy * arm],
pitch, scale,
));
}
if (targets.size === 1) nk = [...targets][0];
}
}
out.push({ ...w, key: nk || w.key, 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;
}
/** A flat-capped body for one already-scaled centreline segment. */
export function linearWallBody(segment: LinearWallSegment): number[][] | null {
const { a, b, halfDepth } = segment;
if (!Array.isArray(a) || !Array.isArray(b) || a.length < 2 || b.length < 2
|| ![a[0], a[1], b[0], b[1]].every(Number.isFinite)) return null;
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy);
if (!(len > 1e-9) || !(halfDepth > 0) || !Number.isFinite(halfDepth)) return null;
const nx = (-dy / len) * halfDepth, ny = (dx / len) * halfDepth;
return [
[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny],
[b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny],
];
}
interface JunctionRay {
u: [number, number];
halfDepth: number;
}
function closePoint(a: number[], b: number[], epsilon: number): boolean {
return Math.hypot(a[0] - b[0], a[1] - b[1]) <= epsilon;
}
function pointOnSegmentInterior(
point: number[], segment: LinearWallSegment, epsilon: number,
): boolean {
const dx = segment.b[0] - segment.a[0], dy = segment.b[1] - segment.a[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > epsilon * epsilon)) return false;
const t = ((point[0] - segment.a[0]) * dx + (point[1] - segment.a[1]) * dy) / len2;
if (!(t > 0 && t < 1)) return false;
const q = [segment.a[0] + dx * t, segment.a[1] + dy * t];
return Math.hypot(point[0] - q[0], point[1] - q[1]) <= epsilon;
}
function addJunctionRay(rays: JunctionRay[], dx: number, dy: number, halfDepth: number): void {
const len = Math.hypot(dx, dy);
if (!(len > 1e-9) || !(halfDepth > 0)) return;
const u: [number, number] = [dx / len, dy / len];
const same = rays.find((ray) =>
Math.abs(ray.u[0] * u[1] - ray.u[1] * u[0]) < 1e-9
&& ray.u[0] * u[0] + ray.u[1] * u[1] > 1 - 1e-9);
if (same) same.halfDepth = Math.max(same.halfDepth, halfDepth);
else rays.push({ u, halfDepth });
}
/**
* Missing node volumes for flat-capped linear wall segments.
*
* Endpoints are the only nodes. An endpoint may also land in another segment's
* interior (the non-persisted T produced by #137); that through segment then
* contributes two incident rays. Each non-collinear ray pair receives the
* same bounded mitre/bevel used by room contours. Unioning these patches with
* the raw bodies removes the tooth without changing caps at degree-one nodes.
*/
export function linearWallJoinPatches(
input: LinearWallSegment[], epsilon = 1e-6,
): number[][][] {
const segments = (input || []).filter((segment) =>
segment && Array.isArray(segment.a) && Array.isArray(segment.b)
&& segment.a.length >= 2 && segment.b.length >= 2
&& segment.a.every(Number.isFinite) && segment.b.every(Number.isFinite)
&& Number.isFinite(segment.halfDepth) && segment.halfDepth > 0
&& Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]) > 1e-9);
if (segments.length < 2) return [];
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
const endpoints = segments.flatMap((segment) => [segment.a, segment.b])
.map((point) => [point[0], point[1]])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const nodes: number[][] = [];
for (const point of endpoints) {
if (!nodes.some((node) => closePoint(node, point, eps))) nodes.push(point);
}
const patches: number[][][] = [];
for (const node of nodes) {
const rays: JunctionRay[] = [];
for (const segment of segments) {
if (closePoint(node, segment.a, eps)) {
addJunctionRay(
rays, segment.b[0] - segment.a[0], segment.b[1] - segment.a[1],
segment.halfDepth,
);
} else if (closePoint(node, segment.b, eps)) {
addJunctionRay(
rays, segment.a[0] - segment.b[0], segment.a[1] - segment.b[1],
segment.halfDepth,
);
} else if (pointOnSegmentInterior(node, segment, eps)) {
addJunctionRay(
rays, segment.a[0] - node[0], segment.a[1] - node[1], segment.halfDepth,
);
addJunctionRay(
rays, segment.b[0] - node[0], segment.b[1] - node[1], segment.halfDepth,
);
}
}
if (rays.length < 2) continue;
rays.sort((a, b) => Math.atan2(a.u[1], a.u[0]) - Math.atan2(b.u[1], b.u[0])
|| a.halfDepth - b.halfDepth);
for (let i = 0; i < rays.length; i++) {
for (let j = i + 1; j < rays.length; j++) {
const a = rays[i], b = rays[j];
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
if (Math.abs(cross) < 1e-9) continue;
const nA = [-a.u[1], a.u[0]];
const nB = [-b.u[1], b.u[0]];
const sign = cross < 0 ? 1 : -1;
const pA = [
node[0] + nA[0] * a.halfDepth * sign,
node[1] + nA[1] * a.halfDepth * sign,
];
const pB = [
node[0] - nB[0] * b.halfDepth * sign,
node[1] - nB[1] * b.halfDepth * sign,
];
const hit = lineIntersect(pA, a.u, pB, b.u);
const limit = MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);
const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= limit
? [node.slice(), pA, hit, pB]
: [node.slice(), pA, pB];
if (Math.abs(signedArea(patch)) > eps * eps) patches.push(patch);
}
}
}
return patches;
}
function unionSimpleBodies(bodies: number[][][]): any | null {
let geom: any = null;
try {
for (const body of bodies) {
if (body.length < 3) continue;
const piece: any = closedRing(body);
// Keep the same MultiPolygon shape for one body and for a union. Returning
// the bare Polygon made `polyclipToPathD()` see points where it expects
// rings, so every single-segment preview (including Thickness hover)
// became an empty path.
geom = geom ? union(geom, piece) : [piece];
}
return geom;
} catch {
return null;
}
}
/**
* SVG path for the thick-wall preview while drawing a room outline.
* Closed contours use outset−inset; open polylines use the same bounded joins
* as persisted independent walls. `segmentHalfDepths` preserves the thickness
* already committed for each draft segment while the last rubber-band uses the
* current session value.
*/
export function drawWallPreviewD(
pts: number[][],
halfDepth: number,
closed: boolean,
segmentHalfDepths?: number[],
): 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((_, i) => segmentHalfDepths?.[i] || halfDepth);
const outset = outsetContour(poly, offs);
const inset = insetContour(poly, offs);
if (outset && inset) {
return `${polyToPath(outset)} ${polyToPath(reversePoly(inset))}`;
}
}
}
const segments: LinearWallSegment[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
const h = segmentHalfDepths?.[i] || halfDepth;
if (Math.hypot(b[0] - a[0], b[1] - a[1]) >= 1e-9 && h > 0)
segments.push({ a, b, halfDepth: h });
}
const bodies = segments.map(linearWallBody).filter((body): body is number[][] => !!body);
const joined = [...bodies, ...linearWallJoinPatches(segments)];
const geom = unionSimpleBodies(joined);
if (geom) return polyclipToPathD(geom);
return joined.map((body) => polyToPath(body)).join(' ');
}
/**
* 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]];
}
/**
* Clear distance between the wall faces along edge `index` (#233).
*
* The resize labels used to measure centrelines while the area label already
* measured the floor, so one bubble carried two conventions: "3.00 x 4.00" from
* wall centres next to an area from inner faces, and neither number could be
* checked with a tape measure.
*
* Indices of `insetContour` are deliberately NOT used: that function emits one
* point per corner (mitre), two (bevel, collinear joint, zero-thickness joint)
* or the original vertex, so inner and centre polygons do not share an index
* space. The span is obtained by intersecting the inner offset LINES instead,
* which is exact for any angle, diagonals included.
*
* `poly` is the room's own polygon and `offsets` carries one half-depth per its
* edge — resolved from the atomic profile by the caller, because a whole-edge
* `thicknessCmAt` lookup returns 0 on a split-thickness edge.
*/
export function innerEdgeSpan(poly: number[][], index: number, offsets: number[]): number {
const n = poly?.length || 0;
if (n < 3 || !Array.isArray(offsets) || offsets.length !== n) return 0;
const i = ((Math.trunc(index) % n) + n) % n;
const a = poly[i], b = poly[(i + 1) % n];
const dx = b[0] - a[0], dy = b[1] - a[1];
const centre = Math.hypot(dx, dy);
if (!(centre > 0)) return 0;
const own = Math.max(0, Number(offsets[i]) || 0);
// The zero rule comes first (#233, spec review r1/H1): a passage or a side
// open to the neighbouring room has no face to measure from, so its label is
// the full centreline. `insetContour` treats the same joint as a flat cap
// (#172) and shortens nothing either — otherwise length and area would
// diverge again, merely at a different boundary.
if (!(own > 0)) return centre;
const u: [number, number] = [dx / centre, dy / centre];
const selfNormal = inwardNormal(poly, i);
const selfPoint = [a[0] + selfNormal[0] * own, a[1] + selfNormal[1] * own];
// Distance from `a` measured along the edge; the inner line of a neighbour
// that has no thickness does not cut this edge at all.
const cutAt = (edge: number): number | null => {
const o = Math.max(0, Number(offsets[edge]) || 0);
if (!(o > 0)) return null;
const p0 = poly[edge], p1 = poly[(edge + 1) % n];
const ex = p1[0] - p0[0], ey = p1[1] - p0[1];
const len = Math.hypot(ex, ey);
if (!(len > 0)) return null;
const nrm = inwardNormal(poly, edge);
const hit = lineIntersect(
selfPoint, u, [p0[0] + nrm[0] * o, p0[1] + nrm[1] * o], [ex / len, ey / len],
);
if (!hit) return null;
return (hit[0] - a[0]) * u[0] + (hit[1] - a[1]) * u[1];
};
const start = cutAt((i - 1 + n) % n) ?? 0;
const end = cutAt((i + 1) % n) ?? centre;
const span = end - start;
// Walls thicker than the room they enclose: report nothing left, never a
// negative length.
return span > 0 ? span : 0;
}
/**
* One half-depth per edge of the room's OWN polygon (#233).
*
* The profile is atomic — its polygon is cut at shared boundaries — so an own
* edge may cover several stretches. The stretch holding the edge midpoint is
* the representative: the clear distance between the walls at the two ENDS of
* the edge is what the label reports, and a differently thick stretch in the
* middle does not change that distance.
*/
export function ownEdgeOffsets(
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 own = roomPoly(room);
if (!own || own.length < 3) return null;
const profile = roomWallProfile(
rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (!profile) return own.map(() => 0);
const eps = openEps(pitch, coordScale) * 4;
return own.map((a, i) => {
const b = own[(i + 1) % own.length];
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
for (let k = 0; k < profile.poly.length; k++) {
const p0 = profile.poly[k], p1 = profile.poly[(k + 1) % profile.poly.length];
// Уже существующая distToSeg принимает координаты, а не точки.
if (distToSeg(mid[0], mid[1], p0[0], p0[1], p1[0], p1[1]) <= eps) {
return Math.max(0, profile.offsets[k] || 0);
}
}
return 0;
});
}
/**
* 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[],
multiWallNodes?: MultiWallNodeMap | null,
): 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;
}
// #172: a physical edge meeting a zero-depth divider owns a square cap,
// not a mitre into the divider. Keep both sides of that cap explicitly:
// the offset point of the physical edge and the untouched vertex of the
// zero edge. Letting the generic mitre/bevel path handle a near-collinear
// join drops the untouched vertex and stretches the cap along the complete
// divider as a triangular wall body.
if ((oA > 0) !== (oB > 0)) {
const v = poly[i];
const pa = oA > 0 ? [v[0] + nAx * oA, v[1] + nAy * oA] : [v[0], v[1]];
const pb = oB > 0 ? [v[0] + nBx * oB, v[1] + nBy * oB] : [v[0], v[1]];
out.push(pa);
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
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);
const joinLimit = multiWallNodeAt(multiWallNodes, poly[i])?.limit
?? MITRE_LIMIT * maxO;
if (hit) {
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
if (Number.isFinite(dist) && dist <= joinLimit) {
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[];
}
interface PendingMultiWallNode {
point: [number, number];
rays: Array<{
u: [number, number]; halfDepth: number; length: number; angle: number;
}>;
}
function spatialBucket(point: number[], epsilon: number): [number, number] {
return [Math.floor(point[0] / epsilon), Math.floor(point[1] / epsilon)];
}
function spatialBucketKey(x: number, y: number): string {
return `${x},${y}`;
}
function nearbyBuckets<T>(
index: Map<string, T[]>,
point: number[],
epsilon: number,
): T[] {
const [bx, by] = spatialBucket(point, epsilon);
const out: T[] = [];
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const values = index.get(spatialBucketKey(bx + dx, by + dy));
if (values) out.push(...values);
}
}
return out;
}
/**
* Canonical degree-3+ physical endpoint map (#249).
*
* Shared intervals may occur once per owning room. They collapse first by
* interval key and again by co-directional ray, while opposite directions
* remain distinct. Sorting makes the representative and ray order independent
* of room/wall input order and winding.
*/
export function buildMultiWallNodeMap(
input: WallInterval[],
epsilon = 1e-6,
coordinateScale = 1,
): MultiWallNodeMap {
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
const scale = Number.isFinite(coordinateScale) && coordinateScale > 0
? coordinateScale : 1;
const valid = (input || [])
.filter((iv) => iv && !iv.open && iv.kind !== null && Number.isFinite(iv.half) && iv.half > 0
&& Array.isArray(iv.a) && Array.isArray(iv.b)
&& iv.a.length >= 2 && iv.b.length >= 2
&& [iv.a[0], iv.a[1], iv.b[0], iv.b[1]].every(Number.isFinite)
&& Math.hypot(iv.b[0] - iv.a[0], iv.b[1] - iv.a[1]) > eps)
.sort((a, b) => a.key.localeCompare(b.key)
|| a.a[0] - b.a[0] || a.a[1] - b.a[1]
|| a.b[0] - b.b[0] || a.b[1] - b.b[1]
|| a.half - b.half);
const byPhysicalKey = new Map<string, WallInterval>();
for (const interval of valid) {
const previous = byPhysicalKey.get(interval.key);
if (!previous) {
byPhysicalKey.set(interval.key, interval);
} else if (interval.half > previous.half) {
// A shared physical interval may be emitted by both room owners. Keep
// one deterministic axis and the largest effective physical half-depth.
byPhysicalKey.set(interval.key, { ...previous, half: interval.half });
}
}
const intervals = [...byPhysicalKey.values()];
const endpoints = intervals.flatMap((iv) => [
{ point: [iv.a[0], iv.a[1]] as [number, number], other: iv.b, halfDepth: iv.half },
{ point: [iv.b[0], iv.b[1]] as [number, number], other: iv.a, halfDepth: iv.half },
]).sort((a, b) => a.point[0] - b.point[0] || a.point[1] - b.point[1]
|| a.other[0] - b.other[0] || a.other[1] - b.other[1]
|| a.halfDepth - b.halfDepth);
const pending: PendingMultiWallNode[] = [];
const pendingIndex = new Map<string, PendingMultiWallNode[]>();
for (const endpoint of endpoints) {
const candidates = nearbyBuckets(pendingIndex, endpoint.point, eps)
.filter((node) => Math.hypot(
node.point[0] - endpoint.point[0], node.point[1] - endpoint.point[1],
) <= eps)
.sort((a, b) => Math.hypot(
a.point[0] - endpoint.point[0], a.point[1] - endpoint.point[1],
) - Math.hypot(
b.point[0] - endpoint.point[0], b.point[1] - endpoint.point[1],
) || a.point[0] - b.point[0] || a.point[1] - b.point[1]);
let node = candidates[0];
if (!node) {
node = { point: [...endpoint.point], rays: [] };
pending.push(node);
const [bx, by] = spatialBucket(node.point, eps);
const key = spatialBucketKey(bx, by);
const bucket = pendingIndex.get(key) || [];
bucket.push(node);
pendingIndex.set(key, bucket);
}
const dx = endpoint.other[0] - endpoint.point[0];
const dy = endpoint.other[1] - endpoint.point[1];
const length = Math.hypot(dx, dy);
if (!(length > eps)) continue;
const u: [number, number] = [dx / length, dy / length];
let angle = Math.atan2(u[1], u[0]);
if (angle < 0) angle += Math.PI * 2;
node.rays.push({ u, halfDepth: endpoint.halfDepth, length, angle });
}
const nodes: MultiWallNode[] = [];
const angleEps = 1e-9;
const canonicalSupports = (
input: MultiWallNodeRaySupport[],
): MultiWallNodeRaySupport[] => {
// Endpoint clustering uses a deliberately visible plan-space tolerance;
// dominance between already matched physical strips must not. Otherwise a
// long thin strip can erase a shorter thick strip merely because their
// half-depth difference is below the node lookup epsilon.
const supportEps = 1e-9 * Math.max(1, scale);
const validSupports = input.filter((support) => Number.isFinite(support.halfDepth)
&& support.halfDepth > 0 && Number.isFinite(support.length) && support.length > eps);
return validSupports
.filter((support, index) => !validSupports.some((other, otherIndex) => (
otherIndex !== index
&& other.halfDepth >= support.halfDepth - supportEps
&& other.length >= support.length - supportEps
&& (other.halfDepth > support.halfDepth + supportEps
|| other.length > support.length + supportEps
|| otherIndex < index)
)))
.sort((a, b) => a.length - b.length || a.halfDepth - b.halfDepth)
.map((support) => ({ ...support }));
};
for (const node of pending) {
const sorted = node.rays.sort((a, b) => a.angle - b.angle
|| a.length - b.length || a.halfDepth - b.halfDepth);
const rays: Array<{
u: [number, number]; angle: number; supports: MultiWallNodeRaySupport[];
}> = [];
for (const ray of sorted) {
const previous = rays[rays.length - 1];
if (previous && Math.abs(ray.angle - previous.angle) <= angleEps) {
previous.supports.push({ halfDepth: ray.halfDepth, length: ray.length });
} else {
rays.push({
u: [...ray.u], angle: ray.angle,
supports: [{ halfDepth: ray.halfDepth, length: ray.length }],
});
}
}
if (rays.length > 1
&& Math.PI * 2 - rays[rays.length - 1].angle + rays[0].angle <= angleEps) {
const last = rays.pop()!;
rays[0].supports.push(...last.supports);
}
if (rays.length < 3) continue;
const canonicalRays = rays.map((ray) => {
const supports = canonicalSupports(ray.supports);
return {
u: [...ray.u] as [number, number],
halfDepth: Math.max(...supports.map((support) => support.halfDepth)),
length: Math.max(...supports.map((support) => support.length)),
supports,
};
}).filter((ray) => Number.isFinite(ray.halfDepth) && ray.halfDepth > 0
&& Number.isFinite(ray.length) && ray.length > eps);
if (canonicalRays.length < 3) continue;
const halfDepth = Math.max(...canonicalRays.map((ray) => ray.halfDepth));
if (!(halfDepth > 0) || !Number.isFinite(halfDepth)) continue;
nodes.push({
point: [...node.point],
rays: canonicalRays,
halfDepth,
limit: MULTI_WALL_JOIN_LIMIT * halfDepth,
});
}
nodes.sort((a, b) => a.point[0] - b.point[0] || a.point[1] - b.point[1]);
const index = new Map<string, MultiWallNode[]>();
for (const node of nodes) {
const [bx, by] = spatialBucket(node.point, eps);
const key = spatialBucketKey(bx, by);
const bucket = index.get(key) || [];
bucket.push(node);
index.set(key, bucket);
}
return { epsilon: eps, coordinateScale: scale, nodes, index };
}
/** Find the canonical degree-3+ node matching a contour vertex. */
export function multiWallNodeAt(
map: MultiWallNodeMap | null | undefined,
point: number[],
): MultiWallNode | null {
if (!map || !Array.isArray(point) || point.length < 2
|| !point.slice(0, 2).every(Number.isFinite)) return null;
return nearbyBuckets(map.index, point, map.epsilon)
.filter((node) => Math.hypot(node.point[0] - point[0], node.point[1] - point[1]) <= map.epsilon)
.sort((a, b) => Math.hypot(a.point[0] - point[0], a.point[1] - point[1])
- Math.hypot(b.point[0] - point[0], b.point[1] - point[1])
|| a.point[0] - b.point[0] || a.point[1] - b.point[1])[0] || null;
}
function multiWallNodesForGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale: number,
): MultiWallNodeMap {
return buildMultiWallNodeMap(
wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale),
openEps(pitch, coordScale) * 4,
coordScale,
);
}
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;
/**
* A bounded inward bevel may cross a neighbouring source edge when incident
* wall depths differ sharply. Keep only the part that is physically inside
* the room and return its largest outer ring; room consumers accept one simple
* contour and handle nested-room holes separately.
*/
function clipInnerContourToRoom(
contour: number[][],
room: number[][],
): number[][] | null {
try {
const clipped = intersection(
closedRing(contour) as any,
closedRing(room) as any,
);
return largestOuterContour(clipped);
} catch {
return null;
}
}
/** Largest simple outer ring from polygon-clipping geometry. */
function largestOuterContour(geometry: any): number[][] | null {
let best: number[][] | null = null;
let bestArea = 0;
for (const polygon of geometry || []) {
const raw = polygon?.[0];
if (!Array.isArray(raw) || raw.length < 4) continue;
const ring = raw.slice(0, -1).map((point: number[]) => [point[0], point[1]]);
const area = Math.abs(signedArea(ring));
if (ring.length >= 3 && area > bestArea) {
best = ring;
bestArea = area;
}
}
return best;
}
/**
* 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,
/** Canonical room-wall masonry before opening cuts; pass the render cache. */
sharedRoomWallGeometry?: any,
): 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]]);
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const inset = insetContour(pr.poly, pr.offsets, multiWallNodes);
if (!inset) return poly.map((p) => [p[0], p[1]]);
if (!multiWallNodes.nodes.length) return inset;
const roomWallGeometry = sharedRoomWallGeometry ?? wallBodiesGeometry(
rooms, walls, openCuts, [], pitch, cellCm, gridPitch, coordScale,
)?.roomGeom;
if (roomWallGeometry) {
try {
const floor = difference(closedRing(pr.poly) as any, roomWallGeometry);
const contour = largestOuterContour(floor);
if (contour) return contour;
} catch {
// Fall through to the bounded contour clip; never return an outside tip.
}
}
return clipInnerContourToRoom(inset, pr.poly)
|| 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 MultiWallRoomRing {
outset: number[][];
inset: number[][] | null;
}
/** Excess pairwise overlap cuts removed to expose the straight bevel. */
function multiWallBevelCutsAt(
map: MultiWallNodeMap | null | undefined,
retainToLimit: boolean,
connectToExterior = false,
): number[][][] {
if (!map) return [];
const cuts: number[][][] = [];
for (const node of map.nodes) {
for (let i = 0; i < node.rays.length; i++) {
const a = node.rays[i], b = node.rays[(i + 1) % node.rays.length];
const angleA = Math.atan2(a.u[1], a.u[0]);
let angleB = Math.atan2(b.u[1], b.u[0]);
while (angleB <= angleA) angleB += Math.PI * 2;
const gap = angleB - angleA;
if (!(gap > 1e-9) || gap >= Math.PI - 1e-9) continue;
const nA = [-a.u[1], a.u[0]];
const nB = [-b.u[1], b.u[0]];
const pA = [
node.point[0] + nA[0] * a.halfDepth,
node.point[1] + nA[1] * a.halfDepth,
];
const pB = [
node.point[0] - nB[0] * b.halfDepth,
node.point[1] - nB[1] * b.halfDepth,
];
const hit = lineIntersect(pA, a.u, pB, b.u);
if (!hit) continue;
const distance = Math.hypot(
hit[0] - node.point[0], hit[1] - node.point[1],
);
if (!Number.isFinite(distance) || distance <= node.limit) continue;
// Canonical masonry and its exterior paper retain pairwise overlap up
// to R so ordinary right-angle arms stay area-connected. Starting the
// cut at the offset origins removes valid exterior half-wall material.
const advanceA = retainToLimit ? Math.sqrt(Math.max(
0, node.limit * node.limit - a.halfDepth * a.halfDepth,
)) : 0;
const advanceB = retainToLimit ? Math.sqrt(Math.max(
0, node.limit * node.limit - b.halfDepth * b.halfDepth,
)) : 0;
const qA = [pA[0] + a.u[0] * advanceA, pA[1] + a.u[1] * advanceA];
const qB = [pB[0] + b.u[0] * advanceB, pB[1] + b.u[1] * advanceB];
const cut = stableJunctionPatch([qA, qB, hit], map.coordinateScale);
if (cut) cuts.push(cut);
if (connectToExterior) {
// The two offset faces meet at `hit`, so a cut that ends exactly there
// only touches the exterior at one mathematical point. Polygon
// clipping and SVG then quite correctly retain it as an enclosed hole
// (the white junction triangles from #272). Add a small physical
// corridor across the tip into the already-empty angular sector so
// the cut has a finite-width exit without changing the approved R
// endpoints.
const dx = hit[0] - node.point[0], dy = hit[1] - node.point[1];
const length = Math.hypot(dx, dy);
const clearance = distance - node.limit;
if (length > map.epsilon && clearance > map.epsilon) {
const bridge = Math.min(
Math.max(map.epsilon * 8, node.halfDepth * 0.05),
clearance * 0.25,
);
const ux = dx / length, uy = dy / length;
const vx = -uy, vy = ux;
// This tapered connector is a strict subset of the original square
// corridor: it keeps the same finite cross-section at `hit` and the
// same exterior reach, but avoids two extra contour corners in every
// large-plan junction path.
const connector = stableJunctionPatch([
[hit[0] - ux * bridge + vx * bridge,
hit[1] - uy * bridge + vy * bridge],
[hit[0] - ux * bridge - vx * bridge,
hit[1] - uy * bridge - vy * bridge],
[hit[0] + ux * bridge, hit[1] + uy * bridge],
], map.coordinateScale);
if (connector) cuts.push(connector);
}
}
}
}
return cuts;
}
export function multiWallBevelTriangles(
map: MultiWallNodeMap | null | undefined,
): number[][][] {
return multiWallBevelCutsAt(map, true);
}
/**
* Collapse local cut patches before subtracting them from a large wall body.
*
* `A − p1 − p2 ...` is geometrically identical to `A − union(p1, p2,
* ...)`, but the latter traverses the large subject only once. The exterior
* connectors from #272 made the former path repeat that expensive traversal
* for every bevel sector. Keep malformed patches isolated just like the old
* per-patch subtraction loop did.
*/
function multiWallCutGeometry(cuts: number[][][]): any {
let geometry: any = null;
for (const cut of cuts) {
const piece: any = closedRing(cut) as any;
try {
geometry = geometry ? union(geometry, piece) : piece;
} catch {
// One unusable local patch must not discard the remaining valid cuts.
}
}
return geometry;
}
/** Rays whose finite strips must survive every bevel cut at this node (#275). */
export function multiWallProtectedRayIndexes(
node: MultiWallNode,
dotEpsilon = MULTI_WALL_ORTHOGONAL_DOT_EPSILON,
): number[] {
const protectedRays = new Set<number>();
const epsilon = Number.isFinite(dotEpsilon) && dotEpsilon >= 0
? dotEpsilon
: MULTI_WALL_ORTHOGONAL_DOT_EPSILON;
for (let i = 0; i < node.rays.length; i++) {
for (let j = i + 1; j < node.rays.length; j++) {
const a = node.rays[i].u, b = node.rays[j].u;
const dot = Math.abs(a[0] * b[0] + a[1] * b[1]);
if (dot <= epsilon) {
protectedRays.add(i);
protectedRays.add(j);
}
}
}
return [...protectedRays].sort((a, b) => a - b);
}
function multiWallRayStripGeometry(
node: MultiWallNode,
map: MultiWallNodeMap,
extent: number,
rayIndexes?: readonly number[],
): any {
const selected = rayIndexes ? new Set(rayIndexes) : null;
let geometry: any = null;
for (let rayIndex = 0; rayIndex < node.rays.length; rayIndex++) {
if (selected && !selected.has(rayIndex)) continue;
const ray = node.rays[rayIndex];
const n = [-ray.u[1], ray.u[0]];
// A canonical direction may be owned by overlapping room intervals with
// different depth/length pairs. Preserve their exact finite union.
for (const support of ray.supports) {
const supportExtent = Math.min(extent, support.length);
if (!(supportExtent > map.epsilon)) continue;
const rectangle = stableJunctionPatch([
[node.point[0] + n[0] * support.halfDepth,
node.point[1] + n[1] * support.halfDepth],
[node.point[0] + ray.u[0] * supportExtent + n[0] * support.halfDepth,
node.point[1] + ray.u[1] * supportExtent + n[1] * support.halfDepth],
[node.point[0] + ray.u[0] * supportExtent - n[0] * support.halfDepth,
node.point[1] + ray.u[1] * supportExtent - n[1] * support.halfDepth],
[node.point[0] - n[0] * support.halfDepth,
node.point[1] - n[1] * support.halfDepth],
], map.coordinateScale);
if (!rectangle) continue;
const piece: any = closedRing(rectangle) as any;
geometry = geometry ? union(geometry, piece) : piece;
}
}
return geometry;
}
/** Finite local strips protected by at least one perpendicular partner. */
export function multiWallProtectedStripGeometry(
node: MultiWallNode,
map: MultiWallNodeMap,
extent = (MITRE_LIMIT * node.halfDepth + map.epsilon * 2) * 2,
): any {
const protectedRays = multiWallProtectedRayIndexes(node);
return protectedRays.length
? multiWallRayStripGeometry(node, map, extent, protectedRays)
: null;
}
function multiWallProtectedMapGeometry(map: MultiWallNodeMap): any {
let geometry: any = null;
for (const node of map.nodes) {
const protectedStrips = multiWallProtectedStripGeometry(node, map);
if (protectedStrips) {
geometry = geometry ? union(geometry, protectedStrips) : protectedStrips;
}
}
return geometry;
}
function multiWallEffectiveCutGeometry(
node: MultiWallNode,
map: MultiWallNodeMap,
retainToLimit: boolean,
connectToExterior: boolean,
protectedStrips: any,
): any {
const nodeMap = { ...map, nodes: [node] };
const cuts = multiWallCutGeometry(
multiWallBevelCutsAt(nodeMap, retainToLimit, connectToExterior),
);
return cuts && protectedStrips ? difference(cuts, protectedStrips) : cuts;
}
function bevelMultiWallBody(
body: any,
map: MultiWallNodeMap,
centre?: any,
envelope?: any,
): any {
if (!body || !map.nodes.length) return body;
let protectedStrips: any = null;
try {
// Node masks may overlap (a short wall can end inside both). Every local
// pass must therefore preserve the protected strips of neighbouring nodes,
// not only its own, or the later pass can erase the earlier repair.
protectedStrips = multiWallProtectedMapGeometry(map);
} catch {
// A bevel is optional. If its protection cannot be built, keep the complete
// pre-bevel body instead of risking another user-visible structural hole.
return body;
}
let current = body;
for (const node of map.nodes) {
const radius = MITRE_LIMIT * node.halfDepth + map.epsilon * 2;
const extent = radius * 2;
const mask = [
[node.point[0] - radius, node.point[1] - radius],
[node.point[0] + radius, node.point[1] - radius],
[node.point[0] + radius, node.point[1] + radius],
[node.point[0] - radius, node.point[1] + radius],
];
try {
let boundedCurrent = current;
const outerCuts = multiWallEffectiveCutGeometry(
node, map, false, true, protectedStrips,
);
if (outerCuts) boundedCurrent = difference(boundedCurrent, outerCuts);
let local = multiWallRayStripGeometry(node, map, extent);
const retainedCuts = multiWallEffectiveCutGeometry(
node, map, true, true, protectedStrips,
);
if (retainedCuts) {
// Rebuild the physical half-strips first, then remove only their
// excessive pairwise overlap. Applying this cut to the legacy room
// ring itself can delete an incident half-strip and strand floor.
local = difference(local, retainedCuts);
}
// The same protected material is restored after subtraction so boolean
// ordering/rounding cannot turn a right-angle wall into an open notch.
if (protectedStrips) local = union(local, protectedStrips);
// Rays share a mathematical endpoint. A tiny physical core turns that
// point contact into a stable polygon contact for boolean/render paths.
const coreRadius = Math.min(
...node.rays.map((ray) => ray.halfDepth),
) * 0.02;
local = union(local, closedRing([
[node.point[0] - coreRadius, node.point[1] - coreRadius],
[node.point[0] + coreRadius, node.point[1] - coreRadius],
[node.point[0] + coreRadius, node.point[1] + coreRadius],
[node.point[0] - coreRadius, node.point[1] + coreRadius],
]) as any);
if (!local) continue;
let localInside = intersection(local, closedRing(mask) as any);
// `envelope` is the bounded physical paper, including the exterior
// half-walls. Clipping the repair to the room-centre union first drops
// exactly the valid T-junction wedge this reconstruction must retain.
if (envelope) localInside = intersection(localInside, envelope);
else if (centre) localInside = intersection(localInside, centre);
const outside = difference(boundedCurrent, closedRing(mask) as any);
const preservedExterior = centre
? difference(
intersection(boundedCurrent, closedRing(mask) as any),
centre,
)
: null;
current = preservedExterior
? union(outside, preservedExterior, localInside)
: union(outside, localInside);
} catch {
// Isolate the failed node. Other valid nodes still receive their repair;
// mandatory surrounding structural failures remain fail-dark upstream.
}
}
if (protectedStrips) {
try {
let protectedInside = protectedStrips;
if (envelope) protectedInside = intersection(protectedInside, envelope);
else if (centre) protectedInside = intersection(protectedInside, centre);
current = union(current, protectedInside);
} catch {
// Per-node reconstruction above already retained the same material.
}
}
return current;
}
function bevelMultiWallPaper(
paper: any,
centre: any,
map: MultiWallNodeMap,
): any {
let beveled = paper;
let protectedStrips: any = null;
try {
protectedStrips = multiWallProtectedMapGeometry(map);
} catch {
return paper;
}
for (const node of map.nodes) {
try {
const cuts = multiWallEffectiveCutGeometry(
node, map, true, true, protectedStrips,
);
if (cuts) beveled = difference(beveled, cuts);
} catch {
// Isolate a failed optional node cut; retain the last valid paper.
}
}
try {
if (protectedStrips) {
const protectedPaper = intersection(protectedStrips, paper);
beveled = union(beveled, protectedPaper);
}
// Paper is the complete room footprint. Interior bevel cuts expose floor,
// not the scene background, so the centre union must always remain solid.
return union(centre, beveled);
} catch {
return paper;
}
}
/**
* Collapse arithmetic noise on computed junction vertices before polyclip sees
* them. The quantum is relative to the caller coordinate scale and remains
* many orders of magnitude below the geometry epsilon: it must never snap a
* physical half-depth or mitre to the drawing grid.
*/
export function stableJunctionPatch(
patch: number[][],
coordScale = 1,
): number[][] | null {
if (!Array.isArray(patch) || patch.length < 3) return null;
const scale = Number.isFinite(coordScale) && coordScale > 0 ? coordScale : 1;
const quantum = Math.max(1, scale) * 1e-12;
const stable: number[][] = [];
for (const point of patch) {
if (!Array.isArray(point) || point.length < 2) return null;
const x = Number(point[0]), y = Number(point[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
const sx = Math.round(x / quantum) * quantum;
const sy = Math.round(y / quantum) * quantum;
if (!Number.isFinite(sx) || !Number.isFinite(sy)) return null;
stable.push([Object.is(sx, -0) ? 0 : sx, Object.is(sy, -0) ? 0 : sy]);
}
return Math.abs(signedArea(stable)) > quantum * quantum ? stable : null;
}
type JunctionUnion = (subject: any, clipping: any) => any;
/**
* Add optional virtual-junction patches transactionally. A single rejected
* patch may retain the local pre-patch contour, but it must not discard the
* already valid masonry for the entire space or prevent later patches.
*/
export function unionJunctionPatches(
body: any,
patches: number[][][],
coordScale = 1,
unionFn: JunctionUnion = union,
): any {
let current = body;
for (const raw of patches || []) {
const patch = stableJunctionPatch(raw, coordScale);
if (!patch) continue;
try {
const piece = closedRing(patch) as any;
const next = current ? unionFn(current, piece) : piece;
current = next;
} catch {
// Keep the last valid body and continue. Returning null here would erase
// every unrelated wall, floor/light barrier and successful later patch.
}
}
return current;
}
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;
const projectedEps = eps * Math.sqrt(len2);
return dot >= -projectedEps && dot <= len2 + projectedEps;
}
/**
* 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;
// `t` below is a dimensionless fraction of this edge, while `eps` is a
// render-space distance. Comparing them directly drops every interior cut
// on production-scale plans (for example eps ~= 0.67 at coordScale=1000).
// Convert the shared geometry tolerance to the edge's local 0..1 domain.
const tEps = eps / Math.sqrt(len2);
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 > tEps && t < 1 - tEps) cuts.push(t);
}
}
cuts.sort((x, y) => x - y);
const unique = cuts.filter(
(t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > tEps,
);
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,
sharedMultiWallNodes?: MultiWallNodeMap | null,
): 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 intervals = wallIntervals(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const outer = intervals.filter((iv) => iv.kind === 'outer' && iv.half > 0);
const eps = openEps(pitch, coordScale) * 4;
const multiWallNodes = sharedMultiWallNodes
|| buildMultiWallNodeMap(intervals, eps, coordScale);
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, multiWallNodes);
const inset = insetContour(profile.poly, profile.offsets, multiWallNodes);
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 || [],
};
}
/**
* Canonical Stage floor footprint: room union plus derived exterior masonry.
* Independent partitions/columns are deliberately not accepted here, so they
* can never enlarge the slab perimeter. Null is a boolean failure; an empty
* array is a valid space without room geometry.
*/
export function floorFootprintGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): any | null {
try {
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, multiWallNodes,
);
if (!exterior) return [];
const paper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
return multiWallNodes.nodes.length
? bevelMultiWallPaper(paper, exterior.centre, multiWallNodes)
: paper;
} catch {
return null;
}
}
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.
*/
export function virtualJunctionPatches(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale: number,
sharedMultiWallNodes?: MultiWallNodeMap | null,
): 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 multiWallNodes = sharedMultiWallNodes
|| buildMultiWallNodeMap(intervals, eps);
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);
const multiNode = multiWallNodeAt(multiWallNodes, v);
const limit = multiNode?.limit ?? MITRE_LIMIT * maxHalf;
const farDistance = Math.hypot(far[0] - v[0], far[1] - v[1]);
let patch: number[][];
if (farDistance <= limit) {
patch = cross > 0 ? [v.slice(), pa, far, pb] : [v.slice(), pb, far, pa];
} else if (multiNode) {
const nA = [-a.u[1], a.u[0]];
const nB = [-b.u[1], b.u[0]];
const sign = cross < 0 ? 1 : -1;
const edgeA = [
v[0] + nA[0] * a.iv.half * sign,
v[1] + nA[1] * a.iv.half * sign,
];
const edgeB = [
v[0] - nB[0] * b.iv.half * sign,
v[1] - nB[1] * b.iv.half * sign,
];
patch = cross > 0 ? [v.slice(), edgeA, edgeB] : [v.slice(), edgeB, edgeA];
} else {
// Preserve the exact two-ray contract: an over-limit legacy mitre
// produces no virtual patch, just as before #249.
continue;
}
if (Math.abs(signedArea(patch)) > eps * eps) out.push(patch);
}
}
}
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[] = [];
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
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, multiWallNodes);
const inset = insetContour(pr.poly, pr.offsets, multiWallNodes);
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;
/** Canonical room masonry before opening cuts and independent bodies. */
roomGeom: any;
paperGeom: any;
depthUnits: number;
openingIndex: OpeningWallIndex | null;
} | null {
if (!walls?.length && !extraBodies.length) return null;
const roomRings: MultiWallRoomRing[] = [];
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
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, multiWallNodes);
const inC = insetContour(pr.poly, pr.offsets, multiWallNodes);
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, multiWallNodes,
);
const openingIndex = openings.length
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
: null;
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, multiWallNodes,
);
// Paper and masonry share this one structural pass. Renderers cache the
// returned pair, so a live HA state update never repeats exterior topology.
const rawPaperGeom = exterior
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
: [];
const paperGeom = multiWallNodes.nodes.length && exterior
? bevelMultiWallPaper(rawPaperGeom, exterior.centre, multiWallNodes)
: rawPaperGeom;
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.
body = unionJunctionPatches(body, junctions, coordScale);
if (body && exterior) body = intersection(body, exterior.centre);
if (exterior?.shell?.length)
body = body ? union(body, exterior.shell) : exterior.shell;
if (body && multiWallNodes.nodes.length)
body = bevelMultiWallBody(body, multiWallNodes, exterior?.centre, paperGeom);
const roomGeom = body || [];
// 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 || [], roomGeom, paperGeom, depthUnits: maxDepth, openingIndex };
} 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;
roomGeom: any;
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, roomGeom: united.roomGeom, 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[],
multiWallNodes?: MultiWallNodeMap | null,
): 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;
}
// Mirror the inset contract above: one physical edge plus one zero-depth
// edge is a local cap with both endpoints present in traversal order.
if ((oA > 0) !== (oB > 0)) {
const v = poly[i];
const pa = oA > 0 ? [v[0] - nAx * oA, v[1] - nAy * oA] : [v[0], v[1]];
const pb = oB > 0 ? [v[0] - nBx * oB, v[1] - nBy * oB] : [v[0], v[1]];
out.push(pa);
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
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);
const joinLimit = multiWallNodeAt(multiWallNodes, poly[i])?.limit
?? MITRE_LIMIT * maxO;
if (hit) {
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
if (Number.isFinite(dist) && dist <= joinLimit) {
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 rawPaper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const paper = multiWallNodes.nodes.length
? bevelMultiWallPaper(rawPaper, exterior.centre, multiWallNodes)
: rawPaper;
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];
}
/**
* Physical half-depth and direction for an opening. Visible symbol placement
* is resolved separately and is always centred; flip_v changes only the
* opening direction (#250).
*/
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);
if (!available.length) return { ox: 0, oy: 0, cm: 0, side: -1 };
// An exterior wall keeps its real room-side direction so gates still open
// outward. A shared wall has no exterior, so use the opening-local negative
// side instead of the first room in model order.
const naturalSide = association.negative && association.positive
? -1
: available[0].side;
const selectedSide = (opening.flip_v ? -naturalSide : naturalSide) as -1 | 1;
const selected = (selectedSide === -1 ? association.negative : association.positive)
|| available[0];
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));
}