import { nothing, render, svg, type TemplateResult } from 'lit'; import { cancelHouseplanPointerMove } from './pointer-move-queue'; import { gridVisualUnits } from './grid-scale'; import type { RenderOpening } from './interaction-types'; import type { SpaceDisplay } from './logic'; import type { SpaceModel, WallEntry } from './types'; import { wallBodyNeedsSolid, wallCmToUnits, wallEdgePathD, wallHatchNeedsSolid, wallHatchStepUnits, type WallEdgeBody, } from './wall-thickness'; import { NORM_W } from './space-geometry'; interface LiveEditorState { raf: number; requestedRevision: number; paintedRevision: number; waiters: { revision: number; resolve: () => void }[]; hidden: HTMLElement[]; transparent: HTMLElement[]; dimmed: HTMLElement[]; } interface LiveEditorHost { isConnected: boolean; renderRoot: ParentNode; requestUpdate: () => void; _mode: 'view' | 'plan' | 'devices' | 'decor'; _tool: string; _deviceDrag: { id: string } | null; _physicalDrag?: unknown; _physicalRotate?: unknown; _decorDraft?: unknown; _decorMove?: { id: string } | null; _dtDrag?: { id: string } | null; _bdDrag?: unknown; _opDrag?: unknown; _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; _opMeasureView: { guide: unknown } | null; _devices: { id: string }[]; _liveEditorPaintCount: number; _baseVb: () => number[]; _viewOr: (viewBox: number[]) => { x: number; y: number; w: number; h: number }; _spaceDisplayForRender: () => SpaceDisplay; _renderWallBodies: (display: unknown) => unknown; _renderResizeMeasurements: () => unknown; _renderOpenings: (display: unknown, onlyIds?: readonly string[]) => unknown; _renderResizeLayer: (view: unknown, roomIds?: readonly string[]) => unknown; _renderMarkupLayer: (viewBox: number[]) => unknown; _renderHiddenWallDiagnosticOverlay: () => unknown; _renderOpeningPlacementPreview: () => unknown; _renderOpeningDimensionGuides: (measure: unknown) => unknown; _renderOpeningCenterTick: (guide: unknown) => unknown; _renderActiveChainInk: () => unknown; _renderPlanSnapOverlay: () => unknown; _renderWallThickUi: () => unknown; _renderDecorLayer: (onlyId?: string | null) => unknown; _renderBackdropFrame: (view: unknown) => unknown; _renderTextFrame: (view: unknown) => unknown; _renderLiveEditorMeasurements: (view: unknown) => unknown; /** #521: guides for the gesture in flight; `nothing` while the runtime loads. */ _renderAlignGuides: () => unknown; _livePos: (device: { id: string }) => { x: number; y: number }; _scenePoint: (point: number[]) => number[]; _renderProjection: string; _spaceModel: () => SpaceModel | null; _spaceWalls: WallEntry[]; _cellCm: number; _gridPitch: number; _openingsR: RenderOpening[]; _stageEl: HTMLElement | null; _fillColors: { wall_fill: { c: string; a: number } }; } const states = new WeakMap(); const liveProperties = new Set([ '_cursorPt', '_opMeasure', '_physicalDrag', '_physicalRotate', '_decorDraft', '_dtBox', '_dtDrag', '_bdDrag', '_furnPreviewInput', '_layout', ]); const hoverProperties = new Set(['_cursorPt', '_opMeasure', '_furnPreviewInput']); const gestureProperties = new Set([ '_physicalDrag', '_physicalRotate', '_decorDraft', '_dtDrag', '_bdDrag', ]); const stateOf = (host: object): LiveEditorState => { let state = states.get(host); if (!state) { state = { raf: 0, requestedRevision: 0, paintedRevision: 0, waiters: [], hidden: [], transparent: [], dimmed: [], }; states.set(host, state); } return state; }; const finishRevision = (state: LiveEditorState, revision: number): void => { state.paintedRevision = Math.max(state.paintedRevision, revision); const ready = state.waiters.filter((waiter) => waiter.revision <= state.paintedRevision); state.waiters = state.waiters.filter((waiter) => waiter.revision > state.paintedRevision); for (const waiter of ready) waiter.resolve(); }; const activeEditorGesture = (host: LiveEditorHost): boolean => host._mode !== 'view' && ( !!host._deviceDrag || !!host._physicalDrag || !!host._physicalRotate || !!host._decorDraft || !!host._decorMove || !!host._dtDrag || !!host._bdDrag || !!host._opDrag || !!host._resize?.dragging ); /** * ReactiveElement calls `requestUpdate(name, oldValue)` from generated state * setters. Only pointer-owned values are diverted; selection, dialogs, toasts * and terminal null assignments still receive a normal host update. */ export function routeHouseplanEditorUpdate( value: object, name?: PropertyKey, oldValue?: unknown, ): boolean { const host = value as LiveEditorHost; if (!host?.isConnected || host._mode === 'view') return false; const namedLive = name !== undefined && liveProperties.has(name); if (name === '_layout' && !host._deviceDrag) return false; if (name === '_dtBox' && !host._dtDrag) return false; if (name !== undefined && gestureProperties.has(name) && oldValue == null && (host as unknown as Record)[name] != null && activeEditorGesture(host)) { // Pointerdown may change selection/chrome once. Subsequent moves stay live. return false; } if (name !== undefined && gestureProperties.has(name) && host[name] == null) return false; if (name !== undefined && hoverProperties.has(name) && (host as unknown as Record)[name] == null) return false; const route = (namedLive && (hoverProperties.has(name!) || activeEditorGesture(host))) || (name === undefined && activeEditorGesture(host)); if (!route) return false; scheduleHouseplanEditor(host); return true; } const hide = (state: LiveEditorState, root: ParentNode, selector: string): void => { for (const element of root.querySelectorAll(selector)) { if (element.closest('[data-hp-live-editor]')) continue; if (element.style.visibility === 'hidden') continue; element.style.visibility = 'hidden'; state.hidden.push(element); } }; /** Keep the captured source hittable while only its live copy stays visible. */ const makeTransparent = (state: LiveEditorState, root: ParentNode, selector: string): void => { for (const element of root.querySelectorAll(selector)) { if (element.closest('[data-hp-live-editor]')) continue; if (element.style.opacity === '0') continue; element.style.opacity = '0'; state.transparent.push(element); } }; const restore = (state: LiveEditorState): void => { for (const element of state.hidden) element.style.removeProperty('visibility'); 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: SpaceDisplay, 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) => !!room.id && affected.has(room.id)) .flatMap((room) => { const poly = room.poly || []; return poly.map((point: number[], index: number) => [ point, poly[(index + 1) % 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` `; }; const planTemplate = (host: LiveEditorHost): TemplateResult => { const vb = host._baseVb(); const view = host._viewOr(vb); const measure = host._opMeasureView; if (host._resize?.dragging) { const display = host._spaceDisplayForRender(); const plan = host._resize.plan; return svg``; } const display = host._spaceDisplayForRender(); return svg``; }; 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``; } // #521: the device editor used to paint nothing here — `paintDevice` moved // the marker element and the settled scene, which owns the guides, never ran // again during the drag. The guides are the only thing this mode needs from // a template: everything else about a dragged marker is an element style. // // Ownership alternates: every settled render ends in `updated()` → // `_commitLiveEditor()`, which empties this root and hands the layer back to // the settled scene, and the next live paint takes it again. Both sides draw // the same `_alignPoint`, live since this issue; the settled copy left over // from a mid-gesture render is hidden by `paintHouseplanEditor`. return svg``; }; const paintDevice = (host: LiveEditorHost, root: ParentNode): void => { const drag = host._deviceDrag; if (!drag) return; const device = host._devices.find((candidate) => candidate.id === drag.id); const element = [...root.querySelectorAll('[data-hp="device"]')] .find((candidate) => candidate.dataset.id === drag.id); if (!device || !element) return; const position = host._livePos(device); const point = host._scenePoint([position.x, position.y]); const view = host._viewOr(host._baseVb()); element.style.left = `${((point[0] - view.x) / view.w) * 100}%`; 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('.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('.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(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; const root = host.renderRoot; if (!root) return; const state = stateOf(host); restore(state); paintDevice(host, root); const target = root.querySelector('[data-hp-live-editor]'); if (!target) return; // #521: the settled copy of the guides layer goes transparent for the whole // gesture, in every editor mode — plan mode already did it for its markup // layer. Ownership alternates rather than doubling *while nothing else // renders*: `updated()` → `_commitLiveEditor()` empties the live root on // every settled render. But a settled render that lands mid-gesture leaves // its own guides behind, and the next live paint then adds a second copy — // measured: two `.alignline`, the settled one a step behind the marker. makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)'); if (host._mode === 'plan') { 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') { const activeId = host._dtDrag?.id || host._decorMove?.id; if (activeId) makeDecorShapeTransparent(state, root, activeId); makeTransparent(state, root, '.dtframe, .backdropframe'); } render(editorTemplate(host), target); const htmlTarget = root.querySelector('[data-hp-live-editor-html]'); if (htmlTarget) { const view = host._viewOr(host._baseVb()); render(host._renderLiveEditorMeasurements(view), htmlTarget); } host._liveEditorPaintCount = (host._liveEditorPaintCount || 0) + 1; } export function scheduleHouseplanEditor(value: object): void { const state = stateOf(value); state.requestedRevision++; if (state.raf || typeof requestAnimationFrame !== 'function') return; state.raf = requestAnimationFrame(() => { state.raf = 0; const revision = state.requestedRevision; paintHouseplanEditor(value); finishRevision(state, revision); }); } /** Await the latest live-editor frame which was scheduled before this call. */ export function whenHouseplanEditorSettled(value: object): Promise { const state = states.get(value); if (!state || !state.raf || state.paintedRevision >= state.requestedRevision) return Promise.resolve(); const revision = state.requestedRevision; return new Promise((resolve) => state.waiters.push({ revision, resolve })); } /** Only text needs a DOM measurement; every box-like item owns its config box. */ export function measureHouseplanDecorText(value: object): void { const host = value as LiveEditorHost; const shape = host._dtSel; if (!shape) { if (host._dtBox) { host._dtBox = null; host.requestUpdate(); } return; } if (shape.kind !== 'text') return; const element = host.renderRoot.querySelector( `text.dtext[data-id="${shape.id}"]`, ) as SVGGraphicsElement | null; if (!element || typeof element.getBBox !== 'function') return; let measured: DOMRect; try { measured = element.getBBox(); } catch { return; } if (!measured || (!measured.width && !measured.height)) return; const box = { id: shape.id, x: measured.x, y: measured.y, w: measured.width, h: measured.height, }; const current = host._dtBox; if (current?.id === box.id && Math.abs(current.x - box.x) < 0.01 && Math.abs(current.y - box.y) < 0.01 && Math.abs(current.w - box.w) < 0.01 && Math.abs(current.h - box.h) < 0.01) return; host._dtBox = box; host.requestUpdate(); } export function commitHouseplanEditor(value: object): void { const host = value as LiveEditorHost; const state = stateOf(value); if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf); state.raf = 0; restore(state); const target = (host.renderRoot as ParentNode | undefined) ?.querySelector('[data-hp-live-editor]'); if (target) render(nothing, target); const htmlTarget = (host.renderRoot as ParentNode | undefined) ?.querySelector('[data-hp-live-editor-html]'); if (htmlTarget) { const view = host._viewOr(host._baseVb()); render(host._renderLiveEditorMeasurements(view), htmlTarget); } finishRevision(state, state.requestedRevision); } export function disposeHouseplanEditor(host: object): void { cancelHouseplanPointerMove(host); const state = states.get(host); if (!state) return; if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf); restore(state); finishRevision(state, state.requestedRevision); states.delete(host); }