mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
v1.59.0-rc.1: optimize plans and polish editor feedback
This commit is contained in:
+317
-109
@@ -25,7 +25,7 @@ import {
|
||||
referencedContentUrls,
|
||||
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
|
||||
coverService, coverEntityOf, COVER_GUARDED_CLASSES,
|
||||
liveText, liveTextToken, hassValue, valueWithUnit, decorTextScale, decorTextLines,
|
||||
liveText, liveTextReference, liveTextToken, hassValue, valueWithUnit, decorTextScale, decorTextLines,
|
||||
DECOR_TEXT_BASE, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX,
|
||||
} from './logic';
|
||||
import {
|
||||
@@ -82,14 +82,14 @@ import {
|
||||
PLAN_SCALE_MIN, PLAN_SCALE_MAX,
|
||||
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
|
||||
} from './space-geometry';
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
import {
|
||||
combineVisualSamples, edgeActivity, entityVisualSample,
|
||||
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
|
||||
} from './device-visual';
|
||||
|
||||
const CARD_VERSION = '1.59.0-beta.10';
|
||||
const CARD_VERSION = '1.59.0-rc.1';
|
||||
/** 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
|
||||
@@ -144,7 +144,7 @@ type WarmViewport = {
|
||||
/** Which dialog was open and its draft (docs/WARM-REMOUNT.md §3). `data` is
|
||||
* the live draft OBJECT — the memo is module state, never serialised, so a
|
||||
* half-filled device dialog with its uploaded pdfs survives for free. */
|
||||
type WarmDialogKind = 'space' | 'marker' | 'settings' | 'opening' | 'decorText' | 'rules' | 'room' | 'info' | 'openingInfo';
|
||||
type WarmDialogKind = 'space' | 'marker' | 'settings' | 'opening' | 'decorText' | 'decorShape' | 'rules' | 'room' | 'info' | 'openingInfo';
|
||||
type WarmDialog = { kind: WarmDialogKind; space: string; mode: string; data: any };
|
||||
/** AUD-159B1-01: one entry per CARD PLACEMENT, not per key. Two cards with an
|
||||
* identical config on one view share the key, so the key alone cannot say
|
||||
@@ -350,6 +350,8 @@ class HouseplanCard extends LitElement {
|
||||
private _unsubCfg: (() => void) | null = null;
|
||||
private _unsubLayout: (() => void) | null = null;
|
||||
private _layoutRev = 0;
|
||||
/** One-deep server snapshot; invalidated by the first later plan edit. */
|
||||
private _canOptimizeUndo = false;
|
||||
private _devices: DevItem[] = [];
|
||||
private _regSignature = '';
|
||||
private _defPos: Record<string, { x: number; y: number }> = {};
|
||||
@@ -377,6 +379,13 @@ class HouseplanCard extends LitElement {
|
||||
private _decorTextDialog: {
|
||||
id?: string; x: number; y: number; text: string; color: string;
|
||||
pickerEntity?: string;
|
||||
/** Keep an old link when inline syntax cannot represent its attr/unit losslessly. */
|
||||
preserveLegacy?: boolean;
|
||||
} | null = null;
|
||||
/** Style editor opened by double-clicking a non-text decor object. */
|
||||
private _decorShapeDialog: {
|
||||
id: string; kind: 'line' | 'rect' | 'ellipse' | 'furniture';
|
||||
color: string; width: number; fill?: boolean;
|
||||
} | null = null;
|
||||
/** Last textarea selection survives moving focus to the HA pickers. */
|
||||
private _decorTextSelection: { start: number; end: number } = { start: 0, end: 0 };
|
||||
@@ -615,14 +624,14 @@ class HouseplanCard extends LitElement {
|
||||
private _onboardingShown = false; // the auto space dialog is shown once per session
|
||||
|
||||
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
|
||||
/** «Выровнять всё по сетке»: the preview the confirmation shows, plus the
|
||||
* already-computed result so the write cannot differ from the promise. */
|
||||
/** Optimization preview plus the exact pair, so commit cannot differ from it. */
|
||||
private _alignDialog: {
|
||||
report: AlignReport; spaces: any[]; layout: Record<string, any>;
|
||||
report: OptimizeReport; config: any; layout: Record<string, any>;
|
||||
/** the promised maximum, in centimetres, ALREADY rounded up (AUD-158B1-01) */
|
||||
cm: number;
|
||||
/** the space that maximum belongs to, named only when there are several */
|
||||
where: string;
|
||||
changed: boolean;
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
|
||||
@@ -847,6 +856,7 @@ class HouseplanCard extends LitElement {
|
||||
_decorDraft: { state: true },
|
||||
_decorSel: { state: true },
|
||||
_decorTextDialog: { state: true },
|
||||
_decorShapeDialog: { state: true },
|
||||
_furnPalette: { state: true },
|
||||
_bdDrag: { state: true },
|
||||
_dtBox: { state: true },
|
||||
@@ -989,6 +999,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._settingsDialog) { this._settingsDialog = null; return; }
|
||||
if (this._markerDialog) { this._markerDialog = null; return; }
|
||||
if (this._openingDialog) { this._openingDialog = null; return; }
|
||||
if (this._decorShapeDialog) { this._decorShapeDialog = null; return; }
|
||||
if (this._decorTextDialog) { this._decorTextDialog = null; return; }
|
||||
if (this._spaceDialog && !this._roomDialog) {
|
||||
// same semantics as the dialog's Cancel: an import queue is abandoned
|
||||
@@ -1405,6 +1416,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._settingsDialog) return this._settingsDialog.busy ? null : at('settings', this._settingsDialog);
|
||||
if (this._markerDialog) return this._markerDialog.busy ? null : at('marker', this._markerDialog);
|
||||
if (this._openingDialog) return at('opening', this._openingDialog);
|
||||
if (this._decorShapeDialog) return at('decorShape', this._decorShapeDialog);
|
||||
if (this._decorTextDialog) return at('decorText', this._decorTextDialog);
|
||||
if (this._roomDialog) {
|
||||
return at('room', {
|
||||
@@ -1461,6 +1473,7 @@ class HouseplanCard extends LitElement {
|
||||
case 'settings': this._settingsDialog = { ...d.data, busy: false }; break;
|
||||
case 'rules': this._rulesDialog = { ...d.data, busy: false }; break;
|
||||
case 'opening': this._openingDialog = { ...d.data }; break;
|
||||
case 'decorShape': this._decorShapeDialog = { ...d.data }; break;
|
||||
case 'decorText': {
|
||||
this._decorTextDialog = { ...d.data };
|
||||
const end = String(this._decorTextDialog?.text ?? '').length;
|
||||
@@ -1679,14 +1692,17 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
protected willUpdate(changed: PropertyValues): void {
|
||||
if (changed?.has?.('hass')) { this._vacTick(); this._activityTick(); }
|
||||
this._skyPlan();
|
||||
if (changed.has('hass') && this.hass) {
|
||||
this._hookConnection();
|
||||
if (!this._loadOk && !this._loading && this._loadTries < 8) {
|
||||
this._loadFromServer();
|
||||
}
|
||||
// Devices must exist before their first state snapshot is classified.
|
||||
// Otherwise the first real off->on edge after mount becomes the baseline.
|
||||
this._maybeRebuildDevices();
|
||||
this._vacTick();
|
||||
this._activityTick();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1768,6 +1784,7 @@ class HouseplanCard extends LitElement {
|
||||
this._serverStorage = true;
|
||||
// absent can_write = older backend / demo stub → keep null (legacy admin fallback)
|
||||
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
|
||||
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
|
||||
if (this._pendingNavMode && this._canEdit && !this._config?.kiosk) {
|
||||
this._mode = this._pendingNavMode;
|
||||
this._pendingNavMode = null;
|
||||
@@ -1856,6 +1873,7 @@ class HouseplanCard extends LitElement {
|
||||
this._loading = false;
|
||||
}
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -1881,6 +1899,7 @@ class HouseplanCard extends LitElement {
|
||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = resp?.rev || 0;
|
||||
this._canOptimizeUndo = !!resp?.can_optimize_undo;
|
||||
if (typeof resp?.can_write === 'boolean') this._serverCanWrite = resp.can_write;
|
||||
if (this._pendingNavMode && this._canEdit && !this._config?.kiosk) {
|
||||
this._mode = this._pendingNavMode;
|
||||
@@ -2013,6 +2032,7 @@ class HouseplanCard extends LitElement {
|
||||
for (const [id, pos] of mine) merged[id] = pos;
|
||||
this._layout = merged;
|
||||
this._layoutRev = resp?.rev ?? this._layoutRev;
|
||||
this._canOptimizeUndo = !!resp?.can_optimize_undo;
|
||||
this._cacheSnapshot();
|
||||
this.requestUpdate();
|
||||
} catch {
|
||||
@@ -2073,6 +2093,9 @@ class HouseplanCard extends LitElement {
|
||||
this._defPos = this._defaultPositions();
|
||||
this._syncNewDevices();
|
||||
this._seedHiddenDevices();
|
||||
// Rebuilds also happen without a hass update (marker save/rebind). Establish
|
||||
// new baselines and clear old flashes synchronously, before the next paint.
|
||||
this._syncActivityRuntime();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2282,14 +2305,16 @@ class HouseplanCard extends LitElement {
|
||||
/** One semantic result feeds the plate and every non-critical activity effect. */
|
||||
private _deviceVisual(d: DevItem): DeviceVisualState {
|
||||
if (d.hidden) return { availability: 'available', status: 'neutral', activity: 'none' };
|
||||
const combined = combineVisualSamples(this._visualSamples(d));
|
||||
const samples = this._visualSamples(d);
|
||||
const combined = combineVisualSamples(samples);
|
||||
// A critical state is never a decorative preference and stays visible
|
||||
// even when the card-wide live-state dressing is disabled.
|
||||
if (combined.status === 'alarm') return combined;
|
||||
if (!this._config?.live_states) return { availability: 'available', status: 'neutral', activity: 'none' };
|
||||
if (combined.availability === 'unavailable') return combined;
|
||||
const rt = this._activityRt.get(d.id);
|
||||
if (rt?.flashTs && rt.flashKind && Date.now() - rt.flashTs < ACTIVITY_WINDOW_MS)
|
||||
const sourceKey = this._activitySourceKey(samples);
|
||||
if (rt?.sources === sourceKey && rt.flashTs && rt.flashKind && Date.now() - rt.flashTs < ACTIVITY_WINDOW_MS)
|
||||
return { ...combined, activity: rt.flashKind };
|
||||
return combined;
|
||||
}
|
||||
@@ -4108,17 +4133,44 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Double click on a text shape (select tool) re-opens its dialog. */
|
||||
private _decorShapeDbl(shape: any): void {
|
||||
if (this._mode !== 'decor' || this._decorTool !== 'select' || shape.kind !== 'text') return;
|
||||
this._decorOpenText(shape);
|
||||
/** Select tool: every decor object has a double-click properties dialog. */
|
||||
private _decorShapeDbl(ev: MouseEvent, shape: any): void {
|
||||
if (this._mode !== 'decor' || this._decorTool !== 'select') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._decorMove = null;
|
||||
this._decorSel = shape.id;
|
||||
if (shape.kind === 'text') {
|
||||
this._decorOpenText(shape);
|
||||
return;
|
||||
}
|
||||
if (!['line', 'rect', 'ellipse', 'furniture'].includes(shape.kind)) return;
|
||||
this._decorShapeDialog = {
|
||||
id: shape.id,
|
||||
kind: shape.kind,
|
||||
color: shape.color || this._decorStyle.color,
|
||||
width: Number(shape.width) > 0 ? Number(shape.width) : this._decorStyle.width,
|
||||
...(shape.kind === 'rect' || shape.kind === 'ellipse' ? { fill: !!shape.fill } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Open the editor of an existing label (double click, or the text tool). */
|
||||
private _decorOpenText(shape: any): void {
|
||||
let text = String(shape.text ?? '');
|
||||
const legacyToken = liveTextToken(shape.entity, shape.attr);
|
||||
if (legacyToken) {
|
||||
const hasInline = [...text.matchAll(/\{([^{}\r\n]+)\}/g)]
|
||||
.some((m) => !!liveTextReference(m[1]));
|
||||
const explicitUnit = String(shape.unit ?? '').trim();
|
||||
// Old dialogs represented the entity state as attr="state"; inline text
|
||||
// represents that same value with a bare `{entity}` token.
|
||||
const legacyAttr = String(shape.attr ?? '').trim().toLowerCase() === 'state'
|
||||
? null
|
||||
: shape.attr;
|
||||
const legacyToken = !hasInline && !explicitUnit
|
||||
? liveTextToken(shape.entity, legacyAttr)
|
||||
: '';
|
||||
const preserveLegacy = !!String(shape.entity ?? '').trim() && !hasInline
|
||||
&& (!legacyToken || !!explicitUnit);
|
||||
if (legacyToken && !preserveLegacy) {
|
||||
const slot = text.indexOf('{}');
|
||||
text = slot >= 0
|
||||
? text.slice(0, slot) + legacyToken + text.slice(slot + 2)
|
||||
@@ -4127,6 +4179,7 @@ class HouseplanCard extends LitElement {
|
||||
this._decorTextDialog = {
|
||||
id: shape.id, x: shape.x, y: shape.y, text, color: shape.color,
|
||||
pickerEntity: shape.entity || '',
|
||||
preserveLegacy: preserveLegacy || undefined,
|
||||
};
|
||||
this._decorTextSelection = { start: text.length, end: text.length };
|
||||
}
|
||||
@@ -4151,7 +4204,9 @@ class HouseplanCard extends LitElement {
|
||||
const text = old.slice(0, start) + token + old.slice(end);
|
||||
const caret = start + token.length;
|
||||
this._decorTextSelection = { start: caret, end: caret };
|
||||
this._decorTextDialog = { ...d, text };
|
||||
// Inserting a new inline reference is the explicit replacement of any
|
||||
// unrepresentable legacy link; the stale side fields can now be dropped.
|
||||
this._decorTextDialog = { ...d, text, preserveLegacy: undefined };
|
||||
this.updateComplete.then(() => {
|
||||
const el = this.renderRoot.querySelector<HTMLTextAreaElement>('textarea.dtarea');
|
||||
if (!el) return;
|
||||
@@ -4169,6 +4224,10 @@ class HouseplanCard extends LitElement {
|
||||
if (d.id) {
|
||||
sp.decor = this._decorList.map((x) => {
|
||||
if (x.id !== d.id) return x;
|
||||
// A legacy unit or non-canonical attribute cannot be represented by
|
||||
// the inline grammar. Keep those fields until the user explicitly
|
||||
// replaces the binding; ordinary representable links still migrate.
|
||||
if (d.preserveLegacy) return { ...x, text, color: d.color };
|
||||
// beta.9 and earlier stored one live link beside the text. Saving in
|
||||
// the new editor migrates it to inline tokens and drops the old fields.
|
||||
const { entity, attr, unit, ...rest } = x;
|
||||
@@ -4185,6 +4244,27 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _decorSaveShape(): void {
|
||||
const d = this._decorShapeDialog;
|
||||
if (!d) return;
|
||||
const width = Math.max(0.1, Math.min(30, Number(d.width) || 0.1));
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = this._decorList.map((x) => x.id === d.id
|
||||
? {
|
||||
...x, color: d.color, width,
|
||||
...(d.kind === 'rect' || d.kind === 'ellipse' ? { fill: !!d.fill } : {}),
|
||||
}
|
||||
: x);
|
||||
this._decorStyle = {
|
||||
color: d.color,
|
||||
width,
|
||||
fill: d.kind === 'rect' || d.kind === 'ellipse' ? !!d.fill : this._decorStyle.fill,
|
||||
};
|
||||
this._decorShapeDialog = null;
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// ---- the text block: scale by a corner, rotate by its handle ----
|
||||
// The mechanics are the backdrop frame's (docs/BACKDROP.md §2), reused
|
||||
// rather than reinvented: chrome that never takes a pointer, finger-sized
|
||||
@@ -4831,24 +4911,27 @@ class HouseplanCard extends LitElement {
|
||||
const shapes = this._decorList.map((sh) => {
|
||||
const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : '');
|
||||
const down = (e: PointerEvent) => this._decorShapeDown(e, sh);
|
||||
const dbl = () => this._decorShapeDbl(sh);
|
||||
const dbl = (e: MouseEvent) => this._decorShapeDbl(e, sh);
|
||||
// docs/STYLING-HOOKS.md §3: every shape carries its kind and its id
|
||||
if (sh.kind === 'line')
|
||||
// round caps: line ends read as circles of the stroke width, so two
|
||||
// lines meeting at an angle join without the notch (owner's screenshot)
|
||||
return svg`<line class="${cls}" data-hp="decor" data-id="${sh.id}" data-kind="${sh.kind}"
|
||||
x1="${sh.x1 * W}" y1="${sh.y1 * H}" x2="${sh.x2 * W}" y2="${sh.y2 * H}"
|
||||
stroke="${sh.color}" stroke-width="${sh.width}" stroke-linecap="round" stroke-linejoin="round" @pointerdown=${down}></line>`;
|
||||
stroke="${sh.color}" stroke-width="${sh.width}" stroke-linecap="round" stroke-linejoin="round"
|
||||
@pointerdown=${down} @dblclick=${dbl}></line>`;
|
||||
if (sh.kind === 'rect')
|
||||
return svg`<rect class="${cls}" data-hp="decor" data-id="${sh.id}" data-kind="${sh.kind}"
|
||||
x="${sh.x * W}" y="${sh.y * H}" width="${sh.w * W}" height="${sh.h * H}"
|
||||
stroke="${sh.color}" stroke-width="${sh.width}"
|
||||
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></rect>`;
|
||||
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}"
|
||||
@pointerdown=${down} @dblclick=${dbl}></rect>`;
|
||||
if (sh.kind === 'ellipse')
|
||||
return svg`<ellipse class="${cls}" data-hp="decor" data-id="${sh.id}" data-kind="${sh.kind}"
|
||||
cx="${(sh.x + sh.w / 2) * W}" cy="${(sh.y + sh.h / 2) * H}"
|
||||
rx="${(sh.w / 2) * W}" ry="${(sh.h / 2) * H}" stroke="${sh.color}" stroke-width="${sh.width}"
|
||||
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></ellipse>`;
|
||||
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}"
|
||||
@pointerdown=${down} @dblclick=${dbl}></ellipse>`;
|
||||
if (sh.kind === 'furniture') {
|
||||
// One path per piece, generated at the shape's REAL size, so the
|
||||
// stroke is an ordinary stroke-width in render units like every other
|
||||
@@ -4864,7 +4947,8 @@ class HouseplanCard extends LitElement {
|
||||
return svg`<path class="${cls} dfurn" data-hp="decor" data-id="${sh.id}"
|
||||
data-kind="${sh.kind}" data-symbol="${sh.symbol}" d="${d}" transform=${tr}
|
||||
stroke="${sh.color}" stroke-width="${sh.width}" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round" @pointerdown=${down}></path>`;
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
@pointerdown=${down} @dblclick=${dbl}></path>`;
|
||||
}
|
||||
if (sh.kind === 'text') {
|
||||
// The label is painted from the LIVE value on every render — the same
|
||||
@@ -5020,7 +5104,9 @@ class HouseplanCard extends LitElement {
|
||||
}}>
|
||||
<option value="">${this._t('decor.live_attr_ph')}</option>
|
||||
<option value="__state__">${this._t('decor.live_state')}</option>
|
||||
${Object.keys(st?.attributes || {}).map((a) => html`<option value=${a}>${a}</option>`)}
|
||||
${Object.keys(st?.attributes || {})
|
||||
.filter((a) => !!liveTextToken(ent, a))
|
||||
.map((a) => html`<option value=${a}>${a}</option>`)}
|
||||
</select>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -5033,6 +5119,38 @@ class HouseplanCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderDecorShapeDialog(): TemplateResult {
|
||||
const d = this._decorShapeDialog!;
|
||||
const canFill = d.kind === 'rect' || d.kind === 'ellipse';
|
||||
const kindLabel = this._t(('decor.' + d.kind) as any);
|
||||
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorShapeDialog = null)}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:pencil-outline"></ha-icon>${this._t('decor.object_title', { kind: kindLabel })}</div>
|
||||
<div class="body">
|
||||
<label>${this._t('decor.color')}</label>
|
||||
<input type="color" .value=${d.color}
|
||||
@input=${(e: Event) => (this._decorShapeDialog = {
|
||||
...d, color: (e.target as HTMLInputElement).value,
|
||||
})} />
|
||||
<label>${this._t('decor.width')}</label>
|
||||
<input class="namein" type="number" min="0.1" max="30" step="0.5" .value=${String(d.width)}
|
||||
@input=${(e: Event) => (this._decorShapeDialog = {
|
||||
...d, width: Number((e.target as HTMLInputElement).value),
|
||||
})} />
|
||||
${canFill ? html`<label class="dfill"><input type="checkbox" .checked=${!!d.fill}
|
||||
@change=${(e: Event) => (this._decorShapeDialog = {
|
||||
...d, fill: (e.target as HTMLInputElement).checked,
|
||||
})} />${this._t('decor.fill')}</label>` : nothing}
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._decorShapeDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn primary" @click=${() => this._decorSaveShape()}>${this._t('btn.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Boundary under the cursor in the open-wall tool (hover preview). */
|
||||
private get _openWallHover(): { segs: number[][]; open: boolean } | null {
|
||||
if (!this._markup || this._tool !== 'openwall' || !this._cursorPt) return null;
|
||||
@@ -5049,7 +5167,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */
|
||||
private _renderOpenWalls(disp?: SpaceDisplay): TemplateResult {
|
||||
if (disp && !disp.showBorders && !this._markup) return svg`` as unknown as TemplateResult;
|
||||
if (disp && !disp.showBorders && !this._editing) return svg`` as unknown as TemplateResult;
|
||||
const cuts = this._openCuts();
|
||||
const hover = this._openWallHover;
|
||||
if (!cuts.length && !hover) return svg`` as unknown as TemplateResult;
|
||||
@@ -5146,12 +5264,26 @@ class HouseplanCard extends LitElement {
|
||||
const sp = this._curSpaceCfg;
|
||||
if (!sp) return;
|
||||
const eps = this._gridPitch * 0.02;
|
||||
const cuts = removeCut(this._openCuts(), sg, eps);
|
||||
const oldCuts = this._openCuts();
|
||||
// Materialise exact endpoints of every real remainder before removing the
|
||||
// cut. Older entries carry only midpoint+angle; without this one cut can be
|
||||
// the sole record of the boundary between (for example) 20 and 30 cm.
|
||||
let seededWalls = Array.isArray(sp.walls) ? sp.walls.slice() : [];
|
||||
for (const iv of wallIntervals(
|
||||
this._spaceModel().rooms, seededWalls, oldCuts,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
)) {
|
||||
if (iv.open || !(iv.cm > 0)) continue;
|
||||
seededWalls = setWallThickness(
|
||||
seededWalls, iv.a, iv.b, iv.cm, this._wallKeyPitch, NORM_W,
|
||||
);
|
||||
}
|
||||
const cuts = removeCut(oldCuts, sg, eps);
|
||||
// The stretch is solid again: rekey thickness onto the merged intervals
|
||||
// first — a partially opened wall keeps the cm of the part that stayed
|
||||
// closed. Only a wall that was open end to end has nothing to inherit and
|
||||
// takes the default (owner 2026-08-05).
|
||||
let walls = this._normalizeWalls(sp.walls, cuts);
|
||||
let walls = this._normalizeWalls(seededWalls, cuts);
|
||||
const inherited = intervalCmAt(
|
||||
this._spaceModel().rooms, walls, cuts, sg,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
@@ -7275,16 +7407,14 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
|
||||
/**
|
||||
* Preview the batch alignment (docs/CANVAS.md §9). Nothing is written here:
|
||||
* the run is pure, so the dialog can show the exact count and the exact
|
||||
* largest shift, and then commit the very object it measured.
|
||||
* Preview whole-plan maintenance. Nothing is written here: the pure run
|
||||
* produces both the report and the exact config/layout pair to commit.
|
||||
*/
|
||||
private _openAlignDialog = (): void => {
|
||||
if (!this._norm || !this._serverCfg) return;
|
||||
const spaces = this._serverCfg.spaces || [];
|
||||
const r = alignAllToGrid(spaces, this._layout || {});
|
||||
// The dialog is a safety gate in front of an action with no undo, so the
|
||||
// number it shows is an UPPER BOUND and not a sample. The run already
|
||||
const r = optimizePlans(this._serverCfg, this._layout || {});
|
||||
// The maximum geometry shift is an UPPER BOUND, not a sample. The run
|
||||
// measured every element in the centimetres of ITS OWN space — converting
|
||||
// one normalised maximum through the first space's `cell_cm` understated
|
||||
// a two-scale plan twentyfold (AUD-158B1-01) — and the last tenth is
|
||||
@@ -7292,55 +7422,93 @@ class HouseplanCard extends LitElement {
|
||||
const cm = Math.ceil(r.report.maxShiftCm * 10) / 10;
|
||||
const sp = spaces.find((x: any) => x?.id != null && String(x.id) === r.report.maxSpace);
|
||||
const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : '';
|
||||
this._alignDialog = { report: r.report, spaces: r.spaces, layout: r.layout, cm, where, busy: false };
|
||||
this._alignDialog = {
|
||||
report: r.report, config: r.config, layout: r.layout, cm, where,
|
||||
changed: r.changed, busy: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Commit it: ONE config write and ONE layout write, so a plan that ends up
|
||||
* wrong ends up wrong exactly once and the previous state is a single undo
|
||||
* away — by re-running nothing, because there is no undo. The dialog says so.
|
||||
* The backend persists an intent before either store changes, then keeps a
|
||||
* one-deep snapshot that remains undoable until the next plan edit.
|
||||
*/
|
||||
private async _runAlignToGrid(): Promise<void> {
|
||||
const d = this._alignDialog;
|
||||
if (!d || d.busy || !this._serverCfg) return;
|
||||
this._alignDialog = { ...d, busy: true };
|
||||
try {
|
||||
this._serverCfg = { ...this._serverCfg, spaces: d.spaces };
|
||||
if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush();
|
||||
await this._writeChain;
|
||||
const resp = await this.hass.callWS({
|
||||
type: 'houseplan/plan/optimize',
|
||||
config: d.config,
|
||||
layout: d.layout,
|
||||
expected_config_rev: this._cfgRev,
|
||||
expected_layout_rev: this._layoutRev,
|
||||
});
|
||||
this._serverCfg = d.config;
|
||||
this._layout = d.layout;
|
||||
for (const id of Object.keys(d.layout)) this._dirtyPos.add(id);
|
||||
this._cfgRev = resp?.config_rev ?? this._cfgRev + 1;
|
||||
this._layoutRev = resp?.layout_rev ?? this._layoutRev + 1;
|
||||
this._canOptimizeUndo = !!resp?.can_undo;
|
||||
this._dirtyPos.clear();
|
||||
this._sentPos.clear();
|
||||
this._cfgEpoch++;
|
||||
this._modelCache = null;
|
||||
this._frame = null;
|
||||
await this._saveConfigNow();
|
||||
await this._persistLayoutNow();
|
||||
this._cacheSnapshot();
|
||||
this._alignDialog = null;
|
||||
this.requestUpdate();
|
||||
this._showToast(this._t('gs.align_done', { n: String(d.report.moved) }));
|
||||
this._showToast(this._t('gs.align_done', {
|
||||
n: String(d.report.moved),
|
||||
m: String(d.report.migrated + d.report.canonicalized
|
||||
+ d.report.wallsMerged + d.report.spansMerged),
|
||||
}));
|
||||
} catch (e: any) {
|
||||
if (this._alignDialog) this._alignDialog = { ...this._alignDialog, busy: false };
|
||||
if (e?.code === 'conflict') {
|
||||
await Promise.all([this._reloadConfigOnly(true), this._reloadLayoutOnly()]);
|
||||
}
|
||||
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
/** The debounced layout writer, awaited — the batch must not return before
|
||||
* the positions it promised are actually on their way. */
|
||||
private async _persistLayoutNow(): Promise<void> {
|
||||
if (!this._serverStorage) {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
|
||||
this._dirtyPos.clear();
|
||||
return;
|
||||
/** Prevent a double click from sending two restores of the same snapshot. */
|
||||
private _optimizeUndoBusy = false;
|
||||
|
||||
/** Restore the one-deep snapshot, provided no later plan edit exists. */
|
||||
private async _undoPlanOptimization(): Promise<void> {
|
||||
if (!this._canOptimizeUndo || this._optimizeUndoBusy) return;
|
||||
this._optimizeUndoBusy = true;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
await this.hass.callWS({
|
||||
type: 'houseplan/plan/optimize_undo',
|
||||
expected_config_rev: this._cfgRev,
|
||||
expected_layout_rev: this._layoutRev,
|
||||
});
|
||||
const [cfgResp, layResp] = await Promise.all([
|
||||
this.hass.callWS({ type: 'houseplan/config/get' }),
|
||||
this.hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
this._serverCfg = cfgResp?.config || this._serverCfg;
|
||||
this._cfgRev = cfgResp?.rev ?? this._cfgRev;
|
||||
this._layout = layResp?.layout || this._layout;
|
||||
this._layoutRev = layResp?.rev ?? this._layoutRev;
|
||||
this._canOptimizeUndo = false;
|
||||
this._cfgEpoch++;
|
||||
this._modelCache = null;
|
||||
this._frame = null;
|
||||
this._cacheSnapshot();
|
||||
this.requestUpdate();
|
||||
this._showToast(this._t('gs.optimize_undone'));
|
||||
} catch (e: any) {
|
||||
this._canOptimizeUndo = false;
|
||||
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
||||
} finally {
|
||||
this._optimizeUndoBusy = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
const ids = [...this._dirtyPos];
|
||||
this._dirtyPos.clear();
|
||||
await Promise.all(ids.map((id) => {
|
||||
const pos = this._layout[id];
|
||||
if (!pos) return Promise.resolve();
|
||||
this._sentPos.set(id, pos);
|
||||
return this.hass
|
||||
.callWS({ type: 'houseplan/layout/update', device_id: id, pos })
|
||||
.then((r: any) => this._noteLayoutRev(r))
|
||||
.finally(() => { if (this._sentPos.get(id) === pos) this._sentPos.delete(id); });
|
||||
}));
|
||||
this._cacheSnapshot();
|
||||
}
|
||||
|
||||
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
|
||||
@@ -7548,36 +7716,38 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmation. It states the two numbers the user needs to decide —
|
||||
* HOW MANY elements move and by HOW MUCH at most — and it says plainly that
|
||||
* there is no undo, because there is not: the action is a single batch write
|
||||
* and the card keeps no snapshot of what the plan looked like before.
|
||||
* The confirmation separates geometry movement from lossless maintenance,
|
||||
* and promises the one-deep undo before either store is changed.
|
||||
*/
|
||||
private _renderAlignDialog(): TemplateResult {
|
||||
const d = this._alignDialog!;
|
||||
const r = d.report;
|
||||
return html`<div class="menuwrap dialogwrap" @click=${() => (this._alignDialog = null)}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:grid"></ha-icon>${this._t('gs.align_title')}</div>
|
||||
<div class="hd"><ha-icon icon="mdi:broom"></ha-icon>${this._t('gs.align_title')}</div>
|
||||
<div class="body">
|
||||
${r.moved === 0
|
||||
${!d.changed
|
||||
? html`<p class="alignmsg">${this._t('gs.align_none')}</p>`
|
||||
: html`
|
||||
<p class="alignmsg">${this._t('gs.align_count', {
|
||||
n: String(r.moved), total: String(r.total), cm: String(d.cm),
|
||||
})}</p>
|
||||
${r.moved ? html`<p class="alignmsg">${this._t('gs.align_count', {
|
||||
n: String(r.moved), total: String(r.total), cm: String(d.cm),
|
||||
})}</p>` : nothing}
|
||||
${d.where
|
||||
? html`<p class="alignmsg">${this._t('gs.align_where', { s: d.where })}</p>`
|
||||
: nothing}
|
||||
${r.rotated
|
||||
? html`<p class="alignmsg">${this._t('gs.align_turned', { n: String(r.rotated) })}</p>`
|
||||
: nothing}
|
||||
<p class="alignmsg">${this._t('gs.optimize_changes', {
|
||||
m: String(r.migrated), c: String(r.canonicalized),
|
||||
w: String(r.wallsMerged), s: String(r.spansMerged),
|
||||
})}</p>
|
||||
<div class="rhint">${this._t('gs.align_warn')}</div>`}
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._alignDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
${r.moved === 0 ? nothing : html`
|
||||
${!d.changed ? nothing : html`
|
||||
<button class="btn on" @click=${this._runAlignToGrid} ?disabled=${d.busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('gs.align_run')}
|
||||
</button>`}
|
||||
@@ -7692,9 +7862,15 @@ class HouseplanCard extends LitElement {
|
||||
<div class="rhint">${this._t('gs.grid_hint')}</div>
|
||||
<div class="colorrow gsrow">
|
||||
<button class="btn ghost alignall" @click=${this._openAlignDialog}>
|
||||
<ha-icon icon="mdi:grid"></ha-icon>${this._t('gs.align_all')}
|
||||
<ha-icon icon="mdi:broom"></ha-icon>${this._t('gs.align_all')}
|
||||
</button>
|
||||
</div>
|
||||
${this._canOptimizeUndo ? html`<div class="colorrow gsrow">
|
||||
<button class="btn ghost alignall" @click=${this._undoPlanOptimization}
|
||||
?disabled=${this._optimizeUndoBusy}>
|
||||
<ha-icon icon="mdi:undo-variant"></ha-icon>${this._t('gs.optimize_undo')}
|
||||
</button>
|
||||
</div>` : nothing}
|
||||
<label class="dispsection">${this._t('gs.about_group')}</label>
|
||||
<div class="aboutver">${this._t('gs.about_version', { v: CARD_VERSION })}</div>
|
||||
<a class="aboutlink" href="https://github.com/Matysh/houseplan-card" target="_blank" rel="noopener">
|
||||
@@ -8051,7 +8227,7 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
const otherPolys = (rr: any) =>
|
||||
space.rooms.filter((o) => o !== rr).map(polyOf).filter(Boolean) as number[][][];
|
||||
return space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||
return space.rooms.filter((r) => r.area || this._mode === 'view' || this._markup || disp.showBorders).map((r) => {
|
||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
||||
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
||||
cls += ' picked';
|
||||
@@ -8086,10 +8262,18 @@ class HouseplanCard extends LitElement {
|
||||
} else st.push('--room-fill:transparent', '--room-fill-op:0');
|
||||
style = st.join(';');
|
||||
}
|
||||
const tip = (e: MouseEvent) =>
|
||||
this._showTip(e, r.name, '',
|
||||
let areaText: string | null | undefined;
|
||||
const tip = (e: MouseEvent) => {
|
||||
if (this._mode !== 'view') return;
|
||||
if (areaText === undefined) areaText = this._roomArea(r);
|
||||
this._showTip(
|
||||
e,
|
||||
r.name || this._t('room.unnamed'),
|
||||
areaText ? this._t('tip.area', { value: areaText }) : '',
|
||||
showLqi ? this._roomLqi(r.area) : null,
|
||||
this._roomTemp(r));
|
||||
this._roomTemp(r),
|
||||
);
|
||||
};
|
||||
const label = !space.bg && !disp.showNames && !this._markup;
|
||||
const c = this._roomCenter(r);
|
||||
// open boundaries: this room's solid stroke must not run beneath
|
||||
@@ -8196,7 +8380,7 @@ class HouseplanCard extends LitElement {
|
||||
space-card, so the two renderers agree. The per-device
|
||||
multiplier and the kiosk scales still feed --dev-size. */}
|
||||
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
|
||||
${devs.map((d) => this._renderDevice(d, view, showLqi, disp.fill === 'glow' && !this._markup))}
|
||||
${devs.map((d) => this._renderDevice(d, view, showLqi))}
|
||||
${this._renderVacuums(devs, view)}
|
||||
${this._renderVacFit(view)}
|
||||
${this._renderOpeningLocks(view)}
|
||||
@@ -8254,6 +8438,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._openingDialog ? this._renderOpeningDialog() : nothing}
|
||||
${this._openingInfo ? this._renderOpeningInfoCard() : nothing}
|
||||
${this._decorTextDialog ? this._renderDecorTextDialog() : nothing}
|
||||
${this._decorShapeDialog ? this._renderDecorShapeDialog() : nothing}
|
||||
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
||||
${this._markerDialog ? this._renderMarkerDialog() : nothing}
|
||||
${this._infoCard ? this._renderInfoCard() : nothing}
|
||||
@@ -8350,11 +8535,12 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass per hass tick records every effective source. Only a witnessed,
|
||||
* meaningful edge starts a short effect: first load and recovery from
|
||||
* unknown/unavailable establish a baseline and never fake a detection.
|
||||
* Bring activity runtime in sync with the marker/source graph without
|
||||
* classifying an edge. Called after every device rebuild as well as before a
|
||||
* hass tick: new sources get a baseline, rebound sources lose the old flash,
|
||||
* and removed markers cannot leave timers behind.
|
||||
*/
|
||||
private _activityTick(): void {
|
||||
private _syncActivityRuntime(): void {
|
||||
if (!this.hass) return;
|
||||
const live = new Set<string>();
|
||||
for (const d of this._devices) {
|
||||
@@ -8369,18 +8555,35 @@ class HouseplanCard extends LitElement {
|
||||
this._activityRt.set(d.id, rt);
|
||||
continue;
|
||||
}
|
||||
// A marker changed what it represents (cover action removed, controls
|
||||
// rebound, primary replaced): the old entity's short effect must never
|
||||
// leak onto the new source. Establish a fresh baseline instead.
|
||||
if (rt.sources !== sourceKey) {
|
||||
clearTimeout(rt.timer);
|
||||
rt.sources = sourceKey;
|
||||
rt.last = {};
|
||||
rt.flashTs = 0;
|
||||
rt.flashKind = null;
|
||||
for (const sample of samples) rt.last[sample.eid] = sample.state;
|
||||
continue;
|
||||
}
|
||||
if (rt.sources === sourceKey) continue;
|
||||
clearTimeout(rt.timer);
|
||||
rt.sources = sourceKey;
|
||||
rt.last = {};
|
||||
rt.flashTs = 0;
|
||||
rt.flashKind = null;
|
||||
for (const sample of samples) rt.last[sample.eid] = sample.state;
|
||||
}
|
||||
for (const [id, rt] of this._activityRt) {
|
||||
if (live.has(id)) continue;
|
||||
clearTimeout(rt.timer);
|
||||
this._activityRt.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass per hass tick records every effective source. Only a witnessed,
|
||||
* meaningful edge starts a short effect: first load and recovery from
|
||||
* unknown/unavailable establish a baseline and never fake a detection.
|
||||
*/
|
||||
private _activityTick(): void {
|
||||
if (!this.hass) return;
|
||||
this._syncActivityRuntime();
|
||||
for (const d of this._devices) {
|
||||
if (d.hidden) continue;
|
||||
const samples = this._visualSamples(d);
|
||||
const sourceKey = this._activitySourceKey(samples);
|
||||
const rt = this._activityRt.get(d.id);
|
||||
if (!rt || rt.sources !== sourceKey) continue;
|
||||
// A direct closed↔open fallback is only a substitute for integrations
|
||||
// that omit opening/closing. Once a real travelling state is observed,
|
||||
// it owns the ring and the old 3.3 s fallback is discarded.
|
||||
@@ -8397,12 +8600,6 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
if (edge) this._stampActivity(d.id, edge, sourceKey);
|
||||
}
|
||||
// Config/registry churn must not leave timers or stale edge baselines alive.
|
||||
for (const [id, rt] of this._activityRt) {
|
||||
if (live.has(id)) continue;
|
||||
clearTimeout(rt.timer);
|
||||
this._activityRt.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8859,7 +9056,7 @@ class HouseplanCard extends LitElement {
|
||||
${pucks}`;
|
||||
}
|
||||
|
||||
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true, glowFill = false): TemplateResult {
|
||||
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true): TemplateResult {
|
||||
const p = this._pos(d);
|
||||
const left = ((p.x - view.x) / view.w) * 100;
|
||||
const top = ((p.y - view.y) / view.h) * 100;
|
||||
@@ -8867,17 +9064,10 @@ class HouseplanCard extends LitElement {
|
||||
// and no live numbers either (HP-1510-02): no value text, no temperature,
|
||||
// no humidity, no LQI badge, no state-morphed icon. The base icon and the
|
||||
// name stay — enough to recognise the device and open its dialog.
|
||||
// The owner's rule for LIGHT SOURCES (2026-07-29): where the glow layer
|
||||
// is VISIBLE, the indicator IS the glow spot — the badge stays standard,
|
||||
// on or off; wherever the spot is not drawn (other fills, the plan
|
||||
// editor) a lit source goes plain yellow like a heating TRV. The gate
|
||||
// must equal the layer's visibility, or a mode ends up with neither
|
||||
// indicator (HP-1520-01). "Source" is exactly the litLightEntity
|
||||
// condition that casts the spot, so a lit socket keeps its yellow.
|
||||
// Yellow is the universal "working now" plate. A source glow supplements
|
||||
// it instead of replacing it, so every fill mode tells the same story.
|
||||
const visual = this._deviceVisual(d);
|
||||
let cls = d.hidden ? '' : this._stateClass(d, visual);
|
||||
if (glowFill && visual.status === 'working' && litLightEntity(this.hass, d))
|
||||
cls = cls.split(/\s+/).filter((c) => c && c !== 'on').join(' ');
|
||||
const cls = d.hidden ? '' : this._stateClass(d, visual);
|
||||
const temp = d.hidden ? null : this._liveTemp(d);
|
||||
const hum = d.hidden ? null : this._liveHum(d);
|
||||
const lqi = showLqi && !d.virtual && !d.hidden ? lqiFor(this.hass, d.entities) : null;
|
||||
@@ -8978,6 +9168,24 @@ class HouseplanCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Clean-floor area shown in the room tooltip (same rule as resize labels). */
|
||||
private _roomArea(r: RoomCfg): string | null {
|
||||
const poly = roomPoly(r);
|
||||
if (!poly) return null;
|
||||
const walls = this._spaceWalls;
|
||||
const floor = walls.length && r.id
|
||||
? (innerContourForRoom(
|
||||
this._spaceModel().rooms, r.id, walls,
|
||||
this._openPairs().flatMap((p) => p.segs),
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
) || poly)
|
||||
: poly;
|
||||
return formatArea(
|
||||
areaM2(floor, this._gridPitch, this._cellCm),
|
||||
this.hass?.config?.unit_system?.length === 'mi',
|
||||
);
|
||||
}
|
||||
|
||||
/** Room temperature honouring the tier-3 source override. */
|
||||
private _roomTemp(r: RoomCfg): number | null {
|
||||
const src = r.settings?.temp_source;
|
||||
|
||||
+13
-8
@@ -63,6 +63,7 @@
|
||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
|
||||
"markup.hint_start": "click a grid dot to start the outline",
|
||||
"tip.lqi": "average zigbee signal:",
|
||||
"tip.area": "area: {value}",
|
||||
"info.device_header": "Device on the plan",
|
||||
"info.model": "Model",
|
||||
"info.state": "State",
|
||||
@@ -278,6 +279,7 @@
|
||||
"decor.w_thick": "Thick",
|
||||
"decor.fill": "Fill",
|
||||
"decor.text_title": "Text label",
|
||||
"decor.object_title": "Edit {kind}",
|
||||
"decor.text_label": "Text",
|
||||
"decor.live_group": "Insert HA variable",
|
||||
"decor.live_entity": "Entity",
|
||||
@@ -451,17 +453,20 @@
|
||||
"canvas.far_objects": "{n} object(s) far from the plan",
|
||||
"canvas.show_far": "Show",
|
||||
"canvas.home_tip": "The plan is over there — click to fit it",
|
||||
"gs.grid_group": "Grid",
|
||||
"gs.grid_hint": "Everything you draw or drag now lands on the grid. Hold Shift to place something between the nodes on purpose.",
|
||||
"gs.align_all": "Align everything to the grid",
|
||||
"gs.align_title": "Align everything to the grid",
|
||||
"gs.align_none": "Everything is already on the grid — nothing to move.",
|
||||
"gs.grid_group": "Plan maintenance",
|
||||
"gs.grid_hint": "Updates data models, aligns plan elements to the grid and merges redundant wall fragments. An exact report is shown before anything is stored.",
|
||||
"gs.align_all": "Optimize plans",
|
||||
"gs.align_title": "Optimize plans",
|
||||
"gs.align_none": "All plans already use the current optimized data model.",
|
||||
"gs.align_count": "{n} of {total} elements will move, by at most {cm} cm.",
|
||||
"gs.align_where": "The largest shift is in “{s}”.",
|
||||
"gs.align_turned": "Openings whose angle is corrected: {n}.",
|
||||
"gs.align_warn": "This rewrites the plan and the device positions in one go. There is no undo: the only way back is to move things by hand. If some elements are deliberately between the nodes, cancel.",
|
||||
"gs.align_run": "Align",
|
||||
"gs.align_done": "Aligned to the grid: {n} elements moved",
|
||||
"gs.optimize_changes": "Model migrations: {m}; plans canonicalized: {c}; merged real-wall fragments: {w}; virtual fragments: {s}.",
|
||||
"gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.",
|
||||
"gs.align_run": "Optimize",
|
||||
"gs.align_done": "Plans optimized: {n} elements moved, {m} records maintained",
|
||||
"gs.optimize_undo": "Undo last optimization",
|
||||
"gs.optimize_undone": "The last optimization was undone",
|
||||
"decor.furniture": "Furniture",
|
||||
"furn.title": "Furniture library",
|
||||
"furn.group_furniture": "Furniture",
|
||||
|
||||
+13
-8
@@ -63,6 +63,7 @@
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||
"tip.lqi": "средний сигнал zigbee:",
|
||||
"tip.area": "площадь: {value}",
|
||||
"info.device_header": "Устройство на плане",
|
||||
"info.model": "Модель",
|
||||
"info.state": "Состояние",
|
||||
@@ -278,6 +279,7 @@
|
||||
"decor.w_thick": "Толстая",
|
||||
"decor.fill": "Залить",
|
||||
"decor.text_title": "Надпись",
|
||||
"decor.object_title": "Редактирование: {kind}",
|
||||
"decor.text_label": "Текст",
|
||||
"decor.live_group": "Вставить переменную HA",
|
||||
"decor.live_entity": "Сущность",
|
||||
@@ -451,17 +453,20 @@
|
||||
"canvas.far_objects": "Объектов далеко от плана: {n}",
|
||||
"canvas.show_far": "Показать",
|
||||
"canvas.home_tip": "План там — нажмите, чтобы вписать",
|
||||
"gs.grid_group": "Сетка",
|
||||
"gs.grid_hint": "Всё, что вы рисуете и перетаскиваете, ложится строго на сетку. Shift — временно поставить элемент между узлами.",
|
||||
"gs.align_all": "Выровнять всё по сетке",
|
||||
"gs.align_title": "Выровнять всё по сетке",
|
||||
"gs.align_none": "Всё уже на сетке — двигать нечего.",
|
||||
"gs.grid_group": "Обслуживание планов",
|
||||
"gs.grid_hint": "Обновляет модели данных, выравнивает элементы по сетке и объединяет лишние фрагменты стен. Перед записью будет показан точный отчёт.",
|
||||
"gs.align_all": "Оптимизировать планы",
|
||||
"gs.align_title": "Оптимизировать планы",
|
||||
"gs.align_none": "Все планы уже используют актуальную и оптимальную модель данных.",
|
||||
"gs.align_count": "Сдвинется элементов: {n} из {total}, максимум на {cm} см.",
|
||||
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
|
||||
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
|
||||
"gs.align_warn": "Действие разом перезапишет план и позиции устройств. Отмены нет: вернуть можно только руками. Если какие-то элементы стоят между узлами намеренно — отмените.",
|
||||
"gs.align_run": "Выровнять",
|
||||
"gs.align_done": "Выровнено по сетке: сдвинуто элементов — {n}",
|
||||
"gs.optimize_changes": "Миграций модели: {m}; канонизировано планов: {c}; объединено отрезков реальных стен: {w}; виртуальных: {s}.",
|
||||
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
|
||||
"gs.align_run": "Оптимизировать",
|
||||
"gs.align_done": "Планы оптимизированы: сдвинуто элементов — {n}, обслужено записей — {m}",
|
||||
"gs.optimize_undo": "Отменить последнюю оптимизацию",
|
||||
"gs.optimize_undone": "Последняя оптимизация отменена",
|
||||
"decor.furniture": "Мебель",
|
||||
"furn.title": "Библиотека мебели",
|
||||
"furn.group_furniture": "Мебель",
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Explicit, idempotent maintenance for a whole House Plan model.
|
||||
*
|
||||
* This is deliberately narrower than "delete anything suspicious". It only
|
||||
* performs transformations whose meaning is known and lossless, then aligns
|
||||
* plan objects to the lattice and rewrites derived wall/open-span storage into
|
||||
* its canonical form. Unknown fields, backdrop calibration, view boxes,
|
||||
* unattached layout entries and files are preserved.
|
||||
*/
|
||||
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { decorTextScale, liveTextReference, liveTextToken, roomPoly } from './logic';
|
||||
import {
|
||||
clipOpenSpansToShared, cutsToSpanEntries, entryToSeg,
|
||||
projectOnSeg, rekeyOpenSpansAfterMove, resolveOpenCuts, sanitizeOpenSpans,
|
||||
snapOpenPoint, spanToEntry, syncOpenToFromCuts,
|
||||
} from './open-spans';
|
||||
import { GRID_PITCH, GRID_STEP_N, NORM_W, spaceModels } from './space-geometry';
|
||||
import {
|
||||
degradeWalls, normalizeWallIntervals, rekeyWallsAfterMove,
|
||||
} from './wall-thickness';
|
||||
|
||||
/** Bump when a new lossless maintenance pass is added. */
|
||||
export const PLAN_MODEL_VERSION = 1;
|
||||
const DEFAULT_CELL_CM = 5;
|
||||
|
||||
export interface OptimizeReport extends AlignReport {
|
||||
modelFrom: number;
|
||||
modelTo: number;
|
||||
/** Legacy fields converted or removed. */
|
||||
migrated: number;
|
||||
/** Spaces whose wall/open-span representation was rewritten canonically. */
|
||||
canonicalized: number;
|
||||
/** Redundant equal-thickness wall entries removed by canonicalisation. */
|
||||
wallsMerged: number;
|
||||
/** Touching/overlapping virtual pieces merged on the same room pair. */
|
||||
spansMerged: number;
|
||||
}
|
||||
|
||||
export interface OptimizeResult {
|
||||
config: any;
|
||||
layout: Record<string, any>;
|
||||
report: OptimizeReport;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const own = (o: any, key: string): boolean => Object.prototype.hasOwnProperty.call(o, key);
|
||||
|
||||
const modelOf = (space: any): any => (
|
||||
spaceModels({ spaces: [space], markers: [], settings: {} } as any)[0]
|
||||
);
|
||||
|
||||
/** Parallel per-room edges; unlike deduped roomEdges(), indices remain stable
|
||||
* while vertices are rounded, which lets exact wall fragments follow a move. */
|
||||
const edgePairs = (rooms: any[]): [number[], number[]][] => {
|
||||
const out: [number[], number[]][] = [];
|
||||
for (const room of rooms || []) {
|
||||
const poly = roomPoly(room);
|
||||
if (!poly?.length) continue;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
out.push([
|
||||
[poly[i][0], poly[i][1]],
|
||||
[poly[(i + 1) % poly.length][0], poly[(i + 1) % poly.length][1]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Convert read-compatible legacy fields where the conversion is exact. */
|
||||
const migrateLosslessly = (config: any): number => {
|
||||
let n = 0;
|
||||
for (const marker of config.markers || []) {
|
||||
if (marker.display === 'ripple') { marker.display = 'icon_ripple'; n++; }
|
||||
const vacuum = marker.vacuum;
|
||||
if (vacuum && own(vacuum, 'trail')) {
|
||||
if (!['never', 'cleaning', 'always'].includes(vacuum.trail_mode)) {
|
||||
vacuum.trail_mode = vacuum.trail === false ? 'never' : 'cleaning';
|
||||
}
|
||||
delete vacuum.trail;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const space of config.spaces || []) {
|
||||
if (own(space, 'segments')) { delete space.segments; n++; }
|
||||
for (const shape of space.decor || []) {
|
||||
if (shape?.kind !== 'text') continue;
|
||||
|
||||
// Old s/m/l text sizes have an exact numerical equivalent.
|
||||
if (shape.scale === undefined && shape.size !== undefined) {
|
||||
shape.scale = decorTextScale(shape);
|
||||
delete shape.size;
|
||||
n++;
|
||||
}
|
||||
|
||||
let text = String(shape.text ?? '');
|
||||
const hasInline = [...text.matchAll(/\{([^{}\r\n]+)\}/g)]
|
||||
.some((m) => !!liveTextReference(m[1]));
|
||||
const hasLegacy = own(shape, 'entity') || own(shape, 'attr') || own(shape, 'unit');
|
||||
if (!hasLegacy) continue;
|
||||
|
||||
// Inline references are already authoritative, so stale side fields can
|
||||
// be discarded. Otherwise migrate only the subset representable without
|
||||
// losing an explicit unit or a non-canonical attribute.
|
||||
const explicitUnit = String(shape.unit ?? '').trim();
|
||||
// The legacy selector stored the entity state as attr="state". In the
|
||||
// inline grammar state is the bare entity token; `:state` would instead
|
||||
// ask Home Assistant for an attribute named "state" and change meaning.
|
||||
const legacyAttr = String(shape.attr ?? '').trim().toLowerCase() === 'state'
|
||||
? null
|
||||
: shape.attr;
|
||||
const token = !hasInline && !explicitUnit
|
||||
? liveTextToken(shape.entity, legacyAttr)
|
||||
: '';
|
||||
if (hasInline || token) {
|
||||
if (token) {
|
||||
const slot = text.indexOf('{}');
|
||||
text = slot >= 0
|
||||
? text.slice(0, slot) + token + text.slice(slot + 2)
|
||||
: `${text}${text ? ' ' : ''}${token}`;
|
||||
shape.text = text;
|
||||
}
|
||||
delete shape.entity;
|
||||
delete shape.attr;
|
||||
delete shape.unit;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Produce the exact config/layout pair that the confirmation dialog previews.
|
||||
* Calling it again on its own output is a no-op.
|
||||
*/
|
||||
export function optimizePlans(configIn: any, layoutIn: Record<string, any>): OptimizeResult {
|
||||
const config = clone(configIn || { spaces: [], markers: [], settings: {} });
|
||||
const original = JSON.stringify(configIn || {});
|
||||
const originalLayout = JSON.stringify(layoutIn || {});
|
||||
const modelFrom = Number.isInteger(Number(config.model_version))
|
||||
? Number(config.model_version)
|
||||
: 0;
|
||||
let migrated = migrateLosslessly(config);
|
||||
|
||||
// Materialise legacy open_to before geometry moves. Once explicit spans
|
||||
// exist, they are the source of truth and open_to becomes a derived index.
|
||||
for (const space of config.spaces || []) {
|
||||
const model = modelOf(space);
|
||||
if (!model) continue;
|
||||
const existing = sanitizeOpenSpans(space.open_spans);
|
||||
if (!existing.length) {
|
||||
const cuts = resolveOpenCuts(
|
||||
model.rooms, null, NORM_W, GRID_PITCH * 0.02, true,
|
||||
);
|
||||
if (cuts.length) {
|
||||
space.open_spans = cutsToSpanEntries(cuts, NORM_W);
|
||||
migrated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const beforeSpaces = clone(config.spaces || []);
|
||||
const aligned = alignAllToGrid(config.spaces || [], layoutIn || {});
|
||||
config.spaces = aligned.spaces;
|
||||
const alignReport: AlignReport = { ...aligned.report };
|
||||
|
||||
let wallsMerged = 0;
|
||||
let spansMerged = 0;
|
||||
let canonicalized = 0;
|
||||
for (let i = 0; i < config.spaces.length; i++) {
|
||||
const before = beforeSpaces[i];
|
||||
const space = config.spaces[i];
|
||||
const canonicalBefore = JSON.stringify({
|
||||
spans: before.open_spans || [],
|
||||
links: (before.rooms || []).map((r: any) => [r.id, r.open_to || []]),
|
||||
walls: before.walls || [],
|
||||
});
|
||||
const oldModel = modelOf(before);
|
||||
const nextModel = modelOf(space);
|
||||
if (!oldModel || !nextModel) continue;
|
||||
|
||||
const oldEdges = edgePairs(oldModel.rooms);
|
||||
const nextEdges = edgePairs(nextModel.rooms);
|
||||
const eps = GRID_PITCH * 0.02;
|
||||
|
||||
const sourceSpans = sanitizeOpenSpans(before.open_spans);
|
||||
let spans = rekeyOpenSpansAfterMove(sourceSpans, oldEdges, nextEdges, NORM_W);
|
||||
const spanParts = spans.length;
|
||||
spans = clipOpenSpansToShared(spans, nextModel.rooms, NORM_W, eps);
|
||||
// Virtual endpoints are wall-bound: snap them by distance along the room
|
||||
// edge, just like the drawing tool, instead of rounding X/Y independently
|
||||
// (which would pull a point off a diagonal wall).
|
||||
spans = spans.map((entry) => {
|
||||
const sg = entryToSeg(entry, NORM_W);
|
||||
const a = [sg[0], sg[1]], b = [sg[2], sg[3]];
|
||||
const mid = [(sg[0] + sg[2]) / 2, (sg[1] + sg[3]) / 2];
|
||||
const dx = sg[2] - sg[0], dy = sg[3] - sg[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const edge = nextEdges
|
||||
.map(([ea, eb]) => [ea[0], ea[1], eb[0], eb[1]])
|
||||
.filter((candidate) => {
|
||||
const ex = candidate[2] - candidate[0], ey = candidate[3] - candidate[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
return Math.abs(dx * ey - dy * ex) / (len * elen) <= 1e-6
|
||||
&& projectOnSeg(mid, candidate).d <= eps * 4
|
||||
&& projectOnSeg(a, candidate).d <= eps * 4
|
||||
&& projectOnSeg(b, candidate).d <= eps * 4;
|
||||
})
|
||||
.sort((x, y) => Math.hypot(y[2] - y[0], y[3] - y[1])
|
||||
- Math.hypot(x[2] - x[0], x[3] - x[1]))[0];
|
||||
alignReport.total++;
|
||||
if (!edge) return entry;
|
||||
const joints = [[edge[0], edge[1]], [edge[2], edge[3]]];
|
||||
const na = snapOpenPoint(a, edge, joints, GRID_PITCH, eps * 2);
|
||||
const nb = snapOpenPoint(b, edge, joints, GRID_PITCH, eps * 2);
|
||||
if (Math.hypot(nb[0] - na[0], nb[1] - na[1]) < GRID_PITCH * 0.5) return entry;
|
||||
const shift = Math.max(Math.hypot(na[0] - a[0], na[1] - a[1]),
|
||||
Math.hypot(nb[0] - b[0], nb[1] - b[1]));
|
||||
if (shift > GRID_PITCH * 1e-6) {
|
||||
alignReport.moved++;
|
||||
const shiftN = shift / NORM_W;
|
||||
if (shiftN > alignReport.maxShift) alignReport.maxShift = shiftN;
|
||||
const shiftCm = (shift / GRID_PITCH)
|
||||
* (Number(space.cell_cm) > 0 ? Number(space.cell_cm) : DEFAULT_CELL_CM);
|
||||
if (shiftCm > alignReport.maxShiftCm) {
|
||||
alignReport.maxShiftCm = shiftCm;
|
||||
alignReport.maxSpace = String(space.id || '');
|
||||
}
|
||||
}
|
||||
return spanToEntry(na, nb, NORM_W);
|
||||
});
|
||||
spans = clipOpenSpansToShared(spans, nextModel.rooms, NORM_W, eps);
|
||||
spansMerged += Math.max(0, spanParts - spans.length);
|
||||
const cuts = spans.map((entry) => entryToSeg(entry, NORM_W));
|
||||
if (spans.length) space.open_spans = spans;
|
||||
else delete space.open_spans;
|
||||
syncOpenToFromCuts(space.rooms || [], nextModel.rooms, cuts, eps);
|
||||
|
||||
const wallParts = Array.isArray(before.walls) ? before.walls.length : 0;
|
||||
let walls = rekeyWallsAfterMove(
|
||||
before.walls, oldEdges, nextEdges, GRID_STEP_N, NORM_W,
|
||||
);
|
||||
walls = normalizeWallIntervals(
|
||||
nextModel.rooms, walls, cuts, GRID_STEP_N,
|
||||
Number(space.cell_cm) > 0 ? Number(space.cell_cm) : DEFAULT_CELL_CM,
|
||||
GRID_PITCH, NORM_W,
|
||||
);
|
||||
walls = degradeWalls(
|
||||
walls, space.rooms || [], GRID_STEP_N, 1,
|
||||
cuts.map((c) => [c[0] / NORM_W, c[1] / NORM_W, c[2] / NORM_W, c[3] / NORM_W]),
|
||||
);
|
||||
wallsMerged += Math.max(0, wallParts - walls.length);
|
||||
if (walls.length) space.walls = walls;
|
||||
else delete space.walls;
|
||||
const canonicalAfter = JSON.stringify({
|
||||
spans: space.open_spans || [],
|
||||
links: (space.rooms || []).map((r: any) => [r.id, r.open_to || []]),
|
||||
walls: space.walls || [],
|
||||
});
|
||||
if (canonicalAfter !== canonicalBefore) canonicalized++;
|
||||
}
|
||||
|
||||
if (modelFrom !== PLAN_MODEL_VERSION) {
|
||||
config.model_version = PLAN_MODEL_VERSION;
|
||||
migrated++;
|
||||
}
|
||||
|
||||
const changed = JSON.stringify(config) !== original
|
||||
|| JSON.stringify(aligned.layout) !== originalLayout;
|
||||
return {
|
||||
config,
|
||||
layout: aligned.layout,
|
||||
report: {
|
||||
...alignReport,
|
||||
modelFrom,
|
||||
modelTo: PLAN_MODEL_VERSION,
|
||||
migrated,
|
||||
canonicalized,
|
||||
wallsMerged,
|
||||
spansMerged,
|
||||
},
|
||||
changed,
|
||||
};
|
||||
}
|
||||
+21
-13
@@ -451,19 +451,20 @@ export const cardStyles = css`
|
||||
stroke: transparent;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.room.overlay:not(.styled):hover {
|
||||
fill: #9aa0a6;
|
||||
fill-opacity: 0.22;
|
||||
stroke: var(--hp-muted);
|
||||
.stage.mode-view .room.overlay:not(.styled):hover {
|
||||
fill: var(--hp-accent);
|
||||
fill-opacity: 0.18;
|
||||
stroke: var(--hp-accent);
|
||||
}
|
||||
.room.yard {
|
||||
fill: rgba(75, 140, 90, 0.14);
|
||||
stroke: #4b8c5a;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.room.yard:not(.styled):hover {
|
||||
fill: rgba(75, 140, 90, 0.24);
|
||||
stroke: #6fbf86;
|
||||
.stage.mode-view .room.yard:not(.styled):hover {
|
||||
fill: var(--hp-accent);
|
||||
fill-opacity: 0.2;
|
||||
stroke: var(--hp-accent);
|
||||
}
|
||||
.room.styled {
|
||||
stroke: var(--room-stroke, transparent);
|
||||
@@ -472,14 +473,15 @@ export const cardStyles = css`
|
||||
fill: var(--room-fill, transparent);
|
||||
fill-opacity: var(--room-fill-op, 0);
|
||||
}
|
||||
/* hover: darken the current fill instead of recoloring; grey when unfilled */
|
||||
.room.styled.filled:hover {
|
||||
filter: brightness(0.78);
|
||||
/* View hover: brighten the current fill; accent wash when unfilled. */
|
||||
.stage.mode-view .room.styled.filled:hover {
|
||||
filter: brightness(1.2) saturate(1.08) drop-shadow(0 0 4px var(--hp-accent));
|
||||
stroke: var(--hp-accent);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
.room.styled:not(.filled):hover {
|
||||
fill: #9aa0a6;
|
||||
fill-opacity: 0.22;
|
||||
.stage.mode-view .room.styled:not(.filled):hover {
|
||||
fill: var(--hp-accent);
|
||||
fill-opacity: 0.18;
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
/* doors & windows */
|
||||
@@ -1075,6 +1077,12 @@ export const cardStyles = css`
|
||||
.decorbar .dfill {
|
||||
display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-s); cursor: pointer;
|
||||
}
|
||||
.dialog .dfill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.opghost {
|
||||
stroke: var(--hp-open, #ff9800);
|
||||
stroke-width: 5;
|
||||
|
||||
@@ -28,6 +28,9 @@ export interface RoomCfg {
|
||||
export interface WallEntry {
|
||||
key: string;
|
||||
cm: number;
|
||||
/** Exact interval endpoints in normalized config coordinates (new writes). */
|
||||
a?: number[];
|
||||
b?: number[];
|
||||
}
|
||||
|
||||
export interface SpaceModel {
|
||||
|
||||
+125
-30
@@ -12,6 +12,9 @@ import { roomPoly, roomEdges, sharedBoundary } from './logic';
|
||||
export interface WallEntry {
|
||||
key: string;
|
||||
cm: number;
|
||||
/** Optional exact interval endpoints in config coordinates (new writes). */
|
||||
a?: number[];
|
||||
b?: number[];
|
||||
}
|
||||
|
||||
export const WALL_MIN_CM = 1;
|
||||
@@ -110,6 +113,26 @@ function keyOf(a: number[], b: number[], pitch: number, scale: number): string {
|
||||
return wallKey([a[0] / scale, a[1] / scale], [b[0] / scale, b[1] / scale], pitch);
|
||||
}
|
||||
|
||||
/** Exact stored interval in the caller's coordinate space, when available. */
|
||||
function entrySpan(w: WallEntry, coordScale: number): [number[], number[]] | null {
|
||||
if (!Array.isArray(w.a) || !Array.isArray(w.b) || w.a.length < 2 || w.b.length < 2) return null;
|
||||
const nums = [Number(w.a[0]), Number(w.a[1]), Number(w.b[0]), Number(w.b[1])];
|
||||
if (!nums.every(Number.isFinite)) return null;
|
||||
const scale = coordScale > 0 ? coordScale : 1;
|
||||
return [[nums[0] * scale, nums[1] * scale], [nums[2] * scale, nums[3] * scale]];
|
||||
}
|
||||
|
||||
/** Persist an interval with both its compatible key and lossless endpoints. */
|
||||
function wallEntry(a: number[], b: number[], cm: number, pitch: number, coordScale: number): WallEntry {
|
||||
const scale = coordScale > 0 ? coordScale : 1;
|
||||
return {
|
||||
key: keyOf(a, b, pitch, scale),
|
||||
cm: clampWallCm(cm),
|
||||
a: [a[0] / scale, a[1] / scale],
|
||||
b: [b[0] / scale, b[1] / scale],
|
||||
};
|
||||
}
|
||||
|
||||
/** One parsed key: midpoint in the caller's coordinate space + angle bucket. */
|
||||
interface ParsedKey {
|
||||
w: WallEntry;
|
||||
@@ -205,7 +228,8 @@ export function degradeWalls(
|
||||
): WallEntry[] {
|
||||
if (!walls?.length) return [];
|
||||
const live = new Set<string>();
|
||||
for (const seg of roomEdges(rooms)) {
|
||||
const edges = roomEdges(rooms);
|
||||
for (const seg of edges) {
|
||||
live.add(keyOf([seg[0], seg[1]], [seg[2], seg[3]], pitch, coordScale));
|
||||
}
|
||||
// partial shared overlaps are keyed by their own mid — keep those too
|
||||
@@ -224,13 +248,41 @@ export function degradeWalls(
|
||||
}
|
||||
for (const room of list) {
|
||||
if (!room?.id) continue;
|
||||
const at = atomicPolyForRoom(list, room.id, openCuts, pitch, coordScale);
|
||||
const at = atomicPolyForRoom(list, room.id, openCuts, pitch, coordScale, walls);
|
||||
if (!at) continue;
|
||||
for (let i = 0; i < at.poly.length; i++) {
|
||||
live.add(keyOf(at.poly[i], at.poly[(i + 1) % at.poly.length], pitch, coordScale));
|
||||
}
|
||||
}
|
||||
return walls.filter((w) => live.has(w.key) && w.cm >= WALL_MIN_CM && w.cm <= WALL_MAX_CM);
|
||||
const exactStillLive = (w: WallEntry): boolean => {
|
||||
const span = entrySpan(w, coordScale);
|
||||
if (!span) return false;
|
||||
const [a, b] = span;
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const L = Math.hypot(dx, dy);
|
||||
if (L <= eps) return false;
|
||||
const onCurrentEdge = edges.some((sg) => {
|
||||
const ea = [sg[0], sg[1]], eb = [sg[2], sg[3]];
|
||||
return angleClose(segAngle(a, b), segAngle(ea, eb))
|
||||
&& distToSeg(a[0], a[1], ea[0], ea[1], eb[0], eb[1]) <= eps
|
||||
&& distToSeg(b[0], b[1], ea[0], ea[1], eb[0], eb[1]) <= eps;
|
||||
});
|
||||
if (!onCurrentEdge) return false;
|
||||
// A stored solid interval must not straddle a newly virtual piece.
|
||||
const overlapsCut = (openCuts || []).some((c) => {
|
||||
const ca = [c[0], c[1]], cb = [c[2], c[3]];
|
||||
if (!angleClose(segAngle(a, b), segAngle(ca, cb))) return false;
|
||||
const lineDist = (p: number[]) => Math.abs((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / L;
|
||||
if (lineDist(ca) > eps || lineDist(cb) > eps) return false;
|
||||
const L2 = L * L;
|
||||
const t0 = ((ca[0] - a[0]) * dx + (ca[1] - a[1]) * dy) / L2;
|
||||
const t1 = ((cb[0] - a[0]) * dx + (cb[1] - a[1]) * dy) / L2;
|
||||
return Math.min(1, Math.max(t0, t1)) - Math.max(0, Math.min(t0, t1)) > eps / L;
|
||||
});
|
||||
return !overlapsCut;
|
||||
};
|
||||
return walls.filter((w) => (live.has(w.key) || exactStillLive(w))
|
||||
&& w.cm >= WALL_MIN_CM && w.cm <= WALL_MAX_CM);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,7 +335,31 @@ export function rekeyWallsAfterMove(
|
||||
const used = new Set<string>();
|
||||
const out: WallEntry[] = [];
|
||||
for (const w of walls) {
|
||||
let nk = map.get(w.key) || '';
|
||||
// Exact endpoints are authoritative for new entries. Never move only their
|
||||
// compatibility key while leaving a/b behind on the old wall.
|
||||
let nk = '';
|
||||
let moved: [number[], number[]] | null = null;
|
||||
const exact = entrySpan(w, scale);
|
||||
if (exact) {
|
||||
for (let i = 0; i < oldSpans.length; i++) {
|
||||
const [oa, ob] = oldSpans[i];
|
||||
const [na, nb] = newSpans[i];
|
||||
if (!angleClose(segAngle(exact[0], exact[1]), segAngle(oa, ob))) continue;
|
||||
if (distToSeg(exact[0][0], exact[0][1], oa[0], oa[1], ob[0], ob[1]) > tol
|
||||
|| distToSeg(exact[1][0], exact[1][1], oa[0], oa[1], ob[0], ob[1]) > tol) continue;
|
||||
const dx = ob[0] - oa[0], dy = ob[1] - oa[1];
|
||||
const L2 = dx * dx + dy * dy;
|
||||
if (L2 < 1e-18) continue;
|
||||
const movePoint = (p: number[]): number[] => {
|
||||
const t = Math.max(0, Math.min(1, ((p[0] - oa[0]) * dx + (p[1] - oa[1]) * dy) / L2));
|
||||
return [na[0] + (nb[0] - na[0]) * t, na[1] + (nb[1] - na[1]) * t];
|
||||
};
|
||||
moved = [movePoint(exact[0]), movePoint(exact[1])];
|
||||
nk = keyOf(moved[0], moved[1], pitch, scale);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!exact) nk = map.get(w.key) || '';
|
||||
if (!nk) {
|
||||
const parsed = parseKeys([w], scale)[0];
|
||||
if (parsed) {
|
||||
@@ -313,7 +389,9 @@ export function rekeyWallsAfterMove(
|
||||
if (!nk) nk = w.key;
|
||||
if (used.has(nk)) continue;
|
||||
used.add(nk);
|
||||
out.push({ key: nk, cm: w.cm });
|
||||
out.push(moved
|
||||
? wallEntry(moved[0], moved[1], w.cm, pitch, scale)
|
||||
: { ...w, key: nk, cm: clampWallCm(w.cm) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -329,7 +407,7 @@ export function setWallThickness(
|
||||
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) }];
|
||||
return [...base, wallEntry(a, b, cm, pitch, coordScale)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,8 +420,9 @@ export function solidIntervalsForRoom(
|
||||
openCuts: number[][],
|
||||
pitch: number,
|
||||
coordScale = 1,
|
||||
wallBreaks: WallEntry[] | null | undefined = [],
|
||||
): Array<{ a: number[]; b: number[] }> {
|
||||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale);
|
||||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale, wallBreaks);
|
||||
if (!at) return [];
|
||||
const out: Array<{ a: number[]; b: number[] }> = [];
|
||||
for (let i = 0; i < at.poly.length; i++) {
|
||||
@@ -368,7 +447,7 @@ export function setWallThicknessForRoom(
|
||||
coordScale = 1,
|
||||
): WallEntry[] {
|
||||
let out = walls ? walls.slice() : [];
|
||||
for (const iv of solidIntervalsForRoom(rooms, roomId, openCuts, pitch, coordScale)) {
|
||||
for (const iv of solidIntervalsForRoom(rooms, roomId, openCuts, pitch, coordScale, out)) {
|
||||
out = setWallThickness(out, iv.a, iv.b, cm, pitch, coordScale);
|
||||
}
|
||||
return out;
|
||||
@@ -389,7 +468,7 @@ export function applyWallThicknessToNewRoom(
|
||||
coordScale = 1,
|
||||
): WallEntry[] {
|
||||
if (cm == null || cm < WALL_MIN_CM) return walls ? walls.slice() : [];
|
||||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale);
|
||||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale, walls);
|
||||
if (!at) return walls ? walls.slice() : [];
|
||||
// effective cm per interval — a neighbour's thickness counts even when it is
|
||||
// still stored under a pre-atomic whole-edge key
|
||||
@@ -649,6 +728,7 @@ export function atomicPolyForRoom(
|
||||
openCuts: number[][],
|
||||
pitch: number,
|
||||
coordScale = 1,
|
||||
wallBreaks: WallEntry[] | null | undefined = [],
|
||||
): AtomicPoly | null {
|
||||
const room = (rooms || []).find((r) => r?.id === roomId);
|
||||
const orig = roomPoly(room);
|
||||
@@ -664,6 +744,13 @@ export function atomicPolyForRoom(
|
||||
}
|
||||
}
|
||||
for (const c of openCuts || []) breaks.push([c[0], c[1]], [c[2], c[3]]);
|
||||
// A closed virtual span may have been the only geometric breakpoint between
|
||||
// two real intervals of different thickness. New wall entries retain their
|
||||
// exact endpoints so deleting that span cannot erase the thickness boundary.
|
||||
for (const w of wallBreaks || []) {
|
||||
const span = entrySpan(w, coordScale);
|
||||
if (span) breaks.push(span[0], span[1]);
|
||||
}
|
||||
const poly: number[][] = [];
|
||||
const parent: number[] = [];
|
||||
for (let i = 0; i < orig.length; i++) {
|
||||
@@ -831,7 +918,7 @@ export function roomWallProfile(
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
): RoomWallProfile | null {
|
||||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale);
|
||||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale, walls);
|
||||
if (!at) return null;
|
||||
const shared = sharedSegsOf(rooms, roomId, openEps(pitch, coordScale));
|
||||
const kinds = kindsForPoly(at.poly, shared, openCuts, pitch, coordScale);
|
||||
@@ -875,10 +962,10 @@ export function wallIntervals(
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `walls` so every entry names the smallest necessary interval of the
|
||||
* CURRENT geometry, and no entry survives under an open span. Atomic entries
|
||||
* are compacted back to the original polygon edge when all of its children
|
||||
* are solid and have the same thickness.
|
||||
* Rewrite `walls` so every entry names a maximal equal-thickness interval of
|
||||
* the CURRENT geometry, and no entry survives under an open span. Atomic
|
||||
* entries compact across every consecutive solid run; a thickness change or
|
||||
* virtual gap remains an exact stored breakpoint.
|
||||
*
|
||||
* This is the single place where the spec invariant "an open span and a
|
||||
* positive thickness never share a key" is enforced: opening a stretch splits
|
||||
@@ -903,11 +990,9 @@ export function normalizeWallIntervals(
|
||||
atomic.push(iv);
|
||||
}
|
||||
|
||||
// A partial shared boundary or a former open span may have split one
|
||||
// original edge into several storage keys. Once every child is solid and
|
||||
// carries the same cm value, the parent key is the canonical representation
|
||||
// again. Prefer the longest valid parent first so a room whose edge contains
|
||||
// a shorter neighbour edge also removes that redundant shorter key.
|
||||
// Compact every maximal solid run of one thickness. This still restores one
|
||||
// whole-edge entry when all children agree, but retains an exact breakpoint
|
||||
// when neighbouring real intervals intentionally have different thicknesses.
|
||||
const parents: Array<{ a: number[]; b: number[]; key: string; cm: number; len: number }> = [];
|
||||
for (const room of rooms || []) {
|
||||
if (!room?.id) continue;
|
||||
@@ -919,15 +1004,25 @@ export function normalizeWallIntervals(
|
||||
if (pr.parent[i] === pi) children.push(i);
|
||||
}
|
||||
if (!children.length) continue;
|
||||
const cm = pr.cms[children[0]];
|
||||
if (!(cm > 0) || children.some((i) => pr.kinds[i] === null || pr.cms[i] !== cm)) continue;
|
||||
const a = pr.orig[pi], b = pr.orig[(pi + 1) % pr.orig.length];
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
if (len <= 0) continue;
|
||||
parents.push({
|
||||
a: [a[0], a[1]], b: [b[0], b[1]],
|
||||
key: keyOf(a, b, pitch, coordScale), cm, len,
|
||||
});
|
||||
for (let at = 0; at < children.length;) {
|
||||
const first = children[at];
|
||||
const cm = pr.cms[first];
|
||||
if (!(cm > 0) || pr.kinds[first] === null) { at++; continue; }
|
||||
let end = at;
|
||||
while (end + 1 < children.length) {
|
||||
const next = children[end + 1];
|
||||
if (pr.kinds[next] === null || pr.cms[next] !== cm) break;
|
||||
end++;
|
||||
}
|
||||
const last = children[end];
|
||||
const a = pr.poly[first], b = pr.poly[(last + 1) % pr.poly.length];
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
if (len > 0) parents.push({
|
||||
a: [a[0], a[1]], b: [b[0], b[1]],
|
||||
key: keyOf(a, b, pitch, coordScale), cm, len,
|
||||
});
|
||||
at = end + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
parents.sort((a, b) => b.len - a.len || a.key.localeCompare(b.key));
|
||||
@@ -947,12 +1042,12 @@ export function normalizeWallIntervals(
|
||||
for (const iv of matches) covered.add(iv.key);
|
||||
if (seen.has(parent.key)) continue;
|
||||
seen.add(parent.key);
|
||||
out.push({ key: parent.key, cm: parent.cm });
|
||||
out.push(wallEntry(parent.a, parent.b, parent.cm, pitch, coordScale));
|
||||
}
|
||||
for (const iv of atomic) {
|
||||
if (covered.has(iv.key) || seen.has(iv.key)) continue;
|
||||
seen.add(iv.key);
|
||||
out.push({ key: iv.key, cm: iv.cm });
|
||||
out.push(wallEntry(iv.a, iv.b, iv.cm, pitch, coordScale));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user