mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
v1.59.0-beta.4: wall thickness + white editor sheet with backdrop
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:
+329
-19
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Кликните по стене, разделяющей две комнаты",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user