mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
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:
+4
-1
@@ -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
|
||||
|
||||
@@ -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 },",
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
+7
-45
@@ -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 };
|
||||
}
|
||||
|
||||
+20
-57
@@ -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<string, IsoSceneCacheEntry>;
|
||||
@@ -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<string, IsoOverlayPlacementCacheEntry>
|
||||
IsoWallGeometry, Map<string, IsoOverlayPlacementCacheEntry>
|
||||
>();
|
||||
const isoOverlayOwnerCache = new WeakMap<
|
||||
readonly IsoOverlayRoomRow[], Map<string, IsoOverlayOwnerCacheEntry>
|
||||
>();
|
||||
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<IsoWallGeometry, IsoOverlayRenderScene>();
|
||||
|
||||
function samePlacementMap<K>(
|
||||
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 locks = new Map<string, IsoOverlayPlacement>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+12
-14
@@ -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);
|
||||
});
|
||||
|
||||
+126
-47
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user