feat(iso): the 2.5D floor is the Flat plane, one wall-top rise for tiles (#713)

- Vertical oblique projection: the floor matrix is the identity and a height
  rises straight up by z·sin 20°, so the on-screen wall height is unchanged
  and the cos 20° foreshortening of the plan, decor and anchors is gone.
- Device tiles and lock badges stand on the wall-top plane with one common
  shift; the #651 placement search no longer runs in the live scene (its
  removal is #714). Room names keep their Flat floor point.
- The 2.5D fit no longer reserves the 48 CSS px nudge budget.
- Switching projection keeps the camera when the previous projection was on
  screen: saving the setting, entering an editor from 2.5D and adopting a warm
  memo from the other projection re-read only the scalar zoom. A cold 2.5D
  start still opens the 2.5D home.
- Opening faces are ordered along the oblique projector (s·y + z).

Witness: demo/smoke_iso_flat_parity.mjs (AC2–AC5, AC11) is red on the old code.

Issue: #713
User-Visible: yes
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 15:18:00 +03:00
parent e19f5b0a0e
commit 0d641ffbfd
26 changed files with 743 additions and 325 deletions
+2 -2
View File
@@ -446,7 +446,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 68);
assert.equal(GOLDEN_MATRIX_VERSION, 69);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -940,7 +940,7 @@ test('issue 570 Stage 4 reuses the historical iso goldens for visual handoff cov
test('#673 Stage 6 designer acceptance scenes are canonical golden entries', () => {
const ids = STAGE6_ACCEPTANCE_SCENARIOS.map((scenario) => scenario.id);
assert.equal(GOLDEN_MATRIX_VERSION, 68);
assert.equal(GOLDEN_MATRIX_VERSION, 69);
assert.deepEqual(ids, [
'isometric-stage6-light-lightfloor',
'isometric-stage6-light-darkfloor',
+15
View File
@@ -347,3 +347,18 @@ test('decoration degradation never removes structure or creates floating panels'
structural: true, panels: false, shadows: true, materialNuance: true, floorSymbols: false,
});
});
test('#713 AC7: opening depth runs along the oblique projector (s·y + z)', () => {
const s = Math.sin(20 * Math.PI / 180);
const basis = buildIsoOpeningBasis(opening());
const leaf = basis.leaves[0];
const top = projectIsoOpening(basis, 0)[0].surfaces.find((surface) => surface.kind === 'leaf-top');
assert.ok(top, 'the closed door leaf has a top face');
// The four top corners straddle the leaf axis symmetrically, so their mean
// plan point is the middle of the closed leaf.
const meanY = leaf.hinge[1] + leaf.closedVector[1] / 2;
assert.ok(Math.abs(top.cameraDepth - (s * meanY + leaf.top)) < 1e-9,
`${top.cameraDepth} != s·y + z = ${s * meanY + leaf.top}`);
assert.ok(Math.abs(top.cameraDepth - (s * meanY + leaf.top * Math.cos(20 * Math.PI / 180))) > 1e-3,
'the former orthographic key y·sin 20° + z·cos 20° is gone');
});
+22 -6
View File
@@ -10,20 +10,35 @@ import {
const close = (actual, expected, epsilon = 1e-9) =>
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
test('Stage 4 camera is the exact fixed 0°/20° orthographic camera', () => {
test('Stage 4 camera is the exact fixed zero-yaw camera with the 20° wall rise', () => {
assert.equal(ISO_CAMERA.rotDeg, 0);
assert.equal(ISO_CAMERA.tiltDeg, 20);
const origin = projectPlanPoint([500, 500], 0);
assert.deepEqual(origin, [500, 500]);
const x = projectPlanPoint([600, 500], 0);
const y = projectPlanPoint([500, 600], 0);
assert.ok(x[0] > 500 && x[1] === 500, 'positive plan X stays horizontal at zero yaw');
assert.ok(y[0] === 500 && y[1] > 500, 'positive plan Y follows the tilted vertical axis');
assert.equal(ISO_WALL_HEIGHT, 84);
assert.equal(ISO_OVERLAY_VISUAL_OFFSET, 4);
assert.equal(ISO_RAISED_OVERLAY_HEIGHT, 4);
});
test('#713 AC1: the 2.5D floor is the Flat plane and height rises straight up', () => {
const s = Math.sin(20 * Math.PI / 180);
assert.deepEqual(isoPlaneMatrix(0).map((value) => value + 0), [1, 0, 0, 1, 0, 0],
'the floor matrix is the identity: no cos 20° foreshortening');
for (const point of [[0, 0], [250, 750], [-3000, 4000], [1000, 1000]]) {
assert.deepEqual(projectPlanPoint(point, 0), point, 'floor points keep their Flat position');
assert.deepEqual(unprojectFloorPoint(point), point, 'floor hit testing is the identity too');
for (const z of [ISO_WALL_HEIGHT, 7, 168]) {
const lifted = projectPlanPoint(point, z);
close(lifted[0], point[0]);
close(lifted[1], point[1] - z * s);
}
}
const wall = [300, 400];
close(projectPlanPoint(wall, 0)[1] - projectPlanPoint(wall, ISO_WALL_HEIGHT)[1],
ISO_WALL_HEIGHT * 0.3420201433256687, 1e-12);
close(ISO_WALL_HEIGHT * s, 28.729692039353627, 1e-9);
});
test('floor projection round-trips across the infinite canvas contract', () => {
for (const point of [[-5000, -5000], [0, 0], [500, 500], [5000, 5000], [-1234.5, 4321.25]]) {
const roundTrip = unprojectFloorPoint(projectPlanPoint(point, 0));
@@ -102,7 +117,8 @@ test('client coordinates map through the current scene view', () => {
test('degenerate cameras and frames throw instead of mixing projections', () => {
assert.throws(() => projectPlanPoint([0, 0], 0, { ...ISO_CAMERA, xyScale: 0 }));
assert.throws(() => unprojectFloorPoint([0, 0], { ...ISO_CAMERA, tiltDeg: 90 }));
assert.throws(() => unprojectFloorPoint([0, 0], { ...ISO_CAMERA, xyScale: 0 }));
assert.throws(() => unprojectFloorPoint([Number.NaN, 0]));
assert.throws(() => clientToScenePoint([0, 0], { left: 0, top: 0, width: 0, height: 1 },
{ x: 0, y: 0, w: 1, h: 1 }));
assert.throws(() => isoOverlayVisualHeight(-1));
+53 -50
View File
@@ -37,6 +37,7 @@ import {
ISO_OVERLAY_VISUAL_OFFSET,
ISO_RAISED_OVERLAY_HEIGHT,
ISO_WALL_HEIGHT,
projectPlanPoint,
unprojectFloorPoint,
} from '../test-build/iso-projection.js';
@@ -140,7 +141,7 @@ test('overlay bounds use final screen footprint and canonical owner filtering',
assert.deepEqual(isoOverlaySceneBounds(scene), { x: 15, y: 27, w: 195, h: 281 });
});
test('stable overlay fit contains a maximum final nudge without zoom feedback', () => {
test('#713 K8: overlay fit is the structure plus visible tiles, no #651 nudge reserve', () => {
const entry = {
id: 'edge', kind: 'device', groundRadius: 1, screenHalfSize: [10, 8],
placement: {
@@ -163,17 +164,9 @@ test('stable overlay fit contains a maximum final nudge without zoom feedback',
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry], stageSize, targetView,
});
assert.ok(fitted);
const unitsPerPixel = Math.max(
fitted.view.w / stageSize.width, fitted.view.h / stageSize.height,
);
const dx = 48 * unitsPerPixel;
const actual = { entries: [{ ...entry, placement: {
...entry.placement, visualScene: [95 + dx, 50], nudgeScene: [dx, 0],
footprint: entry.placement.footprint.map((point) => [point[0] + dx, point[1]]),
} }] };
const final = isoOverlaySceneBounds(actual);
assert.ok(final.x >= fitted.bounds.x - 1e-7
&& final.x + final.w <= fitted.bounds.x + fitted.bounds.w + 1e-7);
assert.deepEqual(fitted.bounds, { x: 0, y: 0, w: 105, h: 100 },
'the tile edge at x=105 is the only growth: no 48 CSS px reserve around it');
assert.deepEqual(fitted.view, targetView(fitted.bounds));
const repeated = resolveIsoOverlayFitEnvelope({
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry], stageSize, targetView,
});
@@ -400,7 +393,11 @@ test('opening lock without a canonical host owner never guesses from point conta
const placement = scene.locks.get('partition-door');
assert.equal(placement?.owner, null);
assert.equal(placement?.nudged, false);
assert.equal(placement?.reason, 'missing-owner');
// #713: no placement search runs, so a missing owner is no longer a search
// failure; the badge simply stands on the wall-top plane above its anchor.
assert.equal(placement?.reason, null);
assert.equal(placement?.plane, 'raised');
assert.deepEqual(placement?.visualScene, projectPlanPoint(placement.floorAnchor, ISO_WALL_HEIGHT));
assert.equal(placement?.tether.visible, false);
});
@@ -446,8 +443,11 @@ test('Stage 4 reuses pure overlay placements and fit probes skip collision searc
'an unchanged frame reuses the exact render-scene snapshot for Lit guards');
assert.strictEqual(repeated.devices.get('device'), live.devices.get('device'),
'unchanged HA/render passes reuse the exact pure placement result');
assert.equal(live.devices.get('device')?.nearWallBefore, true);
assert.equal(live.devices.get('device')?.cleared, true);
// #713: the tile stands on the wall-top plane right above its anchor even
// next to a wall; no wall test and no nudge in the live scene.
assert.equal(live.devices.get('device')?.nearWallBefore, false);
assert.equal(live.devices.get('device')?.nudged, false);
assert.deepEqual(live.devices.get('device')?.visualScene, projectPlanPoint([5, 50], ISO_WALL_HEIGHT));
const zoomedIn = buildIsoOverlayRenderScene({
...input, view: { x: 0, y: 0, w: 80, h: 80 },
@@ -458,8 +458,8 @@ test('Stage 4 reuses pure overlay placements and fit probes skip collision searc
const fit = buildIsoOverlayRenderScene({ ...input, resolveCollisions: false });
assert.equal(fit.devices.get('device')?.nearWallBefore, false,
'fit envelope uses unnudged bounds without running wall collision search');
assert.notStrictEqual(fit.devices.get('device'), live.devices.get('device'),
'fit and live placements use separate bounded cache entries');
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');
const zoomed = buildIsoOverlayRenderScene({ ...input, view: { x: -10, y: -10, w: 120, h: 120 } });
assert.strictEqual(zoomed, live,
@@ -940,46 +940,49 @@ 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: кэш размещений привязан к идентичности массива силуэтов', () => {
test('#473 W2 after #713: walls no longer move a tile, the cache still follows the silhouette array', () => {
const { input } = perfFixture();
const withWall = buildIsoOverlayRenderScene(input).devices.get('device');
assert.equal(withWall.nearWallBefore, true, 'фикстура: стена рядом с плитой');
// Новая геометрия — новый массив. Кэш, ключуемый константой, отдал бы
// размещение «у стены» для плана, в котором стены больше нет.
const noWalls = buildIsoOverlayRenderScene({ ...input, wallSilhouettes: [] }).devices.get('device');
assert.equal(noWalls.nearWallBefore, false, 'без стен плита не у стены');
assert.notStrictEqual(noWalls, withWall);
assert.notStrictEqual(noWalls, withWall, 'a new geometry array is a new cache slot');
assert.deepEqual(noWalls.visualScene, withWall.visualScene,
'the tile position does not depend on nearby walls');
assert.equal(withWall.nearWallBefore, false);
assert.equal(withWall.nudged, false);
});
test('#651 supersedes #473 W3: live zoom never recomputes scene placement', () => {
test('#713 AC3: live zoom never recomputes placement and every tile gets one straight-up shift', () => {
const { input } = perfFixture();
// Далёкая плита: зум внутрь переиспользует доказанно безопасное размещение.
const far = { ...input, positionOf: () => ({ x: 60, y: 20 }) };
const farLive = buildIsoOverlayRenderScene(far).devices.get('device');
assert.equal(farLive.nearWallBefore, false);
const farZoomed = buildIsoOverlayRenderScene({ ...far, view: { x: 0, y: 0, w: 80, h: 80 } })
.devices.get('device');
assert.strictEqual(farZoomed, farLive, 'не у стены — переиспользуется');
// Плита у стены, которую не удалось очистить, также остаётся в тех же
// координатах сцены. Иначе экранный zoom снова становится layout-событием
// и возвращает пользовательский дрейф #651.
const pinned = {
...input,
// упор со всех сторон: узкая комната не даёт места для nudge
space: { ...input.space, rooms: [room('owner', 0, 44, 12, 56)] },
positionOf: () => ({ x: 5, y: 50 }),
};
const pinnedLive = buildIsoOverlayRenderScene(pinned).devices.get('device');
assert.equal(pinnedLive.nearWallBefore, true);
if (pinnedLive.cleared) {
// Фикстура не смогла создать неочищенную плиту — тест обязан сказать об
// этом честно, а не пройти молча (правило после #426).
assert.fail('фикстура «у стены, не очищена» не построилась: cleared=true');
const lift = projectPlanPoint([0, 0], 0)[1] - projectPlanPoint([0, 0], ISO_WALL_HEIGHT)[1];
for (const position of [{ x: 60, y: 20 }, { x: 5, y: 50 }]) {
const at = { ...input, positionOf: () => position };
const live = buildIsoOverlayRenderScene(at).devices.get('device');
assert.deepEqual(live.floorScene, [position.x, position.y], 'the floor anchor is the Flat point');
assert.equal(live.visualScene[0], position.x, 'no horizontal displacement');
assert.ok(Math.abs(live.floorScene[1] - live.visualScene[1] - lift) < 1e-9,
'the vertical displacement is the wall-top rise for every tile');
for (const view of [{ x: 0, y: 0, w: 80, h: 80 }, { x: -20, y: -10, w: 140, h: 140 }]) {
const zoomed = buildIsoOverlayRenderScene({ ...at, view }).devices.get('device');
assert.strictEqual(zoomed, live, 'zoom and pan are not layout events');
}
}
const pinnedZoomed = buildIsoOverlayRenderScene({ ...pinned, view: { x: 0, y: 0, w: 80, h: 80 } })
.devices.get('device');
assert.strictEqual(pinnedZoomed, pinnedLive,
'у стены и не очищена — тот же структурный layout, без zoom-feedback');
});
test('#713 K5: room names stay on the Flat floor point while devices share the wall-top rise', () => {
const { input } = perfFixture();
const owner = { ...input.space.rooms[0], name: 'Owner' };
const scene = buildIsoOverlayRenderScene({
...input,
space: { ...input.space, rooms: [owner] },
display: { showNames: true, cardFontScale: 1 },
labelPositionOf: () => ({ x: 30, y: 40 }),
});
const label = scene.rooms.get(owner);
assert.ok(label, 'the room label is placed');
assert.deepEqual(label.visualScene, [30, 40], 'the name keeps its Flat position');
assert.equal(label.nudged, false);
const device = scene.devices.get('device');
assert.deepEqual(device.visualScene, projectPlanPoint([5, 50], ISO_WALL_HEIGHT));
});
test('#473 W4: AABB-отсечение учитывает зазор безопасности', () => {
+15 -9
View File
@@ -147,11 +147,15 @@ test('Stage 4 keeps device, room and lock roots on one corrected low visual plan
'_renderSunRays(space)', '_renderOpenings(disp)',
'_renderVacuums(this._renderVacuumDevices, view, space.id)',
]) assert.ok(card.includes(renderer), `missing ${renderer}`);
assert.match(card, /const point = isoPlacement\?\.visualScene \?\? this\._scenePoint\(\[pos\.x, pos\.y\]\)/);
assert.match(card, /const point = isoPlacement\?\.visualScene \?\? this\._scenePoint\(\[p\.x, p\.y\]\)/);
assert.match(card, /const point = isoPlacement\?\.visualScene \?\? this\._scenePoint\(floorAnchor\)/);
// #713: the 2.5D floor is the Flat plane, so a floor anchor is its own scene point.
assert.match(card, /const point = isoPlacement\?\.visualScene \?\? \[pos\.x, pos\.y\]/);
assert.match(card, /const point = isoPlacement\?\.visualScene \?\? \[p\.x, p\.y\]/);
assert.match(card, /const point = isoPlacement\?\.visualScene \?\? floorAnchor/);
assert.doesNotMatch(card, /_scenePoint/);
assert.doesNotMatch(readFileSync(new URL('../src/live-editor.ts', import.meta.url), 'utf8'), /_scenePoint/,
'the live editor paints a dragged marker at its Flat point too');
const vacuum = section(card, 'private _renderVacuums(', 'private _renderDevice(');
assert.match(vacuum, /const point = this\._scenePoint\(\[cx, cy\]\)/);
assert.match(vacuum, /const point = \[cx, cy\]/);
assert.doesNotMatch(vacuum, /visualScene|raisedScene|resolveIsoOverlayPlacement/);
for (const kind of ['device', 'room-label', 'opening-lock'])
assert.match(card, new RegExp(`data-hp-iso-overlay-kind=\\$\\{isoPlacement\\?\\.plane === 'raised' \\? '${kind}'`));
@@ -162,7 +166,7 @@ test('Stage 4 structural cache fingerprints geometry/camera/heights and excludes
assert.match(sceneRender, /flipV: !!opening\.flip_v/);
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: 5/);
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 roomProjection = section(sceneRender,
'export function isoStructuralRoomGeometry', 'export type IsoStructuralOpeningHost');
@@ -204,16 +208,18 @@ test('Stage 4 structural cache fingerprints geometry/camera/heights and excludes
assert.match(sceneRender, /wallSilhouettes:\s*Object\.freeze\(\[[\s\S]*?\.\.\.wallTops,[\s\S]*?\.\.\.geometry\.sides\.map\(\(face\) => \(\{ outer: face\.points \}\)\),[\s\S]*?\]\)/);
});
test('show_borders:false keeps the exact zero-yaw floor matrix and removes every volume cue', () => {
test('show_borders:false keeps the Flat floor plane and removes every volume cue', () => {
assert.match(card, /isoLayers && !isoLayers\.floorSymbols/);
assert.match(card, /<svg class="plan-svg"[\s\S]*?data-hp-live-viewbox=\$\{iso \? 'camera' : 'floor'\}/);
assert.match(card, /transform=\$\{iso \? isoFloorMatrixCss\(\) : nothing\}/);
// #713: the floor group carries no transform in either branch.
assert.match(card, /<g class=\$\{iso \? 'iso-floor-scene' : nothing\}>/);
assert.doesNotMatch(card, /isoFloorMatrixCss|unprojectFloorPoint/);
assert.match(card,
/\$\{litCache\(iso && isoLayers\?\.structural \? svg`<svg class="iso-shadows-svg"/);
assert.match(card, /if \(!runtime \|\| !layers\?\.structural \|\| !structural\) return null/);
const baseView = section(card, 'private _baseVb(', '/** How many objects');
assert.match(baseView, /if \(!this\._spaceDisplayForRender\(\)\.showBorders\)[\s\S]*?projectedFrame\(\{[\s\S]*?wallHeight:\s*0/);
const effective = section(card, 'private _effectiveProjection()', 'private _scenePoint');
const effective = section(card, 'private _effectiveProjection()', 'private _floorView');
assert.ok(effective.indexOf("if (!this._spaceDisplayForRender().showBorders)")
< effective.indexOf('const source = this._isoSource()'));
assert.doesNotMatch(baseView, /ISO_WALL_HEIGHT|raisedHeight|floorDepth/);
@@ -221,7 +227,7 @@ test('show_borders:false keeps the exact zero-yaw floor matrix and removes every
});
test('one frame resolves one structural source and latches late topology/projection failures', () => {
const effective = section(card, 'private _effectiveProjection()', 'private _scenePoint');
const effective = section(card, 'private _effectiveProjection()', 'private _floorView');
assert.equal([...effective.matchAll(/this\._isoSource\(\)/g)].length, 1);
assert.match(effective, /this\._isoScene\(source\)/);
assert.match(effective, /this\._isoFallback\.has\(key\)[\s\S]*?try \{/,