refactor(iso): drop the 2.5D overlay data nothing reads (#724)

After #714 the 2.5D overlay scene still carried what decides nothing:

- src/iso-overlays.ts: IsoOverlayPlacement loses tether and grounding (always
  invisible) and raisedScene (always equal to visualScene); IsoOverlayOwner
  loses area; IsoOverlayPlacementInput loses hovered, focused, selected and
  filtersSupported, which the resolver ignored. IsoWallSilhouette and
  tetherGeometry go with them.
- src/iso-scene-render.ts: the structural scene no longer projects wall
  silhouettes (isoWallSilhouettesOf and IsoSceneCacheEntry.wallSilhouettes)
  that served only as a cache key. The placement and render-scene caches are
  keyed by the wall geometry the scene is drawn with (IsoOverlaySceneInput.
  structure = scene.geometry): the structural LRU hands out the same object
  across zoom, stage resize and HA state, and a new one after any wall, room
  or opening edit. The resolveCollisions flag and its fit/live cache slots
  are gone: since #713 both held equal placements, and 2.5D renders only in
  View, where the fit probe and the live frame ask with the same devices, so
  they now read one snapshot.
- src/houseplan-card.ts: the fit call passes no flag; the overlay scene gets
  structural.geometry. data-hp-iso-nudged stays the constant "false" read by
  the golden requireOneRise preflight, the live-touch smoke and the benchmark.

Tests: iso-overlays pins the placement fields; iso-scene-render builds the
structure with buildIsoWallGeometry, the #714 zoom/resize and #711 state tests
stay, fit and live are asserted to share one snapshot, and two #724 AC2 tests
run the production path (createIsoStructuralSource -> resolveIsoScene ->
buildIsoOverlayRenderScene): a thicker wall with the same room rebuilds the
scene (red with a key without walls, e.g. keyed by the room rows), and a room
edit that moves the owner gives the new owner (red with a constant key). The
silhouette-construction test goes with the construction.

Mutants: #473 W2 (iso-placement-cache-survives-silhouette-change, id kept for
history) now keys the placement cache by a constant instead of input.structure
and its guard also runs the #724 AC2 tests; W6 patches the new structure line;
the W5 description no longer speaks of a nudge. The isometric-contract regex
checks the new key instead of the silhouette construction. docs/ISOMETRIC.md
names the key.

Live 2.5D output is unchanged: the 21 isometric golden scenes pass on the
accepted baselines.

Issue: #724
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-30 22:39:24 +00:00
committed by claude[bot]
parent 108427dc77
commit 5f8e8ca7e8
9 changed files with 193 additions and 182 deletions
+4 -1
View File
@@ -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 exactly where Flat puts them, without a position correction. A device never
moves because its Home Assistant state changed (#711 holds trivially: nothing 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 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 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 clusters, a group collision resolver and a nudge of up to 48 CSS px towards the
+12 -10
View File
@@ -11085,21 +11085,22 @@ const MUTANT_DEFINITIONS = [
{ {
id: 'iso-placement-cache-survives-silhouette-change', id: 'iso-placement-cache-survives-silhouette-change',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' 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', + '&& node --test --test-name-pattern="#473 W2|#724 AC2" test/iso-scene-render.test.mjs',
because: 'placements are cached per wall-silhouette array identity; keying the cache by a ' because: 'placements are cached per structural wall geometry (#724; the wall-silhouette '
+ 'constant serves «near the wall» for a plan whose wall is gone (#473)', + '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: [{ patches: [{
file: 'src/iso-scene-render.ts', 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);', replace: ' let placements = isoOverlayPlacementCache.get(ISO_PLACEMENT_CACHE_ANY);',
}, { }, {
file: 'src/iso-scene-render.ts', 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);', replace: ' isoOverlayPlacementCache.set(ISO_PLACEMENT_CACHE_ANY, placements);',
}, { }, {
file: 'src/iso-scene-render.ts', file: 'src/iso-scene-render.ts',
find: 'export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;', 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 // #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', id: 'stage3-w5-runtime-nudge-writes-storage',
guard: 'node demo/smoke_isometric_live_touch.mjs', 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)', + 'browser storage (#160)',
patches: [{ patches: [{
file: 'src/houseplan-card.ts', file: 'src/houseplan-card.ts',
find: ' return runtime.buildIsoOverlayRenderScene({', 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({', + ' return runtime.buildIsoOverlayRenderScene({',
}], }],
}, },
@@ -11415,8 +11417,8 @@ const MUTANT_DEFINITIONS = [
replace: ' if (!runtime || !layers || (!structural && disp.showBorders)) return null;', replace: ' if (!runtime || !layers || (!structural && disp.showBorders)) return null;',
}, { }, {
file: 'src/houseplan-card.ts', file: 'src/houseplan-card.ts',
find: ' wallSilhouettes: structural.wallSilhouettes,', find: ' structure: structural.geometry,',
replace: ' wallSilhouettes: structural?.wallSilhouettes ?? [],', replace: " structure: structural?.geometry ?? { topPath: '', topFaces: [], sides: [], contactPath: '', edgeCount: 0 },",
}], }],
}, },
{ {
+2 -4
View File
@@ -6012,7 +6012,7 @@ export class HouseplanCard extends LitElement {
const overlays = this._isoOverlayScene( const overlays = this._isoOverlayScene(
space, this._renderDevices.filter((device) => device.space === space.id && !device.hidden), space, this._renderDevices.filter((device) => device.space === space.id && !device.hidden),
disp, runtime.resolveIsoDecorationLayers(disp), scene, iconPct, effectiveDeviceBaseSize(iconPct), 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({ const envelope = overlays && runtime.resolveIsoOverlayFitEnvelope({
baseBounds: scene.frame, entries: overlays.entries, stageSize, targetView: (bounds) => baseBounds: scene.frame, entries: overlays.entries, stageSize, targetView: (bounds) =>
fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect) }); fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect) });
@@ -6067,14 +6067,12 @@ export class HouseplanCard extends LitElement {
iconPct: number, iconPct: number,
deviceBasePct: number, deviceBasePct: number,
showLqi: boolean, showLqi: boolean,
resolveCollisions = true,
): IsoOverlayRenderScene | null { ): IsoOverlayRenderScene | null {
const runtime = this._isoSceneRuntime; const runtime = this._isoSceneRuntime;
if (!runtime || !layers?.structural || !structural) return null; if (!runtime || !layers?.structural || !structural) return null;
return runtime.buildIsoOverlayRenderScene({ return runtime.buildIsoOverlayRenderScene({
space, devices: devs, openings: this._openingsR, display: disp, space, devices: devs, openings: this._openingsR, display: disp,
wallSilhouettes: structural.wallSilhouettes, structure: structural.geometry,
resolveCollisions,
iconPct, deviceBasePct, showLqi, cellCm: this._cellCm, iconPct, deviceBasePct, showLqi, cellCm: this._cellCm,
kioskIconScale: this._mode === 'view' ? this._kioskScale.icon : 1, kioskIconScale: this._mode === 'view' ? this._kioskScale.icon : 1,
kioskFontScale: this._mode === 'view' ? this._kioskScale.font : 1, kioskFontScale: this._mode === 'view' ? this._kioskScale.font : 1,
+7 -45
View File
@@ -19,15 +19,8 @@ export interface IsoOverlayRoom {
holes?: readonly (readonly PlanPoint[])[]; 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 { export interface IsoOverlayOwner {
id: string; id: string;
area: number;
} }
export interface IsoOverlayOwnerInput { export interface IsoOverlayOwnerInput {
@@ -45,36 +38,22 @@ export interface IsoOverlayPlacementInput extends IsoOverlayOwnerInput {
/** Half-size of the invisible floor-parallel footprint in plan units. */ /** Half-size of the invisible floor-parallel footprint in plan units. */
footprintHalfSize: PlanPoint; footprintHalfSize: PlanPoint;
visualOffset?: number; visualOffset?: number;
filtersSupported?: boolean;
hovered?: boolean;
focused?: boolean;
selected?: boolean;
camera?: IsoCamera; camera?: IsoCamera;
/** Internal scene-builder fast path; arbitrary callers still resolve safely. */ /** Internal scene-builder fast path; arbitrary callers still resolve safely. */
ownerAlreadyResolved?: boolean; ownerAlreadyResolved?: boolean;
resolvedOwner?: IsoOverlayOwner | null; resolvedOwner?: IsoOverlayOwner | null;
} }
export interface IsoOverlayTetherGeometry {
from: ScenePoint;
to: ScenePoint;
visible: boolean;
length: number;
angleDeg: number;
}
export interface IsoOverlayPlacement { export interface IsoOverlayPlacement {
plane: IsoOverlayPlane; plane: IsoOverlayPlane;
owner: IsoOverlayOwner | null; owner: IsoOverlayOwner | null;
/** This is always the input logical point; the raised visual never mutates it. */ /** This is always the input logical point; the raised visual never mutates it. */
floorAnchor: PlanPoint; floorAnchor: PlanPoint;
floorScene: ScenePoint; floorScene: ScenePoint;
raisedScene: ScenePoint; /** The screen-facing root: the floor anchor lifted by `visualOffset` (#713). */
visualScene: ScenePoint; visualScene: ScenePoint;
/** Invisible fit footprint; never render it as a surface. */ /** Invisible fit footprint; never render it as a surface. */
footprint: readonly ScenePoint[]; footprint: readonly ScenePoint[];
grounding: { center: ScenePoint; visible: boolean };
tether: IsoOverlayTetherGeometry;
} }
const EPS = 1e-9; const EPS = 1e-9;
@@ -167,7 +146,7 @@ export function resolveIsoOverlayOwner(input: IsoOverlayOwnerInput): IsoOverlayO
// a saved label may legitimately lie outside that room. // a saved label may legitimately lie outside that room.
room = preferred; 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 { 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 * Resolve one overlay without mutating its saved coordinate. Since #713 a
* raised root is its floor anchor shifted straight up by `visualOffset`; #714 * 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 floorScene = projectPlanPoint(floorAnchor, 0, camera);
const plane = isoOverlayPlane(input.kind, input.showBorders); const plane = isoOverlayPlane(input.kind, input.showBorders);
if (plane === 'floor') { if (plane === 'floor') {
return { return { plane, owner: null, floorAnchor, floorScene, visualScene: floorScene, footprint: [] };
plane, owner: null, floorAnchor, floorScene, raisedScene: floorScene,
visualScene: floorScene, footprint: [],
grounding: { center: floorScene, visible: false },
tether: tetherGeometry(floorScene, floorScene, false),
};
} }
// The canonical floor anchor and footprint stay; the screen-facing content // The canonical floor anchor and footprint stay; the screen-facing content
// stands `visualOffset` above the floor (#713: the wall top for tiles and // stands `visualOffset` above the floor (#713: the wall top for tiles and
// lock badges, 0 for room names). // lock badges, 0 for room names).
const raisedScene = projectPlanPoint(floorAnchor, visualOffset, camera); const visualScene = projectPlanPoint(floorAnchor, visualOffset, camera);
const owner = input.ownerAlreadyResolved const owner = input.ownerAlreadyResolved
? input.resolvedOwner ?? null ? input.resolvedOwner ?? null
: resolveIsoOverlayOwner(input); : resolveIsoOverlayOwner(input);
const footprint = buildIsoFootprintPolygon(floorAnchor, input.footprintHalfSize, const footprint = buildIsoFootprintPolygon(floorAnchor, input.footprintHalfSize,
visualOffset, camera); visualOffset, camera);
// Ownership remains encoded by the immutable anchor and invisible bounded // Ownership remains encoded by the immutable anchor and invisible bounded
// footprint. Stage 4 deliberately removes the visible ground dot and long // footprint. Stage 4 removed the visible ground dot and long tether which made
// tether which made the architectural view look like a debug overlay. // the architectural view look like a debug overlay; #724 removed their data.
const tetherVisible = false; return { plane, owner, floorAnchor, floorScene, visualScene, footprint };
return {
plane, owner, floorAnchor, floorScene, raisedScene, visualScene: raisedScene, footprint,
grounding: { center: floorScene, visible: false },
tether: tetherGeometry(floorScene, raisedScene, tetherVisible),
};
} }
+20 -57
View File
@@ -13,7 +13,7 @@ import { clampScale, islandsOf, roomPoly, type SpaceDisplay } from './logic';
import { import {
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT,
ISO_RAISED_OVERLAY_HEIGHT, ISO_WALL_HEIGHT, ISO_RAISED_OVERLAY_HEIGHT, ISO_WALL_HEIGHT,
projectPlanPoint, projectedFrame, type PlanPoint, type ScenePoint, projectedFrame, type PlanPoint, type ScenePoint,
} from './iso-projection'; } from './iso-projection';
import { import {
buildIsoFloorGeometry, buildIsoWallGeometry, isoGeometryFingerprint, buildIsoFloorGeometry, buildIsoWallGeometry, isoGeometryFingerprint,
@@ -28,7 +28,6 @@ import {
import { import {
resolveIsoOverlayOwner, resolveIsoOverlayPlacement, resolveIsoOverlayOwner, resolveIsoOverlayPlacement,
type IsoOverlayPlacement, type IsoOverlayRoom, type IsoRaisedOverlayKind, type IsoOverlayPlacement, type IsoOverlayRoom, type IsoRaisedOverlayKind,
type IsoWallSilhouette,
} from './iso-overlays'; } from './iso-overlays';
import { import {
floorFootprintGeometry, openingInnerFaceOffsetFromIndex, floorFootprintGeometry, openingInnerFaceOffsetFromIndex,
@@ -376,7 +375,6 @@ export function isoRaisedOverlayHalfSize(input: IsoRaisedFootprintInput): PlanPo
export type IsoSceneCacheEntry = { export type IsoSceneCacheEntry = {
geometry: IsoWallGeometry; geometry: IsoWallGeometry;
floor: IsoFloorGeometry; floor: IsoFloorGeometry;
wallSilhouettes: readonly IsoWallSilhouette[];
openings: readonly IsoOpeningBasis[]; openings: readonly IsoOpeningBasis[];
openingSurfaces: readonly IsoOpeningRenderSurface[]; 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 { export interface ResolveIsoSceneInput {
source: IsoStructuralSource; source: IsoStructuralSource;
cache: Map<string, IsoSceneCacheEntry>; cache: Map<string, IsoSceneCacheEntry>;
@@ -604,15 +581,9 @@ export function resolveIsoScene(input: ResolveIsoSceneInput): IsoRenderScene {
let value = cached.hit ? cached.value : undefined; let value = cached.hit ? cached.value : undefined;
if (!value) { if (!value) {
const structural = input.source.build(); const structural = input.source.build();
const geometry = buildIsoWallGeometry(structural.walls, ISO_CAMERA, wallHeight);
const wallTops = isoWallSilhouettesOf(structural.walls, wallHeight);
value = { value = {
geometry, geometry: buildIsoWallGeometry(structural.walls, ISO_CAMERA, wallHeight),
floor: buildIsoFloorGeometry(structural.floor, floorEdgeHeight), floor: buildIsoFloorGeometry(structural.floor, floorEdgeHeight),
wallSilhouettes: Object.freeze([
...wallTops,
...geometry.sides.map((face) => ({ outer: face.points })),
]),
openings: structural.openings, openings: structural.openings,
openingSurfaces: structural.openingSurfaces, openingSurfaces: structural.openingSurfaces,
}; };
@@ -706,13 +677,14 @@ export interface IsoOverlaySceneInput {
devices: readonly DevItem[]; devices: readonly DevItem[];
openings: readonly RenderOpening[]; openings: readonly RenderOpening[];
display: SpaceDisplay; 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 * The wall geometry of the structural scene the overlays stand in (#724). The
* #713 there is no collision search, so both slots hold equal placements. * 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; iconPct: number;
deviceBasePct: number; deviceBasePct: number;
showLqi: boolean; showLqi: boolean;
@@ -733,14 +705,13 @@ type IsoOverlayPlacementCacheEntry = { signature: string; placement: IsoOverlayP
type IsoOverlayOwnerCacheEntry = { signature: string; owner: IsoOverlayPlacement['owner'] }; type IsoOverlayOwnerCacheEntry = { signature: string; owner: IsoOverlayPlacement['owner'] };
export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048; export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;
const isoOverlayPlacementCache = new WeakMap< const isoOverlayPlacementCache = new WeakMap<
readonly IsoWallSilhouette[], Map<string, IsoOverlayPlacementCacheEntry> IsoWallGeometry, Map<string, IsoOverlayPlacementCacheEntry>
>(); >();
const isoOverlayOwnerCache = new WeakMap< const isoOverlayOwnerCache = new WeakMap<
readonly IsoOverlayRoomRow[], Map<string, IsoOverlayOwnerCacheEntry> readonly IsoOverlayRoomRow[], Map<string, IsoOverlayOwnerCacheEntry>
>(); >();
const isoOverlayRenderSceneCache = new WeakMap< /** One snapshot per structure: fit and the live frame share it (#724). */
readonly IsoWallSilhouette[], Map<'fit' | 'live', IsoOverlayRenderScene> const isoOverlayRenderSceneCache = new WeakMap<IsoWallGeometry, IsoOverlayRenderScene>();
>();
function samePlacementMap<K>( function samePlacementMap<K>(
previous: ReadonlyMap<K, IsoOverlayPlacement>, next: ReadonlyMap<K, IsoOverlayPlacement>, previous: ReadonlyMap<K, IsoOverlayPlacement>, next: ReadonlyMap<K, IsoOverlayPlacement>,
@@ -795,10 +766,10 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
const roomPlacements = new Map<RoomCfg, IsoOverlayPlacement>(); const roomPlacements = new Map<RoomCfg, IsoOverlayPlacement>();
const locks = new Map<string, IsoOverlayPlacement>(); const locks = new Map<string, IsoOverlayPlacement>();
const entries: IsoOverlayRenderEntry[] = []; const entries: IsoOverlayRenderEntry[] = [];
let placements = isoOverlayPlacementCache.get(input.wallSilhouettes); let placements = isoOverlayPlacementCache.get(input.structure);
if (!placements) { if (!placements) {
placements = new Map(); placements = new Map();
isoOverlayPlacementCache.set(input.wallSilhouettes, placements); isoOverlayPlacementCache.set(input.structure, placements);
} }
let owners = isoOverlayOwnerCache.get(roomRows); let owners = isoOverlayOwnerCache.get(roomRows);
if (!owners) { if (!owners) {
@@ -815,8 +786,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
preferredRoomId?: string | null, preferredRoomId?: string | null,
): IsoOverlayPlacement => { ): IsoOverlayPlacement => {
const visualOffset = kind === 'room-label' ? 0 : wallHeight; const visualOffset = kind === 'room-label' ? 0 : wallHeight;
const collisionMode = input.resolveCollisions === false ? 'fit' : 'live'; const cacheKey = `${kind}\u0000${id}`;
const cacheKey = `${collisionMode}\u0000${kind}\u0000${id}`;
// Nothing here depends on zoom or stage size, so a pinch, wheel or resize // 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. // reuses the immutable placement and Lit keeps the raised SVG subtree.
const signature = [floorAnchor[0], floorAnchor[1], const signature = [floorAnchor[0], floorAnchor[1],
@@ -825,16 +795,15 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
].join('|'); ].join('|');
const cached = lruRead(placements!, cacheKey); const cached = lruRead(placements!, cacheKey);
if (cached.hit && cached.value.signature === signature) return cached.value.placement; 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 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 const owner = cachedOwner.hit && cachedOwner.value.signature === ownerSignature
? cachedOwner.value.owner ? cachedOwner.value.owner
: resolveIsoOverlayOwner({ : resolveIsoOverlayOwner({
kind, floorAnchor, rooms, roomsValidated: true, preferredRoomId, kind, floorAnchor, rooms, roomsValidated: true, preferredRoomId,
}); });
if (!cachedOwner.hit || cachedOwner.value.signature !== ownerSignature) { if (!cachedOwner.hit || cachedOwner.value.signature !== ownerSignature) {
lruWrite(owners!, ownerKey, { signature: ownerSignature, owner }, lruWrite(owners!, cacheKey, { signature: ownerSignature, owner },
ISO_OVERLAY_PLACEMENT_CACHE_LIMIT); ISO_OVERLAY_PLACEMENT_CACHE_LIMIT);
} }
const placement = resolveIsoOverlayPlacement({ const placement = resolveIsoOverlayPlacement({
@@ -927,17 +896,11 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
}); });
} }
} }
const mode = input.resolveCollisions === false ? 'fit' : 'live'; const previous = isoOverlayRenderSceneCache.get(input.structure);
let renderScenes = isoOverlayRenderSceneCache.get(input.wallSilhouettes);
if (!renderScenes) {
renderScenes = new Map();
isoOverlayRenderSceneCache.set(input.wallSilhouettes, renderScenes);
}
const previous = renderScenes.get(mode);
const layoutSignature = entries.map((entry) => { const layoutSignature = entries.map((entry) => {
const placement = entry.placement; const placement = entry.placement;
return [entry.kind, entry.id, placement.floorAnchor[0], placement.floorAnchor[1], 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('|'); layoutHalfSizeOf(entry)[0], layoutHalfSizeOf(entry)[1]].join('|');
}).sort().join('\u0001'); }).sort().join('\u0001');
if (previous?.layoutSignature === layoutSignature if (previous?.layoutSignature === layoutSignature
@@ -960,7 +923,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
? { ...entry, screenHalfSize: next } : entry; ? { ...entry, screenHalfSize: next } : entry;
})), })),
}; };
renderScenes.set(mode, refreshed); isoOverlayRenderSceneCache.set(input.structure, refreshed);
return refreshed; return refreshed;
} }
@@ -983,7 +946,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
&& samePlacementMap(previous.devices, scene.devices) && samePlacementMap(previous.devices, scene.devices)
&& samePlacementMap(previous.rooms, scene.rooms) && samePlacementMap(previous.rooms, scene.rooms)
&& samePlacementMap(previous.locks, scene.locks)) return previous; && samePlacementMap(previous.locks, scene.locks)) return previous;
renderScenes.set(mode, scene); isoOverlayRenderSceneCache.set(input.structure, scene);
return scene; return scene;
} }
+12 -14
View File
@@ -88,8 +88,8 @@ test('a degenerate or missing owner room still raises the overlay straight up',
const result = placement(overrides); const result = placement(overrides);
assert.equal(result.owner, null, name); assert.equal(result.owner, null, name);
assert.equal(result.plane, 'raised', name); assert.equal(result.plane, 'raised', name);
assert.deepEqual(result.visualScene, result.raisedScene, `${name}: no move without an owner`); assert.deepEqual(result.visualScene,
assert.equal(result.tether.visible, false, name); 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'); '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', () => { test('free low overlay separates the immutable floor anchor from its invisible footprint', () => {
const normal = placement(); const normal = placement();
assert.deepEqual(Object.keys(normal).sort(), PLACEMENT_FIELDS);
assert.equal(normal.plane, 'raised'); assert.equal(normal.plane, 'raised');
assert.deepEqual(normal.owner, { id: 'room' });
assert.deepEqual(normal.floorAnchor, [50, 50]); assert.deepEqual(normal.floorAnchor, [50, 50]);
assert.deepEqual(normal.floorScene, projectPlanPoint([50, 50], 0)); assert.deepEqual(normal.floorScene, projectPlanPoint([50, 50], 0));
assert.deepEqual(normal.raisedScene, projectPlanPoint([50, 50], ISO_RAISED_OVERLAY_HEIGHT)); assert.deepEqual(normal.visualScene, projectPlanPoint([50, 50], ISO_RAISED_OVERLAY_HEIGHT));
assert.deepEqual(normal.visualScene, normal.raisedScene);
assert.deepEqual(normal.footprint, assert.deepEqual(normal.footprint,
buildIsoFootprintPolygon([50, 50], [4, 4], ISO_RAISED_OVERLAY_HEIGHT)); 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, assert.deepEqual(placement({ visualOffset: 0 }).visualScene, normal.floorScene,
'a zero offset keeps the root on its floor point (#713 room names)'); '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', () => { 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.plane, 'floor');
assert.equal(result.owner, null);
assert.deepEqual(result.visualScene, result.floorScene); assert.deepEqual(result.visualScene, result.floorScene);
assert.deepEqual(result.footprint, []); assert.deepEqual(result.footprint, []);
assert.equal(result.grounding.visible, false);
assert.equal(result.tether.visible, false);
}); });
+126 -47
View File
@@ -36,6 +36,13 @@ import {
projectPlanPoint, projectPlanPoint,
} from '../test-build/iso-projection.js'; } 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) => ({ const room = (id, x0, y0, x1, y1) => ({
id, id,
poly: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], 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', () => { test('overlay bounds use final screen footprint and canonical owner filtering', () => {
const placement = (owner, center) => ({ const placement = (owner, center) => ({
owner: { id: owner }, floorScene: [center[0] - 5, center[1]], 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], 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]], [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 = { const entry = {
id: 'edge', kind: 'device', groundRadius: 1, screenHalfSize: [10, 8], id: 'edge', kind: 'device', groundRadius: 1, screenHalfSize: [10, 8],
placement: { placement: {
owner: { id: 'room' }, floorScene: [95, 50], raisedScene: [95, 50], owner: { id: 'room' }, floorScene: [95, 50],
visualScene: [95, 50], visualScene: [95, 50],
footprint: [[90, 46], [100, 46], [100, 54], [90, 54]], 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 }, view: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 }, display: { showNames: false, cardFontScale: 1 },
layers: { shadows: true }, layers: { shadows: true },
wallSilhouettes: [], structure: structureOf(),
iconPct: 100, iconPct: 100,
deviceBasePct: 100, deviceBasePct: 100,
showLqi: false, 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 }, view: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 }, display: { showNames: false, cardFontScale: 1 },
layers: { shadows: true }, layers: { shadows: true },
wallSilhouettes: [{ outer: [[0, 45], [100, 45], [100, 55], [0, 55]] }], structure: structureOf(wallRect(0, 45, 100, 55)),
iconPct: 100, iconPct: 100,
deviceBasePct: 100, deviceBasePct: 100,
showLqi: false, 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. // the badge simply stands on the wall-top plane above its anchor.
assert.equal(placement?.plane, 'raised'); assert.equal(placement?.plane, 'raised');
assert.deepEqual(placement?.visualScene, projectPlanPoint(placement.floorAnchor, ISO_WALL_HEIGHT)); 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 owner = room('owner', 0, 0, 100, 100);
const space = { const space = {
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null, id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
}; };
const wallSilhouettes = [{ const structure = structureOf(wallRect(-4, -10, 4, 110));
outer: buildIsoFootprintPolygon([0, 50], [4, 60], ISO_WALL_HEIGHT),
}];
const input = { const input = {
space, space,
devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }], 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 }, referenceView: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 }, display: { showNames: false, cardFontScale: 1 },
layers: { structural: true, shadows: true }, layers: { structural: true, shadows: true },
wallSilhouettes, structure,
iconPct: 3.4, iconPct: 3.4,
deviceBasePct: 3.4, deviceBasePct: 3.4,
showLqi: false, 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 } }); const resized = buildIsoOverlayRenderScene({ ...input, stageSize: { width: 40, height: 40 } });
assert.strictEqual(resized, live, '#714: a stage resize is not a layout event either'); assert.strictEqual(resized, live, '#714: a stage resize is not a layout event either');
const fit = buildIsoOverlayRenderScene({ ...input, resolveCollisions: false }); // #724: the fit envelope and the live frame ask with the same inputs and read
assert.deepEqual(fit.devices.get('device')?.visualScene, live.devices.get('device')?.visualScene, // one snapshot — there is no search for them to differ by (#713).
'#713: fit and live placements agree — there is no search to differ by'); 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 } }); const zoomed = buildIsoOverlayRenderScene({ ...input, view: { x: -10, y: -10, w: 120, h: 120 } });
assert.strictEqual(zoomed, live, 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, id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 400, 400], bg: null,
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
}; };
const walls = [];
const devices = ['b', 'a', 'c'].map((id) => ({ const devices = ['b', 'a', 'c'].map((id) => ({
id, space: 'floor', marker: { room_id: 'owner', x: 200, y: 200 }, 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 }, view: { x: 0, y: 0, w: 400, h: 400 },
display: { showNames: true, cardFontScale: 1 }, display: { showNames: true, cardFontScale: 1 },
layers: { structural: true, shadows: true }, layers: { structural: true, shadows: true },
wallSilhouettes: walls, structure: structureOf(),
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5, iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
kioskIconScale: 1, kioskFontScale: 1, kioskIconScale: 1, kioskFontScale: 1,
stageSize: { width: 200, height: 200 }, 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() }); const permuted = buildIsoOverlayRenderScene({ ...input, devices: [...devices].reverse() });
assert.strictEqual(permuted, scene, assert.strictEqual(permuted, scene,
'HA registry permutations reuse the same immutable group layout snapshot'); 'HA registry permutations reuse the same immutable group layout snapshot');
const fit = buildIsoOverlayRenderScene({ ...input, resolveCollisions: false }); const fit = buildIsoOverlayRenderScene({ ...input });
assert.deepEqual(fit.devices.get('a').visualScene, scene.devices.get('a').visualScene, assert.strictEqual(fit.devices.get('a'), scene.devices.get('a'),
'fit probing and the live scene place the cluster identically'); 'fit probing and the live scene read the same placement');
}); });
test('#711 состояние устройства не двигает значки: раскладка не пересчитывается, границы видят бейдж', () => { test('#711 состояние устройства не двигает значки: раскладка не пересчитывается, границы видят бейдж', () => {
@@ -516,7 +520,7 @@ test('#711 состояние устройства не двигает знач
id: 'floor-710', title: 'Floor', cellCm: 5, vb: [0, 0, 400, 400], bg: null, id: 'floor-710', title: 'Floor', cellCm: 5, vb: [0, 0, 400, 400], bg: null,
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], 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) => ({ const devices = ['lamp', 'plug', 'sensor'].map((id, index) => ({
id, space: 'floor-710', marker: { room_id: 'owner', x: 160 + index * 4, y: 200 }, 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 }, view: { x: 0, y: 0, w: 400, h: 400 },
display: { showNames: false, cardFontScale: 1 }, display: { showNames: false, cardFontScale: 1 },
layers: { structural: true, shadows: true }, layers: { structural: true, shadows: true },
wallSilhouettes, structure,
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5, iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
kioskIconScale: 1, kioskFontScale: 1, kioskIconScale: 1, kioskFontScale: 1,
stageSize: { width: 200, height: 200 }, 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', () => { test('orphan hosted openings never become phantom Stage 4 volumes', () => {
const base = { const base = {
type: 'door', rx: 20, ry: 30, rlen: 40, angle: 0, 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 }, display: { showBorders: true, hideOpenings: false },
scene: { scene: {
key: 'solid-retry', geometry, key: 'solid-retry', geometry,
floor: { footprintPath: '', sides: [] }, wallSilhouettes: [], floor: { footprintPath: '', sides: [] },
openings: [], openingSurfaces: [], frame: { x: 0, y: 0, w: 100, h: 100 }, openings: [], openingSurfaces: [], frame: { x: 0, y: 0, w: 100, h: 100 },
}, },
openings: [], amountOf: () => 0, overlays: () => null, cellCm: 5, 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, id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], 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 = { const input = {
space, space,
devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }], devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }],
@@ -855,7 +839,7 @@ const perfFixture = () => {
referenceView: { x: 0, y: 0, w: 100, h: 100 }, referenceView: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 }, display: { showNames: false, cardFontScale: 1 },
layers: { structural: true, shadows: true }, layers: { structural: true, shadows: true },
wallSilhouettes, structure,
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5, iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
kioskIconScale: 1, kioskFontScale: 1, kioskIconScale: 1, kioskFontScale: 1,
stageSize: { width: 100, height: 100 }, stageSize: { width: 100, height: 100 },
@@ -870,7 +854,7 @@ const perfFixture = () => {
openingEntityAvailable: () => false, openingEntityAvailable: () => false,
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }), openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
}; };
return { input, wallSilhouettes }; return { input, structure };
}; };
test('#570 supersedes #473 W1: selection reuses the cue-free low placement', () => { 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'); 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 { input } = perfFixture();
const withWall = buildIsoOverlayRenderScene(input).devices.get('device'); const withWall = buildIsoOverlayRenderScene(input).devices.get('device');
const noWalls = buildIsoOverlayRenderScene({ ...input, wallSilhouettes: [] }).devices.get('device'); const noWalls = buildIsoOverlayRenderScene({ ...input, structure: structureOf() }).devices.get('device');
assert.notStrictEqual(noWalls, withWall, 'a new geometry array is a new cache slot'); assert.notStrictEqual(noWalls, withWall, 'a new wall geometry is a new cache slot');
assert.deepEqual(noWalls.visualScene, withWall.visualScene, assert.deepEqual(noWalls.visualScene, withWall.visualScene,
'the tile position does not depend on nearby walls'); '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', () => { test('#713 AC3: live zoom never recomputes placement and every tile gets one straight-up shift', () => {
const { input } = perfFixture(); const { input } = perfFixture();
const lift = projectPlanPoint([0, 0], 0)[1] - projectPlanPoint([0, 0], ISO_WALL_HEIGHT)[1]; const lift = projectPlanPoint([0, 0], 0)[1] - projectPlanPoint([0, 0], ISO_WALL_HEIGHT)[1];
+2 -1
View File
@@ -197,7 +197,8 @@ test('#649 AC3 layout and collisions see the 2.5D tile size (× ISO_ICON_SCALE)'
}; };
const scene = buildIsoOverlayRenderScene({ const scene = buildIsoOverlayRenderScene({
space, devices: [{ id: 'd1' }], openings: [], view: { x: 0, y: 0, w: 1000, h: 1000 }, 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, iconPct: 3, deviceBasePct: 3, showLqi: false, cellCm: 5, kioskIconScale: 1, kioskFontScale: 1,
stageSize: { width: 1000, height: 1000 }, positionOf: () => ({ x: 500, y: 500 }), stageSize: { width: 1000, height: 1000 }, positionOf: () => ({ x: 500, y: 500 }),
presentationOf: () => presentation, labelPositionOf: () => ({ x: 0, y: 0 }), labelScaleOf: () => 1, presentationOf: () => presentation, labelPositionOf: () => ({ x: 0, y: 0 }), labelScaleOf: () => 1,
+8 -3
View File
@@ -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 floorEdgeHeight = gridVisualUnits\(ISO_FLOOR_EDGE_HEIGHT, input\.cellCm\)/);
assert.match(sceneRender, /const raisedHeight = gridVisualUnits\(ISO_RAISED_OVERLAY_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/); 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, const roomProjection = section(sceneRender,
'export function isoStructuralRoomGeometry', 'export type IsoStructuralOpeningHost'); 'export function isoStructuralRoomGeometry', 'export type IsoStructuralOpeningHost');
for (const field of ['id', 'x', 'y', 'w', 'h', 'poly', 'wall_ids']) 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, /projectIsoOpening\(basis, amountOf\(opening\)\)/);
assert.match(sceneRender, /lruWrite\(input\.cache, input\.source\.key, value, 8\)/); 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(card, /data-hp-iso-structural-builds=\$\{iso \? this\._isoStructuralBuildCount : nothing\}/);
assert.match(sceneRender, /const wallTops = isoWallSilhouettesOf\(structural\.walls, wallHeight\)/); // #724: the overlay caches are keyed by the structural wall geometry itself —
assert.match(sceneRender, /wallSilhouettes:\s*Object\.freeze\(\[[\s\S]*?\.\.\.wallTops,[\s\S]*?\.\.\.geometry\.sides\.map\(\(face\) => \(\{ outer: face\.points \}\)\),[\s\S]*?\]\)/); // 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', () => { test('show_borders:false keeps the Flat floor plane and removes every volume cue', () => {