mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
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:
+189
-142
@@ -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>`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "фут",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user