v1.59.0-beta.7: audit fixes, wall fill under hatch

Atomic wall intervals, open_spans in resize/Undo/Split/Delete, backend
open_spans schema, warm-nav ownership, smoke hygiene.
Wall fill colour paints under the hatch (both, not either/or).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Matysh
2026-08-05 17:52:26 +03:00
co-authored by Cursor
parent de5e8129a1
commit 7333223a55
30 changed files with 1365 additions and 498 deletions
+189 -142
View File
@@ -44,17 +44,19 @@ import {
cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS, type FurnitureGroup,
} from './furniture';
import {
thicknessCmAt, degradeWalls, rekeyWallsAfterMove,
degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, cmToField, fieldToCm, wallCmToUnits,
wallEdgeBodies, wallBodiesUnionPath, paperRoomShapesWithWalls,
innerContourForRoom, openingInnerFaceOffset, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, type WallEntry,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, wallIntervals, normalizeWallIntervals,
intervalCmAt, type WallEntry,
} from './wall-thickness';
import {
resolveOpenCuts, hitSharedWall, hitOuterWall, hitOpenSpan, snapOpenPoint,
clampToEdgeEnds, jointsOnEdge, cutsToSpanEntries, syncOpenToFromCuts,
clearThicknessUnderSpan, applyThicknessOnClose, purgeOpeningsOnSpan,
pointOnOpenCut, removeCut, rekeyOpenSpansAfterMove, degradeOpenSpans,
applyThicknessOnClose, purgeOpeningsOnSpan,
pointOnOpenCut, removeCut, rekeyOpenSpansAfterMove, degradeOpenSpans, clipOpenSpansToShared,
sanitizeOpenSpans, entryToSeg,
type OpenSpanEntry,
} from './open-spans';
import { ContentSigner } from './signing';
@@ -83,7 +85,7 @@ import {
import { alignAllToGrid, type AlignReport } from './align-grid';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.59.0-beta.6';
const CARD_VERSION = '1.59.0-beta.7';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -1298,7 +1300,12 @@ class HouseplanCard extends LitElement {
this._navApplied = true;
// the editor comes back only where an editor is allowed at all
this._mode = vp.mode !== 'view' && this._canEdit && !config.kiosk ? vp.mode : 'view';
if (vp.mode !== 'view' && !this._canEdit && !config.kiosk) this._pendingNavMode = vp.mode;
// AUD-159B6-04: the memo is the NEWER and MORE SPECIFIC record than the
// global LS nav — a neighbour card writing `mode=devices` into localStorage
// must not be replayed over this owner's viewport once can_write answers
// (it also broke the draft revival, whose guard compares the mode). So the
// pending intent is REPLACED here, never merely added to.
this._pendingNavMode = vp.mode !== 'view' && !this._canEdit && !config.kiosk ? vp.mode : null;
this._zoom = vp.zoom;
this._view = vp.view ? { ...vp.view } : null;
this._viewModeSnap = vp.snap ? { ...vp.snap } : null;
@@ -3371,7 +3378,9 @@ class HouseplanCard extends LitElement {
for (const sp of this._serverCfg?.spaces || []) {
delete (sp as any).segments;
if (Array.isArray(sp.walls)) {
sp.walls = degradeWalls(sp.walls, sp.rooms || [], GRID_STEP_N, 1);
const cuts = sanitizeOpenSpans((sp as any).open_spans)
.map((e) => [e.a[0], e.a[1], e.b[0], e.b[1]]);
sp.walls = degradeWalls(sp.walls, sp.rooms || [], GRID_STEP_N, 1, cuts);
if (!sp.walls.length) delete sp.walls;
}
}
@@ -3570,6 +3579,11 @@ class HouseplanCard extends LitElement {
rooms: sp?.rooms || [],
openings: sp?.openings || [],
walls: sp?.walls || [],
// AUD-159B6-02: `open_spans` is geometry too. Left out of the snapshot it
// survived Undo un-rekeyed, so the room went back and the dash stayed on
// the resized coordinates — a virtual stretch detached from any wall.
// `open_to` rides along inside `rooms`.
open_spans: (sp as any)?.open_spans || [],
});
}
@@ -3582,6 +3596,8 @@ class HouseplanCard extends LitElement {
sp.openings = s.openings;
if (Array.isArray(s.walls) && s.walls.length) sp.walls = s.walls;
else delete sp.walls;
if (Array.isArray(s.open_spans) && s.open_spans.length) (sp as any).open_spans = s.open_spans;
else delete (sp as any).open_spans;
this._cfgEpoch++;
this.requestUpdate();
}
@@ -3718,57 +3734,34 @@ class HouseplanCard extends LitElement {
const r = sp.rooms.find((x: any) => x.id === id);
if (r?.poly) r.poly = simplifyPoly(r.poly, 1e-9);
}
// docs/WALL-THICKNESS.md: rewrite thickness keys for all touched spans
// (edge drag and scale), then drop keys that no longer match any edge.
if (Array.isArray(sp.walls) && sp.walls.length) {
const H = this._spaceH;
const oldSpans: [number[], number[]][] = [];
const newSpans: [number[], number[]][] = [];
for (const id of g.changed) {
const oldR = g.rooms.find((r) => r.id === id);
const nr = sp.rooms.find((x: any) => x.id === id);
if (!oldR || !nr?.poly) continue;
const newPoly = nr.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H] as number[]);
if (oldR.poly.length !== newPoly.length) continue;
for (let i = 0; i < oldR.poly.length; i++) {
oldSpans.push([oldR.poly[i], oldR.poly[(i + 1) % oldR.poly.length]]);
newSpans.push([newPoly[i], newPoly[(i + 1) % newPoly.length]]);
}
// ONE geometry transaction (docs/WALL-THICKNESS.md, AUD-159B6-02):
// old→new edge pairs are built once, then the open spans move (they
// define where a wall is cut into atomic pieces) and only after that the
// thickness keys are rewritten and the dead ones dropped.
const H = this._spaceH;
const oldSpans: [number[], number[]][] = [];
const newSpans: [number[], number[]][] = [];
for (const id of g.changed) {
const oldR = g.rooms.find((r) => r.id === id);
const nr = sp.rooms.find((x: any) => x.id === id);
if (!oldR || !nr?.poly) continue;
const newPoly = nr.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H] as number[]);
if (oldR.poly.length !== newPoly.length) continue;
for (let i = 0; i < oldR.poly.length; i++) {
oldSpans.push([oldR.poly[i], oldR.poly[(i + 1) % oldR.poly.length]]);
newSpans.push([newPoly[i], newPoly[(i + 1) % newPoly.length]]);
}
}
this._commitOpenSpans({ old: oldSpans, next: newSpans });
if (Array.isArray(sp.walls) && sp.walls.length) {
if (oldSpans.length) {
sp.walls = rekeyWallsAfterMove(
sp.walls, oldSpans, newSpans, this._wallKeyPitch, NORM_W,
);
}
sp.walls = degradeWalls(sp.walls, sp.rooms || [], GRID_STEP_N, 1);
sp.walls = degradeWalls(sp.walls, sp.rooms || [], GRID_STEP_N, 1, this._cfgOpenCuts());
if (!sp.walls.length) delete sp.walls;
}
if (Array.isArray((sp as any).open_spans) && (sp as any).open_spans.length) {
const H = this._spaceH;
const oldSpans: [number[], number[]][] = [];
const newSpans: [number[], number[]][] = [];
for (const id of g.changed) {
const oldR = g.rooms.find((r) => r.id === id);
const nr = sp.rooms.find((x: any) => x.id === id);
if (!oldR || !nr?.poly) continue;
const newPoly = nr.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H] as number[]);
if (oldR.poly.length !== newPoly.length) continue;
for (let i = 0; i < oldR.poly.length; i++) {
oldSpans.push([oldR.poly[i], oldR.poly[(i + 1) % oldR.poly.length]]);
newSpans.push([newPoly[i], newPoly[(i + 1) % newPoly.length]]);
}
}
if (oldSpans.length) {
(sp as any).open_spans = rekeyOpenSpansAfterMove(
(sp as any).open_spans, oldSpans, newSpans, NORM_W,
);
}
(sp as any).open_spans = degradeOpenSpans(
(sp as any).open_spans, this._spaceModel().rooms, NORM_W, this._gridPitch * 0.02,
);
if (!(sp as any).open_spans.length) delete (sp as any).open_spans;
else this._persistOpenCuts(this._openCuts());
}
}
this._rszUndo.push({ space: this._space, snap: g.snap });
if (this._rszUndo.length > 30) this._rszUndo.shift();
@@ -5005,15 +4998,15 @@ class HouseplanCard extends LitElement {
/** Boundary under the cursor in the open-wall tool (hover preview). */
private get _openWallHover(): { segs: number[][]; open: boolean } | null {
if (!this._markup || this._tool !== 'openwall' || !this._cursorPt) return null;
// After the anchor: rubber-band from P1 to the clamped cursor — that is the
// only solid-wall preview. Do NOT light up the whole shared edge on hover:
// it hides where the click will land (owner 2026-08-05).
if (this._openWallAnchor) {
const p2 = clampToEdgeEnds(this._cursorPt, this._openWallAnchor.edge);
return { segs: [[this._openWallAnchor.p[0], this._openWallAnchor.p[1], p2[0], p2[1]]], open: false };
}
const cuts = this._openCuts();
const openSg = hitOpenSpan(this._cursorPt, cuts, this._gridPitch * 6);
if (openSg) return { segs: [openSg], open: true };
const sh = hitSharedWall(this._cursorPt, this._spaceModel().rooms, this._gridPitch * 6, this._gridPitch * 0.02);
return sh ? { segs: [sh.edge], open: false } : null;
const openSg = hitOpenSpan(this._cursorPt, this._openCuts(), this._gridPitch * 6);
return openSg ? { segs: [openSg], open: true } : null;
}
/** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */
@@ -5068,6 +5061,36 @@ class HouseplanCard extends LitElement {
return res;
}
/**
* One geometry transaction for open spans (AUD-159B6-02). Called by EVERY
* operation that rewrites the room set — resize/scale commit, Undo-adjacent
* commits, Split, Merge, Delete: rekey explicit spans onto the moved edges,
* drop/clip them against the NEW rooms, then derive `open_to` from what is
* left. The order matters: the legacy `open_to` index is never read in the
* middle, or a removed explicit span resurrects a different stretch.
*/
private _commitOpenSpans(
rekey?: { old: [number[], number[]][]; next: [number[], number[]][] },
): void {
const sp = this._curSpaceCfg;
if (!sp) return;
const eps = this._gridPitch * 0.02;
this._cfgEpoch++; // the room set changed under the model memo
const rooms = this._spaceModel().rooms;
let spans = sanitizeOpenSpans((sp as any).open_spans);
if (spans.length && rekey?.old.length) {
spans = rekeyOpenSpansAfterMove(spans, rekey.old, rekey.next, NORM_W);
} else if (!spans.length) {
// A legacy `open_to`-only space: its truth is connectivity, so the
// stretches are re-derived on the new geometry and persisted here (the
// first-save migration the spec asks for).
spans = cutsToSpanEntries(resolveOpenCuts(rooms, null, NORM_W, eps), NORM_W);
}
spans = degradeOpenSpans(spans, rooms, NORM_W, eps);
spans = clipOpenSpansToShared(spans, rooms, NORM_W, eps);
this._persistOpenCuts(spans.map((e) => entryToSeg(e, NORM_W)));
}
/** Write cuts into space.open_spans and sync open_to. */
private _persistOpenCuts(cuts: number[][]): void {
const sp = this._curSpaceCfg;
@@ -5083,22 +5106,29 @@ class HouseplanCard extends LitElement {
if (!sp) return;
const eps = this._gridPitch * 0.02;
const cuts = removeCut(this._openCuts(), sg, eps);
const solid: number[][] = [];
for (const r of this._spaceModel().rooms) {
const poly = roomPoly(r);
if (!poly) continue;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const edge = [a[0], a[1], b[0], b[1]];
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
if (cuts.some((c) => distToSegment(mid, c) < eps * 4)) continue;
solid.push(edge);
}
}
let walls = applyThicknessOnClose(
sp.walls, sg, solid, this._wallKeyPitch, NORM_W, DRAW_WALL_DEFAULT_CM,
// The stretch is solid again: rekey thickness onto the merged intervals
// first — a partially opened wall keeps the cm of the part that stayed
// closed. Only a wall that was open end to end has nothing to inherit and
// takes the default (owner 2026-08-05).
let walls = this._normalizeWalls(sp.walls, cuts);
const inherited = intervalCmAt(
this._spaceModel().rooms, walls, cuts, sg,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
walls = degradeWalls(walls, sp.rooms || [], GRID_STEP_N, 1);
if (!(inherited > 0)) {
const solid: number[][] = [];
for (const iv of wallIntervals(
this._spaceModel().rooms, walls, cuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
)) {
if (iv.open) continue;
solid.push([iv.a[0], iv.a[1], iv.b[0], iv.b[1]]);
}
walls = applyThicknessOnClose(
walls, sg, solid, this._wallKeyPitch, NORM_W, DRAW_WALL_DEFAULT_CM,
);
walls = this._normalizeWalls(walls, cuts);
}
if (walls.length) sp.walls = walls;
else delete sp.walls;
this._persistOpenCuts(cuts);
@@ -5132,8 +5162,12 @@ class HouseplanCard extends LitElement {
const next = [...cuts, sg];
const sp = this._curSpaceCfg;
if (!sp) return;
let walls = clearThicknessUnderSpan(sp.walls, [sg[0], sg[1]], [sg[2], sg[3]], this._wallKeyPitch, NORM_W);
walls = degradeWalls(walls, sp.rooms || [], GRID_STEP_N, 1);
// A virtual stretch carries no thickness: the key of the wall it cuts is
// SPLIT here — the covered piece is dropped, the solid remainder keeps
// the cm under its own atomic key (spec invariant, AUD-159B6-01). Simply
// deleting every key that touches the span, as this used to do, threw the
// remainder away with it.
const walls = this._normalizeWalls(sp.walls, next);
if (walls.length) sp.walls = walls;
else delete sp.walls;
const beforeOp = (sp.openings || []).length;
@@ -5202,11 +5236,7 @@ class HouseplanCard extends LitElement {
const sp = this._curSpaceCfg;
if (!sp) return;
sp.rooms = sp.rooms.filter((r: any) => r.id !== room.id);
if (Array.isArray((sp as any).open_spans)) {
(sp as any).open_spans = degradeOpenSpans((sp as any).open_spans, this._spaceModel().rooms, NORM_W, eps);
if (!(sp as any).open_spans.length) delete (sp as any).open_spans;
this._persistOpenCuts(this._openCuts());
}
this._commitOpenSpans();
this._saveConfig();
this._regSignature = '';
this._maybeRebuildDevices();
@@ -5226,6 +5256,36 @@ class HouseplanCard extends LitElement {
return Array.isArray(w) ? (w as WallEntry[]) : [];
}
/**
* Open cuts in CONFIG space (normalised), for key work that runs on
* `sp.rooms` rather than on the render model. The canvas is square, so both
* axes divide by NORM_W and a stored span IS a config-space segment.
* `degradeWalls` needs them: a wall split by an open span keeps its solid
* remainder under an atomic key that is only "live" when the cut is known.
*/
private _cfgOpenCuts(): number[][] {
return sanitizeOpenSpans((this._curSpaceCfg as any)?.open_spans)
.map((e) => [e.a[0], e.a[1], e.b[0], e.b[1]]);
}
/** Thickness of the atomic stretch under a segment (docs/WALL-THICKNESS.md). */
private _intervalCm(seg: number[]): number {
return intervalCmAt(
this._spaceModel().rooms, this._spaceWalls, this._openCuts(), seg,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
}
/** Rewrite thickness keys onto the current atomic intervals and drop dead ones. */
private _normalizeWalls(walls: WallEntry[] | null | undefined, cuts: number[][]): WallEntry[] {
const next = normalizeWallIntervals(
this._spaceModel().rooms, walls, cuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
return degradeWalls(next, this._curSpaceCfg?.rooms || [], GRID_STEP_N, 1,
cuts.map((c) => [c[0] / NORM_W, c[1] / NORM_W, c[2] / NORM_W, c[3] / NORM_W]));
}
/** Paper under rooms, grown by shared-wall half-thickness when set. */
private _paperShapes(rooms: any[]): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
const walls = this._spaceWalls;
@@ -5247,54 +5307,34 @@ class HouseplanCard extends LitElement {
).map((b) => [b.a[0], b.a[1], b.b[0], b.b[1]]);
}
/** Nearest room edge under the cursor for the wall-thickness tool. */
/**
* The ATOMIC wall stretch under the cursor for the wall-thickness tool.
*
* AUD-159B6-01: it used to return the whole polygon edge (only preferring a
* shared overlap), so a click on the outer remainder of a partially shared
* wall wrote a key that the renderer then spread over the shared part as
* well. The unit of the tool is now the same interval the renderer draws.
*/
private _wallThickHit(raw: number[]): {
a: number[]; b: number[]; roomId: string; segs: number[][]; open: boolean;
a: number[]; b: number[]; roomId: string; segs: number[][]; open: boolean; cm: number;
} | null {
const rooms = this._spaceModel().rooms.filter((r) => r.id);
const pull = this._gridPitch * 6;
let best: { a: number[]; b: number[]; roomId: string; d: number } | null = null;
for (const room of rooms) {
const poly = roomPoly(room);
if (!poly) continue;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const d = distToSegment(raw, [a[0], a[1], b[0], b[1]]);
if (d <= pull && (!best || d < best.d)) best = { a, b, roomId: room.id!, d };
}
const cuts = this._openCuts();
let best: { iv: ReturnType<typeof wallIntervals>[number]; d: number } | null = null;
for (const iv of wallIntervals(
this._spaceModel().rooms, this._spaceWalls, cuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
)) {
const d = distToSegment(raw, [iv.a[0], iv.a[1], iv.b[0], iv.b[1]]);
if (d <= pull && (!best || d < best.d)) best = { iv, d };
}
if (!best) return null;
// open-boundary stretches refuse thickness
const openSg = hitOpenSpan(raw, this._openCuts(), pull);
const open = !!openSg;
// prefer the atomic shared stretch under the cursor (partial overlaps)
let a = best.a, b = best.b;
let segs: number[][] = [[a[0], a[1], b[0], b[1]]];
const eps = this._gridPitch * 0.02;
const home = rooms.find((r) => r.id === best.roomId);
const homePoly = home ? roomPoly(home) : null;
if (homePoly) {
for (const other of rooms) {
if (other.id === best.roomId) continue;
const op = roomPoly(other);
if (!op) continue;
for (const sg of sharedBoundary(homePoly, op, eps)) {
if (distToSegment(raw, sg) <= pull) {
a = [sg[0], sg[1]];
b = [sg[2], sg[3]];
segs = [sg];
break;
}
}
if (segs.length === 1 && (segs[0][0] !== best.a[0] || segs[0][1] !== best.a[1]
|| segs[0][2] !== best.b[0] || segs[0][3] !== best.b[1])) break;
}
}
if (openSg) {
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
if (distToSegment(mid, openSg) < this._gridPitch) segs = [openSg];
}
return { a, b, roomId: best.roomId, segs, open };
const iv = best.iv;
return {
a: iv.a, b: iv.b, roomId: iv.roomId,
segs: [[iv.a[0], iv.a[1], iv.b[0], iv.b[1]]],
open: iv.open, cm: iv.cm,
};
}
private get _wallThickHover(): { segs: number[][]; open: boolean; d: string } | null {
@@ -5303,7 +5343,7 @@ class HouseplanCard extends LitElement {
if (!hit) return null;
// Whole-wall strip: real half-depth when thickness is set, else a visible
// minimum so thin centreline walls still light up under the cursor.
const cm = thicknessCmAt(this._spaceWalls, hit.a, hit.b, this._wallKeyPitch, NORM_W);
const cm = hit.cm;
const depth = cm > 0
? wallCmToUnits(cm, this._cellCm, this._gridPitch)
: this._gridPitch * 3;
@@ -5327,7 +5367,7 @@ class HouseplanCard extends LitElement {
this._showToast(this._t('toast.wallthick_open'));
return;
}
const cm = thicknessCmAt(this._spaceWalls, hit.a, hit.b, this._wallKeyPitch, NORM_W);
const cm = hit.cm;
const view = this._viewOr(this._baseVb());
const mx = (hit.a[0] + hit.b[0]) / 2, my = (hit.a[1] + hit.b[1]) / 2;
this._wallDialog = {
@@ -5345,17 +5385,16 @@ class HouseplanCard extends LitElement {
const sp = this._curSpaceCfg;
if (!sp) return;
const cm = fieldToCm(d.value, this._imperial);
const openCuts = this._openPairs().flatMap((p) => p.segs);
const openCuts = this._openCuts();
let next: WallEntry[];
if (allRoom && d.roomId) {
const room = this._spaceModel().rooms.find((r) => r.id === d.roomId);
next = setWallThicknessForRoom(
sp.walls, room, cm, this._wallKeyPitch, openCuts, NORM_W,
sp.walls, this._spaceModel().rooms, d.roomId, cm, this._wallKeyPitch, openCuts, NORM_W,
);
} else {
next = setWallThickness(sp.walls, d.a, d.b, cm, this._wallKeyPitch, NORM_W);
}
next = degradeWalls(next, sp.rooms || [], GRID_STEP_N, 1);
next = this._normalizeWalls(next, openCuts);
if (next.length) sp.walls = next;
else delete sp.walls;
this._wallDialog = null;
@@ -5399,7 +5438,12 @@ class HouseplanCard extends LitElement {
const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1;
const stroke = disp?.color || 'var(--hp-muted)';
const solid = united.depthUnits * px < 3;
return svg`<g class="wallbodies" style="--room-stroke:${stroke}">
const wf = this._fillColors.wall_fill;
// Fill colour UNDER the hatch (owner 2026-08-05): both, never one instead
// of the other. When the body is thinner than ~3px on screen the hatch
// collapses into noise — keep the solid fill alone.
return svg`<g class="wallbodies" style="--room-stroke:${stroke};--wall-fill:${wf.c};--wall-fill-op:${wf.a}">
<path class="wallbody-fill" d="${united.d}"></path>
<path class="wallbody ${solid ? 'solid' : ''}"
data-hp="wall" data-id="union" data-kind="union"
d="${united.d}"></path>
@@ -5711,14 +5755,7 @@ class HouseplanCard extends LitElement {
keep.poly = d.poly.map((p) => [p[0] / NORM_W, p[1] / H]);
delete keep.x; delete keep.y; delete keep.w; delete keep.h; // a merged room is never a rect
sp.rooms = sp.rooms.filter((r: any) => r.id !== dropId);
this._cfgEpoch++;
if (Array.isArray((sp as any).open_spans)) {
(sp as any).open_spans = degradeOpenSpans(
(sp as any).open_spans, this._spaceModel().rooms, NORM_W, this._gridPitch * 0.02,
);
if (!(sp as any).open_spans.length) delete (sp as any).open_spans;
else this._persistOpenCuts(this._openCuts());
}
this._commitOpenSpans();
this._saveConfig();
this._mergeDialog = null;
this._regSignature = '';
@@ -5884,17 +5921,24 @@ class HouseplanCard extends LitElement {
...(this._roomSettingsFromDialog() ? { settings: this._roomSettingsFromDialog() } : {}),
};
sp.rooms.push(newRoom);
// A Split rewrites the parent outline and adds a child, so a span that used
// to sit between the parent and a neighbour may now belong to the child.
// Geometry first, then spans and the derived open_to — otherwise border
// trimming reads the new geometry while glow reads the old connectivity
// (AUD-159B6-02).
if (wasSplit) this._commitOpenSpans();
// Draw-session wall thickness: apply to new edges only; keep neighbour cm
// on shared stretches. Split naming does not use the Draw field.
if (!wasSplit) {
const cm = this._drawWallCm;
if (cm != null) {
const openCuts = this._openPairs().flatMap((p) => p.segs);
const roomR = { id: newRoom.id, poly: verts };
this._cfgEpoch++; // the new room must be in the model before keying
const openCuts = this._openCuts();
let next = applyWallThicknessToNewRoom(
sp.walls, roomR, cm, this._wallKeyPitch, openCuts, NORM_W,
sp.walls, this._spaceModel().rooms, newRoom.id, cm,
this._wallKeyPitch, openCuts, NORM_W,
);
next = degradeWalls(next, sp.rooms || [], GRID_STEP_N, 1);
next = this._normalizeWalls(next, openCuts);
if (next.length) sp.walls = next;
else delete sp.walls;
}
@@ -7504,6 +7548,8 @@ class HouseplanCard extends LitElement {
<label class="dispsection">${this._t('gs.glow_group')}</label>
${this._renderColorRow('glow_base', 'gs.glow_base')}
${this._renderColorRow('glow_light', 'gs.glow_light')}
<label class="dispsection">${this._t('gs.wall_group')}</label>
${this._renderColorRow('wall_fill', 'gs.wall_fill')}
<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.glow_radius')}</span>
<input type="number" class="tempin" min="0.5" step="0.5"
@@ -7890,7 +7936,7 @@ class HouseplanCard extends LitElement {
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''};--wall-fill:${this._fillColors.wall_fill.c};--wall-fill-op:${this._fillColors.wall_fill.a}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
@@ -7920,7 +7966,7 @@ class HouseplanCard extends LitElement {
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
)}</g>`}
)}</g>`}
${this._editing ? this._renderMarkupDefs(vb) : nothing}
${''/* the grid is a property of the plane, not of a box: it follows
the VIEW so it is there wherever you pan (docs/CANVAS.md §7) */}
@@ -9663,7 +9709,8 @@ class HouseplanCard extends LitElement {
: nothing}
${segs.map((s) => svg`<line class="seg" x1="${s[0]}" y1="${s[1]}" x2="${s[2]}" y2="${s[3]}"></line>`)}
${previewD
? svg`<path class="drawwall-preview" d="${previewD}"></path>`
? svg`<path class="drawwall-preview-fill" d="${previewD}"></path>
<path class="drawwall-preview" d="${previewD}"></path>`
: nothing}
${path.length > 1
? svg`<polyline class="pathline" points="${path.map((p) => p.join(',')).join(' ')}"></polyline>`
+2
View File
@@ -300,6 +300,8 @@
"gs.glow_group": "Light-sources fill",
"gs.glow_base": "House darkness",
"gs.glow_light": "Default light color / intensity",
"gs.wall_group": "Walls",
"gs.wall_fill": "Wall fill",
"gs.glow_radius": "Glow radius",
"gs.unit_m": "m",
"gs.unit_ft": "ft",
+2
View File
@@ -300,6 +300,8 @@
"gs.glow_group": "Заливка «Свет по источникам»",
"gs.glow_base": "Темнота дома",
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
"gs.wall_group": "Стены",
"gs.wall_fill": "Цвет заливки стен",
"gs.glow_radius": "Радиус свечения",
"gs.unit_m": "м",
"gs.unit_ft": "фут",
+3
View File
@@ -1222,6 +1222,8 @@ export interface FillColors {
/** Glow mode: uniform "darkness" over every room + default light color. */
glow_base: FillColorEntry;
glow_light: FillColorEntry;
/** Thick-wall body fill (docs/WALL-THICKNESS.md); default opaque white. */
wall_fill: FillColorEntry;
}
export const DEFAULT_FILL_COLORS: FillColors = {
@@ -1235,6 +1237,7 @@ export const DEFAULT_FILL_COLORS: FillColors = {
lqi_high: { c: '#4bd28f', a: 0.18 },
glow_base: { c: '#0d1b2a', a: 0.5 },
glow_light: { c: '#ffd9a0', a: 0.85 },
wall_fill: { c: '#ffffff', a: 1 },
};
const HEX_RE = /^#[0-9a-f]{6}$/i;
+90 -10
View File
@@ -37,6 +37,33 @@ export function entryToSeg(e: OpenSpanEntry, coordScale: number): number[] {
return [e.a[0] * s, e.a[1] * s, e.b[0] * s, e.b[1] * s];
}
function finitePoint(p: any): boolean {
return Array.isArray(p) && p.length >= 2
&& Number.isFinite(Number(p[0])) && Number.isFinite(Number(p[1]));
}
/**
* Fail-soft read of `space.open_spans` (AUD-159B6-03). The field is persisted
* data: an old client, a hand-edited YAML or a broken import can put anything
* there, and one malformed entry used to throw inside render and blank the
* card for every reader. Anything that is not two finite points a minimum
* length apart is dropped, the rest keeps working.
*/
export function sanitizeOpenSpans(spans: unknown): OpenSpanEntry[] {
if (!Array.isArray(spans)) return [];
const out: OpenSpanEntry[] = [];
for (const e of spans) {
if (!e || typeof e !== 'object') continue;
const raw = e as any;
if (!finitePoint(raw.a) || !finitePoint(raw.b)) continue;
const a = [Number(raw.a[0]), Number(raw.a[1])];
const b = [Number(raw.b[0]), Number(raw.b[1])];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < OPEN_SPAN_MIN_UNITS) continue;
out.push({ a, b });
}
return out;
}
export function spanKey(a: number[], b: number[], pitch: number, coordScale = 1): string {
if (coordScale === 1) return wallKey(a, b, pitch);
return wallKey([a[0] / coordScale, a[1] / coordScale], [b[0] / coordScale, b[1] / coordScale], pitch);
@@ -178,7 +205,8 @@ export function expandLegacyOpenSpans(
spans: OpenSpanEntry[] | null | undefined,
eps: number,
): OpenSpanEntry[] {
if (spans && spans.length) return spans.map((s) => ({ a: s.a.slice(), b: s.b.slice() }));
const clean = sanitizeOpenSpans(spans);
if (clean.length) return clean;
const out: OpenSpanEntry[] = [];
const list = (rooms || []).filter((r) => r?.id);
const linked = (x: any, y: any) =>
@@ -214,12 +242,15 @@ export function resolveOpenCuts(
spans: OpenSpanEntry[] | null | undefined,
coordScale: number,
eps: number,
allowLegacy = true,
): number[][] {
const list = (rooms || []).filter((r) => r?.id);
if (spans && spans.length) {
return spans.map((e) => entryToSeg(e, coordScale));
}
// legacy
const clean = sanitizeOpenSpans(spans);
if (clean.length) return clean.map((e) => entryToSeg(e, coordScale));
// Legacy `open_to`-only configuration. NEVER read in the middle of a geometry
// transaction (AUD-159B6-02): once explicit spans have been removed the index
// is stale by construction and would resurrect a different stretch.
if (!allowLegacy) return [];
const out: number[][] = [];
const linked = (x: any, y: any) =>
(x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id);
@@ -428,15 +459,63 @@ export function degradeOpenSpans(
coordScale: number,
eps: number,
): OpenSpanEntry[] {
if (!spans?.length) return [];
const clean = sanitizeOpenSpans(spans);
if (!clean.length) return [];
const shared = allSharedSegs(roomsModel, eps);
return spans.filter((e) => {
return clean.filter((e) => {
const sg = entryToSeg(e, coordScale);
const mid = [(sg[0] + sg[2]) / 2, (sg[1] + sg[3]) / 2];
return shared.some((sh) => distToSegment(mid, sh) < eps * 4);
});
}
/**
* Project each open span onto the current shared-boundary geometry and clip
* to the overlap. Spans that no longer overlap any shared stretch are dropped.
* Prevents "solid outline + dashed open" after resize when a span drifts off
* the true shared edge.
*/
export function clipOpenSpansToShared(
spans: OpenSpanEntry[] | null | undefined,
roomsModel: any[],
coordScale: number,
eps: number,
): OpenSpanEntry[] {
const clean = sanitizeOpenSpans(spans);
if (!clean.length) return [];
const shared = allSharedSegs(roomsModel, eps);
if (!shared.length) return [];
const out: OpenSpanEntry[] = [];
const minLen = Math.max(eps * 4, 1e-6);
for (const e of clean) {
const sg = entryToSeg(e, coordScale);
const ax = sg[0], ay = sg[1], bx = sg[2], by = sg[3];
const adx = bx - ax, ady = by - ay;
const aLen = Math.hypot(adx, ady);
if (aLen < minLen) continue;
const ux = adx / aLen, uy = ady / aLen;
let best: { lo: number; hi: number } | null = null;
for (const sh of shared) {
// both endpoints of `sh` must lie on the line of sg
const d1 = Math.abs((sh[0] - ax) * uy - (sh[1] - ay) * ux);
const d2 = Math.abs((sh[2] - ax) * uy - (sh[3] - ay) * ux);
if (d1 > eps * 4 || d2 > eps * 4) continue;
const t1 = (sh[0] - ax) * ux + (sh[1] - ay) * uy;
const t2 = (sh[2] - ax) * ux + (sh[3] - ay) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(aLen, Math.max(t1, t2));
if (hi - lo < minLen) continue;
if (!best || hi - lo > best.hi - best.lo) best = { lo, hi };
}
if (!best) continue;
const na = [ax + ux * best.lo, ay + uy * best.lo];
const nb = [ax + ux * best.hi, ay + uy * best.hi];
if (Math.hypot(nb[0] - na[0], nb[1] - na[1]) < minLen) continue;
out.push(spanToEntry(na, nb, coordScale));
}
return out;
}
/** Rekey span endpoints after parallel old→new edge moves (render units). */
export function rekeyOpenSpansAfterMove(
spans: OpenSpanEntry[] | null | undefined,
@@ -444,10 +523,11 @@ export function rekeyOpenSpansAfterMove(
newSpans: [number[], number[]][],
coordScale: number,
): OpenSpanEntry[] {
if (!spans?.length) return [];
if (oldSpans.length !== newSpans.length) return spans.map((s) => ({ a: s.a.slice(), b: s.b.slice() }));
const clean = sanitizeOpenSpans(spans);
if (!clean.length) return [];
if (oldSpans.length !== newSpans.length) return clean;
const out: OpenSpanEntry[] = [];
for (const e of spans) {
for (const e of clean) {
const sg = entryToSeg(e, coordScale);
const a = [sg[0], sg[1]], b = [sg[2], sg[3]];
let na = a, nb = b;
+7
View File
@@ -214,6 +214,13 @@ class HouseplanSpaceCard extends LitElement {
fill: var(--ha-card-background, var(--card-background-color, #111));
stroke: none;
}
.wallbody-fill {
fill: var(--wall-fill, #ffffff);
fill-opacity: var(--wall-fill-op, 1);
fill-rule: evenodd;
stroke: none;
pointer-events: none;
}
.wallbody {
fill: url(#hp-wall-hatch);
fill-rule: evenodd;
+7 -3
View File
@@ -203,12 +203,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const paperShapes = walls.length
? paperRoomShapesWithWalls(space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
: paperRoomShapes(space.rooms);
const colors = fillColorsOf(o.cfg.settings);
const wallUnion = walls.length && disp.showBorders
? wallBodiesUnionPath(space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
: null;
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${wallUnion ? svg`<defs>
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
@@ -226,8 +227,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: nothing}
${roomShapes}
${wallUnion
? svg`<path class="wallbody" data-hp="wall" data-id="union" data-kind="union"
d="${wallUnion.d}" style="--room-stroke:${disp.color}"></path>`
? svg`<g class="wallbodies" style="--room-stroke:${disp.color}">
<path class="wallbody-fill" d="${wallUnion.d}"></path>
<path class="wallbody" data-hp="wall" data-id="union" data-kind="union"
d="${wallUnion.d}"></path>
</g>`
: nothing}
</svg>
${''/* docs/CANVAS.md §6: the same expression as the full card. The
+17 -2
View File
@@ -1100,6 +1100,14 @@ export const cardStyles = css`
.stage.markup.tool-wallthick { cursor: default; }
.stage.markup.tool-wallthick.wallhot { cursor: pointer; }
/* Solid wall colour sits under the hatch (owner: both, not either/or). */
.wallbody-fill {
fill: var(--wall-fill, #ffffff);
fill-opacity: var(--wall-fill-op, 1);
fill-rule: evenodd;
stroke: none;
pointer-events: none;
}
.wallbody {
fill: url(#hp-wall-hatch);
fill-rule: evenodd;
@@ -1107,9 +1115,9 @@ export const cardStyles = css`
stroke-width: 0.6;
pointer-events: none;
}
/* Thin-on-screen: hatch collapses into noise — fill alone, stroke from fill path's sibling. */
.wallbody.solid {
fill: var(--room-stroke, var(--hp-muted));
fill-opacity: 1;
fill: none;
}
.wallthick-hover {
fill: var(--accent-color, #03a9f4);
@@ -1304,6 +1312,13 @@ export const cardStyles = css`
stroke-dasharray: 6 5;
opacity: 0.7;
}
.drawwall-preview-fill {
fill: var(--wall-fill, #ffffff);
fill-opacity: calc(var(--wall-fill-op, 1) * 0.55);
fill-rule: evenodd;
stroke: none;
pointer-events: none;
}
.drawwall-preview {
fill: url(#hp-wall-hatch);
fill-opacity: 0.55;
+494 -161
View File
@@ -101,7 +101,52 @@ function keyOf(a: number[], b: number[], pitch: number, scale: number): string {
return wallKey([a[0] / scale, a[1] / scale], [b[0] / scale, b[1] / scale], pitch);
}
/** Match within half a grid step on the midpoint (direction must agree). */
/** 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[],
@@ -113,34 +158,15 @@ export function lookupWall(
const hit = walls.find((w) => w.key === want);
if (hit) return hit;
// tolerant fallback: same direction bucket, midpoint within half pitch (norm)
const mx = ((a[0] + b[0]) / 2) / coordScale, my = ((a[1] + b[1]) / 2) / coordScale;
const [dx, dy] = wallDir(
[a[0] / coordScale, a[1] / coordScale],
[b[0] / coordScale, b[1] / coordScale],
);
let ang = Math.atan2(dy, dx);
if (ang < 0) ang += Math.PI;
const tol = Math.max(pitch * 0.5, 1e-9);
const scale = coordScale > 0 ? coordScale : 1;
let best: { w: WallEntry; d: number } | null = null;
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;
let dAng = Math.abs(aq - ang);
if (dAng > Math.PI / 2) dAng = Math.PI - dAng;
if (dAng >= 0.02) continue; // ~1°
const midDist = Math.hypot(sx - mx, sy - my);
if (midDist <= tol) return w;
// atomic shared span: wall mid lies on this edge (partial overlap)
const dist = distToSeg(sx * scale, sy * scale, a[0], a[1], b[0], b[1]);
if (dist <= tol * scale) {
if (!best || midDist < best.d) best = { w, d: midDist };
}
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 best?.w || null;
return null;
}
export function thicknessCmAt(
@@ -153,12 +179,20 @@ export function thicknessCmAt(
return e && e.cm > 0 ? clampWallCm(e.cm) : 0;
}
/** Drop entries whose key matches no current room edge or shared atomic span. */
/**
* 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>();
@@ -179,6 +213,14 @@ export function degradeWalls(
}
}
}
for (const room of list) {
if (!room?.id) continue;
const at = atomicPolyForRoom(list, room.id, openCuts, pitch, coordScale);
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));
}
}
return walls.filter((w) => live.has(w.key) && w.cm >= WALL_MIN_CM && w.cm <= WALL_MAX_CM);
}
@@ -249,49 +291,72 @@ export function setWallThickness(
}
/**
* Apply one thickness to every edge of a room that is allowed to carry one
* (skips open-boundary stretches listed in `openCuts` as [x1,y1,x2,y2]).
* 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 setWallThicknessForRoom(
walls: WallEntry[] | null | undefined,
room: any,
cm: number | null,
export function solidIntervalsForRoom(
rooms: any[],
roomId: string,
openCuts: number[][],
pitch: number,
openCuts: number[][] = [],
coordScale = 1,
): WallEntry[] {
const poly = roomPoly(room);
if (!poly || poly.length < 3) return walls ? walls.slice() : [];
let out = walls ? walls.slice() : [];
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
): Array<{ a: number[]; b: number[] }> {
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale);
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 = setWallThickness(out, a, b, cm, pitch, coordScale);
out.push({ a, b });
}
return out;
}
/**
* After drawing a new room: set session thickness on edges that do not yet
* 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 = 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,
room: any,
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 poly = roomPoly(room);
if (!poly || poly.length < 3) return walls ? walls.slice() : [];
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale);
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 < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
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 (thicknessCmAt(out, a, b, pitch, coordScale) > 0) continue;
if (cms[i] > 0) continue;
out = setWallThickness(out, a, b, cm, pitch, coordScale);
}
return out;
@@ -343,22 +408,31 @@ export function drawWallPreviewD(
return d;
}
/**
* Is this stretch virtual? Interval-exact (AUD-159B6-01): the midpoint must sit
* ON the cut, not merely near the cut's own midpoint. Atomic intervals never
* straddle a cut end (they are split there), so the test is unambiguous —
* a partial open span no longer has to cover the parent edge's midpoint to
* count, and no longer opens the parts it does not cover.
*/
function edgeIsOpen(a: number[], b: number[], cuts: number[][], pitch: number, coordScale = 1): boolean {
if (!cuts.length) return false;
const key = keyOf(a, b, pitch, coordScale);
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) {
if (keyOf([c[0], c[1]], [c[2], c[3]], pitch, coordScale) === key) return true;
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
const cx = (c[0] + c[2]) / 2, cy = (c[1] + c[3]) / 2;
if (Math.hypot(mx - cx, my - cy) < pitch * coordScale) {
const [dx, dy] = wallDir(a, b);
const [ex, ey] = wallDir([c[0], c[1]], [c[2], c[3]]);
if (Math.abs(dx * ey - dy * ex) < 0.05) return true;
}
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 {
@@ -399,6 +473,13 @@ function pointInPoly(p: number[], poly: number[][]): boolean {
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[],
@@ -447,6 +528,19 @@ export function insetContour(poly: number[][], offsets: number[]): number[][] |
continue;
}
// AUD-159B6-01: atomic intervals put COLLINEAR neighbours in one outline.
// Two parallel offset lines never intersect, so the mitre branch below would
// fall through to a bevel that skips the zero side and slants the wall face.
// Equal offsets collapse to one point, different ones step across.
if (collinearJoint(uA, uB)) {
const v = poly[i];
const pa = [v[0] + nAx * oA, v[1] + nAy * oA];
const pb = [v[0] + nBx * oB, v[1] + nBy * oB];
out.push(pa);
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
continue;
}
const hit = lineIntersect(pA, uA, pB, uB);
const maxO = Math.max(oA, oB, 1e-9);
if (hit) {
@@ -488,9 +582,110 @@ 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,
): 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]]);
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 edge of a room: shared with a neighbour, or outer.
* Open-boundary stretches are reported as kind null (no thickness allowed).
* 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[],
@@ -499,41 +694,195 @@ export function edgeKinds(
pitch: number,
coordScale = 1,
): Array<WallKind | null> {
const room = (rooms || []).find((r) => r?.id === roomId);
const poly = roomPoly(room);
if (!poly) return [];
const kinds: 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)) {
kinds.push(null);
continue;
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);
}
let shared = false;
const myKey = keyOf(a, b, pitch, coordScale);
for (const other of rooms || []) {
if (other?.id === roomId) continue;
const op = roomPoly(other);
if (!op) continue;
for (let j = 0; j < op.length; j++) {
const c = op[j], d = op[(j + 1) % op.length];
if (keyOf(c, d, pitch, coordScale) === myKey) {
shared = true;
break;
}
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
const dist = distToSeg(mx, my, c[0], c[1], d[0], d[1]);
if (dist <= pitch * coordScale * 0.5) {
const [dx, dy] = wallDir(a, b);
const [ex, ey] = wallDir(c, d);
if (Math.abs(dx * ey - dy * ex) < 0.08) { shared = true; break; }
}
}
if (shared) break;
}
kinds.push(shared ? 'shared' : 'outer');
}
return kinds;
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);
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 } | null = null;
for (const e of parsed) {
if (claimed.has(e.w.key)) continue;
if (!angleClose(e.ang, ang)) continue;
if (distToSeg(e.x, e.y, a[0], a[1], b[0], b[1]) > tol) continue;
const d = Math.hypot(e.x - mx, e.y - my);
if (!best || d < best.d) best = { cm: clampWallCm(e.w.cm), d };
}
if (!best) continue;
for (const i of idxs) cms[i] = best.cm;
}
return cms;
}
/** One atomic wall stretch of one room, with everything a caller may need. */
export interface WallInterval {
roomId: string;
a: number[];
b: number[];
key: string;
kind: WallKind | null;
cm: number;
open: boolean;
/** Half depth in plan units (0 when there is no thickness). */
half: number;
}
/** Per-room atomic geometry: subdivided outline + kinds + cms + half offsets. */
export interface RoomWallProfile extends AtomicPoly {
kinds: Array<WallKind | null>;
cms: number[];
offsets: number[];
}
export function roomWallProfile(
rooms: any[],
roomId: string,
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): RoomWallProfile | null {
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale);
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;
}
/**
* Rewrite `walls` so every entry names an atomic interval of the CURRENT
* geometry, and no entry survives under an open span.
*
* 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 out: WallEntry[] = [];
const seen = new Set<string>();
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
if (iv.open || !(iv.cm > 0) || seen.has(iv.key)) continue;
seen.add(iv.key);
out.push({ key: iv.key, cm: iv.cm });
}
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 {
@@ -559,20 +908,8 @@ export function insetOffsetsForRoom(
gridPitch: number,
coordScale = 1,
): number[] {
const room = (rooms || []).find((r) => r?.id === roomId);
const poly = roomPoly(room);
if (!poly) return [];
const kinds = edgeKinds(rooms, roomId, openCuts, pitch, coordScale);
const out: number[] = [];
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const kind = kinds[i];
if (!kind) { out.push(0); continue; }
const cm = thicknessCmAt(walls, a, b, pitch, coordScale);
if (!(cm > 0)) { out.push(0); continue; }
out.push(wallCmToUnits(cm, cellCm, gridPitch) / 2);
}
return out;
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
return pr ? pr.offsets : [];
}
/** Alias — half offsets drive both inset and outset. */
@@ -596,11 +933,9 @@ export function innerContourForRoom(
const poly = roomPoly(room);
if (!poly || poly.length < 3) return null;
if (!walls?.length) return poly.map((p) => [p[0], p[1]]);
const offsets = insetOffsetsForRoom(
rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (!offsets.some((o) => o > 0)) return poly.map((p) => [p[0], p[1]]);
return insetContour(poly, offsets) || poly.map((p) => [p[0], p[1]]);
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || !pr.offsets.some((o) => o > 0)) return poly.map((p) => [p[0], p[1]]);
return insetContour(pr.poly, pr.offsets) || poly.map((p) => [p[0], p[1]]);
}
function closedRing(poly: number[][]): number[][][] {
@@ -612,12 +947,18 @@ function closedRing(poly: number[][]): number[][][] {
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[]) {
const ring = poly?.[0];
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]]));
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;
}
@@ -638,25 +979,23 @@ export function wallBodyRings(
if (!walls?.length) return [];
const out: WallBodyPath[] = [];
for (const room of rooms || []) {
const poly = roomPoly(room);
if (!poly || poly.length < 3) continue;
const offsets = insetOffsetsForRoom(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!offsets.some((o) => o > 0)) continue;
const outset = outsetContour(poly, offsets);
const inset = insetContour(poly, offsets);
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || pr.poly.length < 3 || !pr.offsets.some((o) => o > 0)) continue;
const outset = outsetContour(pr.poly, pr.offsets);
const inset = insetContour(pr.poly, pr.offsets);
if (!outset || !inset) continue;
const d = `${polyToPath(outset)} ${polyToPath(reversePoly(inset))}`;
let key = '';
let kind: WallKind = 'outer';
let cm = 0;
let depth = 0;
const kinds = edgeKinds(rooms, room.id, openCuts, pitch, coordScale);
for (let i = 0; i < poly.length; i++) {
if (!(offsets[i] > 0)) continue;
const a = poly[i], b = poly[(i + 1) % poly.length];
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 = kinds[i] || 'outer';
cm = thicknessCmAt(walls, a, b, pitch, coordScale);
kind = pr.kinds[i] || 'outer';
cm = pr.cms[i];
depth = wallCmToUnits(cm, cellCm, gridPitch);
break;
}
@@ -684,17 +1023,12 @@ export function wallBodiesUnionPath(
const insets: number[][][] = [];
let maxDepth = 0;
for (const room of rooms || []) {
const poly = roomPoly(room);
if (!poly || poly.length < 3) continue;
const offsets = insetOffsetsForRoom(
rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (!offsets.some((o) => o > 0)) continue;
for (let i = 0; i < offsets.length; i++) {
if (offsets[i] > 0) maxDepth = Math.max(maxDepth, offsets[i] * 2);
}
const outC = outsetContour(poly, offsets);
const inC = insetContour(poly, offsets);
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || pr.poly.length < 3 || !pr.offsets.some((o) => o > 0)) continue;
for (const o of pr.offsets) if (o > 0) maxDepth = Math.max(maxDepth, o * 2);
const outC = outsetContour(pr.poly, pr.offsets);
const inC = insetContour(pr.poly, pr.offsets);
if (outC) outsets.push(outC);
if (inC) insets.push(inC);
}
@@ -769,14 +1103,15 @@ export function wallEdgeBodies(
const seen = new Set<string>();
const out: WallEdgeBody[] = [];
for (const room of rooms || []) {
const poly = roomPoly(room);
if (!poly) continue;
const kinds = edgeKinds(rooms, room.id, openCuts, pitch, coordScale);
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 = kinds[i];
const kind = pr.kinds[i];
if (!kind) continue;
const cm = thicknessCmAt(walls, a, b, pitch, coordScale);
const cm = pr.cms[i];
if (!(cm > 0)) continue;
const key = keyOf(a, b, pitch, coordScale);
if (seen.has(key)) continue;
@@ -843,20 +1178,8 @@ export function paperOutwardOffsets(
gridPitch: number,
coordScale = 1,
): number[] {
const room = (rooms || []).find((r) => r?.id === roomId);
const poly = roomPoly(room);
if (!poly) return [];
const kinds = edgeKinds(rooms, roomId, openCuts, pitch, coordScale);
const out: number[] = [];
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const kind = kinds[i];
if (!kind) { out.push(0); continue; }
const cm = thicknessCmAt(walls, a, b, pitch, coordScale);
if (!(cm > 0)) { out.push(0); continue; }
out.push(wallCmToUnits(cm, cellCm, gridPitch) / 2);
}
return out;
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
return pr ? pr.offsets : [];
}
/**
@@ -896,6 +1219,14 @@ export function outsetContour(poly: number[][], offsets: number[]): number[][] |
out.push([poly[i][0], poly[i][1]]);
continue;
}
if (collinearJoint(uA, uB)) {
const v = poly[i];
const pa = [v[0] - nAx * oA, v[1] - nAy * oA];
const pb = [v[0] - nBx * oB, v[1] - nBy * oB];
out.push(pa);
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
continue;
}
const hit = lineIntersect(pA, uA, pB, uB);
const maxO = Math.max(oA, oB, 1e-9);
if (hit) {
@@ -929,8 +1260,10 @@ export function paperRoomShapesWithWalls(
for (const r of rooms || []) {
const poly = roomPoly(r);
if (poly && poly.length >= 3) {
const offs = paperOutwardOffsets(rooms, r.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
const grown = offs.some((o) => o > 0) ? outsetContour(poly, offs) : null;
const pr = roomWallProfile(rooms, r.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
const grown = pr && pr.offsets.some((o) => o > 0)
? outsetContour(pr.poly, pr.offsets)
: null;
const use = grown || poly;
out.push({ poly: use.map((p) => p.join(',')).join(' ') });
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {