diff --git a/docs/ISOMETRIC.md b/docs/ISOMETRIC.md index a682ceee..fbf63c0c 100644 --- a/docs/ISOMETRIC.md +++ b/docs/ISOMETRIC.md @@ -244,7 +244,10 @@ may meet wall bodies and each other, and the overlapping pairs are the Flat ones exactly where Flat puts them, without a position correction. A device never moves because its Home Assistant state changed (#711 holds trivially: nothing is laid out). Neither zoom nor a stage resize is a layout event: a placement -depends only on the anchor, its owner room, the footprint and the rise. +depends only on the anchor, its owner room, the footprint and the rise. The +placement and render-scene caches are keyed by the wall geometry of the +structural scene (#724): a wall, room or opening edit starts them afresh, and +the fit envelope and the live frame read one snapshot. History: #651 used to search a place for every marker (rigid same-room clusters, a group collision resolver and a nudge of up to 48 CSS px towards the diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 600ef709..97e4d043 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -11085,21 +11085,22 @@ const MUTANT_DEFINITIONS = [ { id: 'iso-placement-cache-survives-silhouette-change', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' - + '&& node --test --test-name-pattern="#473 W2" test/iso-scene-render.test.mjs', - because: 'placements are cached per wall-silhouette array identity; keying the cache by a ' - + 'constant serves «near the wall» for a plan whose wall is gone (#473)', + + '&& node --test --test-name-pattern="#473 W2|#724 AC2" test/iso-scene-render.test.mjs', + because: 'placements are cached per structural wall geometry (#724; the wall-silhouette ' + + 'array the id names was built only to be this key); keying the cache by a constant ' + + 'serves a placement, and with it the owner room, of a plan whose walls are gone (#473)', patches: [{ file: 'src/iso-scene-render.ts', - find: ' let placements = isoOverlayPlacementCache.get(input.wallSilhouettes);', + find: ' let placements = isoOverlayPlacementCache.get(input.structure);', replace: ' let placements = isoOverlayPlacementCache.get(ISO_PLACEMENT_CACHE_ANY);', }, { file: 'src/iso-scene-render.ts', - find: ' isoOverlayPlacementCache.set(input.wallSilhouettes, placements);', + find: ' isoOverlayPlacementCache.set(input.structure, placements);', replace: ' isoOverlayPlacementCache.set(ISO_PLACEMENT_CACHE_ANY, placements);', }, { file: 'src/iso-scene-render.ts', find: 'export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;', - replace: 'export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;\nconst ISO_PLACEMENT_CACHE_ANY: readonly IsoWallSilhouette[] = [];', + replace: 'export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;\nconst ISO_PLACEMENT_CACHE_ANY = {} as IsoWallGeometry;', }], }, // #474: designer furniture artwork is a lazy chunk. Each protective contract @@ -11395,12 +11396,13 @@ const MUTANT_DEFINITIONS = [ { id: 'stage3-w5-runtime-nudge-writes-storage', guard: 'node demo/smoke_isometric_live_touch.mjs', - because: 'W5: runtime nudge is presentation-only and must never write config, layout or ' + because: 'W5: the 2.5D overlay scene (the wall-top rise of tiles and lock badges; nothing ' + + 'is nudged since #713) is presentation-only and must never write config, layout or ' + 'browser storage (#160)', patches: [{ file: 'src/houseplan-card.ts', find: ' return runtime.buildIsoOverlayRenderScene({', - replace: " localStorage.setItem('houseplan_stage3_nudge_mutant', space.id);\n" + replace: " localStorage.setItem('houseplan_stage3_overlay_mutant', space.id);\n" + ' return runtime.buildIsoOverlayRenderScene({', }], }, @@ -11415,8 +11417,8 @@ const MUTANT_DEFINITIONS = [ replace: ' if (!runtime || !layers || (!structural && disp.showBorders)) return null;', }, { file: 'src/houseplan-card.ts', - find: ' wallSilhouettes: structural.wallSilhouettes,', - replace: ' wallSilhouettes: structural?.wallSilhouettes ?? [],', + find: ' structure: structural.geometry,', + replace: " structure: structural?.geometry ?? { topPath: '', topFaces: [], sides: [], contactPath: '', edgeCount: 0 },", }], }, { diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index de26ee54..b7390e7b 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -6012,7 +6012,7 @@ export class HouseplanCard extends LitElement { const overlays = this._isoOverlayScene( space, this._renderDevices.filter((device) => device.space === space.id && !device.hidden), disp, runtime.resolveIsoDecorationLayers(disp), scene, iconPct, effectiveDeviceBaseSize(iconPct), - disp.showLqi ?? this._config?.show_signal ?? true, false); + disp.showLqi ?? this._config?.show_signal ?? true); const envelope = overlays && runtime.resolveIsoOverlayFitEnvelope({ baseBounds: scene.frame, entries: overlays.entries, stageSize, targetView: (bounds) => fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect) }); @@ -6067,14 +6067,12 @@ export class HouseplanCard extends LitElement { iconPct: number, deviceBasePct: number, showLqi: boolean, - resolveCollisions = true, ): IsoOverlayRenderScene | null { const runtime = this._isoSceneRuntime; if (!runtime || !layers?.structural || !structural) return null; return runtime.buildIsoOverlayRenderScene({ space, devices: devs, openings: this._openingsR, display: disp, - wallSilhouettes: structural.wallSilhouettes, - resolveCollisions, + structure: structural.geometry, iconPct, deviceBasePct, showLqi, cellCm: this._cellCm, kioskIconScale: this._mode === 'view' ? this._kioskScale.icon : 1, kioskFontScale: this._mode === 'view' ? this._kioskScale.font : 1, diff --git a/src/iso-overlays.ts b/src/iso-overlays.ts index c85a6221..d94c5d09 100644 --- a/src/iso-overlays.ts +++ b/src/iso-overlays.ts @@ -19,15 +19,8 @@ export interface IsoOverlayRoom { holes?: readonly (readonly PlanPoint[])[]; } -/** A projected, canonical physical-wall surface. No union is done per marker. */ -export interface IsoWallSilhouette { - outer: readonly ScenePoint[]; - holes?: readonly (readonly ScenePoint[])[]; -} - export interface IsoOverlayOwner { id: string; - area: number; } export interface IsoOverlayOwnerInput { @@ -45,36 +38,22 @@ export interface IsoOverlayPlacementInput extends IsoOverlayOwnerInput { /** Half-size of the invisible floor-parallel footprint in plan units. */ footprintHalfSize: PlanPoint; visualOffset?: number; - filtersSupported?: boolean; - hovered?: boolean; - focused?: boolean; - selected?: boolean; camera?: IsoCamera; /** Internal scene-builder fast path; arbitrary callers still resolve safely. */ ownerAlreadyResolved?: boolean; resolvedOwner?: IsoOverlayOwner | null; } -export interface IsoOverlayTetherGeometry { - from: ScenePoint; - to: ScenePoint; - visible: boolean; - length: number; - angleDeg: number; -} - export interface IsoOverlayPlacement { plane: IsoOverlayPlane; owner: IsoOverlayOwner | null; /** This is always the input logical point; the raised visual never mutates it. */ floorAnchor: PlanPoint; floorScene: ScenePoint; - raisedScene: ScenePoint; + /** The screen-facing root: the floor anchor lifted by `visualOffset` (#713). */ visualScene: ScenePoint; /** Invisible fit footprint; never render it as a surface. */ footprint: readonly ScenePoint[]; - grounding: { center: ScenePoint; visible: boolean }; - tether: IsoOverlayTetherGeometry; } const EPS = 1e-9; @@ -167,7 +146,7 @@ export function resolveIsoOverlayOwner(input: IsoOverlayOwnerInput): IsoOverlayO // a saved label may legitimately lie outside that room. room = preferred; } - return room ? { id: room.id, area: roomArea(room) } : null; + return room ? { id: room.id } : null; } export function isoOverlayPlane(kind: IsoOverlayKind, showBorders: boolean): IsoOverlayPlane { @@ -196,13 +175,6 @@ export function buildIsoFootprintPolygon( }); } -function tetherGeometry( - from: ScenePoint, to: ScenePoint, visible: boolean, -): IsoOverlayTetherGeometry { - const dx = to[0] - from[0], dy = to[1] - from[1]; - return { from, to, visible, length: Math.hypot(dx, dy), angleDeg: Math.atan2(dy, dx) * 180 / Math.PI }; -} - /** * Resolve one overlay without mutating its saved coordinate. Since #713 a * raised root is its floor anchor shifted straight up by `visualOffset`; #714 @@ -220,30 +192,20 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso const floorScene = projectPlanPoint(floorAnchor, 0, camera); const plane = isoOverlayPlane(input.kind, input.showBorders); if (plane === 'floor') { - return { - plane, owner: null, floorAnchor, floorScene, raisedScene: floorScene, - visualScene: floorScene, footprint: [], - grounding: { center: floorScene, visible: false }, - tether: tetherGeometry(floorScene, floorScene, false), - }; + return { plane, owner: null, floorAnchor, floorScene, visualScene: floorScene, footprint: [] }; } // The canonical floor anchor and footprint stay; the screen-facing content // stands `visualOffset` above the floor (#713: the wall top for tiles and // lock badges, 0 for room names). - const raisedScene = projectPlanPoint(floorAnchor, visualOffset, camera); + const visualScene = projectPlanPoint(floorAnchor, visualOffset, camera); const owner = input.ownerAlreadyResolved ? input.resolvedOwner ?? null : resolveIsoOverlayOwner(input); const footprint = buildIsoFootprintPolygon(floorAnchor, input.footprintHalfSize, visualOffset, camera); // Ownership remains encoded by the immutable anchor and invisible bounded - // footprint. Stage 4 deliberately removes the visible ground dot and long - // tether which made the architectural view look like a debug overlay. - const tetherVisible = false; - return { - plane, owner, floorAnchor, floorScene, raisedScene, visualScene: raisedScene, footprint, - grounding: { center: floorScene, visible: false }, - tether: tetherGeometry(floorScene, raisedScene, tetherVisible), - }; + // footprint. Stage 4 removed the visible ground dot and long tether which made + // the architectural view look like a debug overlay; #724 removed their data. + return { plane, owner, floorAnchor, floorScene, visualScene, footprint }; } diff --git a/src/iso-scene-render.ts b/src/iso-scene-render.ts index e53a87db..f39a368a 100644 --- a/src/iso-scene-render.ts +++ b/src/iso-scene-render.ts @@ -13,7 +13,7 @@ import { clampScale, islandsOf, roomPoly, type SpaceDisplay } from './logic'; import { ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_RAISED_OVERLAY_HEIGHT, ISO_WALL_HEIGHT, - projectPlanPoint, projectedFrame, type PlanPoint, type ScenePoint, + projectedFrame, type PlanPoint, type ScenePoint, } from './iso-projection'; import { buildIsoFloorGeometry, buildIsoWallGeometry, isoGeometryFingerprint, @@ -28,7 +28,6 @@ import { import { resolveIsoOverlayOwner, resolveIsoOverlayPlacement, type IsoOverlayPlacement, type IsoOverlayRoom, type IsoRaisedOverlayKind, - type IsoWallSilhouette, } from './iso-overlays'; import { floorFootprintGeometry, openingInnerFaceOffsetFromIndex, @@ -376,7 +375,6 @@ export function isoRaisedOverlayHalfSize(input: IsoRaisedFootprintInput): PlanPo export type IsoSceneCacheEntry = { geometry: IsoWallGeometry; floor: IsoFloorGeometry; - wallSilhouettes: readonly IsoWallSilhouette[]; openings: readonly IsoOpeningBasis[]; openingSurfaces: readonly IsoOpeningRenderSurface[]; }; @@ -568,27 +566,6 @@ export function createIsoStructuralSource( }; } -const unknownArray = (value: unknown): readonly unknown[] => Array.isArray(value) ? value : []; - -/** - * Project a cached physical wall union once. Since #714 no overlay is tested - * against it; the frozen array is the structural identity the overlay caches key on. - */ -export function isoWallSilhouettesOf(geometry: unknown, height: number): IsoWallSilhouette[] { - const projectRing = (raw: unknown): ScenePoint[] => unknownArray(raw).flatMap((point) => { - if (!Array.isArray(point) || !Number.isFinite(point[0]) || !Number.isFinite(point[1])) return []; - return [projectPlanPoint([Number(point[0]), Number(point[1])], height)]; - }); - return unknownArray(geometry).flatMap((rawPolygon) => { - const polygon = unknownArray(rawPolygon); - if (!polygon.length) return []; - const outer = projectRing(polygon[0]); - if (outer.length < 3) return []; - const holes = polygon.slice(1).map(projectRing).filter((ring) => ring.length >= 3); - return [{ outer, holes } satisfies IsoWallSilhouette]; - }); -} - export interface ResolveIsoSceneInput { source: IsoStructuralSource; cache: Map; @@ -604,15 +581,9 @@ export function resolveIsoScene(input: ResolveIsoSceneInput): IsoRenderScene { let value = cached.hit ? cached.value : undefined; if (!value) { const structural = input.source.build(); - const geometry = buildIsoWallGeometry(structural.walls, ISO_CAMERA, wallHeight); - const wallTops = isoWallSilhouettesOf(structural.walls, wallHeight); value = { - geometry, + geometry: buildIsoWallGeometry(structural.walls, ISO_CAMERA, wallHeight), floor: buildIsoFloorGeometry(structural.floor, floorEdgeHeight), - wallSilhouettes: Object.freeze([ - ...wallTops, - ...geometry.sides.map((face) => ({ outer: face.points })), - ]), openings: structural.openings, openingSurfaces: structural.openingSurfaces, }; @@ -706,13 +677,14 @@ export interface IsoOverlaySceneInput { devices: readonly DevItem[]; openings: readonly RenderOpening[]; display: SpaceDisplay; - /** Identity of the structural scene; the placement caches are keyed by it. */ - wallSilhouettes: readonly IsoWallSilhouette[]; /** - * `false` for fit-envelope probes, which keep their own cache slot. Since - * #713 there is no collision search, so both slots hold equal placements. + * The wall geometry of the structural scene the overlays stand in (#724). The + * overlay caches are keyed by its identity: the structural LRU hands out the + * same object while only zoom, stage size or HA state change, and a new one + * after any wall, room or opening edit — so an edit never serves a placement, + * and with it an owner room, of a plan that is gone. */ - resolveCollisions?: boolean; + structure: IsoWallGeometry; iconPct: number; deviceBasePct: number; showLqi: boolean; @@ -733,14 +705,13 @@ type IsoOverlayPlacementCacheEntry = { signature: string; placement: IsoOverlayP type IsoOverlayOwnerCacheEntry = { signature: string; owner: IsoOverlayPlacement['owner'] }; export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048; const isoOverlayPlacementCache = new WeakMap< - readonly IsoWallSilhouette[], Map + IsoWallGeometry, Map >(); const isoOverlayOwnerCache = new WeakMap< readonly IsoOverlayRoomRow[], Map >(); -const isoOverlayRenderSceneCache = new WeakMap< - readonly IsoWallSilhouette[], Map<'fit' | 'live', IsoOverlayRenderScene> ->(); +/** One snapshot per structure: fit and the live frame share it (#724). */ +const isoOverlayRenderSceneCache = new WeakMap(); function samePlacementMap( previous: ReadonlyMap, next: ReadonlyMap, @@ -795,10 +766,10 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver const roomPlacements = new Map(); const locks = new Map(); const entries: IsoOverlayRenderEntry[] = []; - let placements = isoOverlayPlacementCache.get(input.wallSilhouettes); + let placements = isoOverlayPlacementCache.get(input.structure); if (!placements) { placements = new Map(); - isoOverlayPlacementCache.set(input.wallSilhouettes, placements); + isoOverlayPlacementCache.set(input.structure, placements); } let owners = isoOverlayOwnerCache.get(roomRows); if (!owners) { @@ -815,8 +786,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver preferredRoomId?: string | null, ): IsoOverlayPlacement => { const visualOffset = kind === 'room-label' ? 0 : wallHeight; - const collisionMode = input.resolveCollisions === false ? 'fit' : 'live'; - const cacheKey = `${collisionMode}\u0000${kind}\u0000${id}`; + const cacheKey = `${kind}\u0000${id}`; // Nothing here depends on zoom or stage size, so a pinch, wheel or resize // reuses the immutable placement and Lit keeps the raised SVG subtree. const signature = [floorAnchor[0], floorAnchor[1], @@ -825,16 +795,15 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver ].join('|'); const cached = lruRead(placements!, cacheKey); if (cached.hit && cached.value.signature === signature) return cached.value.placement; - const ownerKey = `${kind}\u0000${id}`; const ownerSignature = [floorAnchor[0], floorAnchor[1], preferredRoomId || ''].join('|'); - const cachedOwner = lruRead(owners!, ownerKey); + const cachedOwner = lruRead(owners!, cacheKey); const owner = cachedOwner.hit && cachedOwner.value.signature === ownerSignature ? cachedOwner.value.owner : resolveIsoOverlayOwner({ kind, floorAnchor, rooms, roomsValidated: true, preferredRoomId, }); if (!cachedOwner.hit || cachedOwner.value.signature !== ownerSignature) { - lruWrite(owners!, ownerKey, { signature: ownerSignature, owner }, + lruWrite(owners!, cacheKey, { signature: ownerSignature, owner }, ISO_OVERLAY_PLACEMENT_CACHE_LIMIT); } const placement = resolveIsoOverlayPlacement({ @@ -927,17 +896,11 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver }); } } - const mode = input.resolveCollisions === false ? 'fit' : 'live'; - let renderScenes = isoOverlayRenderSceneCache.get(input.wallSilhouettes); - if (!renderScenes) { - renderScenes = new Map(); - isoOverlayRenderSceneCache.set(input.wallSilhouettes, renderScenes); - } - const previous = renderScenes.get(mode); + const previous = isoOverlayRenderSceneCache.get(input.structure); const layoutSignature = entries.map((entry) => { const placement = entry.placement; return [entry.kind, entry.id, placement.floorAnchor[0], placement.floorAnchor[1], - placement.raisedScene[0], placement.raisedScene[1], placement.owner?.id || '', + placement.visualScene[0], placement.visualScene[1], placement.owner?.id || '', layoutHalfSizeOf(entry)[0], layoutHalfSizeOf(entry)[1]].join('|'); }).sort().join('\u0001'); if (previous?.layoutSignature === layoutSignature @@ -960,7 +923,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver ? { ...entry, screenHalfSize: next } : entry; })), }; - renderScenes.set(mode, refreshed); + isoOverlayRenderSceneCache.set(input.structure, refreshed); return refreshed; } @@ -983,7 +946,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver && samePlacementMap(previous.devices, scene.devices) && samePlacementMap(previous.rooms, scene.rooms) && samePlacementMap(previous.locks, scene.locks)) return previous; - renderScenes.set(mode, scene); + isoOverlayRenderSceneCache.set(input.structure, scene); return scene; } diff --git a/test/iso-overlays.test.mjs b/test/iso-overlays.test.mjs index 9851e782..7a852a91 100644 --- a/test/iso-overlays.test.mjs +++ b/test/iso-overlays.test.mjs @@ -88,8 +88,8 @@ test('a degenerate or missing owner room still raises the overlay straight up', const result = placement(overrides); assert.equal(result.owner, null, name); assert.equal(result.plane, 'raised', name); - assert.deepEqual(result.visualScene, result.raisedScene, `${name}: no move without an owner`); - assert.equal(result.tether.visible, false, name); + assert.deepEqual(result.visualScene, + projectPlanPoint(result.floorAnchor, ISO_RAISED_OVERLAY_HEIGHT), `${name}: no move without an owner`); } }); @@ -106,31 +106,29 @@ test('footprint corners use the same affine camera on the raised plane', () => { 'zero yaw keeps the screen-facing footprint aligned with the plan axes'); }); +// #724: what a placement carries is what someone reads — no invisible tether or +// grounding, no second raised point equal to the visual one, no owner area. +const PLACEMENT_FIELDS = ['floorAnchor', 'floorScene', 'footprint', 'owner', 'plane', 'visualScene']; + test('free low overlay separates the immutable floor anchor from its invisible footprint', () => { const normal = placement(); + assert.deepEqual(Object.keys(normal).sort(), PLACEMENT_FIELDS); assert.equal(normal.plane, 'raised'); + assert.deepEqual(normal.owner, { id: 'room' }); assert.deepEqual(normal.floorAnchor, [50, 50]); assert.deepEqual(normal.floorScene, projectPlanPoint([50, 50], 0)); - assert.deepEqual(normal.raisedScene, projectPlanPoint([50, 50], ISO_RAISED_OVERLAY_HEIGHT)); - assert.deepEqual(normal.visualScene, normal.raisedScene); + assert.deepEqual(normal.visualScene, projectPlanPoint([50, 50], ISO_RAISED_OVERLAY_HEIGHT)); assert.deepEqual(normal.footprint, buildIsoFootprintPolygon([50, 50], [4, 4], ISO_RAISED_OVERLAY_HEIGHT)); - assert.equal(normal.grounding.visible, false); - assert.equal(normal.tether.visible, false); - - for (const state of ['hovered', 'focused', 'selected']) - assert.equal(placement({ [state]: true }).tether.visible, false, state); - assert.equal(placement({ filtersSupported: false }).grounding.visible, false, - 'unsupported filters remove the soft grounding shadow only'); assert.deepEqual(placement({ visualOffset: 0 }).visualScene, normal.floorScene, 'a zero offset keeps the root on its floor point (#713 room names)'); }); test('show_borders:false is exact no-volume: floor anchor, no footprint/cues', () => { - const result = placement({ showBorders: false, hovered: true }); + const result = placement({ showBorders: false }); + assert.deepEqual(Object.keys(result).sort(), PLACEMENT_FIELDS); assert.equal(result.plane, 'floor'); + assert.equal(result.owner, null); assert.deepEqual(result.visualScene, result.floorScene); assert.deepEqual(result.footprint, []); - assert.equal(result.grounding.visible, false); - assert.equal(result.tether.visible, false); }); diff --git a/test/iso-scene-render.test.mjs b/test/iso-scene-render.test.mjs index d886006c..30029480 100644 --- a/test/iso-scene-render.test.mjs +++ b/test/iso-scene-render.test.mjs @@ -36,6 +36,13 @@ import { projectPlanPoint, } from '../test-build/iso-projection.js'; +/** + * #724: the overlay caches are keyed by the structural wall geometry. Every + * fixture gets its own object, as a structural scene of its own would. + */ +const structureOf = (walls = []) => buildIsoWallGeometry(walls); +const wallRect = (x0, y0, x1, y1) => [[[[x0, y0], [x1, y0], [x1, y1], [x0, y1]]]]; + const room = (id, x0, y0, x1, y1) => ({ id, poly: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], @@ -121,7 +128,7 @@ test('room footprint treats wide ASCII glyphs conservatively', () => { test('overlay bounds use final screen footprint and canonical owner filtering', () => { const placement = (owner, center) => ({ owner: { id: owner }, floorScene: [center[0] - 5, center[1]], - raisedScene: center, visualScene: center, + visualScene: center, footprint: [[center[0] - 2, center[1] - 1], [center[0] + 2, center[1] - 1], [center[0] + 2, center[1] + 1], [center[0] - 2, center[1] + 1]], }); @@ -139,7 +146,7 @@ test('#713 K8: overlay fit is the structure plus visible tiles, no #651 nudge re const entry = { id: 'edge', kind: 'device', groundRadius: 1, screenHalfSize: [10, 8], placement: { - owner: { id: 'room' }, floorScene: [95, 50], raisedScene: [95, 50], + owner: { id: 'room' }, floorScene: [95, 50], visualScene: [95, 50], footprint: [[90, 46], [100, 46], [100, 54], [90, 54]], }, @@ -324,7 +331,7 @@ test('opening-lock scene keeps physical host ownership when spatial fallback poi view: { x: 0, y: 0, w: 100, h: 100 }, display: { showNames: false, cardFontScale: 1 }, layers: { shadows: true }, - wallSilhouettes: [], + structure: structureOf(), iconPct: 100, deviceBasePct: 100, showLqi: false, @@ -365,7 +372,7 @@ test('opening lock without a canonical host owner never guesses from point conta view: { x: 0, y: 0, w: 100, h: 100 }, display: { showNames: false, cardFontScale: 1 }, layers: { shadows: true }, - wallSilhouettes: [{ outer: [[0, 45], [100, 45], [100, 55], [0, 55]] }], + structure: structureOf(wallRect(0, 45, 100, 55)), iconPct: 100, deviceBasePct: 100, showLqi: false, @@ -386,18 +393,15 @@ test('opening lock without a canonical host owner never guesses from point conta // the badge simply stands on the wall-top plane above its anchor. assert.equal(placement?.plane, 'raised'); assert.deepEqual(placement?.visualScene, projectPlanPoint(placement.floorAnchor, ISO_WALL_HEIGHT)); - assert.equal(placement?.tether.visible, false); }); -test('Stage 4 reuses pure overlay placements and fit probes agree with the live scene', () => { +test('Stage 4 reuses pure overlay placements, and fit and the live frame share one snapshot', () => { const owner = room('owner', 0, 0, 100, 100); const space = { id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null, rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], }; - const wallSilhouettes = [{ - outer: buildIsoFootprintPolygon([0, 50], [4, 60], ISO_WALL_HEIGHT), - }]; + const structure = structureOf(wallRect(-4, -10, 4, 110)); const input = { space, devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }], @@ -406,7 +410,7 @@ test('Stage 4 reuses pure overlay placements and fit probes agree with the live referenceView: { x: 0, y: 0, w: 100, h: 100 }, display: { showNames: false, cardFontScale: 1 }, layers: { structural: true, shadows: true }, - wallSilhouettes, + structure, iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, @@ -442,9 +446,10 @@ test('Stage 4 reuses pure overlay placements and fit probes agree with the live const resized = buildIsoOverlayRenderScene({ ...input, stageSize: { width: 40, height: 40 } }); assert.strictEqual(resized, live, '#714: a stage resize is not a layout event either'); - const fit = buildIsoOverlayRenderScene({ ...input, resolveCollisions: false }); - assert.deepEqual(fit.devices.get('device')?.visualScene, live.devices.get('device')?.visualScene, - '#713: fit and live placements agree — there is no search to differ by'); + // #724: the fit envelope and the live frame ask with the same inputs and read + // one snapshot — there is no search for them to differ by (#713). + const fit = buildIsoOverlayRenderScene({ ...input }); + assert.strictEqual(fit, live, 'fit and live share one render-scene snapshot'); const zoomed = buildIsoOverlayRenderScene({ ...input, view: { x: -10, y: -10, w: 120, h: 120 } }); assert.strictEqual(zoomed, live, @@ -462,7 +467,6 @@ test('render scene keeps coincident devices coincident, leaves labels alone and id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 400, 400], bg: null, rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], }; - const walls = []; const devices = ['b', 'a', 'c'].map((id) => ({ id, space: 'floor', marker: { room_id: 'owner', x: 200, y: 200 }, })); @@ -471,7 +475,7 @@ test('render scene keeps coincident devices coincident, leaves labels alone and view: { x: 0, y: 0, w: 400, h: 400 }, display: { showNames: true, cardFontScale: 1 }, layers: { structural: true, shadows: true }, - wallSilhouettes: walls, + structure: structureOf(), iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5, kioskIconScale: 1, kioskFontScale: 1, stageSize: { width: 200, height: 200 }, @@ -505,9 +509,9 @@ test('render scene keeps coincident devices coincident, leaves labels alone and const permuted = buildIsoOverlayRenderScene({ ...input, devices: [...devices].reverse() }); assert.strictEqual(permuted, scene, 'HA registry permutations reuse the same immutable group layout snapshot'); - const fit = buildIsoOverlayRenderScene({ ...input, resolveCollisions: false }); - assert.deepEqual(fit.devices.get('a').visualScene, scene.devices.get('a').visualScene, - 'fit probing and the live scene place the cluster identically'); + const fit = buildIsoOverlayRenderScene({ ...input }); + assert.strictEqual(fit.devices.get('a'), scene.devices.get('a'), + 'fit probing and the live scene read the same placement'); }); test('#711 состояние устройства не двигает значки: раскладка не пересчитывается, границы видят бейдж', () => { @@ -516,7 +520,7 @@ test('#711 состояние устройства не двигает знач id: 'floor-710', title: 'Floor', cellCm: 5, vb: [0, 0, 400, 400], bg: null, rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], }; - const wallSilhouettes = [{ outer: buildIsoFootprintPolygon([150, 200], [154, 260], ISO_WALL_HEIGHT) }]; + const structure = structureOf(wallRect(146, 140, 154, 260)); const devices = ['lamp', 'plug', 'sensor'].map((id, index) => ({ id, space: 'floor-710', marker: { room_id: 'owner', x: 160 + index * 4, y: 200 }, })); @@ -533,7 +537,7 @@ test('#711 состояние устройства не двигает знач view: { x: 0, y: 0, w: 400, h: 400 }, display: { showNames: false, cardFontScale: 1 }, layers: { structural: true, shadows: true }, - wallSilhouettes, + structure, iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5, kioskIconScale: 1, kioskFontScale: 1, stageSize: { width: 200, height: 200 }, @@ -562,26 +566,6 @@ test('#711 состояние устройства не двигает знач } }); -test('structural wall silhouettes reuse every visible side quad', () => { - const walls = [[[[45, 20], [55, 20], [55, 80], [45, 80]]]]; - const scene = resolveIsoScene({ - source: { - key: 'visible-side-silhouettes', - build: () => ({ walls, floor: walls, openings: [], openingSurfaces: [] }), - }, - cache: new Map(), - cellCm: 5, - liveFrame: { x: 0, y: 0, w: 100, h: 100 }, - }); - assert.ok(scene.geometry.sides.length > 0, 'fixture exposes visible vertical wall faces'); - assert.equal(scene.wallSilhouettes.length, 1 + scene.geometry.sides.length, - 'the silhouette set contains the top footprint and every visible side'); - scene.geometry.sides.forEach((face, index) => { - assert.strictEqual(scene.wallSilhouettes[index + 1].outer, face.points, - `visible side ${index} reuses its exact render quad`); - }); -}); - test('orphan hosted openings never become phantom Stage 4 volumes', () => { const base = { type: 'door', rx: 20, ry: 30, rlen: 40, angle: 0, @@ -719,7 +703,7 @@ test('removed contact shadows are never read while ambient shadow capability rem display: { showBorders: true, hideOpenings: false }, scene: { key: 'solid-retry', geometry, - floor: { footprintPath: '', sides: [] }, wallSilhouettes: [], + floor: { footprintPath: '', sides: [] }, openings: [], openingSurfaces: [], frame: { x: 0, y: 0, w: 100, h: 100 }, }, openings: [], amountOf: () => 0, overlays: () => null, cellCm: 5, @@ -846,7 +830,7 @@ const perfFixture = () => { id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null, rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], }; - const wallSilhouettes = [{ outer: buildIsoFootprintPolygon([0, 50], [4, 60], ISO_WALL_HEIGHT) }]; + const structure = structureOf(wallRect(-4, -10, 4, 110)); const input = { space, devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }], @@ -855,7 +839,7 @@ const perfFixture = () => { referenceView: { x: 0, y: 0, w: 100, h: 100 }, display: { showNames: false, cardFontScale: 1 }, layers: { structural: true, shadows: true }, - wallSilhouettes, + structure, iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5, kioskIconScale: 1, kioskFontScale: 1, stageSize: { width: 100, height: 100 }, @@ -870,7 +854,7 @@ const perfFixture = () => { openingEntityAvailable: () => false, openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }), }; - return { input, wallSilhouettes }; + return { input, structure }; }; test('#570 supersedes #473 W1: selection reuses the cue-free low placement', () => { @@ -885,15 +869,110 @@ test('#570 supersedes #473 W1: selection reuses the cue-free low placement', () assert.strictEqual(again, selected, 'clearing selection keeps the same immutable placement'); }); -test('#473 W2 after #713: walls no longer move a tile, the cache still follows the silhouette array', () => { +test('#473 W2 after #724: walls never move a tile, the cache follows the structural wall geometry', () => { const { input } = perfFixture(); const withWall = buildIsoOverlayRenderScene(input).devices.get('device'); - const noWalls = buildIsoOverlayRenderScene({ ...input, wallSilhouettes: [] }).devices.get('device'); - assert.notStrictEqual(noWalls, withWall, 'a new geometry array is a new cache slot'); + const noWalls = buildIsoOverlayRenderScene({ ...input, structure: structureOf() }).devices.get('device'); + assert.notStrictEqual(noWalls, withWall, 'a new wall geometry is a new cache slot'); assert.deepEqual(noWalls.visualScene, withWall.visualScene, 'the tile position does not depend on nearby walls'); }); +// #724 AC2: the overlay caches follow the wall geometry of the structural scene +// on the production path — createIsoStructuralSource → resolveIsoScene (the +// LRU) → buildIsoOverlayRenderScene({ structure: scene.geometry }). +const unhostedWalls = (poly, cm) => poly.map((a, index) => ({ + key: wallKey(a, poly[(index + 1) % poly.length], 1), cm, +})); +const overlayInput = (space, structure, overrides = {}) => ({ + space, structure, openings: [], + devices: [{ id: 'device', space: space.id }], + view: { x: 0, y: 0, w: 100, h: 100 }, + display: { showNames: false, cardFontScale: 1 }, + iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5, + kioskIconScale: 1, kioskFontScale: 1, + stageSize: { width: 100, height: 100 }, + positionOf: () => ({ x: 50, y: 50 }), + presentationOf: () => ({ + scale: 1, valueText: null, valueFullText: '', valueBadge: null, + tempText: null, humText: null, lqiText: null, + pulse: { animated: false, diameterScale: 1 }, + }), + labelPositionOf: () => ({ x: 0, y: 0 }), + labelScaleOf: () => 1, + openingEntityAvailable: () => false, + openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }), + ...overrides, +}); + +test('#724 AC2: the overlay scene survives zoom, resize and HA state, and is rebuilt when a wall changes', () => { + const square = cacheRoom({ wall_ids: [] }); + const cache = new Map(); + const structural = (cm) => { + const input = structuralInput({ room: square, walls: unhostedWalls(square.poly, cm), coordinateScale: 1 }); + return { space: input.space, scene: resolveIsoScene({ + source: createIsoStructuralSource(input), cache, cellCm: 5, liveFrame: { x: 0, y: 0, w: 100, h: 100 }, + }) }; + }; + const thin = structural(20); + assert.ok(thin.scene.geometry.sides.length > 0, 'fixture has real wall bodies'); + const live = buildIsoOverlayRenderScene(overlayInput(thin.space, thin.scene.geometry)); + + // Same walls: the structural LRU hands out the same geometry, and neither + // zoom, a stage resize nor an HA state change is a layout event. + const again = structural(20); + assert.strictEqual(again.scene.geometry, thin.scene.geometry); + const zoomed = buildIsoOverlayRenderScene(overlayInput(again.space, again.scene.geometry, { + view: { x: 20, y: 20, w: 40, h: 40 }, stageSize: { width: 320, height: 180 }, + })); + assert.strictEqual(zoomed, live, 'zoom and resize reuse the render scene'); + const lit = buildIsoOverlayRenderScene(overlayInput(again.space, again.scene.geometry, { + presentationOf: () => ({ + scale: 1, valueText: null, valueFullText: '', tempText: null, humText: null, lqiText: null, + valueBadge: { configured: true, enabled: true, text: '100 %', fullText: '100 %', position: 'right', tone: 'default' }, + pulse: { animated: false, diameterScale: 1 }, + }), + })); + assert.strictEqual(lit.devices.get('device'), live.devices.get('device'), 'HA state keeps the placement'); + + // A thicker wall with the same room: a new structure, so a new scene. A cache + // keyed without the walls would serve the placement of the old plan. + const thick = structural(30); + assert.notEqual(thick.scene.key, thin.scene.key); + assert.notStrictEqual(thick.scene.geometry, thin.scene.geometry); + const rebuilt = buildIsoOverlayRenderScene(overlayInput(thick.space, thick.scene.geometry)); + assert.notStrictEqual(rebuilt, live, 'a wall edit rebuilds the overlay scene'); + assert.notStrictEqual(rebuilt.devices.get('device'), live.devices.get('device'), + 'and its placements'); + assert.deepEqual(rebuilt.devices.get('device').visualScene, live.devices.get('device').visualScene, + 'the tile itself stays where it was: walls never move it'); +}); + +test('#724 AC2: a room edit that changes the owner is a new structure — no owner of a plan that is gone', () => { + const cache = new Map(); + const structural = (split) => { + const base = structuralInput(); + const rooms = [ + cacheRoom({ id: 'west', poly: [[0, 0], [split, 0], [split, 100], [0, 100]], wall_ids: [] }), + cacheRoom({ id: 'east', poly: [[split, 0], [200, 0], [200, 100], [split, 100]], wall_ids: [] }), + ]; + const space = { ...base.space, vb: [0, 0, 200, 100], rooms }; + return { space, scene: resolveIsoScene({ + source: createIsoStructuralSource({ ...base, space }), cache, cellCm: 5, + liveFrame: { x: 0, y: 0, w: 200, h: 100 }, + }) }; + }; + const at = { positionOf: () => ({ x: 90, y: 50 }) }; + const before = structural(100); + const west = buildIsoOverlayRenderScene(overlayInput(before.space, before.scene.geometry, at)); + assert.equal(west.devices.get('device').owner?.id, 'west'); + const after = structural(80); + assert.notEqual(after.scene.key, before.scene.key, 'room geometry is structural'); + const east = buildIsoOverlayRenderScene(overlayInput(after.space, after.scene.geometry, at)); + assert.equal(east.devices.get('device').owner?.id, 'east', + 'the tile at the same point now belongs to the room that grew over it'); +}); + test('#713 AC3: live zoom never recomputes placement and every tile gets one straight-up shift', () => { const { input } = perfFixture(); const lift = projectPlanPoint([0, 0], 0)[1] - projectPlanPoint([0, 0], ISO_WALL_HEIGHT)[1]; diff --git a/test/iso-stage6.test.mjs b/test/iso-stage6.test.mjs index 0e2dfedc..b4810eec 100644 --- a/test/iso-stage6.test.mjs +++ b/test/iso-stage6.test.mjs @@ -197,7 +197,8 @@ test('#649 AC3 layout and collisions see the 2.5D tile size (× ISO_ICON_SCALE)' }; const scene = buildIsoOverlayRenderScene({ space, devices: [{ id: 'd1' }], openings: [], view: { x: 0, y: 0, w: 1000, h: 1000 }, - display: { showNames: false, cardFontScale: 1 }, layers: { shadows: true }, wallSilhouettes: [], + display: { showNames: false, cardFontScale: 1 }, layers: { shadows: true }, + structure: { topPath: '', topFaces: [], sides: [], contactPath: '', edgeCount: 0 }, iconPct: 3, deviceBasePct: 3, showLqi: false, cellCm: 5, kioskIconScale: 1, kioskFontScale: 1, stageSize: { width: 1000, height: 1000 }, positionOf: () => ({ x: 500, y: 500 }), presentationOf: () => presentation, labelPositionOf: () => ({ x: 0, y: 0 }), labelScaleOf: () => 1, diff --git a/test/isometric-contract.test.mjs b/test/isometric-contract.test.mjs index 43cddfc8..49817e91 100644 --- a/test/isometric-contract.test.mjs +++ b/test/isometric-contract.test.mjs @@ -167,7 +167,7 @@ test('Stage 4 structural cache fingerprints geometry/camera/heights and excludes assert.match(sceneRender, /const floorEdgeHeight = gridVisualUnits\(ISO_FLOOR_EDGE_HEIGHT, input\.cellCm\)/); assert.match(sceneRender, /const raisedHeight = gridVisualUnits\(ISO_RAISED_OVERLAY_HEIGHT, input\.cellCm\)/); assert.match(sceneRender, /camera: ISO_CAMERA,[\s\S]*?wallHeight,[\s\S]*?raisedHeight,[\s\S]*?floorEdgeHeight,[\s\S]*?algorithm: 6/); - const source = section(sceneRender, 'export function createIsoStructuralSource', 'const unknownArray'); + const source = section(sceneRender, 'export function createIsoStructuralSource', 'export interface ResolveIsoSceneInput'); const roomProjection = section(sceneRender, 'export function isoStructuralRoomGeometry', 'export type IsoStructuralOpeningHost'); for (const field of ['id', 'x', 'y', 'w', 'h', 'poly', 'wall_ids']) @@ -204,8 +204,13 @@ test('Stage 4 structural cache fingerprints geometry/camera/heights and excludes assert.match(sceneRender, /projectIsoOpening\(basis, amountOf\(opening\)\)/); assert.match(sceneRender, /lruWrite\(input\.cache, input\.source\.key, value, 8\)/); assert.match(card, /data-hp-iso-structural-builds=\$\{iso \? this\._isoStructuralBuildCount : nothing\}/); - assert.match(sceneRender, /const wallTops = isoWallSilhouettesOf\(structural\.walls, wallHeight\)/); - assert.match(sceneRender, /wallSilhouettes:\s*Object\.freeze\(\[[\s\S]*?\.\.\.wallTops,[\s\S]*?\.\.\.geometry\.sides\.map\(\(face\) => \(\{ outer: face\.points \}\)\),[\s\S]*?\]\)/); + // #724: the overlay caches are keyed by the structural wall geometry itself — + // no silhouette array is built only to be that key. + assert.match(sceneRender, /geometry: buildIsoWallGeometry\(structural\.walls, ISO_CAMERA, wallHeight\)/); + assert.match(sceneRender, /structure: IsoWallGeometry;/); + assert.match(sceneRender, /let placements = isoOverlayPlacementCache\.get\(input\.structure\)/); + assert.match(sceneRender, /const previous = isoOverlayRenderSceneCache\.get\(input\.structure\)/); + assert.doesNotMatch(sceneRender, /wallSilhouettes|isoWallSilhouettesOf|resolveCollisions/); }); test('show_borders:false keeps the Flat floor plane and removes every volume cue', () => {