mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
+16
-11
@@ -668,6 +668,7 @@ export class HouseplanCard extends LitElement {
|
||||
);
|
||||
if (!render) return;
|
||||
}
|
||||
if (name !== undefined) this._terminalFrame = 0;
|
||||
if (this._editorRuntime?._routeLiveEditorUpdate(name, oldValue)) return;
|
||||
this._liveRt?.clear();
|
||||
super.requestUpdate(name, oldValue, options);
|
||||
@@ -3797,6 +3798,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _cfgEpoch = 0;
|
||||
/** Exact settings-only Area lifecycle replacement which may reuse geometry. */
|
||||
private _cfgEpochPreservedConfig: ServerConfig | null = null;
|
||||
private _terminalFrame: 0 | 1 | 2 = 0; // 1=restored cancel, 2=deferred HA
|
||||
private _modelCache: { key: string; model: SpaceModel[] } | null = null;
|
||||
private _emptySpaceStateActive = false;
|
||||
private _decorSnapCache: {
|
||||
@@ -4516,7 +4518,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _liveVp(now = false): void { this._liveRt ? this._liveRt.viewport(now) : this.requestUpdate(); }
|
||||
private _syncLiveHover(): void { this._liveRt ? this._liveRt.hover() : this.requestUpdate(); }
|
||||
/** Bypass live routing so a gesture terminal publishes the last HA frame. */
|
||||
private _flushHa(): void { if (this._liveRt?.take()) super.requestUpdate(); }
|
||||
private _flushHa(): void { if (this._liveRt?.take()) { this._terminalFrame = 2; super.requestUpdate(); } }
|
||||
private _onHaRegistryUpdate = (): void => {
|
||||
const snapshot = haRegistrySnapshot(this.hass);
|
||||
if (snapshot.revision === this._haRegistryRev && this._devices.length) return;
|
||||
@@ -7787,10 +7789,9 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** #329 П1-П5 over one space. Pure input, no side effects. */
|
||||
private _junctionLimitViolations(
|
||||
config: any, spaceId: string, sharedGeometry?: any,
|
||||
): JunctionLimitViolation[] {
|
||||
return this._editorRuntimeOrThrow()._junctionLimitViolations(config, spaceId, sharedGeometry);
|
||||
private _junctionLimitViolations(config: any, spaceId: string, sharedGeometry?: any,
|
||||
roomIds?: ReadonlySet<string>): JunctionLimitViolation[] {
|
||||
return this._editorRuntimeOrThrow()._junctionLimitViolations(config, spaceId, sharedGeometry, roomIds);
|
||||
}
|
||||
|
||||
/** Localised refusal text for the first violation a write introduces. */
|
||||
@@ -8244,8 +8245,9 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
/** Fixed-topology wall handles. Disabled geometry remains visible and
|
||||
* explains why it cannot start a gesture; the old corner scale is gone. */
|
||||
private _renderResizeLayer(view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
||||
return this._editorRuntimeOrThrow()._renderResizeLayer(view);
|
||||
private _renderResizeLayer(view: { x: number; y: number; w: number; h: number },
|
||||
roomIds?: readonly string[]): TemplateResult {
|
||||
return this._editorRuntimeOrThrow()._renderResizeLayer(view, roomIds);
|
||||
}
|
||||
|
||||
/** Openings of the current space in render units. */
|
||||
@@ -8766,7 +8768,7 @@ export class HouseplanCard extends LitElement {
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
private _renderDecorLayer(): TemplateResult {
|
||||
private _renderDecorLayer(onlyId?: string | null): TemplateResult {
|
||||
const W = NORM_W, H = this._decorH;
|
||||
const editing = this._mode === 'decor';
|
||||
const erasing = editing && this._decorTool === 'erase';
|
||||
@@ -8784,7 +8786,7 @@ export class HouseplanCard extends LitElement {
|
||||
const furnitureScreenScale = this._renderProjection === 'iso' ? 1 : furniturePlanScreenScale(
|
||||
stage?.clientWidth, stage?.clientHeight, planView.w, planView.h,
|
||||
);
|
||||
const shapes = this._decorList.map((sh) => {
|
||||
const shapes = this._decorList.filter((sh) => onlyId === undefined || sh.id === onlyId).map((sh) => {
|
||||
const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : '');
|
||||
const style = this._decorResolvedStyle(sh);
|
||||
const strokeWidth = this._decorWidthUnits(sh);
|
||||
@@ -11298,6 +11300,9 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
private _renderBody(): TemplateResult | typeof nothing | typeof noChange {
|
||||
if (!this._config || !this.hass) return nothing;
|
||||
const terminalFrame = this._terminalFrame;
|
||||
this._terminalFrame = 0;
|
||||
if (terminalFrame === 1) return noChange;
|
||||
const localeGate = this._syncDangerConfirmLocaleGate();
|
||||
if (localeGate === 'cold') return languageLoadingTemplate();
|
||||
if (localeGate === 'warm') return noChange;
|
||||
@@ -13006,8 +13011,8 @@ export class HouseplanCard extends LitElement {
|
||||
return this._editorRuntimeOrThrow()._renderOpeningPlacementPreview();
|
||||
}
|
||||
|
||||
private _renderOpenings(disp: SpaceDisplay): TemplateResult {
|
||||
const items = this._openingsR;
|
||||
private _renderOpenings(disp: SpaceDisplay, onlyIds?: readonly string[]): TemplateResult {
|
||||
const items = onlyIds ? this._openingsR.filter((item) => onlyIds.includes(item.id)) : this._openingsR;
|
||||
if (!items.length) return svg``;
|
||||
const space = this._spaceModel();
|
||||
if (!space) return svg``;
|
||||
|
||||
@@ -104,8 +104,8 @@ import {
|
||||
innerEdgeSpan, ownEdgeOffsets, thicknessCmAt,
|
||||
} from './wall-thickness';
|
||||
import {
|
||||
checkNodeDistances, checkNodes, checkRoomClearance, checkSegmentLengths,
|
||||
increasedViolations, type JunctionLimitViolation, type LimitSegment,
|
||||
junctionLimitViolations, increasedViolations,
|
||||
type JunctionLimitViolation, type LimitSegment,
|
||||
} from './junction-limits';
|
||||
import {
|
||||
pointOnOpenCut, sanitizeOpenSpans,
|
||||
@@ -974,7 +974,7 @@ export interface HouseplanEditorHostPort {
|
||||
_infoCard: DevItem | null;
|
||||
_innerRoomContour: (space: SpaceModel, roomId: string, openCuts?: number[][], roomWalls?: any, multiWallNodes?: MultiWallNodeMap | null | undefined) => number[][] | null;
|
||||
_junctionLimitViolations: (
|
||||
config: unknown, spaceId: string, sharedGeometry?: unknown,
|
||||
config: unknown, spaceId: string, sharedGeometry?: unknown, roomIds?: ReadonlySet<string>,
|
||||
) => JunctionLimitViolation[];
|
||||
_isVacDev: (d: DevItem) => boolean;
|
||||
_kiosk: boolean;
|
||||
@@ -1101,6 +1101,7 @@ export interface HouseplanEditorHostPort {
|
||||
_serverCfg: ServerConfig | null;
|
||||
_serverStorage: boolean;
|
||||
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; };
|
||||
_terminalFrame: 0 | 1 | 2;
|
||||
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; busy: boolean; } | null;
|
||||
_supportDialog: SupportDialogState | null;
|
||||
_showAll: boolean;
|
||||
@@ -1185,10 +1186,11 @@ export interface HouseplanEditorHostPort {
|
||||
export const EDITOR_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
export class HouseplanEditorRuntime {
|
||||
/** #330: baseline limits are stable across one resize gesture. */
|
||||
private _junctionBaselineCache = new WeakMap<object, {
|
||||
spaceId: string; fingerprint: string; violations: JunctionLimitViolation[];
|
||||
}>();
|
||||
private _resizePreviewNodes: MultiWallNodeMap | null = null;
|
||||
private _resizeBaselineLimits: JunctionLimitViolation[] = [];
|
||||
private _supportExpiryTimer?: number;
|
||||
private _supportPreviewGeneration = 0;
|
||||
private _decorAssetGuardReplace: boolean | null = null;
|
||||
@@ -2049,51 +2051,12 @@ public _junctionLimitViolations(
|
||||
/** #330 §4.7: an already computed masonry pass of THIS config (e.g. the
|
||||
* resize preflight's artifact) — the union is never paid twice. */
|
||||
sharedGeometry?: any,
|
||||
roomIds?: ReadonlySet<string>,
|
||||
): JunctionLimitViolation[] {
|
||||
const space = (config?.spaces || []).find((item) => item?.id === spaceId);
|
||||
if (!space) return [];
|
||||
const cellCm = Number(space.cell_cm) > 0 ? Number(space.cell_cm) : 5;
|
||||
const segments = this._limitSegmentsOf(space);
|
||||
const violations = [
|
||||
...checkNodes(segments),
|
||||
...checkSegmentLengths(segments, cellCm, GRID_STEP_N),
|
||||
...checkNodeDistances(segments, cellCm, GRID_STEP_N),
|
||||
];
|
||||
// #330 §4.7: without the shared passes every room recomputed the FULL
|
||||
// junction topology and masonry union for itself. Compute both at most
|
||||
// once and hand them to every room clearance check.
|
||||
let sharedNodes: ReturnType<typeof multiWallNodesForGeometry> | null = null;
|
||||
try {
|
||||
sharedNodes = multiWallNodesForGeometry(
|
||||
space.rooms || [], space.walls || [], [], GRID_STEP_N, cellCm, GRID_STEP_N, 1,
|
||||
);
|
||||
} catch { sharedNodes = null; }
|
||||
let sharedRoomGeometry: any = sharedGeometry?.status === 'ok'
|
||||
|| sharedGeometry?.status === 'degraded-extra'
|
||||
? sharedGeometry.roomGeom : null;
|
||||
if (!sharedRoomGeometry && sharedNodes && sharedNodes.nodes.length) {
|
||||
try {
|
||||
const geometry = wallBodiesGeometry(
|
||||
space.rooms || [], space.walls || [], [], [], GRID_STEP_N, cellCm, GRID_STEP_N, 1,
|
||||
);
|
||||
sharedRoomGeometry = geometry?.status === 'ok' || geometry?.status === 'degraded-extra'
|
||||
? geometry.roomGeom : null;
|
||||
} catch { sharedRoomGeometry = null; }
|
||||
}
|
||||
for (const room of (space.rooms || [])) {
|
||||
const roomId = String(room?.id || '');
|
||||
if (!roomId) continue;
|
||||
let inner: number[][] | null = null;
|
||||
try {
|
||||
inner = innerContourForRoom(
|
||||
space.rooms || [], roomId, space.walls || [], [],
|
||||
GRID_STEP_N, cellCm, GRID_STEP_N, 1,
|
||||
sharedRoomGeometry ?? undefined, sharedNodes,
|
||||
);
|
||||
} catch { inner = null; }
|
||||
violations.push(...checkRoomClearance(roomId, inner, cellCm, GRID_STEP_N));
|
||||
}
|
||||
return violations;
|
||||
return space ? junctionLimitViolations(
|
||||
config, spaceId, this._limitSegmentsOf(space), sharedGeometry, roomIds,
|
||||
) : [];
|
||||
}
|
||||
|
||||
public _junctionLimitLabel(violation: JunctionLimitViolation): string {
|
||||
@@ -2106,6 +2069,7 @@ public _junctionLimitLabel(violation: JunctionLimitViolation): string {
|
||||
public _junctionLimitsIntroduced(
|
||||
candidate: any, previousConfig: any, spaceId: string,
|
||||
candidateGeometry?: unknown,
|
||||
affectedRoomIds?: readonly string[],
|
||||
): JunctionLimitViolation[] {
|
||||
// The baseline must be the previous document AS THE CANDIDATE SEES IT: a
|
||||
// legacy space carries no wall catalogue at all, so comparing it raw with
|
||||
@@ -2113,13 +2077,14 @@ public _junctionLimitsIntroduced(
|
||||
// refused legitimate resizes of real plans. Both sides therefore cross
|
||||
// the same identity barrier first. Current-version documents are already
|
||||
// materialized, while legacy documents cross that barrier once.
|
||||
const affected = affectedRoomIds?.length ? new Set(affectedRoomIds) : undefined;
|
||||
let inherited: JunctionLimitViolation[] | undefined;
|
||||
const previousSpace = (previousConfig?.spaces || [])
|
||||
.find((space: any) => space?.id === spaceId);
|
||||
let fingerprint = '';
|
||||
try { fingerprint = spacePhysicalGeometryFingerprint(previousSpace); }
|
||||
catch { fingerprint = ''; }
|
||||
const cached = previousConfig && typeof previousConfig === 'object'
|
||||
const cached = !affected && previousConfig && typeof previousConfig === 'object'
|
||||
? this._junctionBaselineCache.get(previousConfig) : undefined;
|
||||
if (cached && fingerprint && cached.fingerprint === fingerprint
|
||||
&& cached.spaceId === spaceId) {
|
||||
@@ -2130,26 +2095,34 @@ public _junctionLimitsIntroduced(
|
||||
const baseline = Number(previousConfig?.model_version || 0) >= WALL_SEGMENT_MODEL_VERSION
|
||||
? previousConfig
|
||||
: commitWallSegmentModel(previousConfig).config;
|
||||
inherited = this.host._junctionLimitViolations(baseline, spaceId);
|
||||
inherited = this.host._junctionLimitViolations(
|
||||
baseline, spaceId, affected ? null : undefined, affected,
|
||||
);
|
||||
} catch {
|
||||
// An unmigratable baseline proves nothing about inheritance; never
|
||||
// refuse the write on that basis.
|
||||
return [];
|
||||
}
|
||||
if (previousConfig && typeof previousConfig === 'object' && fingerprint) {
|
||||
if (!affected && previousConfig && typeof previousConfig === 'object' && fingerprint) {
|
||||
this._junctionBaselineCache.set(previousConfig, {
|
||||
spaceId, fingerprint, violations: inherited,
|
||||
});
|
||||
}
|
||||
}
|
||||
let next: JunctionLimitViolation[] = [];
|
||||
try { next = this.host._junctionLimitViolations(candidate, spaceId, candidateGeometry); }
|
||||
let comparison = inherited;
|
||||
try {
|
||||
next = this.host._junctionLimitViolations(candidate, spaceId, candidateGeometry, affected);
|
||||
if (affected) comparison = inherited.filter(
|
||||
(item) => item.rule !== 'clearance' || affected.has(item.subject),
|
||||
);
|
||||
}
|
||||
catch {
|
||||
// #331 §2.5: candidate judgment fails closed; only an unprovable
|
||||
// baseline above is allowed to fail open.
|
||||
return [{ rule: 'check_failed', subject: spaceId, actual: 0, limit: 0 }];
|
||||
}
|
||||
return increasedViolations(next, inherited);
|
||||
return increasedViolations(next, comparison);
|
||||
}
|
||||
|
||||
public _commitPhysicalGeometry(
|
||||
@@ -3681,10 +3654,6 @@ public _rszProjectPreview(
|
||||
if (wallRecordCarrierViolations(
|
||||
changedWalls, wallCarriers, this.host._wallKeyPitch, NORM_W, s.walls || [],
|
||||
).length) return { ok: false, reason: 'wall-metadata' };
|
||||
const preflight = this._rszSpaceCandidateGeometry(this.host._space, sp);
|
||||
if (!preflight.ok) {
|
||||
return { ok: false, reason: 'physical-geometry' };
|
||||
}
|
||||
// #329 AC7a: a step that would ADD a junction-limit violation is never
|
||||
// projected, so the drag stops at the last allowed position instead of
|
||||
// committing an impossible plan.
|
||||
@@ -3694,11 +3663,23 @@ public _rszProjectPreview(
|
||||
(space) => (space?.id === this.host._space ? sp : space),
|
||||
),
|
||||
};
|
||||
// #330 §4.7: reuse the masonry pass already computed by the resize
|
||||
// preflight instead of rebuilding it while checking room clearance.
|
||||
const limited = this._junctionLimitsIntroduced(
|
||||
limitCandidate, this.host._serverCfg, this.host._space, preflight.wallGeometry,
|
||||
);
|
||||
try {
|
||||
this._resizePreviewNodes = multiWallNodesForGeometry(
|
||||
sp.rooms || [], sp.walls || [], [], GRID_STEP_N,
|
||||
this.host._cellCm, this.host._gridPitch, NORM_W,
|
||||
);
|
||||
} catch { this._resizePreviewNodes = null; }
|
||||
let limited: JunctionLimitViolation[];
|
||||
try {
|
||||
const affected = new Set(changedRoomIds);
|
||||
const next = this.host._junctionLimitViolations(limitCandidate, this.host._space,
|
||||
{ status: 'lightweight', multiWallNodes: this._resizePreviewNodes }, affected);
|
||||
const baseline = this._resizeBaselineLimits.filter(
|
||||
(item) => item.rule !== 'clearance' || affected.has(item.subject));
|
||||
limited = increasedViolations(next, baseline);
|
||||
} catch {
|
||||
limited = [{ rule: 'check_failed', subject: this.host._space, actual: 0, limit: 0 }];
|
||||
}
|
||||
if (limited.length) {
|
||||
this.host._rszLimitViolation = limited[0];
|
||||
return { ok: false, reason: 'junction-limit' };
|
||||
@@ -3709,7 +3690,7 @@ public _rszProjectPreview(
|
||||
preview: { space: this.host._space, sp },
|
||||
beforeWalls: s.walls || [],
|
||||
afterWalls: sp.walls || [],
|
||||
artifact: preflight.wallGeometry,
|
||||
artifact: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3718,6 +3699,8 @@ public _rszAcceptPreview(
|
||||
preview: ResizePreview | null, wallGeometry: ResizeWallArtifact | null,
|
||||
): void {
|
||||
this.host._cfgEpoch++;
|
||||
if (this.host._physicalBodiesCache) this.host._physicalBodiesCache.key =
|
||||
`${this.host._space}|${this.host._cfgEpoch}|${this.host._cellCm}|${this.host._gridPitch}`;
|
||||
if (!preview || !wallGeometry) return;
|
||||
const projected = wallBodiesGeometryPath(wallGeometry);
|
||||
if (!projected) return;
|
||||
@@ -3783,6 +3766,10 @@ public _rszEdgeDown(ev: PointerEvent, roomId: string, edge: number): void {
|
||||
}
|
||||
capturePointer(ev);
|
||||
const plan = resolution.plan;
|
||||
try {
|
||||
this._resizeBaselineLimits = this.host._junctionLimitViolations(
|
||||
this.host._serverCfg, this.host._space, null, new Set(plan.roomIds));
|
||||
} catch { this._resizeBaselineLimits = []; }
|
||||
const start = this._svgPoint(ev);
|
||||
const wallUnionKey = `${this.host._space}|${this.host._cfgEpoch}|${rooms.length}`;
|
||||
const wallUnionBefore = this.host._wallUnionCache?.key === wallUnionKey
|
||||
@@ -3794,6 +3781,7 @@ public _rszEdgeDown(ev: PointerEvent, roomId: string, edge: number): void {
|
||||
openings: this._rszOpenings(), snapshotIdentity,
|
||||
before: JSON.parse(snapshotIdentity) as SpaceGeometryState,
|
||||
wallUnionBefore,
|
||||
epochBefore: this.host._cfgEpoch,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3899,15 +3887,13 @@ public _rszCancelDrag(pointerId?: number): void {
|
||||
cancelHouseplanPointerMove(this.host, 'resize');
|
||||
const result = this.host._resize.cancel(this._rszSnapshot(), pointerId);
|
||||
if (result.kind === 'no-op') return;
|
||||
// HP-1550-01/-03: a cancel just drops the overlay — the real config was
|
||||
// never touched, so there is nothing to restore, no undo step and no write
|
||||
this.host._cfgEpoch++;
|
||||
// An identical cancel reuses the pre-drag structural caches and writes nothing.
|
||||
if (result.restoreEpoch !== null) this.host._cfgEpoch = result.restoreEpoch;
|
||||
else this.host._cfgEpoch++;
|
||||
if (this.host._physicalBodiesCache) this.host._physicalBodiesCache.key =
|
||||
`${this.host._space}|${this.host._cfgEpoch}|${this.host._cellCm}|${this.host._gridPitch}`;
|
||||
if (result.restoreWallUnion) {
|
||||
// The server-backed geometry did not change during the gesture. The
|
||||
// preview advanced the structural epoch and temporarily selected its
|
||||
// candidate union, but Cancel returns to the byte-identical pre-drag
|
||||
// config. Alias that already-proved result under the fresh epoch instead
|
||||
// of paying another O(n log n) polygon union on the next paint.
|
||||
// Alias the already-proved pre-drag union under the restored epoch.
|
||||
const space = this.host._spaceModel();
|
||||
if (space) {
|
||||
const key = `${this.host._space}|${this.host._cfgEpoch}|${space.rooms.length}`;
|
||||
@@ -3916,6 +3902,10 @@ public _rszCancelDrag(pointerId?: number): void {
|
||||
this.host._wallUnionCache = entry;
|
||||
}
|
||||
}
|
||||
// Retain the canonical frame only when the drag was actually painted in
|
||||
// the isolated plan-editor layer. Programmatic/non-editor callers still
|
||||
// need a full render to replace their preview DOM.
|
||||
if (result.restoreEpoch !== null && this.host._mode === 'plan') this.host._terminalFrame = 1;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -3989,7 +3979,10 @@ public _rszEdgeLabels(
|
||||
// masonry union + contour cache that the following render consumes;
|
||||
// rebuilding both independently here doubled one Resize frame.
|
||||
const floor = walls.length && space
|
||||
? (this.host._innerRoomContour(space, id) || poly)
|
||||
? (innerContourForRoom(
|
||||
space.rooms, id, walls, this.host._openCuts(), this.host._wallKeyPitch,
|
||||
this.host._cellCm, this.host._gridPitch, NORM_W, null, this._resizePreviewNodes,
|
||||
) || poly)
|
||||
: poly;
|
||||
const m2 = physical.length
|
||||
? geometryArea(floorMinusBodies(floor, physical))
|
||||
@@ -4052,7 +4045,9 @@ public _renderResizeMeasurements(): TemplateResult | typeof nothing {
|
||||
</g>`;
|
||||
}
|
||||
|
||||
public _renderResizeLayer(view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
||||
public _renderResizeLayer(
|
||||
view: { x: number; y: number; w: number; h: number }, roomIds?: readonly string[],
|
||||
): TemplateResult {
|
||||
const hr = Math.max(view.w * 0.013, 5); // finger-sized HIT radius on touch, like .vacfithandle
|
||||
// Wall-handle glyph: half the old circle — a wall segment with two arrows
|
||||
// pointing perpendicular to it (the directions the wall can be dragged).
|
||||
@@ -4071,7 +4066,9 @@ public _renderResizeLayer(view: { x: number; y: number; w: number; h: number }):
|
||||
// never once per room edge. During a drag the immutable gesture snapshot
|
||||
// remains authoritative inside _rszResolution.
|
||||
const renderSnapshot = this.host._resize.snapshotIdentity || this._rszSnapshot();
|
||||
const visibleRooms = roomIds?.length ? new Set(roomIds) : null;
|
||||
for (const r of rooms) {
|
||||
if (visibleRooms && !visibleRooms.has(r.id)) continue;
|
||||
for (let i = 0; i < r.poly.length; i++) {
|
||||
const a = r.poly[i], b = r.poly[(i + 1) % r.poly.length];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < this.host._gridPitch) continue;
|
||||
@@ -4189,11 +4186,14 @@ public _cancelDecorGesture(): void {
|
||||
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(before.plan_transform || {}));
|
||||
this.host._cfgEpoch++;
|
||||
// The restored transform is byte-identical; keep geometry caches.
|
||||
this.host._decorSnapCache = null;
|
||||
}
|
||||
this.host._decorMove = null;
|
||||
this.host._dtDrag = null;
|
||||
this.host._bdDrag = null;
|
||||
// Mark after named terminal setters; only the unnamed cancel frame may be retained.
|
||||
if (before && sp && this.host._mode === 'decor') this.host._terminalFrame = 1;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
|
||||
+57
-1
@@ -10,7 +10,12 @@
|
||||
*
|
||||
* Thresholds are absolute (centimetres, degrees) and do not scale with the
|
||||
* space's `cell_cm` (spec r1-L1).
|
||||
*/
|
||||
*/
|
||||
|
||||
import { GRID_STEP_N } from './space-geometry';
|
||||
import {
|
||||
innerContourForRoom, multiWallNodesForGeometry, wallBodiesGeometry,
|
||||
} from './wall-thickness';
|
||||
|
||||
export const MIN_JUNCTION_ANGLE_DEG = 15;
|
||||
export const MAX_JUNCTION_VALENCE = 6;
|
||||
@@ -41,6 +46,57 @@ export interface LimitSegment {
|
||||
cm?: number;
|
||||
}
|
||||
|
||||
/** Shared full/affected-room validation used by editor writes and lightweight resize previews. */
|
||||
export function junctionLimitViolations(
|
||||
config: any,
|
||||
spaceId: string,
|
||||
segments: readonly LimitSegment[],
|
||||
sharedGeometry?: any,
|
||||
roomIds?: ReadonlySet<string>,
|
||||
): JunctionLimitViolation[] {
|
||||
const space = (config?.spaces || []).find((item: any) => item?.id === spaceId);
|
||||
if (!space) return [];
|
||||
const cellCm = Number(space.cell_cm) > 0 ? Number(space.cell_cm) : 5;
|
||||
const violations = [
|
||||
...checkNodes(segments), ...checkSegmentLengths(segments, cellCm, GRID_STEP_N),
|
||||
...checkNodeDistances(segments, cellCm, GRID_STEP_N),
|
||||
];
|
||||
let nodes: ReturnType<typeof multiWallNodesForGeometry> | null =
|
||||
sharedGeometry?.multiWallNodes || null;
|
||||
if (!nodes) {
|
||||
try {
|
||||
nodes = multiWallNodesForGeometry(
|
||||
space.rooms || [], space.walls || [], [], GRID_STEP_N, cellCm, GRID_STEP_N, 1,
|
||||
);
|
||||
} catch { nodes = null; }
|
||||
}
|
||||
let roomGeometry: any = sharedGeometry?.status === 'ok'
|
||||
|| sharedGeometry?.status === 'degraded-extra' ? sharedGeometry.roomGeom : null;
|
||||
const lightweight = sharedGeometry === null || sharedGeometry?.status === 'lightweight';
|
||||
if (!roomGeometry && !lightweight && nodes?.nodes.length) {
|
||||
try {
|
||||
const geometry = wallBodiesGeometry(
|
||||
space.rooms || [], space.walls || [], [], [], GRID_STEP_N, cellCm, GRID_STEP_N, 1,
|
||||
);
|
||||
roomGeometry = geometry?.status === 'ok' || geometry?.status === 'degraded-extra'
|
||||
? geometry.roomGeom : null;
|
||||
} catch { roomGeometry = null; }
|
||||
}
|
||||
for (const room of space.rooms || []) {
|
||||
const roomId = String(room?.id || '');
|
||||
if (!roomId || (roomIds && !roomIds.has(roomId))) continue;
|
||||
let inner: number[][] | null = null;
|
||||
try {
|
||||
inner = innerContourForRoom(
|
||||
space.rooms || [], roomId, space.walls || [], [], GRID_STEP_N, cellCm, GRID_STEP_N, 1,
|
||||
lightweight ? null : roomGeometry ?? undefined, nodes,
|
||||
);
|
||||
} catch { inner = null; }
|
||||
violations.push(...checkRoomClearance(roomId, inner, cellCm, GRID_STEP_N));
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
const EPS = 1e-9;
|
||||
/**
|
||||
* #331 §2.1: below this two points are ONE node / a node is ON the wall —
|
||||
|
||||
+138
-14
@@ -1,10 +1,18 @@
|
||||
import { nothing, render, svg, type TemplateResult } from 'lit';
|
||||
import { cancelHouseplanPointerMove } from './pointer-move-queue';
|
||||
import { gridVisualUnits } from './grid-scale';
|
||||
import {
|
||||
wallBodyNeedsSolid, wallCmToUnits, wallEdgePathD,
|
||||
wallHatchNeedsSolid, wallHatchStepUnits,
|
||||
type WallEdgeBody,
|
||||
} from './wall-thickness';
|
||||
import { NORM_W } from './space-geometry';
|
||||
|
||||
interface LiveEditorState {
|
||||
raf: number;
|
||||
hidden: HTMLElement[];
|
||||
transparent: HTMLElement[];
|
||||
dimmed: HTMLElement[];
|
||||
}
|
||||
|
||||
interface LiveEditorHost {
|
||||
@@ -17,11 +25,11 @@ interface LiveEditorHost {
|
||||
_physicalDrag?: unknown;
|
||||
_physicalRotate?: unknown;
|
||||
_decorDraft?: unknown;
|
||||
_decorMove?: unknown;
|
||||
_dtDrag?: unknown;
|
||||
_decorMove?: { id: string } | null;
|
||||
_dtDrag?: { id: string } | null;
|
||||
_bdDrag?: unknown;
|
||||
_opDrag?: unknown;
|
||||
_resize?: { dragging: boolean };
|
||||
_resize?: { dragging: boolean; plan?: { roomIds: string[]; movingOpeningIds: string[] } | null };
|
||||
_layout: unknown;
|
||||
_dtBox: { id: string; x: number; y: number; w: number; h: number } | null;
|
||||
_dtSel: { id: string; kind: string } | null;
|
||||
@@ -33,8 +41,8 @@ interface LiveEditorHost {
|
||||
_spaceDisplayForRender: () => unknown;
|
||||
_renderWallBodies: (display: unknown) => unknown;
|
||||
_renderResizeMeasurements: () => unknown;
|
||||
_renderOpenings: (display: unknown) => unknown;
|
||||
_renderResizeLayer: (view: unknown) => unknown;
|
||||
_renderOpenings: (display: unknown, onlyIds?: readonly string[]) => unknown;
|
||||
_renderResizeLayer: (view: unknown, roomIds?: readonly string[]) => unknown;
|
||||
_renderMarkupLayer: (viewBox: number[]) => unknown;
|
||||
_renderHiddenWallDiagnosticOverlay: () => unknown;
|
||||
_renderOpeningPlacementPreview: () => unknown;
|
||||
@@ -43,12 +51,20 @@ interface LiveEditorHost {
|
||||
_renderActiveChainInk: () => unknown;
|
||||
_renderPlanSnapOverlay: () => unknown;
|
||||
_renderWallThickUi: () => unknown;
|
||||
_renderDecorLayer: () => unknown;
|
||||
_renderDecorLayer: (onlyId?: string | null) => unknown;
|
||||
_renderBackdropFrame: (view: unknown) => unknown;
|
||||
_renderTextFrame: (view: unknown) => unknown;
|
||||
_renderLiveEditorMeasurements: (view: unknown) => unknown;
|
||||
_livePos: (device: { id: string }) => { x: number; y: number };
|
||||
_scenePoint: (point: number[]) => number[];
|
||||
_renderProjection: string;
|
||||
_spaceModel: () => any;
|
||||
_spaceWalls: any[];
|
||||
_cellCm: number;
|
||||
_gridPitch: number;
|
||||
_openingsR: any[];
|
||||
_stageEl: HTMLElement | null;
|
||||
_fillColors: { wall_fill: { c: string; a: number } };
|
||||
}
|
||||
|
||||
const states = new WeakMap<object, LiveEditorState>();
|
||||
@@ -65,7 +81,7 @@ const gestureProperties = new Set<PropertyKey>([
|
||||
const stateOf = (host: object): LiveEditorState => {
|
||||
let state = states.get(host);
|
||||
if (!state) {
|
||||
state = { raf: 0, hidden: [], transparent: [] };
|
||||
state = { raf: 0, hidden: [], transparent: [], dimmed: [] };
|
||||
states.set(host, state);
|
||||
}
|
||||
return state;
|
||||
@@ -132,6 +148,76 @@ const restore = (state: LiveEditorState): void => {
|
||||
state.hidden.length = 0;
|
||||
for (const element of state.transparent) element.style.removeProperty('opacity');
|
||||
state.transparent.length = 0;
|
||||
for (const element of state.dimmed) element.style.removeProperty('opacity');
|
||||
state.dimmed.length = 0;
|
||||
};
|
||||
|
||||
/** Resize moves paint individual wall strips; pointerup still validates the canonical union. */
|
||||
const resizePreviewWalls = (
|
||||
host: LiveEditorHost, display: any, roomIds: readonly string[] = [],
|
||||
): TemplateResult => {
|
||||
if (host._renderProjection === 'iso') return svg``;
|
||||
const space = host._spaceModel();
|
||||
if (!space) return svg``;
|
||||
const affected = new Set(roomIds);
|
||||
const boundary = (space.rooms || []).filter((room: any) => affected.has(room.id))
|
||||
.flatMap((room: any) => (room.poly || []).map((point: number[], index: number) => [
|
||||
point, room.poly[(index + 1) % room.poly.length],
|
||||
]));
|
||||
const onSegment = (point: number[], segment: number[][]): boolean => {
|
||||
const [a, b] = segment;
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length2 = dx * dx + dy * dy;
|
||||
if (!(length2 > 0)) return false;
|
||||
const t = ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / length2;
|
||||
const clamped = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(point[0] - a[0] - dx * clamped, point[1] - a[1] - dy * clamped) < 1e-4;
|
||||
};
|
||||
const edge = (a: number[], b: number[], cm: number, key: string): WallEdgeBody | null => {
|
||||
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
if (!(length > 1e-9) || !(cm > 0)) return null;
|
||||
const half = wallCmToUnits(cm, host._cellCm, host._gridPitch) / 2;
|
||||
const nx = -(b[1] - a[1]) / length * half;
|
||||
const ny = (b[0] - a[0]) / length * half;
|
||||
return {
|
||||
key, kind: 'outer', cm, a, b, depthUnits: half * 2,
|
||||
quad: [[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny],
|
||||
[b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny]],
|
||||
};
|
||||
};
|
||||
const edges = [
|
||||
...host._spaceWalls.filter((wall) => {
|
||||
const a = (wall.a || []).map((value: number) => value * NORM_W);
|
||||
const b = (wall.b || []).map((value: number) => value * NORM_W);
|
||||
return boundary.some((segment: number[][]) => onSegment(a, segment) && onSegment(b, segment));
|
||||
}).map((wall, index) => edge(
|
||||
(wall.a || []).map((value: number) => value * NORM_W),
|
||||
(wall.b || []).map((value: number) => value * NORM_W),
|
||||
wall.cm, wall.key || `wall-${index}`,
|
||||
)),
|
||||
].filter((item): item is WallEdgeBody => !!item);
|
||||
const openings = host._openingsR.map((opening) => ({
|
||||
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
|
||||
}));
|
||||
const paths = edges.map((edge) => wallEdgePathD(edge, openings)).filter(Boolean);
|
||||
if (!paths.length) return svg``;
|
||||
const depth = Math.max(...edges.map((edge) => edge.depthUnits), 0);
|
||||
const view = host._viewOr(host._baseVb());
|
||||
const px = host._stageEl?.clientWidth && view.w ? host._stageEl.clientWidth / view.w : 1;
|
||||
const solid = wallBodyNeedsSolid(depth, px)
|
||||
|| wallHatchNeedsSolid(wallHatchStepUnits(host._cellCm), px);
|
||||
const stroke = display?.color || '#607d8b';
|
||||
const fill = host._fillColors.wall_fill;
|
||||
const d = paths.join(' ');
|
||||
return svg`<g class="wallbodies hp-resize-preview-walls"
|
||||
style="--room-stroke:${stroke};--wall-fill:${fill.c};--wall-fill-op:${fill.a}">
|
||||
<path class="wallbody-fill" data-component="preview" d=${d}
|
||||
fill=${fill.c} fill-opacity=${fill.a} fill-rule="nonzero" stroke="none"></path>
|
||||
<path class="wallbody ${solid ? 'solid' : ''}" data-hp="wall"
|
||||
data-id="preview" data-kind="preview" d=${d}
|
||||
fill=${solid ? 'none' : 'url(#hp-wall-hatch)'} fill-rule="nonzero"
|
||||
stroke=${stroke} stroke-width=${gridVisualUnits(0.6, host._cellCm)}></path>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
const planTemplate = (host: LiveEditorHost): TemplateResult => {
|
||||
@@ -140,11 +226,12 @@ const planTemplate = (host: LiveEditorHost): TemplateResult => {
|
||||
const measure = host._opMeasureView;
|
||||
if (host._resize?.dragging) {
|
||||
const display = host._spaceDisplayForRender();
|
||||
const plan = host._resize.plan;
|
||||
return svg`<g class="hp-live-resize" aria-hidden="true" pointer-events="none">
|
||||
${host._renderWallBodies(display)}
|
||||
${resizePreviewWalls(host, display, plan?.roomIds)}
|
||||
${host._renderResizeMeasurements()}
|
||||
${host._renderOpenings(display)}
|
||||
${host._renderResizeLayer(view)}
|
||||
${host._renderOpenings(display, plan?.movingOpeningIds)}
|
||||
${host._renderResizeLayer(view, plan?.roomIds)}
|
||||
</g>`;
|
||||
}
|
||||
const display = host._spaceDisplayForRender();
|
||||
@@ -166,8 +253,9 @@ const editorTemplate = (host: LiveEditorHost): TemplateResult | typeof nothing =
|
||||
if (host._mode === 'plan') return planTemplate(host);
|
||||
if (host._mode === 'decor') {
|
||||
const view = host._viewOr(host._baseVb());
|
||||
const activeId = host._dtDrag?.id || host._decorMove?.id || null;
|
||||
return svg`<g class="hp-live-decor" aria-hidden="true" pointer-events="none">
|
||||
${host._renderDecorLayer()}
|
||||
${host._renderDecorLayer(activeId)}
|
||||
${host._renderBackdropFrame(view)}
|
||||
${host._renderTextFrame(view)}
|
||||
</g>`;
|
||||
@@ -189,6 +277,37 @@ const paintDevice = (host: LiveEditorHost, root: ParentNode): void => {
|
||||
element.style.top = `${((point[1] - view.y) / view.h) * 100}%`;
|
||||
};
|
||||
|
||||
const makeDecorShapeTransparent = (
|
||||
state: LiveEditorState, root: ParentNode, id: string,
|
||||
): void => {
|
||||
for (const element of root.querySelectorAll<HTMLElement>('.decorlayer [data-hp="decor"]')) {
|
||||
if (element.closest('[data-hp-live-editor]') || element.dataset.id !== id) continue;
|
||||
if (element.style.opacity === '0') continue;
|
||||
element.style.opacity = '0';
|
||||
state.transparent.push(element);
|
||||
}
|
||||
};
|
||||
|
||||
const makeMovingOpeningsTransparent = (
|
||||
state: LiveEditorState, root: ParentNode, ids: readonly string[],
|
||||
): void => {
|
||||
const moving = new Set(ids);
|
||||
for (const element of root.querySelectorAll<HTMLElement>('.openinglayer [data-hp="opening"]')) {
|
||||
if (!element.closest('[data-hp-live-editor]') && moving.has(element.dataset.id || '')) {
|
||||
element.style.opacity = '0';
|
||||
state.transparent.push(element);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dim = (state: LiveEditorState, root: ParentNode, selector: string): void => {
|
||||
for (const element of root.querySelectorAll<HTMLElement>(selector)) {
|
||||
if (element.closest('[data-hp-live-editor]')) continue;
|
||||
element.style.opacity = '0.35';
|
||||
state.dimmed.push(element);
|
||||
}
|
||||
};
|
||||
|
||||
/** One editor-only render root per card; the settled scene remains untouched. */
|
||||
export function paintHouseplanEditor(value: object): void {
|
||||
const host = value as LiveEditorHost;
|
||||
@@ -201,11 +320,16 @@ export function paintHouseplanEditor(value: object): void {
|
||||
if (!target) return;
|
||||
if (host._mode === 'plan') {
|
||||
makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)');
|
||||
if (host._resize?.dragging || host._opDrag) hide(state, root, '.wallbodies');
|
||||
if (host._resize?.dragging) makeTransparent(state, root, '.resize-layer');
|
||||
if (host._opDrag) hide(state, root, '.wallbodies');
|
||||
if (host._resize?.dragging) {
|
||||
dim(state, root, '.wallbodies');
|
||||
makeTransparent(state, root, '.resize-layer');
|
||||
makeMovingOpeningsTransparent(state, root, host._resize.plan?.movingOpeningIds || []);
|
||||
}
|
||||
if (host._opDrag) makeTransparent(state, root, '.openinglayer');
|
||||
} else if (host._mode === 'decor') {
|
||||
makeTransparent(state, root, '.decorlayer');
|
||||
const activeId = host._dtDrag?.id || host._decorMove?.id;
|
||||
if (activeId) makeDecorShapeTransparent(state, root, activeId);
|
||||
makeTransparent(state, root, '.dtframe, .backdropframe');
|
||||
}
|
||||
render(editorTemplate(host), target);
|
||||
|
||||
@@ -40,7 +40,7 @@ export type ResizeFinishOutcome<TPreview, TBefore> =
|
||||
| { kind: 'no-op' };
|
||||
|
||||
export type ResizeCancelOutcome<TWallUnion> =
|
||||
| { kind: 'cancelled'; restoreWallUnion: TWallUnion | null }
|
||||
| { kind: 'cancelled'; restoreWallUnion: TWallUnion | null; restoreEpoch: number | null }
|
||||
| { kind: 'no-op' };
|
||||
|
||||
export interface ResizeBeginInput<TBefore, TWallUnion> {
|
||||
@@ -54,6 +54,7 @@ export interface ResizeBeginInput<TBefore, TWallUnion> {
|
||||
snapshotIdentity: string;
|
||||
before: TBefore;
|
||||
wallUnionBefore: TWallUnion | null;
|
||||
epochBefore: number;
|
||||
}
|
||||
|
||||
interface AcceptedPreview<TPreview, TLabels, TArtifact> {
|
||||
@@ -79,6 +80,7 @@ interface ResizeSession<TPreview, TLabels, TBefore, TWallUnion, TArtifact> {
|
||||
changedRoomIds: string[];
|
||||
rejectionNotified: boolean;
|
||||
wallUnionBefore: TWallUnion | null;
|
||||
epochBefore: number;
|
||||
accepted: AcceptedPreview<TPreview, TLabels, TArtifact> | null;
|
||||
}
|
||||
|
||||
@@ -149,6 +151,7 @@ export class ResizeController<TPreview, TLabels, TBefore, TWallUnion, TArtifact>
|
||||
changedRoomIds: [...input.plan.roomIds],
|
||||
rejectionNotified: false,
|
||||
wallUnionBefore: input.wallUnionBefore,
|
||||
epochBefore: input.epochBefore,
|
||||
accepted: null,
|
||||
};
|
||||
return true;
|
||||
@@ -273,10 +276,11 @@ export class ResizeController<TPreview, TLabels, TBefore, TWallUnion, TArtifact>
|
||||
}
|
||||
this._session = null;
|
||||
this._eligibility = null;
|
||||
const snapshotMatches = currentSnapshotIdentity === session.snapshotIdentity;
|
||||
return {
|
||||
kind: 'cancelled',
|
||||
restoreWallUnion: currentSnapshotIdentity === session.snapshotIdentity
|
||||
? session.wallUnionBefore : null,
|
||||
restoreWallUnion: snapshotMatches ? session.wallUnionBefore : null,
|
||||
restoreEpoch: snapshotMatches ? session.epochBefore : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user