fix: address render performance review

Issue: #451
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-04 23:45:57 +03:00
parent f8b3445dc3
commit cb68492c99
41 changed files with 6985 additions and 6600 deletions
+16 -11
View File
@@ -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``;
+73 -73
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+7 -3
View File
@@ -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,
};
}