fix: isolate wall union failures and guard geometry writes

Issue: #278
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 08:48:37 +03:00
parent 930a911a36
commit 8156f80140
31 changed files with 2101 additions and 1119 deletions
+182 -102
View File
@@ -192,12 +192,14 @@ import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import type { SpaceReferenceRepairContext } from './space-reference-repair';
import { collectSpaceMarkerDependencies } from './space-deletion';
import {
checkSpacePhysicalGeometry,
checkOptimizeGeometry,
geometryOpenCuts,
geometryOpenings,
geometryOpenPairs,
geometryPartitionOpeningCuts,
geometryRoomOpeningInputs,
spacePhysicalGeometryFingerprint,
type GeometryOpeningProjection,
type OptimizeGeometryPreflightResult,
} from './plan-geometry-preflight';
@@ -2704,8 +2706,7 @@ class HouseplanCard extends LitElement {
segments: this._draftSegmentCms.map((cm) => ({ cm })),
};
}
this._recordGeometry(this._t('history.draft_segment_delete'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.draft_segment_delete'), before);
}
return;
}
@@ -3777,6 +3778,7 @@ class HouseplanCard extends LitElement {
// A genuinely different baseline invalidates local geometry undo. A
// reconnect echo with identical content deliberately does not.
this._geometryHistory.clear();
this._pendingPhysicalWrites.clear();
if (this._serverCfg) this._clearGeometryGesture();
this._serverCfg = nextConfig;
this._cfgContentFingerprint = nextCfgFingerprint;
@@ -5298,7 +5300,7 @@ class HouseplanCard extends LitElement {
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
)
: null;
if ((walls.length || extras.length) && !united)
if (united && (united.status === 'failed-core' || united.status === 'not-applicable'))
throw new Error('wall boolean geometry failed');
const floor = united?.paperGeom ?? floorFootprintGeometry(
space.rooms, walls, openCuts,
@@ -5325,7 +5327,10 @@ class HouseplanCard extends LitElement {
: { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 };
return buildIsoOpeningBasis({ ...opening, face }, wallHeight);
});
return { walls: united?.geom || [], floor, openings: Object.freeze(openingBases) };
return {
walls: united?.components.flatMap((component) => component.geom) || [],
floor, openings: Object.freeze(openingBases),
};
},
};
}
@@ -6750,8 +6755,7 @@ class HouseplanCard extends LitElement {
sp.room_drafts = sp.room_drafts.filter((draft: any) => draft.id !== this._activeDraftId);
if (!sp.room_drafts.length) delete sp.room_drafts;
}
this._recordGeometry(this._t('history.wall_chain_finish'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.wall_chain_finish'), before);
delete this._resumeDraftBySpace[this._space];
this._path = [];
this._activeDraftId = null;
@@ -7031,6 +7035,9 @@ class HouseplanCard extends LitElement {
*/
private _writesPending = 0;
private _writeChain: Promise<void> = Promise.resolve();
private _pendingPhysicalWrites = new Map<string, {
fingerprint: string; before: SpaceGeometryState;
}>();
/** A config write is in flight — the card must not adopt a server revision. */
private get _cfgWriting(): boolean {
@@ -7044,6 +7051,22 @@ class HouseplanCard extends LitElement {
this._dropLegacySegments();
const candidate = canonicalizeConfigGeometry(this._serverCfg);
const candidateFingerprint = contentFingerprint(candidate);
const strictEntries = [...this._pendingPhysicalWrites.entries()];
for (const [spaceId, accepted] of strictEntries) {
const candidateSpace = candidate.spaces.find((space: any) => space.id === spaceId);
const exactFingerprint = spacePhysicalGeometryFingerprint(candidateSpace);
if (exactFingerprint === accepted.fingerprint) continue;
let safe = false;
try { safe = this._checkSpacePhysicalGeometry(candidate, spaceId).ok; } catch { safe = false; }
if (!safe) {
this._restoreGeometryStateLocal(accepted.before);
this._pendingPhysicalWrites.delete(spaceId);
this._geometryHistory.clear();
this._showToast(this._t('toast.geometry_unsafe'));
throw Object.assign(new Error('unsafe wall geometry'), { code: 'geometry-unsafe' });
}
accepted.fingerprint = exactFingerprint;
}
// Do not replace the reactive root merely because the pure helper
// returned a clone. Besides an unnecessary render, that used to expose
// unrelated write-time cleanup as a visual change (#224 review H1).
@@ -7057,6 +7080,10 @@ class HouseplanCard extends LitElement {
type: 'houseplan/config/set', config: candidate, expected_rev: this._cfgRev,
});
this._cfgRev = r?.rev ?? this._cfgRev + 1;
for (const [spaceId, accepted] of strictEntries) {
if (this._pendingPhysicalWrites.get(spaceId)?.fingerprint === accepted.fingerprint)
this._pendingPhysicalWrites.delete(spaceId);
}
});
const mine = this._writeChain.finally(() => { this._writesPending--; });
// keep the chain itself unadorned so the next link waits for the write only
@@ -7114,6 +7141,65 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
}
/** Replace only persisted geometry in memory; no history entry and no WS. */
private _restoreGeometryStateLocal(state: SpaceGeometryState): boolean {
const sp = this._serverCfg?.spaces.find((space: any) => space.id === state.spaceId);
if (!sp) return false;
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value));
sp.rooms = copy(state.rooms);
const assign = (key: 'openings' | 'walls' | 'open_spans' | 'room_drafts'
| 'partitions' | 'wall_columns' | 'decor', value: unknown): void => {
if (value !== undefined) (sp as any)[key] = copy(value);
else delete (sp as any)[key];
};
assign('openings', state.openings);
assign('walls', state.walls);
assign('open_spans', state.open_spans);
assign('room_drafts', state.room_drafts);
assign('partitions', state.partitions);
assign('wall_columns', state.wall_columns);
assign('decor', state.decor);
for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const)
delete (sp as any)[key];
Object.assign(sp, copy(state.plan_transform || {}));
this._cfgEpoch++;
this._modelCache = null;
this._wallUnionCache = null;
this._physicalBodiesCache = null;
this._frame = null;
this.requestUpdate();
return true;
}
/** Shared fail-closed transaction boundary for every physical writer. */
private _commitPhysicalGeometry(name: string, before: SpaceGeometryState | null): boolean {
if (!before || !this._serverCfg) return false;
const after = this._geometrySnapshot(before.spaceId);
if (!after || spacePhysicalGeometryFingerprint(before)
=== spacePhysicalGeometryFingerprint(after)) return false;
let safe = false;
try {
safe = this._checkSpacePhysicalGeometry(this._serverCfg, before.spaceId).ok;
} catch {
safe = false;
}
if (!safe) {
this._clearGeometryGesture();
this._restoreGeometryStateLocal(before);
this._showToast(this._t('toast.geometry_unsafe'));
return false;
}
this._recordGeometry(name, before);
const afterSpace = this._serverCfg.spaces.find((space: any) => space.id === before.spaceId);
const pending = this._pendingPhysicalWrites.get(before.spaceId);
this._pendingPhysicalWrites.set(before.spaceId, {
before: pending?.before || before,
fingerprint: spacePhysicalGeometryFingerprint(afterSpace),
});
this._saveConfig();
return true;
}
/** Drop every transient gesture before replacing committed geometry. */
private _clearGeometryGesture(): void {
this._path = [];
@@ -7194,27 +7280,28 @@ class HouseplanCard extends LitElement {
private _applyGeometryState(state: SpaceGeometryState): boolean {
if (!this._canCommitSpace(state.spaceId)) return false;
const sp = this._serverCfg?.spaces.find((s: any) => s.id === state.spaceId);
if (!sp) return false;
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value));
sp.rooms = copy(state.rooms);
if (state.openings !== undefined) sp.openings = copy(state.openings);
else delete sp.openings;
if (state.walls !== undefined) sp.walls = copy(state.walls);
else delete sp.walls;
if (state.open_spans !== undefined) (sp as any).open_spans = copy(state.open_spans);
else delete (sp as any).open_spans;
if (state.room_drafts !== undefined) (sp as any).room_drafts = copy(state.room_drafts);
else delete (sp as any).room_drafts;
if (state.partitions !== undefined) (sp as any).partitions = copy(state.partitions);
else delete (sp as any).partitions;
if (state.wall_columns !== undefined) (sp as any).wall_columns = copy(state.wall_columns);
else delete (sp as any).wall_columns;
if (state.decor !== undefined) sp.decor = copy(state.decor);
else delete sp.decor;
for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const)
delete sp[key];
Object.assign(sp, copy(state.plan_transform || {}));
const before = this._geometrySnapshot(state.spaceId);
if (!before || !this._restoreGeometryStateLocal(state)) return false;
const physicalChanged = spacePhysicalGeometryFingerprint(before)
!== spacePhysicalGeometryFingerprint(state);
if (physicalChanged) {
let safe = false;
try { safe = !!this._serverCfg
&& this._checkSpacePhysicalGeometry(this._serverCfg, state.spaceId).ok; } catch { safe = false; }
if (!safe) {
this._restoreGeometryStateLocal(before);
this._showToast(this._t('toast.geometry_unsafe'));
return false;
}
const afterSpace = this._serverCfg?.spaces.find(
(space: any) => space.id === state.spaceId,
);
const pending = this._pendingPhysicalWrites.get(state.spaceId);
this._pendingPhysicalWrites.set(state.spaceId, {
before: pending?.before || before,
fingerprint: spacePhysicalGeometryFingerprint(afterSpace),
});
}
this._clearGeometryGesture();
if (this._space !== state.spaceId) {
this._commitSpace(state.spaceId);
@@ -7277,7 +7364,9 @@ class HouseplanCard extends LitElement {
private _saveConfigDebounced = debounce(() => {
if (!this._serverCfg) return;
this._writeConfig().catch((e: any) => {
if (e?.code === 'conflict') {
if (e?.code === 'geometry-unsafe') {
return;
} else if (e?.code === 'conflict') {
// a real one now: another window wrote between our read and our write
this._showToast(this._t('toast.conflict'));
this._cancelPath();
@@ -7603,8 +7692,7 @@ class HouseplanCard extends LitElement {
persistedPoints, persistedSegments.map((s: any) => Number(s.cm)), id,
);
this._physicalSel = null;
this._recordGeometry(this._t('history.draft_merge'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.draft_merge'), before);
if (connectorCm != null) {
this._offerWallFaces(beforePath, beforePath.length - 1, beforeGraphSources);
@@ -7656,8 +7744,7 @@ class HouseplanCard extends LitElement {
};
if (i >= 0) sp.room_drafts[i] = saved;
else sp.room_drafts.push(saved);
this._recordGeometry(this._t('history.draft_segment'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.draft_segment'), before);
}
private _activeWallSourceKey(index: number): string {
@@ -7859,8 +7946,7 @@ class HouseplanCard extends LitElement {
const sp = this._curSpaceCfg as any;
sp.wall_columns ||= [];
sp.wall_columns.push({ ...candidate, center: [center[0] / NORM_W, center[1] / NORM_W] });
this._recordGeometry(this._t('history.column_add'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.column_add'), before);
}
private _openPhysicalDialog(
@@ -7940,9 +8026,9 @@ class HouseplanCard extends LitElement {
const draft = (sp.room_drafts || []).find((x: any) => x.id === d.id);
if (draft?.segments?.[d.segment || 0]) draft.segments[d.segment || 0].cm = cmRaw;
}
this._recordGeometry(this._t('history.physical_edit'), before);
const committed = this._commitPhysicalGeometry(this._t('history.physical_edit'), before);
this._physicalDialog = null;
this._saveConfig();
if (!committed) this.requestUpdate();
};
private _deletePhysicalSelection = (): void => {
@@ -7971,8 +8057,7 @@ class HouseplanCard extends LitElement {
if (this._activeDraftId === sel.id) this._cancelPath();
this._physicalSel = null;
this._physicalDialog = null;
this._recordGeometry(this._t('history.physical_delete'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.physical_delete'), before);
};
private _confirmPartitionDelete = (): void => {
@@ -7989,8 +8074,7 @@ class HouseplanCard extends LitElement {
this._partitionDeleteDialog = null;
this._physicalSel = null;
this._physicalDialog = null;
this._recordGeometry(this._t('history.physical_delete'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.physical_delete'), before);
};
private _deleteDraftWhole = (): void => {
@@ -8005,8 +8089,7 @@ class HouseplanCard extends LitElement {
if (this._activeDraftId === id) this._cancelPath();
this._physicalSel = null;
this._physicalDialog = null;
this._recordGeometry(this._t('history.physical_delete'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.physical_delete'), before);
};
private _deleteDraftSegment = (): void => {
@@ -8038,8 +8121,7 @@ class HouseplanCard extends LitElement {
if (this._activeDraftId === draft.id) this._cancelPath();
this._physicalDialog = null;
this._physicalSel = pieces.length ? { kind: 'draft', id: pieces[0].id } : null;
this._recordGeometry(this._t('history.draft_segment_delete'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.draft_segment_delete'), before);
};
private _physicalDown(ev: PointerEvent, kind: 'partition' | 'column', id: string): void {
@@ -8168,8 +8250,7 @@ class HouseplanCard extends LitElement {
(base.center[1] + drag.delta[1]) / NORM_W,
];
}
this._recordGeometry(this._t('history.physical_move'), drag.before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.physical_move'), drag.before);
}
private _registerPhysicalTap(
@@ -8239,8 +8320,7 @@ class HouseplanCard extends LitElement {
const stored = (this._curSpaceCfg as any).wall_columns?.find((c: any) => c.id === drag.id);
if (!stored) return;
stored.angle = drag.angle;
this._recordGeometry(this._t('history.physical_edit'), drag.before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.physical_edit'), drag.before);
}
@@ -8448,22 +8528,17 @@ class HouseplanCard extends LitElement {
return true;
}
/** Final fail-closed check for the exact overlay. With physical walls the
* production renderer has normally populated `_wallUnionCache` for this
* cfg epoch already; pointerup reuses that exact result instead of repeating
* a second multi-polygon union. A release before the frame still computes it
* here. Wall-less plans use the shared production floor preflight. */
/** Final fail-closed check for the exact overlay through the common barrier. */
private _rszCandidateRenderable(preview: { space: string; sp: any } | null): boolean {
if (!preview || preview.space !== this._space || this._rszPreview !== preview
|| !this._serverCfg) return false;
const hasPhysical = !!preview.sp.walls?.length
|| !!preview.sp.partitions?.length || !!preview.sp.room_drafts?.length
|| !!preview.sp.wall_columns?.length;
try {
if (hasPhysical) return !!this._wallUnionGeometry();
return this._checkOptimizeGeometry({
...this._serverCfg, spaces: [preview.sp],
} as ServerConfig).ok;
const candidate = {
...this._serverCfg,
spaces: this._serverCfg.spaces.map((space: any) =>
space.id === preview.space ? preview.sp : space),
} as ServerConfig;
return this._checkSpacePhysicalGeometry(candidate, preview.space).ok;
} catch {
return false;
}
@@ -8581,8 +8656,7 @@ class HouseplanCard extends LitElement {
// the click synthesized after the drag must not re-pick the selection
this._suppressClick = true;
setTimeout(() => (this._suppressClick = false), 0);
this._recordGeometry(this._t('history.resize_room'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.resize_room'), before);
this._rszEligibilityCache = null;
this.requestUpdate();
}
@@ -11144,9 +11218,8 @@ class HouseplanCard extends LitElement {
if (plan.walls.length) sp.walls = plan.walls;
else delete sp.walls;
this._persistOpenCuts(plan.cuts);
this._showToast(this._t('toast.boundary_restored'));
this._recordGeometry(this._t('history.close_boundary'), before);
this._saveConfig();
if (this._commitPhysicalGeometry(this._t('history.close_boundary'), before))
this._showToast(this._t('toast.boundary_restored'));
this.requestUpdate();
}
@@ -11196,13 +11269,12 @@ class HouseplanCard extends LitElement {
else delete sp.walls;
const beforeOp = (sp.openings || []).length;
sp.openings = purgeOpeningsOnSpan(sp.openings, sg, NORM_W, this._gridPitch * 6);
if ((sp.openings || []).length < beforeOp) {
this._showToast(this._t('toast.openwall_openings_removed'));
}
const removedOpenings = (sp.openings || []).length < beforeOp;
this._persistOpenCuts(next);
this._showToast(this._t('toast.boundary_opened'));
this._recordGeometry(this._t('history.open_boundary'), before);
this._saveConfig();
if (this._commitPhysicalGeometry(this._t('history.open_boundary'), before)) {
if (removedOpenings) this._showToast(this._t('toast.openwall_openings_removed'));
this._showToast(this._t('toast.boundary_opened'));
}
this.requestUpdate();
return;
}
@@ -11334,10 +11406,9 @@ class HouseplanCard extends LitElement {
if (normalized.length) sp.walls = normalized;
else delete sp.walls;
this._roomDeleteDialog = null;
this._recordGeometry(this._t(
this._commitPhysicalGeometry(this._t(
keepWalls ? 'history.delete_room_keep_walls' : 'history.delete_room_with_walls',
), before);
this._saveConfig();
this._regSignature = '';
this._maybeRebuildDevices();
this.requestUpdate();
@@ -11541,9 +11612,8 @@ class HouseplanCard extends LitElement {
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._recordGeometry(this._t('history.wall_thickness'), before);
this._saveConfig();
if (this._commitPhysicalGeometry(this._t('history.wall_thickness'), before))
this._showToast(this._t(cm == null ? 'toast.wallthick_cleared' : 'toast.wallthick_set'));
this.requestUpdate();
}
@@ -11795,14 +11865,16 @@ class HouseplanCard extends LitElement {
// of the other. When the body is thinner than ~3px on screen the hatch
// collapses into noise — keep the solid fill alone.
return svg`<g class="wallbodies" style="--room-stroke:${stroke};--wall-fill:${wf.c};--wall-fill-op:${wf.a}">
<path class="wallbody-fill" d="${united.d}"
fill="${wf.c}" fill-opacity="${wf.a}" fill-rule=${united.fillRule}
stroke="none" pointer-events="none"></path>
<path class="wallbody ${solid ? 'solid' : ''}"
data-hp="wall" data-id="union" data-kind="union"
d="${united.d}" fill="${solid ? 'none' : 'url(#hp-wall-hatch)'}" fill-rule=${united.fillRule}
stroke="${stroke}" stroke-width="${gridVisualUnits(0.6, this._cellCm)}"
pointer-events="none"></path>
${united.paths.map((component) => svg`
<path class="wallbody-fill" data-component=${component.id} d="${component.d}"
fill="${wf.c}" fill-opacity="${wf.a}" fill-rule=${component.fillRule}
stroke="none" pointer-events="none"></path>
<path class="wallbody ${solid ? 'solid' : ''}"
data-hp="wall" data-id="union" data-kind="union" data-component=${component.id}
d="${component.d}" fill="${solid ? 'none' : 'url(#hp-wall-hatch)'}"
fill-rule=${component.fillRule}
stroke="${stroke}" stroke-width="${gridVisualUnits(0.6, this._cellCm)}"
pointer-events="none"></path>`)}
</g>` as unknown as TemplateResult;
}
@@ -12459,8 +12531,7 @@ class HouseplanCard extends LitElement {
this._opMeasure = null; // badges and the center tick live only through the drag
// only write when the geometry actually changed (audit L4)
if (moved && drag.dirty) {
this._recordGeometry(this._t('history.move_opening'), drag.before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.move_opening'), drag.before);
}
// keep the flag until the click event that follows pointerup, then let it go
if (moved) window.setTimeout(() => (this._opDrag = null), 0);
@@ -12559,8 +12630,9 @@ class HouseplanCard extends LitElement {
const i = sp.openings.findIndex((x: OpeningCfg) => x.id === o.id);
if (i >= 0) sp.openings[i] = o;
else sp.openings.push(o);
this._recordGeometry(this._t(d.id ? 'history.edit_opening' : 'history.add_opening'), before);
this._saveConfig();
this._commitPhysicalGeometry(
this._t(d.id ? 'history.edit_opening' : 'history.add_opening'), before,
);
this._openingDialog = null;
this.requestUpdate();
}
@@ -12571,8 +12643,7 @@ class HouseplanCard extends LitElement {
if (!d?.id || !sp?.openings) return;
const before = this._geometrySnapshot();
sp.openings = sp.openings.filter((x: OpeningCfg) => x.id !== d.id);
this._recordGeometry(this._t('history.delete_opening'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.delete_opening'), before);
this._openingDialog = null;
this.requestUpdate();
}
@@ -12667,12 +12738,11 @@ class HouseplanCard extends LitElement {
delete keep.x; delete keep.y; delete keep.w; delete keep.h; // a merged room is never a rect
sp.rooms = sp.rooms.filter((r: any) => r.id !== dropId);
this._commitOpenSpans();
this._recordGeometry(this._t('history.merge_rooms'), before);
this._saveConfig();
const committed = this._commitPhysicalGeometry(this._t('history.merge_rooms'), before);
this._mergeDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(this._t('toast.rooms_merged', { name: keep.name || '' }));
if (committed) this._showToast(this._t('toast.rooms_merged', { name: keep.name || '' }));
}
/** Split: click the room, then two points on its walls. */
@@ -13162,8 +13232,7 @@ class HouseplanCard extends LitElement {
else delete sp.walls;
}
this._recordGeometry(this._t('history.wall_face_batch'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.wall_face_batch'), before);
delete this._resumeDraftBySpace[this._space];
this._path = [];
this._activeDraftId = null;
@@ -13303,8 +13372,9 @@ class HouseplanCard extends LitElement {
else delete sp.walls;
}
}
this._recordGeometry(this._t(wasSplit ? 'history.split_room' : 'history.add_room'), before);
this._saveConfig();
const committed = this._commitPhysicalGeometry(
this._t(wasSplit ? 'history.split_room' : 'history.add_room'), before,
);
this._path = [];
delete this._resumeDraftBySpace[this._space];
this._activeDraftId = null;
@@ -13318,6 +13388,7 @@ class HouseplanCard extends LitElement {
this._roomDialog = false;
this._regSignature = '';
this._maybeRebuildDevices();
if (!committed) return;
// auto-add the area's device icons + PIN their positions in the layout,
// so that icons do not get reshuffled when the order in the HA registry changes.
let added = 0;
@@ -13674,8 +13745,7 @@ class HouseplanCard extends LitElement {
sp.room_drafts = sp.room_drafts.filter((d: any) => d.id !== this._activeDraftId);
if (!sp.room_drafts.length) delete sp.room_drafts;
}
this._recordGeometry(this._t('history.contour_to_partitions'), before);
this._saveConfig();
this._commitPhysicalGeometry(this._t('history.contour_to_partitions'), before);
this._roomDialog = false;
this._path = [];
delete this._resumeDraftBySpace[this._space];
@@ -15109,6 +15179,13 @@ class HouseplanCard extends LitElement {
});
}
/** One strict production source shared by editor commits and stale rechecks. */
private _checkSpacePhysicalGeometry(config: ServerConfig, spaceId: string) {
return checkSpacePhysicalGeometry(config, spaceId, {
fallbackSpaceName: (index) => this._t('gs.align_preflight_space', { n: String(index) }),
});
}
private _optimizeReferenceContext(
removeLiveMissingPositions: boolean,
): SpaceReferenceRepairContext {
@@ -15957,8 +16034,10 @@ class HouseplanCard extends LitElement {
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, lightPhysical,
)
: null;
if (masonry) {
for (const ring of geometryAllRings(masonry.geom)) occluders.push(...polygonSegments(ring));
if (masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')) {
for (const component of masonry.components) {
for (const ring of geometryAllRings(component.geom)) occluders.push(...polygonSegments(ring));
}
} else {
// Malformed legacy geometry must remain opaque even when the canonical
// boolean pass cannot produce a joined result.
@@ -15975,7 +16054,8 @@ class HouseplanCard extends LitElement {
occluders: splitAtIntersections(occluders),
floor: polys.map((x) => x.poly),
fingerprint,
masonryGeometry: masonry?.geom || [],
masonryGeometry: masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')
? masonry.components.flatMap((component) => component.geom) : [],
// The source guard must fail dark against the same type/floor-filtered
// bodies used to build the light masonry. The ordinary render bodies are
// cut by every hosted opening, including opaque windows and exterior
+1
View File
@@ -535,6 +535,7 @@
"toast.physical_range": "Enter a value from 1 to {max} {unit}",
"toast.physical_angle": "Enter a rotation angle from 0° up to, but not including, 90°",
"toast.physical_limit": "The space has reached the limit for this type of geometry",
"toast.geometry_unsafe": "Change canceled: wall geometry could not be built safely.",
"markup.boundary_hint": "Select a shared room boundary or a dashed stretch",
"markup.boundary_hint_open": "Click the start of the open stretch",
"markup.boundary_hint_second": "Choose the end on the selected boundary · Esc to cancel",
+1
View File
@@ -535,6 +535,7 @@
"toast.physical_range": "Введите значение от 1 до {max} {unit}",
"toast.physical_angle": "Введите угол поворота от 0° до 90°, не включая 90°",
"toast.physical_limit": "В пространстве достигнут лимит объектов этого типа",
"toast.geometry_unsafe": "Изменение отменено: геометрию стен нельзя безопасно построить.",
"markup.boundary_hint": "Выберите общую границу комнат или пунктирный участок",
"markup.boundary_hint_open": "Кликните начало открытого участка",
"markup.boundary_hint_second": "Укажите конец на выбранной границе · Esc — отмена",
+57 -1
View File
@@ -60,6 +60,8 @@ export interface SpacePhysicalGeometryInputs {
export type OptimizeGeometryFailureReason =
| 'prepare-exception'
| 'wall-null'
| 'wall-degraded-extra'
| 'wall-failed-core'
| 'wall-exception'
| 'floor-null'
| 'floor-exception';
@@ -90,6 +92,52 @@ export interface CheckOptimizeGeometryOptions {
fingerprint?: typeof contentFingerprint;
}
export interface SpacePhysicalGeometryResult extends OptimizeSpaceGeometryCheck {
fingerprint: string;
ok: boolean;
}
/** Fingerprint only fields capable of changing canonical physical geometry. */
export function spacePhysicalGeometryFingerprint(spaceConfig: any): string {
return contentFingerprint({
id: spaceConfig?.id ?? '',
cell_cm: spaceConfig?.cell_cm,
rooms: spaceConfig?.rooms || [],
walls: spaceConfig?.walls || [],
open_spans: spaceConfig?.open_spans || [],
openings: spaceConfig?.openings || [],
partitions: spaceConfig?.partitions || [],
room_drafts: spaceConfig?.room_drafts || [],
wall_columns: spaceConfig?.wall_columns || [],
});
}
/** Strict one-space barrier backed by the exact same pass as Optimize. */
export function checkSpacePhysicalGeometry(
config: ServerConfig | any,
spaceId: string,
options: CheckOptimizeGeometryOptions = {},
): SpacePhysicalGeometryResult {
const allSpaces = Array.isArray(config?.spaces) ? config.spaces : [];
const raw = allSpaces.find((space: any) => String(space?.id || '') === String(spaceId || ''));
const fingerprint = spacePhysicalGeometryFingerprint(raw);
if (!raw) {
return {
spaceId: String(spaceId || ''), displayName: '', status: 'failed',
reason: 'prepare-exception', fingerprint, ok: false,
};
}
const result = checkOptimizeGeometry(
{ ...config, spaces: [raw] },
{ ...options, fingerprint: () => fingerprint },
);
const space = result.spaces[0] || {
spaceId: String(spaceId || ''), displayName: '', status: 'failed' as const,
reason: 'prepare-exception' as const,
};
return { ...space, fingerprint, ok: space.status !== 'failed' };
}
/** Resolve explicit spans and the legacy open_to fallback exactly as the card. */
export function geometryOpenCuts(
spaceConfig: any,
@@ -293,7 +341,7 @@ export function checkOptimizeGeometry(
continue;
}
let united: ReturnType<typeof wallBodiesGeometry> = null;
let united: ReturnType<typeof wallBodiesGeometry> | null = null;
if (hasWallPass) {
try {
united = buildWalls(
@@ -309,6 +357,14 @@ export function checkOptimizeGeometry(
spaces.push({ ...identity, status: 'failed', reason: 'wall-null' });
continue;
}
if (united.status === 'degraded-extra') {
spaces.push({ ...identity, status: 'failed', reason: 'wall-degraded-extra' });
continue;
}
if (united.status === 'failed-core') {
spaces.push({ ...identity, status: 'failed', reason: 'wall-failed-core' });
continue;
}
}
if (input.space.rooms.length && united?.paperGeom == null) {
+9 -6
View File
@@ -541,12 +541,15 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
${passageGlowTunnels}
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
<path class="wallbody-fill" d="${wallUnion.d}"
fill="${colors.wall_fill.c}" fill-opacity="${colors.wall_fill.a}" fill-rule=${wallUnion.fillRule}
stroke="none" pointer-events="none"></path>
<path class="wallbody ${solidWall ? 'solid' : ''}" data-hp="wall" data-id="union" data-kind="union"
d="${wallUnion.d}" fill="${solidWall ? 'none' : 'url(#hp-wall-hatch)'}" fill-rule=${wallUnion.fillRule}
stroke="${wallStroke}" stroke-width="${gridVisualUnits(0.6, cellCm)}" pointer-events="none"></path>
${wallUnion.paths.map((component) => svg`
<path class="wallbody-fill" data-component=${component.id} d="${component.d}"
fill="${colors.wall_fill.c}" fill-opacity="${colors.wall_fill.a}"
fill-rule=${component.fillRule} stroke="none" pointer-events="none"></path>
<path class="wallbody ${solidWall ? 'solid' : ''}"
data-hp="wall" data-id="union" data-kind="union" data-component=${component.id}
d="${component.d}" fill="${solidWall ? 'none' : 'url(#hp-wall-hatch)'}"
fill-rule=${component.fillRule} stroke="${wallStroke}"
stroke-width="${gridVisualUnits(0.6, cellCm)}" pointer-events="none"></path>`)}
</g>`
: nothing}
${hostedOpeningSymbols}
+151 -29
View File
@@ -17,6 +17,38 @@ export interface WallEntry {
b?: number[];
}
export type WallGeometryStatus =
| 'ok'
| 'degraded-extra'
| 'failed-core'
| 'not-applicable';
/** One independently drawable/physical polygon-clipping component. */
export interface WallGeometryComponent {
id: string;
geom: any;
}
export interface WallBodiesGeometryResult {
status: WallGeometryStatus;
/** Last successful primary union. Isolated fallback components stay separate. */
geom: any;
components: readonly WallGeometryComponent[];
/** Canonical room masonry before independent bodies. */
roomGeom: any;
paperGeom: any;
depthUnits: number;
openingIndex: OpeningWallIndex | null;
degradedExtraCount: number;
}
export interface WallGeometryOperations {
/** Test seam around the transaction which may fail for one independent body. */
mergeExtra?: (primary: any, extra: any, index: number) => any;
/** Bounded diagnostic seam; never receives coordinates, ids or exceptions. */
onCoreFailure?: (phase: string) => void;
}
export const WALL_MIN_CM = 1;
export const WALL_MAX_CM = 100;
/** Default thickness offered in the Draw toolbar (docs/WALL-THICKNESS.md §6). */
@@ -1988,9 +2020,13 @@ export function innerContourForRoom(
const inset = insetContour(pr.poly, pr.offsets, multiWallNodes);
if (!inset) return poly.map((p) => [p[0], p[1]]);
if (!multiWallNodes.nodes.length) return inset;
const roomWallGeometry = sharedRoomWallGeometry ?? wallBodiesGeometry(
rooms, walls, openCuts, [], pitch, cellCm, gridPitch, coordScale,
)?.roomGeom;
const computedWallGeometry = sharedRoomWallGeometry === undefined
? wallBodiesGeometry(rooms, walls, openCuts, [], pitch, cellCm, gridPitch, coordScale)
: null;
const roomWallGeometry = sharedRoomWallGeometry ?? (
computedWallGeometry?.status === 'ok' || computedWallGeometry?.status === 'degraded-extra'
? computedWallGeometry.roomGeom : undefined
);
if (roomWallGeometry) {
try {
const floor = difference(closedRing(pr.poly) as any, roomWallGeometry);
@@ -2010,6 +2046,15 @@ function closedRing(poly: number[][]): number[][][] {
return [ring];
}
function structurallyValidWallGeometry(geometry: any): boolean {
if (!Array.isArray(geometry) || !geometry.length) return false;
return geometry.every((polygon: any) => Array.isArray(polygon) && polygon.length
&& polygon.every((ring: any) => Array.isArray(ring) && ring.length >= 4
&& ring.every((point: any) => Array.isArray(point) && point.length >= 2
&& Number.isFinite(point[0]) && Number.isFinite(point[1])))
&& Math.abs(polygonArea(polygon[0])) > 1e-9);
}
interface MultiWallRoomRing {
outset: number[][];
inset: number[][] | null;
@@ -2559,7 +2604,7 @@ export function floorFootprintGeometry(
}
}
function polyclipToPathD(geom: any): string {
export function polyclipToPathD(geom: any): string {
if (!geom) return '';
let d = '';
// polyclip Geom: MultiPolygon = Polygon[]; Polygon = Ring[] where ring[0]
@@ -2737,8 +2782,8 @@ export function wallBodyRings(
* uses the same geometry as its occluders, so a wall blocks light exactly
* where the plan shows a wall — with its real thickness, and with a doorway
* that is a real gap between two jamb faces. A successful empty operation is
* returned as an empty geometry; null means the boolean pass itself failed,
* so drawing callers may distinguish it from "nothing solid" and fall back.
* returned as an empty typed result. Core failure is `failed-core`; an optional
* merge failure is `degraded-extra` with every known-valid component retained.
*/
export function wallBodiesGeometry(
rooms: any[],
@@ -2750,15 +2795,12 @@ export function wallBodiesGeometry(
gridPitch: number,
coordScale = 1,
extraBodies: number[][][] = [],
): {
geom: any;
/** Canonical room masonry before opening cuts and independent bodies. */
roomGeom: any;
paperGeom: any;
depthUnits: number;
openingIndex: OpeningWallIndex | null;
} | null {
if (!walls?.length && !extraBodies.length) return null;
operations: WallGeometryOperations = {},
): WallBodiesGeometryResult {
if (!walls?.length && !extraBodies.length) return {
status: 'not-applicable', geom: [], components: [], roomGeom: [], paperGeom: [],
depthUnits: 0, openingIndex: null, degradedExtraCount: 0,
};
const roomRings: MultiWallRoomRing[] = [];
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
@@ -2795,12 +2837,14 @@ export function wallBodiesGeometry(
const openingIndex = openings.length
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
: null;
let corePhase = 'exterior';
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, multiWallNodes,
);
// Paper and masonry share this one structural pass. Renderers cache the
// returned pair, so a live HA state update never repeats exterior topology.
corePhase = 'paper';
const rawPaperGeom = exterior
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
: [];
@@ -2811,6 +2855,7 @@ export function wallBodiesGeometry(
const outset: any = closedRing(ring.outset);
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
};
corePhase = 'room-rings';
let body: any = null;
for (const ring of roomRings) {
try {
@@ -2826,6 +2871,7 @@ export function wallBodiesGeometry(
// also included so a rejected acute child ring cannot remove a divider or
// an interior half-wall. Clipping them to the centre union gives a hard
// facade boundary; the canonical exterior shell is added afterwards.
corePhase = 'edge-bodies';
if (exterior) {
for (const edge of wallEdgeBodies(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
@@ -2842,14 +2888,37 @@ export function wallBodiesGeometry(
// The room-ring subtraction above cannot infer a mitre between real arms
// owned by different contours at a virtual T. Add only those missing
// junction pieces, then let physical openings cut through them as usual.
corePhase = 'junctions';
body = unionJunctionPatches(body, junctions, coordScale);
corePhase = 'facade-clip';
if (body && exterior) body = intersection(body, exterior.centre);
if (exterior?.shell?.length)
body = body ? union(body, exterior.shell) : exterior.shell;
corePhase = 'exterior-shell';
const isolatedCore: WallGeometryComponent[] = [];
let degradedCoreCount = 0;
if (exterior?.shell?.length) {
if (!body) body = exterior.shell;
else {
try {
const merged = union(body, exterior.shell);
if (!structurallyValidWallGeometry(merged)) throw new Error('invalid shell union');
body = merged;
} catch {
// Both mandatory halves exist; only their boolean merge failed.
// Preserve them as non-cancelling components for read-only render,
// while strict writers still reject the degraded structural result.
if (!structurallyValidWallGeometry(body)
|| !structurallyValidWallGeometry(exterior.shell)) throw new Error('invalid shell');
isolatedCore.push({ id: 'exterior-shell', geom: exterior.shell });
degradedCoreCount++;
}
}
}
corePhase = 'multi-wall-bevel';
if (body && multiWallNodes.nodes.length)
body = bevelMultiWallBody(body, multiWallNodes, exterior?.centre, paperGeom);
const roomGeom = body || [];
// cut opening tunnels (axis-aligned to opening angle)
corePhase = 'openings';
for (const o of openings) {
if (!(o.length > 0)) continue;
const association = resolveOpeningWallAssociation(openingIndex!, o, true);
@@ -2866,16 +2935,59 @@ export function wallBodiesGeometry(
[o.x - ux * half + nx * pad, o.y - uy * half + ny * pad],
];
if (body) body = difference(body, closedRing(slot) as any);
for (const component of isolatedCore) {
component.geom = difference(component.geom, closedRing(slot) as any);
}
}
// Independent bodies are physical but own no openings. Unioning here (not
// before the loop above) preserves them under coincident room openings.
for (const extra of extraBodies) {
if (extra.length < 3) continue;
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
// Independent bodies are physical but own no openings. Each merge is a
// transaction: a local boolean failure must not discard the last valid
// room/extra union. A valid offending body remains an isolated component,
// which renderers paint separately so coincident evenodd rings never punch
// a transparent hole in the primary masonry.
corePhase = 'extras';
const isolated: WallGeometryComponent[] = [];
let degradedExtraCount = 0;
const mergeExtra = operations.mergeExtra
|| ((primary: any, extra: any) => primary ? union(primary, extra) : extra);
for (let index = 0; index < extraBodies.length; index++) {
const extra = extraBodies[index];
if (extra.length < 3 || !extra.every((point) => point.length >= 2
&& Number.isFinite(point[0]) && Number.isFinite(point[1]))
|| Math.abs(polygonArea(extra)) <= 1e-9) {
degradedExtraCount++;
continue;
}
const standalone: any = [closedRing(extra)];
try {
const merged = mergeExtra(body, standalone, index);
if (!structurallyValidWallGeometry(merged)) throw new Error('invalid extra union');
body = merged;
} catch {
degradedExtraCount++;
if (structurallyValidWallGeometry(standalone)) {
isolated.push({ id: `extra-${index}`, geom: standalone });
}
}
}
return { geom: body || [], roomGeom, paperGeom, depthUnits: maxDepth, openingIndex };
isolated.push(...isolatedCore);
isolated.sort((left, right) => polyclipToPathD(left.geom).localeCompare(polyclipToPathD(right.geom)));
const primary = body || [];
const components: WallGeometryComponent[] = [
...(structurallyValidWallGeometry(primary) ? [{ id: 'primary', geom: primary }] : []),
...isolated.map((component, index) => ({ ...component, id: `isolated-${index}` })),
];
return {
status: degradedExtraCount || degradedCoreCount ? 'degraded-extra' : 'ok',
geom: primary, components, roomGeom, paperGeom,
depthUnits: maxDepth, openingIndex,
degradedExtraCount: degradedExtraCount + degradedCoreCount,
};
} catch {
return null;
operations.onCoreFailure?.(corePhase);
return {
status: 'failed-core', geom: [], components: [], roomGeom: [], paperGeom: [],
depthUnits: maxDepth, openingIndex: null, degradedExtraCount: 0,
};
}
}
@@ -2891,8 +3003,12 @@ export function wallBodiesUnionPath(
/** Independent physical bodies are unioned only after room openings are cut,
* so a door/window/gate can never punch a coincident partition or column. */
extraBodies: number[][][] = [],
operations: WallGeometryOperations = {},
): {
status: 'ok' | 'degraded-extra';
d: string;
paths: readonly { id: string; d: string; fillRule: 'evenodd' }[];
components: readonly WallGeometryComponent[];
roomGeom: any;
paperD: string;
depthUnits: number;
@@ -2901,14 +3017,20 @@ export function wallBodiesUnionPath(
if (!walls?.length && !extraBodies.length) return null;
const united = wallBodiesGeometry(
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
operations,
);
const d = united ? polyclipToPathD(united.geom) : '';
const paperD = united ? polyclipToPathD(united.paperGeom) : '';
if (united && d) return {
d, roomGeom: united.roomGeom, paperD,
if (united.status === 'failed-core' || united.status === 'not-applicable') return null;
const paths = united.components.map((component) => ({
id: component.id, d: polyclipToPathD(component.geom), fillRule: 'evenodd' as const,
})).filter((component) => !!component.d);
const d = paths[0]?.d || '';
const paperD = polyclipToPathD(united.paperGeom);
if (paths.length) return {
status: united.status, d, paths, components: united.components,
roomGeom: united.roomGeom, paperD,
depthUnits: united.depthUnits, fillRule: 'evenodd',
};
if (united) return null; // successful empty result: do not resurrect raw rings
// successful empty result: do not resurrect raw rings
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
// that creates an exterior tooth at a corner Split, so resurrecting it after
// a boolean failure would make malformed input violate the facade invariant.