mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
- 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
129 lines
5.6 KiB
JavaScript
129 lines
5.6 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
ISO_CAMERA, ISO_OVERLAY_VISUAL_OFFSET, ISO_RAISED_OVERLAY_HEIGHT,
|
|
ISO_WALL_HEIGHT, applyIsoMatrix, clientToScenePoint, isoFloorMatrix,
|
|
isoOverlayVisualHeight, isoPlaneMatrix, isoPlaneMatrixCss, projectPlanPoint,
|
|
projectedFrame, unprojectFloorPoint,
|
|
} from '../test-build/iso-projection.js';
|
|
|
|
const close = (actual, expected, epsilon = 1e-9) =>
|
|
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
|
|
|
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]);
|
|
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));
|
|
close(roundTrip[0], point[0]);
|
|
close(roundTrip[1], point[1]);
|
|
}
|
|
});
|
|
|
|
test('floor matrix is identical to point projection', () => {
|
|
const [a, b, c, d, e, f] = isoFloorMatrix();
|
|
for (const point of [[0, 0], [250, 750], [-3000, 4000]]) {
|
|
const projected = projectPlanPoint(point, 0);
|
|
close(a * point[0] + c * point[1] + e, projected[0]);
|
|
close(b * point[0] + d * point[1] + f, projected[1]);
|
|
}
|
|
});
|
|
|
|
test('one canonical affine helper projects floor and raised planes', () => {
|
|
for (const z of [0, ISO_WALL_HEIGHT, ISO_RAISED_OVERLAY_HEIGHT]) {
|
|
const matrix = isoPlaneMatrix(z);
|
|
for (const point of [[0, 0], [250, 750], [-3000, 4000]]) {
|
|
const projected = projectPlanPoint(point, z);
|
|
const affine = applyIsoMatrix(point, matrix);
|
|
close(affine[0], projected[0]);
|
|
close(affine[1], projected[1]);
|
|
}
|
|
assert.match(isoPlaneMatrixCss(z), /^matrix\([-0-9.e ]+\)$/);
|
|
}
|
|
assert.deepEqual(isoPlaneMatrix(0), isoFloorMatrix());
|
|
assert.equal(isoOverlayVisualHeight(4), 4);
|
|
});
|
|
|
|
test('projected frame includes raised wall tops and is view-state independent', () => {
|
|
const rect = { x: 100, y: 200, w: 400, h: 300 };
|
|
const frame = projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT });
|
|
const floorOnly = projectedFrame({ rect, wallHeight: 0 });
|
|
assert.ok(frame.y < floorOnly.y);
|
|
assert.ok(frame.h > floorOnly.h);
|
|
assert.deepEqual(projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT }), frame);
|
|
});
|
|
|
|
test('Stage 2 frame includes opening tops and the structural floor edge, not blur', () => {
|
|
const rect = { x: 100, y: 200, w: 400, h: 300 };
|
|
const stage1 = projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT });
|
|
const stage2 = projectedFrame({
|
|
rect, wallHeight: ISO_WALL_HEIGHT, openingHeight: ISO_WALL_HEIGHT + 5, floorDepth: 10,
|
|
});
|
|
assert.ok(stage2.y < stage1.y);
|
|
assert.ok(stage2.h > stage1.h);
|
|
assert.deepEqual(Object.keys(stage2).sort(), ['h', 'w', 'x', 'y']);
|
|
});
|
|
|
|
test('Stage 4 frame already contains the low overlay plane at zero yaw', () => {
|
|
const rect = { x: 100, y: 200, w: 400, h: 300 };
|
|
const wallFrame = projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT });
|
|
const raisedFrame = projectedFrame({
|
|
rect,
|
|
wallHeight: ISO_WALL_HEIGHT,
|
|
raisedHeight: ISO_RAISED_OVERLAY_HEIGHT,
|
|
});
|
|
assert.deepEqual(raisedFrame, wallFrame, 'the wall top encloses the low overlay plane');
|
|
const projectedFloor = [
|
|
[rect.x, rect.y], [rect.x + rect.w, rect.y],
|
|
[rect.x + rect.w, rect.y + rect.h], [rect.x, rect.y + rect.h],
|
|
].map((point) => projectPlanPoint(point, 0));
|
|
const projectedWidth = Math.max(...projectedFloor.map((point) => point[0]))
|
|
- Math.min(...projectedFloor.map((point) => point[0]));
|
|
close(projectedWidth, rect.w);
|
|
});
|
|
|
|
test('client coordinates map through the current scene view', () => {
|
|
const scene = clientToScenePoint([250, 175], { left: 50, top: 25, width: 400, height: 300 },
|
|
{ x: 100, y: 200, w: 800, h: 600 });
|
|
assert.deepEqual(scene, [500, 500]);
|
|
});
|
|
|
|
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, 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));
|
|
assert.throws(() => projectedFrame({
|
|
rect: { x: 0, y: 0, w: 1, h: 1 }, wallHeight: 64, raisedHeight: -1,
|
|
}));
|
|
});
|