v1.59.0-beta.4: wall thickness + white editor sheet with backdrop
Validate / hassfest (push) Failing after 8s
Validate / hacs (push) Failing after 9s
Validate / frontend (push) Successful in 2m58s
Validate / backend (push) Failing after 9m24s
Validate / smoke (push) Failing after 22m47s

Plan-editor wall thickness (docs/WALL-THICKNESS.md) and keep the white drawing sheet under the grid in editors even when a backdrop image is loaded.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Matysh
2026-08-05 12:27:09 +03:00
co-authored by Cursor
parent 309bd59358
commit a7d956a072
29 changed files with 2305 additions and 247 deletions
+329 -19
View File
@@ -43,6 +43,12 @@ import {
furniturePathD, furnitureCorners, furnitureResize, snapFurnitureToWall,
cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS, type FurnitureGroup,
} from './furniture';
import {
wallKey, lookupWall, thicknessCmAt, degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, cmToField, fieldToCm,
wallEdgeBodies, wallEdgePathD, paperRoomShapesWithWalls,
openingInnerFaceOffset, type WallEntry,
} from './wall-thickness';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
import {
@@ -69,7 +75,7 @@ import {
import { alignAllToGrid, type AlignReport } from './align-grid';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.59.0-beta.3';
const CARD_VERSION = '1.59.0-beta.4';
/** 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
@@ -235,7 +241,7 @@ const unionRect = (a: Rect, b: Rect): Rect => {
return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
};
type MarkupTool = 'draw' | 'merge' | 'split' | 'resize' | 'opening' | 'openwall' | 'delroom';
type MarkupTool = 'draw' | 'merge' | 'split' | 'resize' | 'opening' | 'openwall' | 'wallthick' | 'delroom';
/** Tools of the decor (background) editor. `furniture` is the library
* (docs/FURNITURE.md): it opens a palette and places a symbol at real size. */
type DecorTool = 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text' | 'furniture' | 'erase';
@@ -474,6 +480,12 @@ class HouseplanCard extends LitElement {
return this._mode === 'plan';
}
private _tool: MarkupTool = 'draw';
/** Wall-thickness tool dialog (docs/WALL-THICKNESS.md). */
private _wallDialog: {
a: number[]; b: number[];
value: string; roomId: string | null;
sx: number; sy: number;
} | null = null;
// room resize tool (docs/RESIZE.md): selection, live drag, its own undo stack
private _rszSel: string | null = null;
private _rszDrag: {
@@ -786,6 +798,7 @@ class HouseplanCard extends LitElement {
_serverCfg: { state: true },
_mode: { state: true },
_tool: { state: true },
_wallDialog: { state: true },
_rszSel: { state: true },
_rszLive: { state: true },
_opMeasure: { state: true },
@@ -1020,7 +1033,12 @@ class HouseplanCard extends LitElement {
else this._tool = 'draw';
return;
}
if (this._tool === 'openwall' || this._tool === 'opening' || this._tool === 'delroom') {
if (this._wallDialog) {
e.preventDefault();
this._wallDialog = null;
return;
}
if (this._tool === 'openwall' || this._tool === 'opening' || this._tool === 'wallthick' || this._tool === 'delroom') {
e.preventDefault();
this._tool = 'draw';
}
@@ -3293,7 +3311,13 @@ class HouseplanCard extends LitElement {
* weight: drop it on every save. Configs written before v1.19.0 shed it on first write.
*/
private _dropLegacySegments(): void {
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
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);
if (!sp.walls.length) delete sp.walls;
}
}
}
/**
@@ -3427,6 +3451,10 @@ class HouseplanCard extends LitElement {
this._mergeClick(raw);
return;
}
if (this._tool === 'wallthick') {
this._wallThickClick(raw);
return;
}
if (this._tool === 'openwall') {
this._openWallClick(raw);
return;
@@ -3632,6 +3660,18 @@ 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 the moved stretches
if (g.kind === 'edge' && g.plan && Array.isArray(sp.walls) && sp.walls.length) {
const D = g.d;
const n = g.plan.n as [number, number];
const oldA = g.plan.a, oldB = g.plan.b;
const newA = [oldA[0] + n[0] * D, oldA[1] + n[1] * D];
const newB = [oldB[0] + n[0] * D, oldB[1] + n[1] * D];
// plan a/b are render units → keys use NORM_W scale
sp.walls = rekeyWallsAfterMove(
sp.walls, [[oldA, oldB]], [[newA, newB]], this._wallKeyPitch, NORM_W,
);
}
}
this._rszUndo.push({ space: this._space, snap: g.snap });
if (this._rszUndo.length > 30) this._rszUndo.shift();
@@ -4965,6 +5005,227 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
}
// ================= WALL THICKNESS (docs/WALL-THICKNESS.md) =================
/** Keys are always stored in normalised space (GRID_STEP_N). */
private get _wallKeyPitch(): number {
return GRID_STEP_N;
}
private get _spaceWalls(): WallEntry[] {
const w = this._curSpaceCfg?.walls;
return Array.isArray(w) ? (w as WallEntry[]) : [];
}
/** 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;
if (!walls.length) return paperRoomShapes(rooms);
const openCuts = this._openPairs().flatMap((p) => p.segs);
return paperRoomShapesWithWalls(
rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
}
/** Thick-wall spans in render units — suppress centreline stroke under bodies. */
private _thickWallCuts(): number[][] {
const walls = this._spaceWalls;
if (!walls.length) return [];
const openCuts = this._openPairs().flatMap((p) => p.segs);
return wallEdgeBodies(
this._spaceModel().rooms, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
).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. */
private _wallThickHit(raw: number[]): {
a: number[]; b: number[]; roomId: string; segs: number[][]; open: boolean;
} | 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 };
}
}
if (!best) return null;
// open-boundary stretches refuse thickness
const openHit = this._openWallHit(raw);
const open = !!(openHit && openHit.open
&& distToSegment(raw, openHit.segs[0]) <= pull + 1e-6);
// highlight the whole physical wall: shared → sharedBoundary segments
let segs: number[][] = [[best.a[0], best.a[1], best.b[0], best.b[1]]];
const key = wallKey(
[best.a[0] / NORM_W, best.a[1] / NORM_W],
[best.b[0] / NORM_W, best.b[1] / NORM_W],
this._wallKeyPitch,
);
for (const room of rooms) {
if (room.id === best.roomId) continue;
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 k = wallKey(
[a[0] / NORM_W, a[1] / NORM_W], [b[0] / NORM_W, b[1] / NORM_W], this._wallKeyPitch,
);
if (k === key) {
segs = [[a[0], a[1], b[0], b[1]]];
break;
}
}
}
if (openHit?.open) {
const mid = [(best.a[0] + best.b[0]) / 2, (best.a[1] + best.b[1]) / 2];
for (const sg of openHit.segs) {
if (distToSegment(mid, sg) < this._gridPitch) {
segs = openHit.segs;
break;
}
}
}
return { a: best.a, b: best.b, roomId: best.roomId, segs, open };
}
private get _wallThickHover(): { segs: number[][]; open: boolean } | null {
if (!this._markup || this._tool !== 'wallthick' || !this._cursorPt || this._wallDialog) return null;
const hit = this._wallThickHit(this._cursorPt);
return hit ? { segs: hit.segs, open: hit.open } : null;
}
private _wallThickClick(raw: number[]): void {
const hit = this._wallThickHit(raw);
if (!hit) {
this._showToast(this._t('toast.wallthick_pick'));
return;
}
if (hit.open) {
this._showToast(this._t('toast.wallthick_open'));
return;
}
const cm = thicknessCmAt(this._spaceWalls, hit.a, hit.b, this._wallKeyPitch, NORM_W);
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 = {
a: hit.a, b: hit.b,
value: cmToField(cm, this._imperial),
roomId: hit.roomId,
sx: ((mx - view.x) / view.w) * 100,
sy: ((my - view.y) / view.h) * 100,
};
}
private _wallThickApply(allRoom: boolean): void {
const d = this._wallDialog;
if (!d) return;
const sp = this._curSpaceCfg;
if (!sp) return;
const cm = fieldToCm(d.value, this._imperial);
const openCuts = this._openPairs().flatMap((p) => p.segs);
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,
);
} else {
next = setWallThickness(sp.walls, d.a, d.b, cm, this._wallKeyPitch, NORM_W);
}
next = degradeWalls(next, sp.rooms || [], GRID_STEP_N, 1);
if (next.length) sp.walls = next;
else delete sp.walls;
this._wallDialog = null;
this._showToast(this._t(cm == null ? 'toast.wallthick_cleared' : 'toast.wallthick_set'));
this._saveConfig();
this.requestUpdate();
}
private _wallHatchDefs(): TemplateResult {
if (!this._spaceWalls.length && !this._markup) return svg`` as unknown as TemplateResult;
const inv = Math.max(0.4, 1 / Math.max(this._zoom, 0.4));
return svg`<defs>
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
patternTransform="rotate(45) scale(${inv.toFixed(3)})">
<path d="M0 0 L0 8" stroke="var(--room-stroke, #607d8b)" stroke-width="2" opacity="0.55"></path>
</pattern>
</defs>` as unknown as TemplateResult;
}
private _renderWallBodies(disp: SpaceDisplay): TemplateResult {
if (disp && !disp.showBorders && !this._markup) return svg`` as unknown as TemplateResult;
const walls = this._spaceWalls;
if (!walls.length) return svg`` as unknown as TemplateResult;
const openCuts = this._openPairs().flatMap((p) => p.segs);
const bodies = wallEdgeBodies(
this._spaceModel().rooms, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
if (!bodies.length) return svg`` as unknown as TemplateResult;
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
angle: Number(o.angle) || 0,
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
}));
// screen px per plan unit — below ~3px wall depth → solid fill
const stage = this._stageEl;
const v = this._viewOr(this._baseVb());
const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1;
const stroke = disp?.color || 'var(--hp-muted)';
return svg`<g class="wallbodies" style="--room-stroke:${stroke}">
${bodies.map((b) => {
const solid = b.depthUnits * px < 3;
return svg`<path class="wallbody ${solid ? 'solid' : ''}"
data-hp="wall" data-id=${b.key} data-kind=${b.kind}
d="${wallEdgePathD(b, openings)}"></path>`;
})}
</g>` as unknown as TemplateResult;
}
/** Hover highlight for the wall-thickness tool (SVG). */
private _renderWallThickUi(): TemplateResult {
const hover = this._wallThickHover;
if (!hover) return svg`` as unknown as TemplateResult;
return svg`${hover.segs.map((sg) => svg`<line class="wallthick-hover ${hover.open ? 'isopen' : ''}"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`)}` as unknown as TemplateResult;
}
/** Thickness input popover, anchored in stage % like measure labels. */
private _renderWallThickDialog(): TemplateResult {
const d = this._wallDialog;
if (!d) return html``;
return html`<div class="wallthick-dlg" style="left:${d.sx.toFixed(2)}%;top:${d.sy.toFixed(2)}%"
@click=${(e: Event) => e.stopPropagation()}>
<div class="row">
<label>${this._t('wallthick.field')}</label>
<input type="number" min="0" max="100" step="any" .value=${d.value}
@input=${(e: Event) => {
this._wallDialog = { ...d, value: (e.target as HTMLInputElement).value };
}}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); this._wallThickApply(false); }
}} />
<span class="opl">${this._t(this._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}</span>
</div>
<div class="row">
<button class="btn ghost" @click=${() => this._wallThickApply(true)}>
${this._t('wallthick.apply_room')}
</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => this._wallThickApply(false)}>
<ha-icon icon="mdi:check"></ha-icon>
</button>
</div>
</div>`;
}
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
private _openingClick(raw: number[], shift = false): void {
const eps = this._gridPitch * 1.5;
@@ -7332,7 +7593,7 @@ class HouseplanCard extends LitElement {
${this._mode === 'decor' && this._decorTool === 'furniture' ? this._renderFurnPalette() : nothing}
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' 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' : ''}"
<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}` : ''}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -7359,7 +7620,7 @@ class HouseplanCard extends LitElement {
One <g> around ALL paper shapes: the daynight drop shadow
(styles.ts) is composited once for the whole sheet, so
adjacent rooms never cast seams onto each other's paper. */}
${svg`<g class="hp-paperg">${paperRoomShapes(space.rooms).map((sh) =>
${this._wallHatchDefs()}${svg`<g class="hp-paperg">${this._paperShapes(space.rooms).map((sh) =>
'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>`,
@@ -7439,7 +7700,9 @@ class HouseplanCard extends LitElement {
const openCuts = r.id && !isPicked
? allPairs.filter((pp) => pp.a.id === r.id || pp.b.id === r.id).flatMap((pp) => pp.segs)
: [];
if (openCuts.length) cls += ' noedge';
const thickCuts = !isPicked ? this._thickWallCuts() : [];
const edgeCuts = openCuts.concat(thickCuts);
if (edgeCuts.length) cls += ' noedge';
// island rooms punch holes in their parent's fill (evenodd)
const myPoly = polyOf(r);
const holes = myPoly ? islandsOf(myPoly, otherPolys(r)) : [];
@@ -7465,8 +7728,8 @@ class HouseplanCard extends LitElement {
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
@mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></rect>`;
const trimmed = openCuts.length && myPoly
? outlineWithout(myPoly, openCuts, this._gridPitch * 0.02)
const trimmed = edgeCuts.length && myPoly
? outlineWithout(myPoly, edgeCuts, this._gridPitch * 0.02)
: null;
const outline = trimmed
? svg`<path class="room-outline ${this._markup ? 'outlined' : ''}"
@@ -7490,7 +7753,9 @@ class HouseplanCard extends LitElement {
an opening MEANS is untouched: light still spills through
it, the sun still comes in at its window, and the contact
sensor still opens it. */}
${this._renderWallBodies(disp)}
${disp.hideOpenings && !this._markup ? nothing : this._renderOpenings(disp)}
${this._renderWallThickUi()}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
${''/* editor chrome, not plan content: the backdrop frame sits on
top of everything the plan draws so its handles stay
@@ -7532,6 +7797,9 @@ class HouseplanCard extends LitElement {
class="measurelabel opshoulder"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
: nothing}
${this._wallDialog
? html`<div class="measurelayer">${this._renderWallThickDialog()}</div>`
: nothing}
${decorMeasure
? html`<div class="measurelayer"><div
class="measurelabel dmeasure ${decorMeasure.on45 ? 'on45' : ''}"
@@ -8778,19 +9046,46 @@ class HouseplanCard extends LitElement {
const items = this._openingsR;
if (!items.length) return svg``;
const base = disp.color;
const walls = this._spaceWalls;
const rooms = this._spaceModel().rooms;
return svg`${items.map((o) => {
const half = o.rlen / 2;
const amt = this._openingAmt(o);
const active = amt > 0 && !!o.contact;
const tone = active ? 'var(--hp-open)' : base;
const jamb = 8;
const face = walls.length
? openingInnerFaceOffset(
rooms,
{ x: o.rx, y: o.ry, angle: o.angle, length: o.rlen, flip_v: o.flip_v },
walls, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
)
: { ox: 0, oy: 0, cm: 0 };
// jambs span the full wall depth, centred on the centreline
const jambHalf = face.cm > 0
? ((face.cm / this._cellCm) * this._gridPitch) / 2
: 4;
const sx = o.flip_h ? -1 : 1;
const sy = o.flip_v ? -1 : 1;
// shift swing geometry to the inner face (plan space → local via inverse rotate)
let swingTx = 0, swingTy = 0;
if (face.cm > 0 && (face.ox || face.oy)) {
const rad = (-o.angle * Math.PI) / 180;
const c = Math.cos(rad), s = Math.sin(rad);
swingTx = face.ox * c - face.oy * s;
swingTy = face.ox * s + face.oy * c;
// undo flip_v on the offset — scale(sy) will re-apply it
swingTy *= sy;
swingTx *= sx;
}
let body;
if (o.type === 'window') {
// two casement leaves hinged at the jambs, meeting in the middle
const arcLen = (Math.PI / 2) * half;
const glass = face.cm > 0
? svg`<line class="op-glass" x1="0" y1="${-jambHalf}" x2="0" y2="${jambHalf}"
stroke="${tone}" stroke-width="1.5"></line>`
: nothing;
body = svg`
<g transform="translate(${swingTx} ${swingTy})">
<path class="op-arc" d="M 0 0 A ${half} ${half} 0 0 0 ${-half} ${-half}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<path class="op-arc" d="M 0 0 A ${half} ${half} 0 0 1 ${half} ${-half}" fill="none"
@@ -8804,29 +9099,32 @@ class HouseplanCard extends LitElement {
<g class="op-leaf" style="transform:rotate(${90 * amt}deg)">
<rect x="${-half}" y="-1.5" width="${half}" height="3" fill="${tone}"></rect>
</g>
</g>
${glass}
</g>`;
} else {
// door leaf hinged at the left jamb, swinging up; arc from tip to tip
const L = o.rlen;
const arcLen = (Math.PI / 2) * L;
body = svg`
<g transform="translate(${swingTx} ${swingTy})">
<path class="op-arc" d="M ${half} 0 A ${L} ${L} 0 0 0 ${-half} ${-L}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${-90 * amt}deg)">
<rect x="0" y="-1.75" width="${L}" height="3.5" fill="${tone}"></rect>
</g>
</g>
</g>`;
}
return svg`<g class="opening" data-hp="opening" data-id="${o.id}" data-kind="${o.type}"
transform="translate(${o.rx} ${o.ry}) rotate(${o.angle})">
<g transform="scale(${sx} ${sy})">
<line x1="${-half}" y1="${-jamb / 2}" x2="${-half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
<line x1="${half}" y1="${-jamb / 2}" x2="${half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
<line x1="${-half}" y1="${-jambHalf}" x2="${-half}" y2="${jambHalf}" stroke="${base}" stroke-width="2.5"></line>
<line x1="${half}" y1="${-jambHalf}" x2="${half}" y2="${jambHalf}" stroke="${base}" stroke-width="2.5"></line>
${body}
</g>
<rect class="op-outline" x="${-half - 10}" y="-16" width="${o.rlen + 20}" height="32" rx="6"></rect>
<rect class="op-hit" x="${-half - 12}" y="-20" width="${o.rlen + 24}" height="40"
<rect class="op-outline" x="${-half - 10}" y="${-Math.max(16, jambHalf + 8)}" width="${o.rlen + 20}" height="${Math.max(32, jambHalf * 2 + 16)}" rx="6"></rect>
<rect class="op-hit" x="${-half - 12}" y="${-Math.max(20, jambHalf + 10)}" width="${o.rlen + 24}" height="${Math.max(40, jambHalf * 2 + 20)}"
@click=${(e: MouseEvent) => this._opClick(e, o)}
@pointerdown=${(e: PointerEvent) => this._opPointerDown(e, o)}
@pointermove=${(e: PointerEvent) => this._opPointerMove(e, o)}
@@ -9022,14 +9320,20 @@ class HouseplanCard extends LitElement {
<circle cx="0" cy="${G}" r="${dotR * 2.1}" class="griddot major"></circle>
<circle cx="${G}" cy="${G}" r="${dotR * 2.1}" class="griddot major"></circle>
</pattern>
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
patternTransform="rotate(45) scale(${Math.max(0.4, 1 / Math.max(this._zoom, 0.4)).toFixed(3)})">
<path d="M0 0 L0 8" stroke="var(--room-stroke, #607d8b)" stroke-width="2" opacity="0.55"></path>
</pattern>
</defs>`;
}
private _renderMarkupLayer(vb: number[]): TemplateResult {
// derived walls minus the open stretches — those are drawn dashed on top
const openCuts = this._openPairs().flatMap((p) => p.segs);
const segs = openCuts.length
? cutSegments(this._segments, openCuts, this._gridPitch * 0.02)
const thickCuts = this._thickWallCuts();
const allCuts = openCuts.concat(thickCuts);
const segs = allCuts.length
? cutSegments(this._segments, allCuts, this._gridPitch * 0.02)
: this._segments;
const path = this._path;
const g = this._gridPitch;
@@ -9098,10 +9402,15 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')}
</button>
<button class="btn ${this._tool === 'openwall' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'openwall'; }}
@click=${() => { this._cancelPath(); this._tool = 'openwall'; this._wallDialog = null; }}
title=${this._t('title.markup_openwall')}>
<ha-icon icon="mdi:border-none-variant"></ha-icon>${this._t('markup.openwall')}
</button>
<button class="btn ${this._tool === 'wallthick' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'wallthick'; this._wallDialog = null; }}
title=${this._t('title.markup_wallthick')}>
<ha-icon icon="mdi:wall"></ha-icon>${this._t('markup.wallthick')}
</button>
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
title=${this._t('title.markup_delroom')}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
@@ -9114,6 +9423,7 @@ class HouseplanCard extends LitElement {
${this._path.length ? html`<button class="btn ghost" @click=${this._cancelPath}>${this._t('btn.reset')}</button>` : nothing}`
: nothing}
${this._tool === 'resize' ? html`<span class="hint">${this._t('markup.hint_resize')}</span>` : nothing}
${this._tool === 'wallthick' ? html`<span class="hint">${this._t('markup.hint_wallthick')}</span>` : nothing}
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
+11
View File
@@ -308,6 +308,17 @@
"info.controls": "Controls",
"marker.glow_radius_label": "Glow radius (light-sources fill)",
"marker.glow_radius_hint": "empty = default from general settings",
"markup.wallthick": "Wall thickness",
"title.markup_wallthick": "Wall thickness — click a wall to set how thick it is. Empty or zero removes the thickness.",
"markup.hint_wallthick": "click a wall · Esc closes without applying",
"wallthick.field": "Thickness",
"wallthick.unit_cm": "cm",
"wallthick.unit_in": "in",
"wallthick.apply_room": "Apply to all walls of this room",
"toast.wallthick_pick": "Click a wall (not an open boundary)",
"toast.wallthick_open": "Open boundaries have no thickness",
"toast.wallthick_set": "Wall thickness set",
"toast.wallthick_cleared": "Wall thickness removed",
"markup.openwall": "Open boundary",
"title.markup_openwall": "Open boundary — click a wall shared by two rooms to make it virtual: light flows through, the line turns dashed. Click again to close it.",
"toast.openwall_pick": "Click a wall shared by two rooms",
+11
View File
@@ -308,6 +308,17 @@
"info.controls": "Управляет",
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек",
"markup.wallthick": "Толщина стен",
"title.markup_wallthick": "Толщина стен — клик по стене задаёт толщину. Пустое поле или ноль убирает толщину.",
"markup.hint_wallthick": "клик по стене · Esc закрывает без применения",
"wallthick.field": "Толщина",
"wallthick.unit_cm": "см",
"wallthick.unit_in": "дюйм",
"wallthick.apply_room": "Применить ко всем стенам комнаты",
"toast.wallthick_pick": "Кликните по стене (не по открытой границе)",
"toast.wallthick_open": "У открытой границы нет толщины",
"toast.wallthick_set": "Толщина стены задана",
"toast.wallthick_cleared": "Толщина стены убрана",
"markup.openwall": "Открытая граница",
"title.markup_openwall": "Открытая граница — клик по общей стене двух комнат делает её условной: свет проходит насквозь, линия становится пунктирной. Повторный клик закрывает.",
"toast.openwall_pick": "Кликните по стене, разделяющей две комнаты",
+8
View File
@@ -214,6 +214,14 @@ class HouseplanSpaceCard extends LitElement {
fill: var(--ha-card-background, var(--card-background-color, #111));
stroke: none;
}
.wallbody {
fill: url(#hp-wall-hatch);
fill-rule: evenodd;
stroke: var(--room-stroke, var(--hp-muted, #607d8b));
stroke-width: 0.6;
pointer-events: none;
opacity: 0.95;
}
.hp-static-stage .devlayer {
position: absolute;
inset: 0;
+21 -1
View File
@@ -9,12 +9,14 @@
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf, paperRoomShapes } from './logic';
import { wallEdgeBodies, wallEdgePathD, paperRoomShapesWithWalls, type WallEntry } from './wall-thickness';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
GRID_STEP_N, GRID_PITCH,
type Layout, type ContentItem,
} from './space-geometry';
@@ -195,10 +197,26 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// gaps between detached buildings, and an empty space has no paper at all,
// image or no image. The picture is drawn ON the paper, one layer above.
const spCfg: any = o.cfg.spaces.find((s: any) => s.id === o.spaceId) || {};
const walls: WallEntry[] = Array.isArray(spCfg.walls) ? spCfg.walls : [];
const cellCm = Number(spCfg.cell_cm) > 0 ? Number(spCfg.cell_cm) : 5;
const paperShapes = walls.length
? paperRoomShapesWithWalls(space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
: paperRoomShapes(space.rooms);
const wallBodies = walls.length && disp.showBorders
? wallEdgeBodies(space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
: [];
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${paperRoomShapes(space.rooms).map((sh) =>
${wallBodies.length ? svg`<defs>
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
patternTransform="rotate(45)">
<path d="M0 0 L0 8" stroke="${disp.color}" stroke-width="2" opacity="0.55"></path>
</pattern>
</defs>` : nothing}
${paperShapes.map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></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}"></rect>`,
@@ -207,6 +225,8 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
: nothing}
${roomShapes}
${wallBodies.map((b) => svg`<path class="wallbody" data-hp="wall" data-id=${b.key} data-kind=${b.kind}
d="${wallEdgePathD(b, [])}" style="--room-stroke:${disp.color}"></path>`)}
</svg>
${''/* docs/CANVAS.md §6: the same expression as the full card. The
static card has no zoom, but its frame is the CONTENT now, so a
+65 -1
View File
@@ -161,6 +161,15 @@ export const cardStyles = css`
.stage.noplan {
background: #ffffff;
}
/* Editors always keep the white drawing sheet — even when a backdrop
image is loaded. Without this, a picture plan painted the theme card
colour under the grid (owner 2026-08-05). View keeps the historical
theme / .noplan split. */
.stage.mode-plan,
.stage.mode-devices,
.stage.mode-decor {
background: #ffffff;
}
.stage {
position: relative;
width: 100%;
@@ -409,7 +418,10 @@ export const cardStyles = css`
fill: var(--ha-card-background, var(--card-background-color, #111));
stroke: none;
}
.stage.noplan .hp-paper {
.stage.noplan .hp-paper,
.stage.mode-plan .hp-paper,
.stage.mode-devices .hp-paper,
.stage.mode-decor .hp-paper {
fill: #ffffff;
}
/* White day (owner 2026-08-03): at high sun the daynight sky is #ffffff —
@@ -1085,6 +1097,58 @@ export const cardStyles = css`
cursor: pointer;
}
/* open-wall tool: default until a shared wall is under the cursor */
.stage.markup.tool-wallthick { cursor: default; }
.stage.markup.tool-wallthick.wallhot { cursor: pointer; }
.wallbody {
fill: url(#hp-wall-hatch);
fill-rule: evenodd;
stroke: var(--room-stroke, var(--hp-muted));
stroke-width: 0.6;
pointer-events: none;
opacity: 0.95;
}
.wallbody.solid {
fill: var(--room-stroke, var(--hp-muted));
fill-opacity: 0.35;
}
.wallthick-hover {
stroke: var(--accent-color, #03a9f4);
stroke-width: 4;
stroke-linecap: round;
pointer-events: none;
opacity: 0.85;
}
.wallthick-dlg {
position: absolute;
z-index: 40;
min-width: 200px;
transform: translate(-50%, 8px);
padding: 10px 12px;
border-radius: 10px;
background: var(--card-background-color, #fff);
box-shadow: 0 8px 28px rgba(0,0,0,.22);
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: auto;
}
.wallthick-hover.isopen {
stroke: var(--error-color, #f44336);
}
.wallthick-dlg .row {
display: flex;
align-items: center;
gap: 8px;
}
.wallthick-dlg input[type="number"] {
width: 5.5em;
padding: 4px 6px;
border: 1px solid var(--divider-color, #ccc);
border-radius: 6px;
background: var(--input-fill, transparent);
color: var(--primary-text-color);
}
.stage.markup.tool-openwall { cursor: default; }
.stage.markup.tool-openwall.wallhot { cursor: pointer; }
.openwall {
+6
View File
@@ -24,6 +24,12 @@ export interface RoomCfg {
poly?: number[][]; // polygon in render units (model) / normalized (config)
}
/** Wall thickness entry (docs/WALL-THICKNESS.md) — always centimetres in config. */
export interface WallEntry {
key: string;
cm: number;
}
export interface SpaceModel {
id: string;
title: string;
+747
View File
@@ -0,0 +1,747 @@
/**
* Wall thickness — pure geometry (docs/WALL-THICKNESS.md).
*
* Thickness is a rendering layer keyed by a segment identity that survives
* resize. Area, sun, glow and rulers keep reading the room polygon; nothing
* here changes that contract.
*/
import { roomPoly, roomEdges } from './logic';
export interface WallEntry {
key: string;
cm: number;
}
export const WALL_MIN_CM = 1;
export const WALL_MAX_CM = 100;
/** Mitre spikes longer than this × thickness fall back to a bevel. */
export const MITRE_LIMIT = 4;
// ------------------------------- units --------------------------------------
export function clampWallCm(cm: number): number {
if (!Number.isFinite(cm)) return WALL_MIN_CM;
return Math.max(WALL_MIN_CM, Math.min(WALL_MAX_CM, cm));
}
/** Config cm → the thickness field (cm, or inches when HA is imperial). */
export function cmToField(cm: number, imperial: boolean): string {
if (!Number.isFinite(cm) || cm <= 0) return '';
if (imperial) return String(Math.round((cm / 2.54) * 100) / 100);
return String(Math.round(cm * 100) / 100);
}
/**
* Field value → cm. Empty / non-finite / ≤0 means "remove thickness"
* (returns null). Imperial field is inches.
*/
export function fieldToCm(raw: string | number, imperial: boolean): number | null {
const v = typeof raw === 'number' ? raw : parseFloat(String(raw).trim().replace(',', '.'));
if (!Number.isFinite(v) || v <= 0) return null;
const cm = imperial ? v * 2.54 : v;
return clampWallCm(cm);
}
/** Real cm → length in the same units as the room polygon (via cell_cm). */
export function wallCmToUnits(cm: number, cellCm: number, gridPitch: number): number {
const c = Number(cellCm) > 0 ? Number(cellCm) : 5;
return (clampWallCm(cm) / c) * gridPitch;
}
// ------------------------------- segment key --------------------------------
function q(v: number, pitch: number): number {
if (!(pitch > 0) || !Number.isFinite(v)) return v;
return Math.round(v / pitch) * pitch;
}
/**
* Direction of a wall, modulo 180° (a wall is the same from either end),
* as a unit vector with a stable sign (prefer +x, then +y).
*/
export function wallDir(a: number[], b: number[]): [number, number] {
let dx = b[0] - a[0], dy = b[1] - a[1];
const L = Math.hypot(dx, dy);
if (L < 1e-12) return [1, 0];
dx /= L; dy /= L;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) {
dx = -dx; dy = -dy;
}
return [dx, dy];
}
/**
* Segment key: quantised midpoint + direction. Same wall from either end,
* survives whole-grid moves when re-keyed by the resize commit.
*/
export function wallKey(a: number[], b: number[], pitch: number): string {
const mx = q((a[0] + b[0]) / 2, pitch);
const my = q((a[1] + b[1]) / 2, pitch);
const [dx, dy] = wallDir(a, b);
// angle bucket: round to ~0.1° so float noise does not fork keys
let ang = Math.atan2(dy, dx);
if (ang < 0) ang += Math.PI;
const aq = Math.round(ang * 1800) / 1800; // π rad ≈ 3.14 → 0.1° steps
const prec = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
return `${mx.toFixed(prec)},${my.toFixed(prec)}@${aq.toFixed(4)}`;
}
/**
* Scale applied to endpoints before keying. Render-space edges use
* `coordScale = NORM_W` with `pitch = GRID_STEP_N` so keys match the
* normalised config; config-space edges use `coordScale = 1`.
*/
function keyOf(a: number[], b: number[], pitch: number, scale: number): string {
if (scale === 1) return wallKey(a, b, pitch);
return wallKey([a[0] / scale, a[1] / scale], [b[0] / scale, b[1] / scale], pitch);
}
/** Match within half a grid step on the midpoint (direction must agree). */
export function lookupWall(
walls: WallEntry[] | null | undefined,
a: number[], b: number[],
pitch: number,
coordScale = 1,
): WallEntry | null {
if (!walls?.length) return null;
const want = keyOf(a, b, pitch, coordScale);
const hit = walls.find((w) => w.key === want);
if (hit) return hit;
// tolerant fallback: same direction bucket, midpoint within half pitch (norm)
const 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);
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;
if (Math.hypot(sx - mx, sy - my) > tol) continue;
let dAng = Math.abs(aq - ang);
if (dAng > Math.PI / 2) dAng = Math.PI - dAng;
if (dAng < 0.02) return w; // ~1°
}
return null;
}
export function thicknessCmAt(
walls: WallEntry[] | null | undefined,
a: number[], b: number[],
pitch: number,
coordScale = 1,
): number {
const e = lookupWall(walls, a, b, pitch, coordScale);
return e && e.cm > 0 ? clampWallCm(e.cm) : 0;
}
/** Drop entries whose key matches no current room edge. */
export function degradeWalls(
walls: WallEntry[] | null | undefined,
rooms: any[],
pitch: number,
coordScale = 1,
): WallEntry[] {
if (!walls?.length) return [];
const live = new Set<string>();
for (const seg of roomEdges(rooms)) {
live.add(keyOf([seg[0], seg[1]], [seg[2], seg[3]], pitch, coordScale));
}
return walls.filter((w) => live.has(w.key) && w.cm >= WALL_MIN_CM && w.cm <= WALL_MAX_CM);
}
/**
* After an edge drag: rewrite keys whose old span mid/dir map to a moved
* stretch. `oldSpans` / `newSpans` are parallel lists of [a,b] endpoints.
*/
export function rekeyWallsAfterMove(
walls: WallEntry[] | null | undefined,
oldSpans: [number[], number[]][],
newSpans: [number[], number[]][],
pitch: number,
coordScale = 1,
): WallEntry[] {
if (!walls?.length) return [];
if (oldSpans.length !== newSpans.length) return walls.slice();
const map = new Map<string, string>();
for (let i = 0; i < oldSpans.length; i++) {
const [oa, ob] = oldSpans[i];
const [na, nb] = newSpans[i];
const ok = keyOf(oa, ob, pitch, coordScale);
const nk = keyOf(na, nb, pitch, coordScale);
if (ok !== nk) map.set(ok, nk);
}
if (!map.size) return walls.slice();
const used = new Set<string>();
const out: WallEntry[] = [];
for (const w of walls) {
const nk = map.get(w.key) || w.key;
if (used.has(nk)) continue;
used.add(nk);
out.push({ key: nk, cm: w.cm });
}
return out;
}
/** Upsert or remove a wall entry by endpoints. */
export function setWallThickness(
walls: WallEntry[] | null | undefined,
a: number[], b: number[],
cm: number | null,
pitch: number,
coordScale = 1,
): WallEntry[] {
const key = keyOf(a, b, pitch, coordScale);
const base = (walls || []).filter((w) => w.key !== key);
if (cm == null || cm < WALL_MIN_CM) return base;
return [...base, { key, cm: clampWallCm(cm) }];
}
/**
* 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]).
*/
export function setWallThicknessForRoom(
walls: WallEntry[] | null | undefined,
room: any,
cm: number | null,
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];
if (edgeIsOpen(a, b, openCuts, pitch, coordScale)) continue;
out = setWallThickness(out, a, b, cm, pitch, coordScale);
}
return out;
}
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);
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;
}
}
return false;
}
// ------------------------------- inset / rings ------------------------------
function signedArea(poly: number[][]): number {
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
s += a[0] * b[1] - b[0] * a[1];
}
return s / 2;
}
/** Inward unit normal for edge i (into the polygon). */
export function inwardNormal(poly: number[][], i: number): [number, number] {
const a = poly[i], b = poly[(i + 1) % poly.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const L = Math.hypot(dx, dy) || 1;
// left normal of edge direction; flip if it points outward
let nx = -dy / L, ny = dx / L;
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const probe = [mid[0] + nx * 1e-3, mid[1] + ny * 1e-3];
// winding-agnostic: a probe that leaves the poly means we had the outward normal
if (!pointInPoly(probe, poly)) {
nx = -nx; ny = -ny;
}
// if area is negative (CW), left normal already points inward for standard math —
// pointInPoly check above handles both.
void signedArea;
return [nx, ny];
}
function pointInPoly(p: number[], poly: number[][]): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i][0], yi = poly[i][1], xj = poly[j][0], yj = poly[j][1];
if ((yi > p[1]) !== (yj > p[1]) &&
p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi + 0) + xi) inside = !inside;
}
return inside;
}
function lineIntersect(
p: number[], r: number[],
q: number[], s: number[],
): number[] | null {
// p + t r = q + u s
const rxs = r[0] * s[1] - r[1] * s[0];
if (Math.abs(rxs) < 1e-12) return null;
const qp = [q[0] - p[0], q[1] - p[1]];
const t = (qp[0] * s[1] - qp[1] * s[0]) / rxs;
return [p[0] + t * r[0], p[1] + t * r[1]];
}
/**
* Inset a polygon by a per-edge inward distance (same units as poly).
* Zero-offset edges stay on the original. Mitre joins; bevel when the mitre
* would spike longer than MITRE_LIMIT × max(adjacent offsets).
*/
export function insetContour(poly: number[][], offsets: number[]): number[][] | null {
const n = poly?.length || 0;
if (n < 3 || offsets.length !== n) return null;
if (offsets.every((o) => !(o > 0))) return poly.map((p) => [p[0], p[1]]);
const out: number[][] = [];
for (let i = 0; i < n; i++) {
const iPrev = (i - 1 + n) % n;
const a0 = poly[iPrev], a1 = poly[i];
const b0 = poly[i], b1 = poly[(i + 1) % n];
const oA = Math.max(0, offsets[iPrev]);
const oB = Math.max(0, offsets[i]);
const [nAx, nAy] = inwardNormal(poly, iPrev);
const [nBx, nBy] = inwardNormal(poly, i);
const dA = [a1[0] - a0[0], a1[1] - a0[1]];
const dB = [b1[0] - b0[0], b1[1] - b0[1]];
const LA = Math.hypot(dA[0], dA[1]) || 1;
const LB = Math.hypot(dB[0], dB[1]) || 1;
const uA = [dA[0] / LA, dA[1] / LA];
const uB = [dB[0] / LB, dB[1] / LB];
const pA = [a0[0] + nAx * oA, a0[1] + nAy * oA];
const pB = [b0[0] + nBx * oB, b0[1] + nBy * oB];
if (!(oA > 0) && !(oB > 0)) {
out.push([poly[i][0], poly[i][1]]);
continue;
}
const hit = lineIntersect(pA, uA, pB, uB);
const maxO = Math.max(oA, oB, 1e-9);
if (hit) {
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
if (dist <= MITRE_LIMIT * maxO) {
out.push(hit);
continue;
}
}
// bevel: two points, each edge's offset line stopped at the vertex offset
if (oA > 0) out.push([poly[i][0] + nAx * oA, poly[i][1] + nAy * oA]);
if (oB > 0) out.push([poly[i][0] + nBx * oB, poly[i][1] + nBy * oB]);
if (!(oA > 0) && !(oB > 0)) out.push([poly[i][0], poly[i][1]]);
}
return out.length >= 3 ? out : null;
}
export type WallKind = 'shared' | 'outer';
export interface WallBodyPath {
/** SVG path `d` for the wall ring (evenodd: outer + reverse inset). */
d: string;
key: string;
kind: WallKind;
cm: number;
/** Screen-thickness hint in plan units (full wall depth). */
depthUnits: number;
}
function polyToPath(poly: number[][], close = true): string {
if (!poly.length) return '';
let d = `M ${poly[0][0]} ${poly[0][1]}`;
for (let i = 1; i < poly.length; i++) d += ` L ${poly[i][0]} ${poly[i][1]}`;
if (close) d += ' Z';
return d;
}
function reversePoly(poly: number[][]): number[][] {
return poly.slice().reverse();
}
/**
* Classify each edge of a room: shared with a neighbour, or outer.
* Open-boundary stretches are reported as kind null (no thickness allowed).
*/
export function edgeKinds(
rooms: any[],
roomId: string,
openCuts: number[][],
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;
}
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;
}
function distToSeg(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
const abx = bx - ax, aby = by - ay;
const L2 = abx * abx + aby * aby;
if (L2 < 1e-18) return Math.hypot(px - ax, py - ay);
let t = ((px - ax) * abx + (py - ay) * aby) / L2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (ax + abx * t), py - (ay + aby * t));
}
/**
* Per-room inward offsets (plan units) for insetContour: shared → half,
* outer → full, open/none → 0.
*/
export function insetOffsetsForRoom(
rooms: any[],
roomId: string,
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
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; }
const full = wallCmToUnits(cm, cellCm, gridPitch);
out.push(kind === 'shared' ? full / 2 : full);
}
return out;
}
/**
* One evenodd ring path per room that has any thick edge. Shared walls are
* drawn as two half-rings that meet at the centreline (one entry each side).
*/
export function wallBodyRings(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): WallBodyPath[] {
if (!walls?.length) return [];
const out: WallBodyPath[] = [];
for (const room of rooms || []) {
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 inset = insetContour(poly, offsets);
if (!inset) continue;
const d = `${polyToPath(poly)} ${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];
key = keyOf(a, b, pitch, coordScale);
kind = kinds[i] || 'outer';
cm = thicknessCmAt(walls, a, b, pitch, coordScale);
depth = wallCmToUnits(cm, cellCm, gridPitch);
break;
}
out.push({ d, key, kind, cm, depthUnits: depth });
}
return out;
}
/**
* Per-edge wall quads for styling hooks and opening cuts — one body per
* unique wall key. Shared walls are a single full-depth quad centred on the
* centreline; outer walls are a full-depth quad inward only.
*/
export interface WallEdgeBody {
key: string;
kind: WallKind;
cm: number;
/** Quad corners (4 points), CCW. */
quad: number[][];
a: number[];
b: number[];
depthUnits: number;
}
export function wallEdgeBodies(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): WallEdgeBody[] {
if (!walls?.length) return [];
const seen = new Set<string>();
const out: WallEdgeBody[] = [];
for (const room of rooms || []) {
const poly = roomPoly(room);
if (!poly) continue;
const kinds = edgeKinds(rooms, room.id, openCuts, pitch, coordScale);
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const kind = kinds[i];
if (!kind) continue;
const cm = thicknessCmAt(walls, a, b, pitch, coordScale);
if (!(cm > 0)) continue;
const key = keyOf(a, b, pitch, coordScale);
if (seen.has(key)) continue;
seen.add(key);
const depth = wallCmToUnits(cm, cellCm, gridPitch);
const [inx, iny] = inwardNormal(poly, i);
const ox = -inx, oy = -iny;
let quad: number[][];
if (kind === 'shared') {
const h = depth / 2;
quad = [
[a[0] + ox * h, a[1] + oy * h],
[b[0] + ox * h, b[1] + oy * h],
[b[0] + inx * h, b[1] + iny * h],
[a[0] + inx * h, a[1] + iny * h],
];
} else {
quad = [
[a[0], a[1]],
[b[0], b[1]],
[b[0] + inx * depth, b[1] + iny * depth],
[a[0] + inx * depth, a[1] + iny * depth],
];
}
out.push({ key, kind, cm, quad, a: [a[0], a[1]], b: [b[0], b[1]], depthUnits: depth });
}
}
return out;
}
/** SVG path for an edge body, with optional opening slots cut (evenodd holes). */
export function wallEdgePathD(
body: WallEdgeBody,
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
): string {
let d = polyToPath(body.quad);
const [dx, dy] = wallDir(body.a, body.b);
const ux = dx, uy = dy;
// normal across the wall (from a toward inward of first room estimate = perp)
const nx = -uy, ny = ux;
for (const o of openings) {
// only openings whose centre lies on (or very near) this span's centreline
const dist = distToSeg(o.x, o.y, body.a[0], body.a[1], body.b[0], body.b[1]);
if (dist > Math.max(body.depthUnits * 0.55, 1e-3)) continue;
const half = o.length / 2;
// slot covers full depth of this quad
const pad = body.depthUnits; // generous across
// project opening onto wall direction
const cx = o.x, cy = o.y;
const slot = [
[cx - ux * half - nx * pad, cy - uy * half - ny * pad],
[cx + ux * half - nx * pad, cy + uy * half - ny * pad],
[cx + ux * half + nx * pad, cy + uy * half + ny * pad],
[cx - ux * half + nx * pad, cy - uy * half + ny * pad],
];
d += ` ${polyToPath(reversePoly(slot))}`;
}
return d;
}
/**
* Outward paper growth offsets per edge (plan units): enough paper under the
* wall body so the scene background never shows through. Shared → half;
* outer → 0 (body is entirely inward); open → 0.
*/
export function paperOutwardOffsets(
rooms: any[],
roomId: string,
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
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 !== 'shared') { 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;
}
/**
* Expand a polygon outward by per-edge offsets (mirror of inset with flipped
* normals). Used for paper under shared thick walls.
*/
export function outsetContour(poly: number[][], offsets: number[]): number[][] | null {
const n = poly?.length || 0;
if (n < 3 || offsets.length !== n) return null;
if (offsets.every((o) => !(o > 0))) return poly.map((p) => [p[0], p[1]]);
// outset = inset of the reversed winding with same offsets, then reverse back
const rev = reversePoly(poly);
const revOff = offsets.slice().reverse();
// shift so revOff[i] applies to edge rev[i]→rev[i+1] which was poly edge
// after reverse: edge i of rev was edge (n-1-i) of original... careful.
// Simpler: negate inward normals by using inset on poly with negative?
// Build by flipping offset direction manually:
const out: number[][] = [];
for (let i = 0; i < n; i++) {
const iPrev = (i - 1 + n) % n;
const oA = Math.max(0, offsets[iPrev]);
const oB = Math.max(0, offsets[i]);
const [nAx, nAy] = inwardNormal(poly, iPrev);
const [nBx, nBy] = inwardNormal(poly, i);
// outward = -inward
const a0 = poly[iPrev], a1 = poly[i];
const b0 = poly[i], b1 = poly[(i + 1) % n];
const dA = [a1[0] - a0[0], a1[1] - a0[1]];
const dB = [b1[0] - b0[0], b1[1] - b0[1]];
const LA = Math.hypot(dA[0], dA[1]) || 1;
const LB = Math.hypot(dB[0], dB[1]) || 1;
const uA = [dA[0] / LA, dA[1] / LA];
const uB = [dB[0] / LB, dB[1] / LB];
const pA = [a0[0] - nAx * oA, a0[1] - nAy * oA];
const pB = [b0[0] - nBx * oB, b0[1] - nBy * oB];
if (!(oA > 0) && !(oB > 0)) {
out.push([poly[i][0], poly[i][1]]);
continue;
}
const hit = lineIntersect(pA, uA, pB, uB);
const maxO = Math.max(oA, oB, 1e-9);
if (hit) {
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
if (dist <= MITRE_LIMIT * maxO) {
out.push(hit);
continue;
}
}
if (oA > 0) out.push([poly[i][0] - nAx * oA, poly[i][1] - nAy * oA]);
if (oB > 0) out.push([poly[i][0] - nBx * oB, poly[i][1] - nBy * oB]);
}
void rev; void revOff;
return out.length >= 3 ? out : null;
}
/**
* Paper shapes grown under thick shared walls. Falls back to exact room
* contours when there is no thickness (byte-compatible with paperRoomShapes).
*/
export function paperRoomShapesWithWalls(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
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 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) {
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
}
}
return out;
}
/**
* Half-depth from the centreline toward the door's inner face (the room the
* leaf swings into). Used to shift the swing arc off the centreline.
*/
export function openingInnerFaceOffset(
rooms: any[],
opening: { x: number; y: number; angle: number; length: number; flip_v?: boolean },
walls: WallEntry[] | null | undefined,
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): { ox: number; oy: number; cm: number } {
let best: { a: number[]; b: number[]; room: any; edge: number; dist: 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 = distToSeg(opening.x, opening.y, a[0], a[1], b[0], b[1]);
if (!best || d < best.dist) best = { a, b, room, edge: i, dist: d };
}
}
if (!best || best.dist > pitch * coordScale) return { ox: 0, oy: 0, cm: 0 };
const cm = thicknessCmAt(walls, best.a, best.b, pitch, coordScale);
if (!(cm > 0)) return { ox: 0, oy: 0, cm: 0 };
const depth = wallCmToUnits(cm, cellCm, gridPitch);
const poly = roomPoly(best.room)!;
const [inx, iny] = inwardNormal(poly, best.edge);
const s = opening.flip_v ? -1 : 1;
const kinds = edgeKinds(rooms, best.room.id, [], pitch, coordScale);
const kind = kinds[best.edge] || 'outer';
const along = kind === 'shared' ? depth / 2 : depth;
return { ox: inx * along * s, oy: iny * along * s, cm };
}