fix(2.5D): keep device overlays stable while zooming

Issue: #651
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-26 03:41:32 +03:00
parent c6937f9fc7
commit 4ab7ecb4a2
79 changed files with 1153 additions and 679 deletions
+39
View File
@@ -11,6 +11,7 @@ import {
resolveIsoOverlayCollisions,
resolveIsoOverlayOwner,
resolveIsoOverlayPlacement,
resolveIsoOverlayRigidGroups,
} from '../test-build/iso-overlays.js';
import {
ISO_RAISED_OVERLAY_HEIGHT,
@@ -107,6 +108,44 @@ test('group collision separates a solvable dense set independently of input orde
'a previous exact placement is only an upper bound and cannot change the nearest result');
});
test('rigid overlay groups preserve a row and use one deterministic wall displacement', () => {
const room = square('room', 0, 0, 240, 160, [120, 80]);
const wall = { outer: [[0, 0], [240, 0], [240, 14], [0, 14]] };
const make = (id, x) => ({
id,
kind: id === 'lock' ? 'opening-lock' : 'device',
placement: placement({
kind: id === 'lock' ? 'opening-lock' : 'device',
floorAnchor: [x, 18], rooms: [room], preferredRoomId: room.id,
wallSilhouettes: [wall], footprintHalfSize: [5, 5], wallHeight: 0,
visualOffset: 0, sceneUnitsPerCssPixel: 1, camera: identityCamera,
}),
screenHalfSize: [7, 7],
});
const items = [make('a', 70), make('lock', 90), make('b', 110)];
const solve = (value) => resolveIsoOverlayRigidGroups({
items: value, rooms: [room], wallSilhouettes: [wall],
sceneUnitsPerCssPixel: 1, visualOffset: 0, camera: identityCamera,
});
const normal = solve(items);
const reversed = solve([...items].reverse());
const placements = items.map((item) => normal.placements.get(
isoOverlayCollisionKey(item.kind, item.id),
));
const nudges = placements.map((value) => value.nudgeCss);
nudges.slice(1).forEach((value) => assert.deepEqual(value, nudges[0],
'every member receives the exact same group vector'));
assert.ok(nudges[0][1] > 0, 'the complete row moves away from the wall');
assert.deepEqual(placements.map((value) => value.visualScene[1]),
[placements[0].visualScene[1], placements[0].visualScene[1], placements[0].visualScene[1]],
'a horizontal canonical row stays horizontal');
for (const item of items) {
const key = isoOverlayCollisionKey(item.kind, item.id);
assert.deepEqual(reversed.placements.get(key), normal.placements.get(key),
'input order cannot change a rigid layout');
}
});
test('group collision checks room-edge events before accepting a legal farther hint', () => {
const room = {
id: 'diagonal-strip',
+19 -16
View File
@@ -418,6 +418,7 @@ test('Stage 4 reuses pure overlay placements and fit probes skip collision searc
devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }],
openings: [],
view: { x: 0, y: 0, w: 100, h: 100 },
referenceView: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
layers: { structural: true, shadows: true },
wallSilhouettes,
@@ -460,13 +461,14 @@ test('Stage 4 reuses pure overlay placements and fit probes skip collision searc
assert.notStrictEqual(fit.devices.get('device'), live.devices.get('device'),
'fit and live placements use separate bounded cache entries');
const zoomed = buildIsoOverlayRenderScene({ ...input, view: { x: 0, y: 0, w: 120, h: 120 } });
assert.notStrictEqual(zoomed, live, 'a changed placement produces a new render-scene snapshot');
assert.notStrictEqual(zoomed.devices.get('device'), live.devices.get('device'),
'view scale invalidates the placement signature');
const zoomed = buildIsoOverlayRenderScene({ ...input, view: { x: -10, y: -10, w: 120, h: 120 } });
assert.strictEqual(zoomed, live,
'zooming out and panning reuse the same immutable scene placement');
assert.strictEqual(zoomed.devices.get('device'), live.devices.get('device'),
'live view is absent from the placement signature');
});
test('render scene separates device roots without moving labels and caches permutations', () => {
test('render scene keeps a close device cluster rigid without moving labels and caches permutations', () => {
const owner = {
...room('owner', 0, 0, 400, 400),
name: 'Owner label', area: 'living', settings: {},
@@ -500,17 +502,16 @@ test('render scene separates device roots without moving labels and caches permu
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
};
const scene = buildIsoOverlayRenderScene(input);
assert.deepEqual(scene.residualPairs, [], 'the roomy fixture must fully separate all roots');
assert.deepEqual(scene.residualPairs, [], 'intra-group overlap is not reported as inter-group debt');
const deviceEntries = scene.entries.filter((entry) => entry.kind === 'device');
for (let index = 0; index < deviceEntries.length; index++) {
assert.ok(deviceEntries[index].placement.nudgeDistanceCss <= 48);
for (let other = 0; other < index; other++) {
const a = deviceEntries[index], b = deviceEntries[other];
const dx = Math.abs(a.placement.visualScene[0] - b.placement.visualScene[0]);
const dy = Math.abs(a.placement.visualScene[1] - b.placement.visualScene[1]);
assert.ok(dx >= a.screenHalfSize[0] + b.screenHalfSize[0] + 8 - 1e-7
|| dy >= a.screenHalfSize[1] + b.screenHalfSize[1] + 8 - 1e-7,
`device roots ${a.id}/${b.id} must not overlap after the 4 CSS px gap`);
assert.deepEqual(a.placement.visualScene, b.placement.visualScene,
`device roots ${a.id}/${b.id} keep their canonical coincident relationship`);
assert.deepEqual(a.placement.nudgeCss, b.placement.nudgeCss,
`device roots ${a.id}/${b.id} receive one rigid displacement`);
}
}
const label = scene.entries.find((entry) => entry.kind === 'room-label');
@@ -854,6 +855,7 @@ const perfFixture = () => {
devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }],
openings: [],
view: { x: 0, y: 0, w: 100, h: 100 },
referenceView: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
layers: { structural: true, shadows: true },
wallSilhouettes,
@@ -897,7 +899,7 @@ test('#473 W2: кэш размещений привязан к идентичн
assert.notStrictEqual(noWalls, withWall);
});
test('#473 W3: при зуме внутрь плита у стены не переиспользуется вслепую', () => {
test('#651 supersedes #473 W3: live zoom never recomputes scene placement', () => {
const { input } = perfFixture();
// Далёкая плита: зум внутрь переиспользует доказанно безопасное размещение.
const far = { ...input, positionOf: () => ({ x: 60, y: 20 }) };
@@ -906,9 +908,9 @@ test('#473 W3: при зуме внутрь плита у стены не пер
const farZoomed = buildIsoOverlayRenderScene({ ...far, view: { x: 0, y: 0, w: 80, h: 80 } })
.devices.get('device');
assert.strictEqual(farZoomed, farLive, 'не у стены — переиспользуется');
// Плита у стены, которую не удалось очистить: зум внутрь обязан идти в
// точный резолвер. Гард `!nearWallBefore || cleared && …` без первой
// половины переиспользовал бы её на масштабе, где она уже режет стену.
// Плита у стены, которую не удалось очистить, также остаётся в тех же
// координатах сцены. Иначе экранный zoom снова становится layout-событием
// и возвращает пользовательский дрейф #651.
const pinned = {
...input,
// упор со всех сторон: узкая комната не даёт места для nudge
@@ -924,7 +926,8 @@ test('#473 W3: при зуме внутрь плита у стены не пер
}
const pinnedZoomed = buildIsoOverlayRenderScene({ ...pinned, view: { x: 0, y: 0, w: 80, h: 80 } })
.devices.get('device');
assert.notStrictEqual(pinnedZoomed, pinnedLive, 'у стены и не очищена — точный резолвер, не кэш');
assert.strictEqual(pinnedZoomed, pinnedLive,
'у стены и не очищена — тот же структурный layout, без zoom-feedback');
});
test('#473 W4: AABB-отсечение учитывает зазор безопасности', () => {