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
+1 -1
View File
@@ -714,7 +714,7 @@ try {
const currentView = card._viewOr(card._baseVb());
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
const position = currentDevice ? card._pos(currentDevice) : null;
const scene = position ? card._scenePoint([position.x, position.y]) : null;
const scene = position ? [position.x, position.y] : null;
const deviceRect = device.getBoundingClientRect();
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
+1 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 68;
export const GOLDEN_MATRIX_VERSION = 69;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
-1
View File
@@ -34,7 +34,6 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_rszMove',
'_rszRooms',
'_setMode',
'_scenePoint',
'_viewOr',
]),
fields: Object.freeze([
+332
View File
@@ -0,0 +1,332 @@
// #713: the 2.5D floor is the Flat plane. Switching projection keeps the
// floor, the decor and the room names on the same CSS pixels, lifts every
// device tile and lock badge by one straight-up wall-top rise, and keeps the
// camera when the previous projection was on screen (setting, editor entry,
// warm remount); a cold 2.5D start still opens the 2.5D home.
// FAILS on the build before #713: the floor was foreshortened by cos 20°
// (15–23 px at the edges), tiles moved by per-cluster vectors and the scalar
// zoom was carried instead of the camera.
import { launch, check, finish } from './serve.mjs';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
const LIFT_UNITS = 84 * Math.sin(20 * Math.PI / 180);
// The facade waits for `.stage.projection-iso`, i.e. for the lazy 2.5D runtime.
const setIso = (page, on) => page.evaluate(async (value) => {
await window.__hpTest.setVolumetricView(value);
}, on);
const out = {};
const decorate = () => window.__hpTest.setServerConfig((cfg) => {
const space = cfg.spaces.find((item) => item.id === 'f1');
space.settings = { ...(space.settings || {}), show_borders: true, show_names: true };
space.partitions = [{ id: 'p713-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15 }];
space.openings = [
{ id: 'p713-door', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.12,
contact: 'binary_sensor.window', lock: 'lock.front_door' },
];
space.decor = [
{ id: 'nw', kind: 'furniture', symbol: 'sofa', x: 0.10, y: 0.10, w: 0.12, h: 0.06, width_cm: 2 },
{ id: 'ne', kind: 'furniture', symbol: 'sofa', x: 0.78, y: 0.10, w: 0.12, h: 0.06, width_cm: 2 },
{ id: 'c', kind: 'furniture', symbol: 'bed', x: 0.44, y: 0.47, w: 0.12, h: 0.10, width_cm: 2 },
{ id: 'sw', kind: 'furniture', symbol: 'sofa', x: 0.10, y: 0.84, w: 0.12, h: 0.06, width_cm: 2 },
{ id: 'se', kind: 'furniture', symbol: 'sofa', x: 0.78, y: 0.84, w: 0.12, h: 0.06, width_cm: 2 },
{ id: 'line', kind: 'line', x1: 0.12, y1: 0.7, x2: 0.45, y2: 0.7, width_cm: 1 },
{ id: 'box', kind: 'rect', x: 0.6, y: 0.6, w: 0.1, h: 0.08, angle: 30, width_cm: 1 },
];
cfg.settings = { ...(cfg.settings || {}), volumetric_view: false };
return cfg;
});
/** Everything the owner watches, in stage CSS px, plus the camera. */
const snapshot = () => window.__hpSnap713();
const installSnapshot = () => {
window.__hpSnap713 = () => {
const card = window.__card;
const root = card.renderRoot;
const stage = root.querySelector('.stage');
const s = stage.getBoundingClientRect();
const box = (el) => {
const r = el.getBoundingClientRect();
return [r.left - s.left, r.top - s.top, r.width, r.height];
};
const centre = (el) => {
const r = el.getBoundingClientRect();
return [r.left - s.left + r.width / 2, r.top - s.top + r.height / 2];
};
// Screen-facing roots are positioned by their anchor (left/top in % of the
// stage); the tile's own #649 lift and edge are styling on top of it.
const anchor = (el) => [parseFloat(el.style.left) / 100 * s.width,
parseFloat(el.style.top) / 100 * s.height];
const byId = (selector, fn) => Object.fromEntries([...root.querySelectorAll(selector)]
.filter((el) => el.dataset.id).map((el) => [el.dataset.id, fn(el)]));
const view = card._view ? { ...card._view } : null;
return {
iso: stage.classList.contains('projection-iso'),
stage: [stage.clientWidth, stage.clientHeight],
rooms: byId('[data-hp="room"]', box),
decor: byId('.decorlayer [data-hp="decor"]:not(.dselecthit):not(.derasehit):not(.dfurniturehit)', box),
labels: byId('.roomlabel', centre),
devices: byId('[data-hp="device"]', anchor),
// Lock badges carry no id of their own; the opening order is stable.
locks: Object.fromEntries([...root.querySelectorAll('.oplock:not(.iso-tile-shadow)')]
.map((el, index) => [`lock${index}`, anchor(el)])),
backdrop: [...root.querySelectorAll('.hp-backdrop')].map(box),
view,
zoom: card._zoom,
pxPerUnit: view ? stage.clientWidth / view.w : NaN,
};
};
window.__hpFitW713 = () => {
const card = window.__card;
const vb = card._baseVb();
const v = card._view;
const aspect = v.w / v.h;
return aspect > vb[2] / vb[3] ? vb[3] * aspect : vb[2];
};
};
const maxDelta = (a, b) => {
let worst = 0, missing = 0;
for (const [key, value] of Object.entries(a)) {
const other = b[key];
if (!other) { missing++; continue; }
for (let i = 0; i < value.length; i++) worst = Math.max(worst, Math.abs(value[i] - other[i]));
}
return { worst, missing, count: Object.keys(a).length };
};
const sameView = (a, b, eps = 0.01) => !!a && !!b
&& ['x', 'y', 'w', 'h'].every((key) => Math.abs(a[key] - b[key]) <= eps);
// ---- AC2 / AC3 / AC4 / AC5 on two stage shapes ----
for (const [label, width, height] of [['wide', 1280, 900], ['tall', 1800, 700]]) {
const { page, browser } = await launch({ width, height });
await page.evaluate(installSnapshot);
await page.evaluate(decorate);
const flat = await page.evaluate(snapshot);
await setIso(page, true);
const iso = await page.evaluate(snapshot);
const floor = ['rooms', 'decor', 'labels'].map((key) => [key, maxDelta(flat[key], iso[key])]);
const backdrop = maxDelta(Object.assign({}, flat.backdrop), Object.assign({}, iso.backdrop));
out[`${label}AC2FloorDecorLabelsKeepPixels`] = iso.iso && !flat.iso
&& floor.every(([, d]) => d.count > 0 && d.missing === 0 && d.worst <= 0.5)
&& backdrop.missing === 0 && backdrop.worst <= 0.5;
out[`${label}AC2Detail`] = JSON.stringify(Object.fromEntries(floor.map(([k, d]) => [k, +d.worst.toFixed(2)])));
const lift = LIFT_UNITS * (5 / await page.evaluate(() => window.__card._cellCm)) * iso.pxPerUnit;
const shifts = [...Object.entries(iso.devices), ...Object.entries(iso.locks)].map(([id, point]) => {
const before = flat.devices[id] || flat.locks[id];
return before ? [point[0] - before[0], point[1] - before[1]] : [NaN, NaN];
});
out[`${label}AC3OneStraightUpShift`] = shifts.length >= 5
&& Object.keys(iso.locks).length >= 1
&& shifts.every(([dx, dy]) => Math.abs(dx) <= 0.5 && Math.abs(dy + lift) <= 0.5);
out[`${label}AC3Detail`] = JSON.stringify({ lift: +lift.toFixed(2),
shifts: shifts.map(([dx, dy]) => [+dx.toFixed(2), +dy.toFixed(2)]) });
if (label === 'wide') {
// AC5 (a): a moved, zoomed camera survives the switch in both directions.
await page.evaluate(async () => {
const card = window.__card;
card._applyView(1.7, 620, 380);
await window.__hpTest.settled();
});
const isoCam = await page.evaluate(snapshot);
await setIso(page, false);
const flatCam = await page.evaluate(snapshot);
const flatFitW = await page.evaluate(() => window.__hpFitW713());
out.AC5SettingOffKeepsCamera = Math.abs(isoCam.zoom - 1.7) < 1e-9
&& sameView(flatCam.view, isoCam.view)
&& Math.abs(flatCam.zoom - flatFitW / flatCam.view.w) < 1e-6
&& maxDelta(isoCam.rooms, flatCam.rooms).worst <= 0.5;
await setIso(page, true);
const isoBack = await page.evaluate(snapshot);
const isoFitW = await page.evaluate(() => window.__hpFitW713());
out.AC5SettingOnKeepsCamera = sameView(isoBack.view, flatCam.view)
&& Math.abs(isoBack.zoom - isoFitW / isoBack.view.w) < 1e-6
&& maxDelta(flatCam.rooms, isoBack.rooms).worst <= 0.5;
// AC5 (b): entering the Plan editor from 2.5D equals entering it from a
// Flat View that shows the same floor picture; leaving restores the View.
const settleMode = async (mode) => page.evaluate(async (next) => {
await window.__hpTest.setMode(next);
const card = window.__card;
for (let i = 0; i < 90 && (card._modeTransitionBusy || card._modeTransition?.state); i++)
await new Promise((done) => requestAnimationFrame(done));
await window.__hpTest.settled();
return card._view ? { ...card._view } : null;
}, mode);
const viewIso = isoBack.view;
const editorFromIso = await settleMode('plan');
const backToIso = await settleMode('view');
await setIso(page, false);
const flatSame = await page.evaluate(snapshot);
const editorFromFlat = await settleMode('plan');
await settleMode('view');
out.AC5EditorEntryMatchesFlat = sameView(flatSame.view, viewIso)
&& sameView(editorFromIso, editorFromFlat);
out.AC5LeavingEditorRestoresView = sameView(backToIso, viewIso);
out.AC5Detail = JSON.stringify({ editorFromIso, editorFromFlat });
// AC6: a floor point hits the same room at the same client point.
await setIso(page, false);
const hit = () => page.evaluate(() => {
const root = window.__card.renderRoot;
const s = root.querySelector('.stage').getBoundingClientRect();
return [[0.3, 0.55], [0.7, 0.4], [0.2, 0.3]].map(([fx, fy]) => {
const stack = root.elementsFromPoint(s.left + s.width * fx, s.top + s.height * fy);
return stack.find((el) => el.dataset?.hp === 'room')?.dataset.id ?? null;
});
});
const flatHits = await hit();
await setIso(page, true);
const isoHits = await hit();
out.AC6FloorHitSameRoom = flatHits.some(Boolean)
&& JSON.stringify(flatHits) === JSON.stringify(isoHits);
// AC4: without borders the whole 2.5D scene is the Flat geometry.
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => {
const space = cfg.spaces.find((item) => item.id === 'f1');
space.settings = { ...space.settings, show_borders: false };
cfg.settings = { ...cfg.settings, volumetric_view: false };
return cfg;
}));
await page.evaluate(async () => {
const card = window.__card;
card._fitAll();
for (let i = 0; i < 120 && card._cameraTransition.active; i++)
await new Promise((done) => requestAnimationFrame(done));
await window.__hpTest.settled();
});
const flatNoBorders = await page.evaluate(snapshot);
await setIso(page, true);
const isoNoBorders = await page.evaluate(snapshot);
const noBorders = ['rooms', 'decor', 'labels', 'devices', 'locks']
.map((key) => [key, maxDelta(flatNoBorders[key], isoNoBorders[key])]);
out.AC4NoBordersIsFlatGeometry = isoNoBorders.iso
&& noBorders.every(([, d]) => d.count > 0 && d.missing === 0 && d.worst <= 0.5);
out.AC4Detail = JSON.stringify(Object.fromEntries(noBorders));
}
await finish(browser);
}
// ---- AC11: warm remount across a projection change, and the cold 2.5D start ----
{
const { page, browser } = await launch({ width: 1280, height: 900 });
await page.evaluate(installSnapshot);
await page.evaluate(decorate);
const result = await page.evaluate(async () => {
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const host = document.getElementById('host');
const remount = async (volumetric) => {
const cfg = structuredClone(window.__card._serverCfg);
cfg.settings = { ...(cfg.settings || {}), volumetric_view: volumetric };
window.__card.remove();
window.__pushServerConfig(cfg);
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card' });
host.replaceChildren(card);
card.hass = window.__mkHass();
window.__card = card;
const started = performance.now();
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
if (volumetric) await card._ensureIsoSceneRuntime();
const stage = () => card.renderRoot.querySelector('.stage');
while (stage()?.classList.contains('projection-iso') !== volumetric
&& performance.now() - started < 9000) await wait(30);
await window.__hpTest.settled();
return card;
};
const res = {};
// The memo belongs to this card configuration: mount it once cold first.
await remount(false);
// Flat memo → 2.5D after remount.
window.__card._applyView(1.6, 540, 460);
await window.__hpTest.settled();
const flatView = { ...window.__card._view };
let card = await remount(true);
res.warmFlatToIso = !card._booting && card.renderRoot.querySelector('.stage.projection-iso')
&& ['x', 'y', 'w', 'h'].every((k) => Math.abs(card._view[k] - flatView[k]) <= 0.01)
&& Math.abs(card._zoom - window.__hpFitW713() / card._view.w) < 1e-6;
res.warmFlatToIsoDetail = JSON.stringify({ flatView, view: card._view, zoom: card._zoom });
// 2.5D memo → Flat after remount.
card._applyView(1.4, 470, 520);
await window.__hpTest.settled();
const isoView = { ...card._view };
card = await remount(false);
res.warmIsoToFlat = !card._booting && !card.renderRoot.querySelector('.stage.projection-iso')
&& ['x', 'y', 'w', 'h'].every((k) => Math.abs(card._view[k] - isoView[k]) <= 0.01)
&& Math.abs(card._zoom - window.__hpFitW713() / card._view.w) < 1e-6;
res.warmIsoToFlatDetail = JSON.stringify({ isoView, view: card._view, zoom: card._zoom });
// Cold 2.5D start: no memo, home frame at zoom 1.
customElements.get('houseplan-card')?._warmBootReset?.();
card = await remount(true);
const vb = card._baseVb();
const v = card._viewOr(vb);
const aspect = v.w / v.h;
const homeW = aspect > vb[2] / vb[3] ? vb[3] * aspect : vb[2];
res.coldIsoStartsAtHome = Math.abs(card._zoom - 1) < 1e-9 && Math.abs(v.w - homeW) < 0.01
&& !!card.renderRoot.querySelector('.stage.projection-iso');
return res;
});
out.AC11WarmFlatToIsoKeepsCamera = !!result.warmFlatToIso;
out.AC11WarmIsoToFlatKeepsCamera = !!result.warmIsoToFlat;
out.AC11ColdIsoStartsAtHome = !!result.coldIsoStartsAtHome;
out.AC11Detail = `${result.warmFlatToIsoDetail} ${result.warmIsoToFlatDetail}`;
await finish(browser);
}
// ---- AC3 on large-house floor 1 (80 devices): one rise for every tile ----
{
const { page, browser } = await launch({ width: 1280, height: 900 });
const fixture = makeLargeHouseFixture();
fixture.config.settings = { ...(fixture.config.settings || {}), volumetric_view: true };
for (const space of fixture.config.spaces) space.settings = { ...(space.settings || {}), show_borders: true };
const res = await page.evaluate(async (fixture) => {
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
window.__card?.remove?.();
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: 'p713', icon_size: 3.4 });
const hass = { language: 'en', locale: { language: 'en' }, user: { id: 'p', name: 'p', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states: fixture.states,
floors: { one: { floor_id: 'one', name: 'One', level: 0 } },
callWS: async (m) => m.type === 'houseplan/config/get' ? { config: structuredClone(fixture.config), rev: 1, can_write: true }
: m.type === 'houseplan/layout/get' ? { layout: structuredClone(fixture.layout), rev: 1 }
: m.type === 'config/device_registry/list' ? Object.values(fixture.devices)
: m.type === 'config/entity_registry/list' ? Object.values(fixture.entities) : { ok: true },
callService: async () => undefined,
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
localize: () => null, formatEntityState: (s) => s.state, config: { unit_system: { length: 'km' } } };
document.getElementById('host').replaceChildren(card);
card.hass = hass;
await card._ensureIsoSceneRuntime();
const started = performance.now();
while (!(card._loadOk && card._model?.length) && performance.now() - started < 15000) await wait(30);
while (!card.renderRoot.querySelector('.stage.projection-iso') && performance.now() - started < 15000) await wait(30);
await card.updateComplete;
await wait(300);
const lift = 84 * (5 / card._cellCm) * Math.sin(20 * Math.PI / 180);
const roots = [...card.renderRoot.querySelectorAll('[data-hp-iso-raised="true"][data-hp-iso-floor]')]
.filter((el) => el.dataset.hpIsoOverlayKind !== 'room-label');
const bad = roots.filter((el) => {
const [fx, fy] = el.dataset.hpIsoFloor.split(',').map(Number);
const [vx, vy] = el.dataset.hpIsoVisual.split(',').map(Number);
return Math.abs(vx - fx) > 1e-6 || Math.abs(fy - vy - lift) > 1e-6
|| el.dataset.hpIsoNudged !== 'false';
});
const labels = [...card.renderRoot.querySelectorAll('[data-hp-iso-overlay-kind="room-label"]')];
const movedLabels = labels.filter((el) => el.dataset.hpIsoFloor !== el.dataset.hpIsoVisual);
return { devices: roots.filter((el) => el.dataset.hpIsoOverlayKind === 'device').length,
bad: bad.length, labels: labels.length, movedLabels: movedLabels.length };
}, fixture);
out.AC3LargeHouseOneRise = res.devices >= 80 && res.bad === 0;
out.AC3LargeHouseNamesOnFloor = res.labels > 0 && res.movedLabels === 0;
out.AC3LargeHouseDetail = JSON.stringify(res);
await finish(browser);
}
for (const [key, value] of Object.entries(out)) {
if (key.endsWith('Detail')) continue;
check(key, value, true);
}
console.log(JSON.stringify(out, null, 1));
await finish(null);
+17 -2
View File
@@ -173,11 +173,26 @@ const out = await page.evaluate(async () => {
['iso-centred-gate', 'leaf-front', 'matte-leaf'],
].every(([id, surface, material]) => hasOpeningSurface(id, surface, material))
&& !root().querySelector('[data-hp="iso-openings"] .iso-material-dark-glass');
// #713: device tiles and lock badges rise straight up by the wall-top height;
// room names keep their Flat floor point.
const isoLift = 84 * (5 / card._cellCm) * Math.sin(20 * Math.PI / 180);
const liftOf = (node) => {
const floorPoint = (node?.getAttribute('data-hp-iso-floor') || '').split(',').map(Number);
const visual = (node?.getAttribute('data-hp-iso-visual') || '').split(',').map(Number);
return floorPoint.length === 2 && visual.length === 2
? [visual[0] - floorPoint[0], floorPoint[1] - visual[1]] : [NaN, NaN];
};
const liftMatches = (node) => {
const [dx, dy] = liftOf(node);
return node?.getAttribute('data-hp-iso-overlay-kind') === 'room-label'
? Math.abs(dx) < 1e-6 && Math.abs(dy) < 1e-6
: Math.abs(dx) < 1e-6 && Math.abs(dy - isoLift) < 1e-6;
};
const raisedRoot = (node, kind) => node?.getAttribute('data-hp-iso-overlay-kind') === kind
&& node.getAttribute('data-hp-iso-raised') === 'true'
&& node.getAttribute('data-hp-iso-floor')
&& node.getAttribute('data-hp-iso-visual')
&& node.getAttribute('data-hp-iso-floor') !== node.getAttribute('data-hp-iso-visual');
&& liftMatches(node);
result.stage4UsesExactLowInteractiveRoots = raisedRoot(device, 'device')
&& raisedRoot(roomLabel, 'room-label')
&& raisedRoot(openingLock, 'opening-lock')
@@ -198,7 +213,7 @@ const out = await page.evaluate(async () => {
if (visual.length !== 2 || floorPoint.length !== 2) return false;
const expected = sceneToClient(visual), actual = center(node);
return Math.hypot(actual[0] - expected[0], actual[1] - expected[1]) <= 1
&& Math.hypot(floorPoint[0] - visual[0], floorPoint[1] - visual[1]) > 0;
&& liftMatches(node);
};
result.globalFitContainsRaisedFootprints = raisedNodes.length >= 3
&& raisedNodes.every((node) => inside(root().querySelector('.stage'), node));
+14 -15
View File
@@ -53,8 +53,8 @@ const out = await page.evaluate(async () => {
calibration: { m1: [0.5, 0, 0, 0, 0.5, 0] },
},
});
// Put one real interactive device almost on the shared wall so this smoke
// always owns a deterministic non-zero nudge witness.
// Put one real interactive device almost on the shared wall: since #713 it
// must rise straight up like every other tile, without a nudge.
original._layout.d_light1 = { ...original._layout.d_light1, s: 'f1', x: 0.548, y: 0.22 };
original._layout['iso-vacuum'] = { s: 'f1', x: 0.18, y: 0.78 };
original._hoverRoom = { space: 'f1', room: original._spaceModel('f1').rooms[0] };
@@ -189,8 +189,10 @@ const out = await page.evaluate(async () => {
const overlaySvg = root(original).querySelector('.iso-overlays-svg');
const wallSvg = root(original).querySelector('.iso-walls-svg');
const doorResolved = original._openingsR.find((opening) => opening.id === 'iso-door');
const isoFloorMatrix = (root(original).querySelector('.iso-floor-scene')
?.getAttribute('transform')?.match(/-?[\d.e+]+/gi) || []).map(Number);
// #713: the 2.5D floor is the Flat plane — the floor group carries no transform.
const isoFloorGroupPlain = !!root(original).querySelector('.iso-floor-scene')
&& !root(original).querySelector('.iso-floor-scene')?.hasAttribute('transform');
const isoFloorMatrix = [1, 0, 0, 1, 0, 0];
const wallHeight = 84 * (5 / original._cellCm);
const openingTopPoint = doorResolved && isoFloorMatrix.length === 6
? new DOMPoint(
@@ -246,6 +248,7 @@ const out = await page.evaluate(async () => {
oldFloorSymbolsNotDuplicated: before.openings === 0,
visibleOpeningHasMasonryCut: Boolean(openingCutIsEmpty)
&& wallTopPathsBeforeHide.length > 0,
isoFloorGroupPlain,
};
configSpace.settings.hide_openings = true;
@@ -288,18 +291,13 @@ const out = await page.evaluate(async () => {
original.requestUpdate();
await original.updateComplete;
const noBordersFloor = root(original).querySelector('.plan-svg .iso-floor-scene');
const floorMatrix = (noBordersFloor?.getAttribute('transform')?.match(/-?[\d.e+]+/gi) || [])
.map(Number);
const noBordersDevice = root(original).querySelector('[data-hp="device"]');
const noBordersRoom = root(original).querySelector('[data-hp="room-label"]');
const noBordersLock = root(original).querySelector('.oplock');
result.noBordersUsesTrueAffineFloor = original._effectiveProjection() === 'iso'
result.noBordersUsesFlatFloor = original._effectiveProjection() === 'iso'
&& root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '4'
&& root(original).querySelector('.plan-svg')?.getAttribute('data-hp-live-viewbox') === 'camera'
&& floorMatrix.length === 6 && floorMatrix.every(Number.isFinite)
&& Math.abs(floorMatrix[0] - 1) < 1e-6
&& Math.abs(floorMatrix[1]) < 1e-6 && Math.abs(floorMatrix[2]) < 1e-6
&& Math.abs(floorMatrix[3] - Math.cos(20 * Math.PI / 180)) < 1e-6
&& !!noBordersFloor && !noBordersFloor.hasAttribute('transform')
&& root(original).querySelectorAll('[data-hp="opening"]').length === 3
&& [noBordersDevice, noBordersRoom, noBordersLock].every((node) => node
&& !node.hasAttribute('data-hp-iso-raised'));
@@ -348,9 +346,10 @@ const out = await page.evaluate(async () => {
&& root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint
&& original._isoGeometryCache.get(wallFingerprint)?.geometry === cachedGeometry
&& original._isoGeometryCache.size === 1;
result.runtimeNudgeNeverPersists = JSON.stringify(original._layout) === floorLayoutBeforeLive
result.runtimeLiftNeverPersists = JSON.stringify(original._layout) === floorLayoutBeforeLive
&& forbiddenWrites.length === 0 && storageWrites.length === 0
&& !!root(original).querySelector('[data-hp-iso-nudged="true"]')
&& !!root(original).querySelector('[data-hp-iso-raised="true"][data-hp-iso-nudged="false"]')
&& !root(original).querySelector('[data-hp-iso-nudged="true"]')
&& !root(original).querySelector('.iso-overlay-tether, .iso-overlay-ground');
Storage.prototype.setItem = nativeStorageSet;
@@ -364,8 +363,8 @@ const out = await page.evaluate(async () => {
CSS.supports = () => false;
original.requestUpdate();
await original.updateComplete;
const fallbackNudged = root(original).querySelector('[data-hp-iso-nudged="true"][data-id]');
const fallbackId = fallbackNudged?.getAttribute('data-id');
const fallbackRaised = root(original).querySelector('[data-hp-iso-raised="true"][data-id]');
const fallbackId = fallbackRaised?.getAttribute('data-id');
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
await frame();
result.unsupportedDecorationKeepsIsoStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
+2 -2
View File
@@ -91,8 +91,8 @@ const out = await page.evaluate(async () => {
const activeView = c._view;
const activeLabel = root.querySelector('.roomlabel[data-id="room-a"]');
const activeSvg = root.querySelector('.plan-svg') || root.querySelector('.zoomwrap > svg');
const position = c._scenePoint([c._labelPos(room(), 'fit-floor').x,
c._labelPos(room(), 'fit-floor').y]);
// #713: the 2.5D floor is the Flat plane — a label anchor is its scene point.
const position = [c._labelPos(room(), 'fit-floor').x, c._labelPos(room(), 'fit-floor').y];
const expectedLeft = ((position[0] - activeView.x) / activeView.w) * 100;
const expectedTop = ((position[1] - activeView.y) / activeView.h) * 100;
const actualView = activeSvg?.getAttribute('viewBox')?.trim().split(/\s+/).map(Number) || [];
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- In 2.5D View the plan no longer moves: rooms, furniture, other decor and room
names stay exactly where they are on the flat plan, and walls grow straight up
from it. Every device icon and door lock is lifted by the same distance, the
wall height, instead of being shifted differently near walls. Turning 2.5D on
or off, or opening an editor from it, keeps the zoom and position on screen
([#713](https://github.com/Matysh/houseplan-card/issues/713)).
- On the "Follow the Sun" background, the moon in its current phase now appears
in the top-left corner of the scene at dawn, dusk and night, behind the plan.
It follows the real moon over your home (Home Assistant home location), fades
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- В объёмном 2.5D-виде план больше не сдвигается: комнаты, мебель, прочий декор
и названия комнат остаются ровно там же, где на обычном плане, а стены
вырастают из него строго вверх. Все значки устройств и замков поднимаются на
одно и то же расстояние — высоту стен — вместо разных сдвигов у стен.
Включение и выключение объёма и вход в редактор из него сохраняют масштаб и
положение плана на экране
([#713](https://github.com/Matysh/houseplan-card/issues/713)).
- На фоне «Следует за Солнцем» в сумерках и ночью в левом верхнем углу сцены,
за планом, теперь видна луна в текущей фазе. Она следует настоящей луне над
вашим домом (по координатам дома из Home Assistant), плавно появляется и
+64 -65
View File
@@ -19,8 +19,10 @@ means Flat, which is also the rollback. The rule is one for the card, the
sidebar page and the kiosk: the View is 2.5D when the setting is on and Flat
otherwise. Editors and `houseplan-space-card` are always Flat.
Saving the setting switches the View at once, without a reload, and the view
centre is carried over through the logical plan (#583 §6.3). The lazy
Saving the setting switches the View at once, without a reload, and keeps the
camera: the floor is the same plane in both projections (#713), so the plan,
the decor and the room names stay on the same pixels; only the scalar zoom is
re-read against the new frame (see [Camera and overlay placement](#camera-and-overlay-placement)). The lazy
`iso-scene-render` graph is loaded only while the setting is on. The fingerprint
fallback (#89) is unchanged: a failed scene falls back to Flat for that key.
@@ -51,15 +53,19 @@ There is no toggle on the card and no alpha entry: `iso` is gone from
- `clientToScenePoint()` maps a client point to the current scene; floor hit
testing then uses `unprojectFloorPoint()`.
The presentation uses a fixed orthographic affine camera:
The presentation uses a fixed vertical oblique projection (#713, owner's «third
way»): the floor is not transformed at all, and a height moves a point straight
up the screen.
```text
rotDeg=0, tiltDeg=20, xyScale=1, zScale=1, origin=[500,500]
wallHeight=84 scale-aware visual units
screen(x, y, z) = (x, y − z·sin 20°) floor (z = 0): the Flat plane
wallHeight=84 scale-aware visual units → the wall top rises 0.342·H on screen
```
There is no perspective, free rotation or user tilt. Switching projections
preserves scalar zoom and converts the view centre through logical floor space.
`tiltDeg` now only sets the on-screen wall height (`sin 20°`, the same 28.73
units as the former 20° orthographic camera); there is no `cos 20°` floor
foreshortening anywhere. There is no perspective, free rotation or user tilt.
`projectedFrame()` includes both floor corners and wall-top corners so fit/home
cannot clip the volume.
@@ -109,7 +115,8 @@ the rollback path and does not depend on the iso cache.
Internal fail-closed evidence, not public API (`STYLING-HOOKS.md` §7.7):
`.stage[data-hp-iso-stage="4"]` with `data-hp-iso-structural-builds`,
`data-hp-iso-overlay-kind|raised|nudged` on low-plane roots, and
`data-hp-iso-overlay-kind|raised|nudged` on screen-facing roots (`nudged` is always
`false` since #713), and
`data-hp-iso-material-def` on shared material definitions. The structural LRU is
`_isoGeometryCache`.
@@ -118,8 +125,8 @@ Internal fail-closed evidence, not public API (`STYLING-HOOKS.md` §7.7):
- No volumetric editor and no volumetric `houseplan-space-card`: editors and
the static card are always Flat (see Activation).
- No perspective, free rotation or user tilt; no marker occlusion by walls —
screen-facing overlays sit on a low plane above the floor and are shifted,
never hidden.
device tiles and lock badges stand on the wall-top plane and may overlap wall
bodies and each other (owner's decision, #713), never hidden.
- No per-opening schema field for heights or leaves: every vertical element is
a fixed presentation ratio of `ISO_WALL_HEIGHT`.
- No YAML/config option beyond `settings.volumetric_view`.
@@ -141,7 +148,7 @@ The per-card LRU is capped at eight entries. A scene contains:
The key fingerprints rooms, masonry/opening geometry, flips, scale/camera, wall
and edge heights, the `0°/20°/84` profile, opening policy revision 3 and
structural algorithm 5. It excludes HA state, live opening amount, theme,
structural algorithm 6 (the #713 oblique projection). It excludes HA state, live opening amount, theme,
hover/selection, day/night, SUN and filter capability. `openingAmount()` is
applied only after an LRU hit by `projectIsoOpening()`, so a contact update
projects O(O) leaves without repeating a wall or floor boolean operation.
@@ -157,7 +164,8 @@ not enlarge it.
## Layer order and materials
All geometry roots use one scene `viewBox`. The existing floor/live nodes are
grouped under the affine matrix; HTML anchors still use `projectPlanPoint()`.
grouped in `.iso-floor-scene` without a transform (the floor is the Flat plane);
HTML anchors still use `projectPlanPoint()`.
```text
stage background
@@ -213,72 +221,63 @@ differences instead of strokes; window frame/glass borders remain.
- `hide_openings: true`: panels disappear, while masonry cuts, Glow/sun and
contact/lock meaning remain;
- `show_borders: false` is the exact no-volume branch: the volumetric roots are
absent, the floor keeps the real 0°/20° affine matrix, the floor symbols
and the projected frame return (subject to `hide_openings`) and interactive
overlays return to their floor anchors;
absent, the floor is the Flat plane, the floor symbols and the projected
frame return (subject to `hide_openings`) and interactive overlays return to
their floor anchors — geometrically the scene is the Flat View;
- Flat, editors and `houseplan-space-card` retain their old symbols and DOM.
## Camera and overlay placement
The camera is orthographic `rotDeg=0`, `tiltDeg=20`, with the `[500,500]`
pivot and scale-aware 84-unit wall height. Floor SVG, wall/opening projection,
inverse hit mapping, invisible collision footprints and fit bounds share that
one affine authority.
`isoPlaneMatrix()` (`src/iso-projection.ts`) is that authority. The projected
frame also includes the low overlay plane; blur and shadow extents never enter
fit.
The projection is the fixed vertical oblique one above: rotation 0, the
`[500,500]` pivot, a scale-aware 84-unit wall height rising `0.342·H` straight
up. Floor SVG, wall/opening projection, inverse hit mapping (the identity on the
floor), invisible collision footprints and fit bounds share that one affine
authority. `isoPlaneMatrix()` (`src/iso-projection.ts`) is that authority; the
floor plane matrix is the identity.
Device markers, room labels/cards and opening-lock badges keep their canonical
floor anchors but render on a low plane four visual units above the floor.
Devices and lock badges in the same room whose canonical reference-fit bounds
(expanded by 12 CSS px) connect form one rigid cluster. Every member receives
the same scene-space displacement, so pairwise vectors, rows and intervals are
the affine projection of the Flat layout rather than a per-marker fan toward a
room safe point. Room labels never enter a cluster and stay below interactive
roots.
**Placement (#713, owner's variant B).** Device tiles and opening-lock badges
keep their canonical floor anchors and stand on the wall-top plane: every one of
them is drawn at its Flat position shifted straight up by the same `H·sin 20°`.
There is no placement search, no rigid clusters and no per-marker vector; tiles
may meet wall bodies and each other, and the overlapping pairs are the Flat ones
(scaled by the 1.12 tile). Room names with their metrics row stay on the floor
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). The #651 resolvers (`resolveIsoOverlayRigidGroups`,
`resolveIsoOverlayCollisions` and the nudge search in `resolveIsoOverlayPlacement`)
are no longer called by the scene; their removal is
[#714](https://github.com/Matysh/houseplan-card/issues/714).
A device never moves because its Home Assistant state changed (owner's
decision, #711). The layout sees the state-free tile of a device — its icon at
its configured size, without value text, value badges or supplemental metrics,
which change with state. Those still count in the fit bounds, and an HA-only
change refreshes that visual extent without a new collision search.
Vertical openings are ordered along the oblique projector: a face's
`cameraDepth` is the mean of `s·y + z` over its corners (`s = sin 20°`).
`src/iso-overlays.ts` is the pure placement boundary. A device accepts its
explicit room only when that room strictly contains its floor anchor, otherwise
the smallest strictly containing room (stable id tie-break); room labels use
their own room; lock badges inherit the physical room side selected by
opening-host geometry. Wall clearance uses a 4 CSS px safety gap
(`ISO_OVERLAY_SAFETY_GAP_CSS_PX`), and every candidate path must stay strictly
inside the owner and outside its island holes. Boundary candidates are evaluated
on the integer CSS-pixel lattice through a bounded spatial grid (#585), so
sub-4 px legal slits are found without an all-pairs or disk scan.
**Fit.** Home and room fit (#152) project floor vertices at floor and floor-edge
depth and boundary-wall vertices at floor and wall-top height; the overlay fit
envelope adds the visible tiles themselves and reserves no nudge budget.
The reference-fit view, not the current live view, converts CSS safety values
into scene units. Wheel/button zoom, pinch and pan therefore transform an
already resolved scene and cannot invalidate placement. Structural changes —
stage/camera, walls, rooms, marker membership or canonical anchors — rebuild it
deterministically; viewport movement and HA-only state do not. One common
vector clears the exact wall silhouettes and already placed clusters within an
absolute 48 CSS-pixel reference-fit budget, using stable size/required-shift/
kind-id order and boundary candidates instead of scanning the displacement
disk. The correction is runtime-only and is never written to configuration.
**Switching projection keeps the camera.** When the projection changes while the
previous one was on screen — saving the setting (also when the 2.5D runtime
arrives afterwards), entering an editor from the 2.5D View, or adopting a warm
memo saved in the other projection (the window size is part of its key) — the
viewBox itself is kept and the scalar zoom is re-read as `fit'.w / view.w`
against the new frame, clamped to `[1/3, 8]`. Entering an editor from 2.5D
therefore produces the camera a Flat View with the same floor picture would.
Leaving the editor restores the View camera as before. A cold 2.5D start
without a memo opens the 2.5D home (zoom 1, frame with wall tops); a setting
changed while an editor is open restores the View snapshot by centre and scalar
zoom. Witness: `demo/smoke_iso_flat_parity.mjs`.
If no completely legal common vector exists, the nearest deterministic result
keeps the cluster rigid and prioritises room ownership, then wall clearance,
then overlap with an earlier cluster. It never splits or shrinks a cluster;
residual overlap is an explicit degraded diagnostic. The two full isometric
profiles keep the ordinary 150/60/75 ms resize/pan/state noise allowances
(#585, #651). Fit probes reserve the maximum correction but do not execute
live collision search. There is no painted plate, long tether, ground dot or
per-marker shadow. The original screen-facing HTML root remains the only hit,
focus, tooltip and action target, and selection/hover cannot invalidate the
placement cache. Vacuum, Glow/spill, SUN, room fills/hover, arbitrary decor,
furniture/backdrop, stairs and every persisted coordinate remain on `z=0`.
The placement is runtime-only and never written to configuration. There is no
painted plate, long tether, ground dot or per-marker shadow beyond the #649
tile shadow. The original screen-facing HTML root remains the only hit, focus,
tooltip and action target, and selection/hover cannot invalidate the placement
cache. Vacuum, Glow/spill, SUN, room fills/hover, arbitrary decor,
furniture/backdrop, stairs and every persisted coordinate remain on `z=0`, which
is the Flat plane.
Room names remain screen-facing and lose stroke, text shadow, drop shadow and
halo. Iso uses `#303936` on a light presentation and `#f2f0e8` on a dark one;
contrast comes from colour and the bounded position correction, never an
outline.
contrast comes from colour, never an outline.
## Stage 6: public mode, tiles, sun and materials (#649)
+11 -7
View File
@@ -327,13 +327,17 @@ device tooltips.
An administrator turns the volumetric plan on once: **General settings →
Display → Show the plan in 2.5D**. After saving, View and kiosk in every space
and on every device show walls, doors, windows and device markers with depth:
markers are raised tiles with a soft shadow on the floor, and windows cast a
soft wash of sunlight (when sun rays are on and north is set). Walls keep the
wall colour from General settings in light and dark themes alike; furniture and
other decor look exactly as on the flat plan. Editors are always flat. The
option is off by default; turning it off or **Reset** returns the flat plan.
There is no separate button on the card.
and on every device show walls, doors, windows and device markers with depth.
The plan itself does not move: rooms, furniture, other decor and room names
stay exactly where they are on the flat plan, and walls grow straight up from
it. Every device marker and door lock is lifted by the same distance — the wall
height — as a raised tile with a soft shadow; near a wall a tile may overlap it.
Windows cast a soft wash of sunlight (when sun rays are on and north is set).
Walls keep the wall colour from General settings in light and dark themes
alike. Switching the option, or opening an editor from the 2.5D View, keeps the
zoom and position on screen. Editors are always flat. The option is off by
default; turning it off or **Reset** returns the flat plan. There is no
separate button on the card.
For an occasional Zigbee placement check, an administrator can enable
**General settings → Show Zigbee links when hovering over a device**. The option
+11 -6
View File
@@ -344,12 +344,17 @@ Escape и закрытие диалога отбрасывают изменен
Объёмный вид плана включает администратор один раз: **Общие настройки →
Отображение → Объёмный вид плана (2.5D)**. После сохранения Просмотр и киоск во
всех пространствах и на всех устройствах сразу показывают стены, двери, окна и
значки устройств с объёмом: значки — приподнятые плитки с мягкой тенью на полу,
из окон на пол ложится мягкий свет по солнцу (если включены солнечные лучи и
задан север). Цвет стен — тот, что задан в общих настройках, одинаковый в
светлой и тёмной теме; мебель и прочий декор выглядят так же, как на обычном
плане. Редакторы всегда плоские. Выключенная опция (по умолчанию) и
**Сбросить** возвращают обычный план. Отдельной кнопки на карточке нет.
значки устройств с объёмом. Сам план не сдвигается: комнаты, мебель, прочий
декор и названия комнат остаются ровно там же, где на обычном плане, а стены
вырастают из него строго вверх. Все значки устройств и замков поднимаются на
одно и то же расстояние — высоту стен — и выглядят приподнятыми плитками с
мягкой тенью; у стены плитка может заходить на неё. Из окон на пол ложится
мягкий свет по солнцу (если включены солнечные лучи и задан север). Цвет стен —
тот, что задан в общих настройках, одинаковый в светлой и тёмной теме.
Переключение опции и вход в редактор из объёмного вида сохраняют масштаб и
положение плана на экране. Редакторы всегда плоские. Выключенная опция (по
умолчанию) и **Сбросить** возвращают обычный план. Отдельной кнопки на карточке
нет.
Для разовой проверки размещения Zigbee администратор может включить **Общие
настройки → Показывать связи Zigbee при наведении на устройство**. По умолчанию
+62 -17
View File
@@ -11286,9 +11286,9 @@ const MUTANT_DEFINITIONS = [
{
id: 'iso-rigid-groups-use-live-zoom-scale',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#651 supersedes" test/iso-scene-render.test.mjs',
because: '#651 makes the fit-scale viewport the only layout scale. Falling back to the live '
+ 'view makes every pinch or wheel zoom a fresh placement event and the device row drifts again',
+ '&& node --test --test-name-pattern="#713 AC3" test/iso-scene-render.test.mjs',
because: '#651/#713 make the fit-scale viewport the only layout scale. Falling back to the live '
+ 'view makes every pinch or wheel zoom a fresh placement event again',
patches: [{
file: 'src/iso-scene-render.ts',
find: ' const layoutView = input.referenceView || input.view;',
@@ -11335,19 +11335,64 @@ const MUTANT_DEFINITIONS = [
}],
},
{
id: 'iso-scene-restores-per-marker-collision-resolver',
id: 'iso-scene-live-placement-search-returns',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="keeps a close device cluster rigid" test/iso-scene-render.test.mjs',
because: '#651 is ineffective unless the production scene uses the rigid-group resolver. The '
+ 'legacy per-marker resolver may still pass its own geometry tests while moving neighbours apart',
+ '&& node --test --test-name-pattern="#713 AC3" test/iso-scene-render.test.mjs',
because: '#713: every tile gets one straight-up wall-top rise. Feeding the wall silhouettes '
+ 'back into the live placement brings back the per-marker nudge next to walls',
patches: [{
file: 'src/iso-scene-render.ts',
find: ' resolveIsoOverlayOwner, resolveIsoOverlayPlacement, resolveIsoOverlayRigidGroups,',
replace: ' resolveIsoOverlayCollisions, resolveIsoOverlayOwner, resolveIsoOverlayPlacement, resolveIsoOverlayRigidGroups,',
}, {
find: ' wallSilhouettes: [],\n wallGeometryValidated: true,',
replace: ' wallSilhouettes: input.wallSilhouettes,\n wallGeometryValidated: true,',
}],
},
{
id: 'iso-floor-foreshortening-returns',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#713 AC1" test/iso-projection.test.mjs',
because: '#713: the 2.5D floor is the Flat plane. Restoring the cos 20° floor factor squashes '
+ 'the plan, the decor and every anchor towards the centre again',
patches: [{
file: 'src/iso-projection.ts',
find: ' const d = camera.xyScale * Math.cos(rot);',
replace: ' const d = camera.xyScale * Math.cos(rot) * Math.cos(tilt);',
}],
},
{
id: 'iso-room-labels-lift-with-devices',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#713 K5" test/iso-scene-render.test.mjs',
because: '#713 (owner): room names stay on the floor where Flat puts them; only device tiles '
+ 'and lock badges rise to the wall top',
patches: [{
file: 'src/iso-scene-render.ts',
find: " const collision = mode === 'live' ? resolveIsoOverlayRigidGroups({",
replace: " const collision = mode === 'live' ? resolveIsoOverlayCollisions({",
find: " const visualOffset = kind === 'room-label' ? 0 : wallHeight;",
replace: ' const visualOffset = wallHeight;',
}],
},
{
id: 'iso-fit-reserves-nudge-again',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#713 K8" test/iso-scene-render.test.mjs',
because: '#713: tiles no longer move at runtime, so the home frame must not reserve the former '
+ '48 CSS px #651 budget around them',
patches: [{
file: 'src/iso-scene-render.ts',
find: ' const base = overlay ? unionRect(input.baseBounds, overlay) : input.baseBounds;',
replace: ' const base = overlay ? unionRect(input.baseBounds, { x: overlay.x - 48, y: overlay.y - 48, '
+ 'w: overlay.w + 96, h: overlay.h + 96 }) : input.baseBounds;',
}],
},
{
id: 'iso-opening-depth-orthographic-again',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#713 AC7" test/iso-openings.test.mjs',
because: '#713: opening faces are ordered along the oblique projector s·y + z; the former '
+ 'orthographic key weighs height by cos 20° and can reorder glass, sill and leaf faces',
patches: [{
file: 'src/iso-openings.ts',
find: ' + value.z, 0) / points.length;',
replace: ' + value.z * Math.cos(tilt), 0) / points.length;',
}],
},
{
@@ -11391,10 +11436,10 @@ const MUTANT_DEFINITIONS = [
+ 'roots are raised (#160)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const point = this._scenePoint([cx, cy]);',
find: ' const point = [cx, cy];',
replace: " const point = this._renderProjection === 'iso'\n"
+ ' ? projectPlanPoint([cx, cy], gridVisualUnits(68, this._cellCm))\n'
+ ' : [cx, cy] as ScenePoint;',
+ ' ? [cx, cy - gridVisualUnits(28, this._cellCm)]\n'
+ ' : [cx, cy];',
}],
},
{
@@ -11448,8 +11493,8 @@ const MUTANT_DEFINITIONS = [
replace: ' onBuild(): void;\n liveFingerprint?: string;\n}',
}, {
file: 'src/iso-scene-render.ts',
find: ' algorithm: 5,\n })}`;',
replace: ' algorithm: 5,\n })}|${input.liveFingerprint ?? \'\'}`;',
find: ' algorithm: 6, // #713: vertical oblique projection\n })}`;',
replace: ' algorithm: 6, // #713: vertical oblique projection\n })}|${input.liveFingerprint ?? \'\'}`;',
}, {
file: 'src/houseplan-card.ts',
find: ' onBuild: () => { this._isoStructuralBuildCount += 1; },',
+11
View File
@@ -27,6 +27,17 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'isoPlaneMatrix', 'unprojectFloorPoint', '_convertProjectionView', '_rezoom',
'_warmAdoptViewport', '_logicalViewCenter', '_floorView', 'resolveIsoOverlayFitEnvelope',
'buildIsoOverlayRenderScene',
],
smokes: ['smoke_iso_flat_parity.mjs', 'smoke_isometric_contract.mjs'],
because: '#713: the Flat-plane floor, the one straight-up tile rise and the kept camera are '
+ 'observed only as CSS pixels of rooms, decor, labels and tile anchors across a setting '
+ 'switch, an editor entry and a warm remount; no smoke names these helpers',
},
{
symbols: [
'moonLayer', 'renderMoon', 'moonTick', 'moonView', 'moonShownAt', 'moonPhasePath',
+43 -39
View File
@@ -251,8 +251,7 @@ import {
currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot,
} from './labs';
import {
ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, isoFloorMatrixCss, projectPlanPoint, projectedFrame,
unprojectFloorPoint, type ScenePoint,
ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, projectedFrame,
} from './iso-projection';
import type { IsoDecorationLayers } from './iso-openings';
import type { IsoOverlayPlacement } from './iso-overlays';
@@ -2182,7 +2181,7 @@ export class HouseplanCard extends LitElement {
private _navApplied = false; // the saved space was restored (or the user navigated)
private _labs: LabsSnapshot = { alpha: false, active: Object.freeze([]), space: '' };
private _labsUnsub?: () => void;
private _isoEnabledSeen = false; private _isoLightFloors: ReadonlySet<string> | null = null; private readonly _isoFirstFrame = new IsoFirstFrameState(); // #649/#654
private _isoEnabledSeen = false; private _projectionShown: 'flat' | 'iso' | null = null; private _isoLightFloors: ReadonlySet<string> | null = null; private readonly _isoFirstFrame = new IsoFirstFrameState(); // #649/#654
private _renderProjection: 'flat' | 'iso' = 'flat';
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 }; private _kioskDialog = false;
@@ -2320,23 +2319,33 @@ export class HouseplanCard extends LitElement {
return this._mode === 'view' && this._isoEnabled ? 'iso' : 'flat';
}
private _logicalViewCenter(projection: 'flat' | 'iso'): { x: number; y: number } | null {
/** #713: the 2.5D floor is the Flat plane, so the view centre is already logical. */
private _logicalViewCenter(): { x: number; y: number } | null {
const view = this._view;
if (!view) return null;
const center: ScenePoint = [view.x + view.w / 2, view.y + view.h / 2];
const point = projection === 'iso' ? unprojectFloorPoint(center) : center;
return { x: point[0], y: point[1] };
return view && { x: view.x + view.w / 2, y: view.y + view.h / 2 };
}
/** #713: the floor is one plane in both projections. Keep the camera (screen
* scale and centre) and re-read the scalar zoom against the effective frame. */
private _rezoom(): void {
const v = this._view;
const z = v ? fitView(this._baseVb(), v.w / v.h).w / v.w : 0;
if (z >= MIN_ZOOM && z <= 8) this._zoom = z;
else if (v) this._applyView(z, v.x + v.w / 2, v.y + v.h / 2);
}
private _convertProjectionView(from: 'flat' | 'iso', to: 'flat' | 'iso'): void {
if (from === to) return;
this._clearRoomFocus(true);
const logical = this._logicalViewCenter(from);
this._view = null;
const target = logical
? to === 'iso' ? projectPlanPoint([logical.x, logical.y], 0) : [logical.x, logical.y] as ScenePoint
: null;
this._applyView(this._zoom, target?.[0], target?.[1]);
// A structural change: a running camera tween stops on its presented frame.
this._cancelCameraTransition(false);
// Only a camera that was on screen is kept; a cold 2.5D start opens home.
if (this._projectionShown === from) this._rezoom();
else {
const center = this._logicalViewCenter();
this._view = null;
this._applyView(this._zoom, center?.x, center?.y);
}
this._warmPatch({ vp: this._warmViewportState() });
this.requestUpdate();
}
@@ -3320,14 +3329,15 @@ export class HouseplanCard extends LitElement {
this._zoom = vp.zoom;
const projection = this._effectiveProjection();
const sameProjection = projection === vp.projection && this._isoEnabled === vp.activeLabsIso;
this._view = sameProjection && vp.view ? { ...vp.view } : null;
// #713: one floor plane, and the window size is in the memo key, so the
// saved camera is valid in either projection; only zoom is re-read.
this._view = vp.view ? { ...vp.view } : null;
this._viewModeSnap = sameProjection && vp.snap ? { ...vp.snap } : null;
if (!sameProjection && vp.logicalCenter) {
const center = projection === 'iso'
? projectPlanPoint([vp.logicalCenter.x, vp.logicalCenter.y], 0)
: [vp.logicalCenter.x, vp.logicalCenter.y] as ScenePoint;
this._applyView(vp.zoom, center[0], center[1]);
if (!sameProjection) {
if (this._view) this._rezoom();
else if (vp.logicalCenter) this._applyView(vp.zoom, vp.logicalCenter.x, vp.logicalCenter.y);
}
this._projectionShown = projection;
this._tool = normalizeMarkupTool(vp.tool);
this._decorTool = vp.decorTool;
this._showHidden = vp.showHidden;
@@ -3380,7 +3390,7 @@ export class HouseplanCard extends LitElement {
mode: this._mode,
projection,
activeLabsIso: this._isoEnabled,
logicalCenter: this._logicalViewCenter(projection),
logicalCenter: this._logicalViewCenter(),
zoom: this._zoom,
view: this._view ? { ...this._view } : null,
snap: this._viewModeSnap ? { ...this._viewModeSnap } : null,
@@ -4047,6 +4057,7 @@ export class HouseplanCard extends LitElement {
this._pruneDevicePressFeedback();
this._syncDayCycleClock();
this._syncRadarLive();
if (!this._booting && !this._isoFirstFrame.pending(this._desiredProjection, !!this._isoSceneRuntime)) this._projectionShown = this._renderProjection;
this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen
// Decor selection cannot exist before the lazy editor runtime is ready.
if (this._editorRuntime) this._dtMeasure();
@@ -6042,13 +6053,9 @@ export class HouseplanCard extends LitElement {
}
}
private _scenePoint(point: readonly [number, number]): ScenePoint {
return this._renderProjection === 'iso' ? projectPlanPoint(point, 0) : point;
}
/** #713: the 2.5D floor is the Flat plane; the live viewport still asks. */
private _floorView(view: { x: number; y: number; w: number; h: number }): { x: number; y: number; w: number; h: number } {
if (this._renderProjection !== 'iso') return view;
const start = unprojectFloorPoint([view.x, view.y]), end = unprojectFloorPoint([view.x + view.w, view.y + view.h]);
return { x: start[0], y: start[1], w: end[0] - start[0], h: end[1] - start[1] };
return view;
}
/** Stage 4 keeps one immutable floor point and one runtime visual point per
* raised item. This snapshot is presentation-only and never enters config. */
@@ -10888,16 +10895,13 @@ export class HouseplanCard extends LitElement {
data-hp-live-viewbox=${iso ? 'camera' : 'floor'} data-hp-live-overflow="clip"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
<g transform=${iso ? isoFloorMatrixCss() : nothing}>
${renderPaperShapes(paperShapes, 'hp-paper-outline-shapes')}
</g>
${renderPaperShapes(paperShapes, 'hp-paper-outline-shapes')}
</svg>` : nothing}
<svg class="plan-svg" data-hp-layer="plan"
data-hp-live-viewbox=${iso ? 'camera' : 'floor'}
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet">
<g class=${iso ? 'iso-floor-scene' : nothing}
transform=${iso ? isoFloorMatrixCss() : nothing}>
<g class=${iso ? 'iso-floor-scene' : nothing}>
${''/* THE PAPER IS THE ROOMS (docs/DECOR-EDITOR.md §3.3, owner
2026-08-04). Opaque shapes stop the scene background —
bg_color or the day-cycle environment — from bleeding through the
@@ -11160,7 +11164,7 @@ export class HouseplanCard extends LitElement {
return !!marker && radarMarkerLiveInSpace(marker, this._space);
}),
view,
(point) => this._scenePoint(point),
(point) => point,
)}
${keyed(space.id, repeat(devs, (d) => d.id, (d) => this._renderDevice(
d, view, showLqi, isoOverlays?.devices.get(d.id),
@@ -11643,7 +11647,7 @@ export class HouseplanCard extends LitElement {
const calibrated = path.map((segment) => segment.map(([x, y]) => applyAffine(matrix, x, y)));
const commands = smoothVacPath(calibrated, this._cmToUnits(VAC_TRAIL_SMOOTH_RADIUS_CM));
const pointText = (point: readonly [number, number]): string => {
const scene = this._scenePoint(point);
const scene = point;
return `${scene[0].toFixed(1)} ${scene[1].toFixed(1)}`;
};
return commands.map((segment) => segment.map((command) => {
@@ -11728,7 +11732,7 @@ export class HouseplanCard extends LitElement {
if (moving && last?.length >= 2) {
const anchor = last[last.length - 1];
const [ax, ay] = applyAffine(matrix, anchor[0], anchor[1]);
const point = this._scenePoint([ax, ay]);
const point = [ax, ay];
const a1 = point[0].toFixed(1), a2 = point[1].toFixed(1);
trails.push(svg`<line class="case tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line><line class="core tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line>`);
}
@@ -11736,7 +11740,7 @@ export class HouseplanCard extends LitElement {
}
if (!moving || !tele.pos || !matrix) continue;
const [cx, cy] = applyAffine(matrix, tele.pos.x, tele.pos.y);
const point = this._scenePoint([cx, cy]);
const point = [cx, cy];
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const stale = rt && rt.lastTs > 0 && Date.now() - rt.lastTs > VAC_STALE_MS;
@@ -11766,7 +11770,7 @@ export class HouseplanCard extends LitElement {
ghost = false,
): TemplateResult {
const pos = this._pos(d);
const point = isoPlacement?.visualScene ?? this._scenePoint([pos.x, pos.y]);
const point = isoPlacement?.visualScene ?? [pos.x, pos.y];
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const presentation = this._devicePresentation(d, showLqi);
@@ -12019,7 +12023,7 @@ export class HouseplanCard extends LitElement {
// editor — that is where you name them (field report, 2026-07-27)
if (!r.name && !this._markup) return nothing;
const p = this._labelPos(r, space.id);
const point = isoPlacement?.visualScene ?? this._scenePoint([p.x, p.y]);
const point = isoPlacement?.visualScene ?? [p.x, p.y];
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const op = Math.min(1, disp.opacity + 0.25);
@@ -12413,7 +12417,7 @@ export class HouseplanCard extends LitElement {
gateFace,
}, this._cellCm)[0];
const isoPlacement = isoPlacements?.get(String(o.id));
const point = isoPlacement?.visualScene ?? this._scenePoint(floorAnchor);
const point = isoPlacement?.visualScene ?? floorAnchor;
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const lockState = `${locked ? 'locked' : known ? 'unlocked' : 'unknown'} ${this._isoLightFloors?.has(isoPlacement?.owner?.id ?? '') ? 'iso-floor-light' : ''}`;
+14 -9
View File
@@ -47,7 +47,7 @@ import {
spaceDisplayOf, DEFAULT_FILL_COLORS, customFillOf, DEFAULT_CUSTOM_FILL, type FillColors,
type FillColorEntry, RUN_TARGET_DOMAINS, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
showRoomTooltipOf, volumetricViewOf, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, normalizeDeviceDisplay,
type DeviceDisplayMode, liveTextReference, liveTextToken, DECOR_TEXT_BASE,
type DeviceDisplayMode, liveTextReference, liveTextToken, DECOR_TEXT_BASE, fitView,
} from './logic';
import {
resolveSafeResize,
@@ -458,7 +458,7 @@ export interface HouseplanEditorHostPort {
_backdropDialog: { widthCm: number; heightCm: number; angle: string; } | null;
_backupExportDialog: { kind: "full" | "space"; planOnly: boolean; busy: boolean; error: string; } | null;
_backupImportDialog: { filename: string; size: number; token: string; preview: any; expectedConfigRev: number; expectedLayoutRev: number; duplicatePolicy: "skip" | "virtual"; confirmMissing: boolean; busy: boolean; error: string; } | null;
_baseVb: () => number[];
_baseVb: (projection?: 'flat' | 'iso') => number[];
_bdActive: boolean;
_bdBase: Rect | null;
_bdDrag: { kind: "move" | "scale" | "rotate"; pid: number; sx: number; sy: number; base: Rect; p0: { dx: number; dy: number; sx: number; sy: number; angle: number; }; fx: number; fy: number; sgx: number; sgy: number; rect0: DecorBox; before: SpaceGeometryState | null; moved: boolean; } | null;
@@ -601,7 +601,7 @@ export interface HouseplanEditorHostPort {
_lastValidStageSize: [number, number] | null;
_layout: DeviceLayout;
readonly _layoutRev: number;
_logicalViewCenter: (projection: "flat" | "iso") => { x: number; y: number; } | null;
_logicalViewCenter: () => { x: number; y: number; } | null;
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; radar: RadarEditorDraft | null; radarEligible: boolean; radarTouched: boolean; radarRemove: boolean; hideFromPlan: boolean; busy: boolean; } | null;
_markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null;
_markerPreviewMemo: { key: string; device: DevItem | null; } | null;
@@ -989,16 +989,21 @@ public _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): vo
this.host._viewModeSnap = {
space: this.host._space,
zoom: this.host._zoom,
cx: v ? this.host._logicalViewCenter(previousProjection)?.x : undefined,
cy: v ? this.host._logicalViewCenter(previousProjection)?.y : undefined, w: previousProjection === 'flat' ? v?.w : undefined,
cx: v ? this.host._logicalViewCenter()?.x : undefined,
cy: v ? this.host._logicalViewCenter()?.y : undefined, w: previousProjection === 'flat' ? v?.w : undefined,
};
if (previousProjection === 'iso') {
const logical = this.host._logicalViewCenter('iso');
targetCenterX = logical?.x;
targetCenterY = logical?.y;
// #713: the 2.5D floor is the Flat plane. Enter the Flat editor with
// the same screen scale and centre, exactly as from a Flat View
// showing this picture: only the scalar zoom is re-read.
if (v) {
targetZoom = fitView(this.host._baseVb('flat'), v.w / v.h).w / v.w;
targetCenterX = v.x + v.w / 2;
targetCenterY = v.y + v.h / 2;
}
this.host._view = null;
this.host._mode = mode;
this.host._applyView(this.host._zoom, logical?.x, logical?.y);
this.host._applyView(targetZoom, targetCenterX, targetCenterY);
}
if (baseChanges) {
targetZoom = 1;
+3 -2
View File
@@ -163,7 +163,7 @@ export interface IsoOpeningSurface {
kind: IsoOpeningSurfaceKind;
material: IsoOpeningMaterial;
d: string;
/** Orthographic camera depth of the face centre, used for local occlusion. */
/** Camera depth of the face centre along the projector, used for local occlusion. */
cameraDepth: number;
depth: number;
jamb?: 0 | 1;
@@ -373,10 +373,11 @@ function projectedSurface(
const projected = points.map((value) => projectPlanPoint(value.point, value.z, camera));
const rotation = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
// #713: depth along the oblique projector (s, 1) of `y' = y − z·s`.
const cameraDepth = points.reduce((sum, value) => sum
+ (value.point[0] * Math.sin(rotation) + value.point[1] * Math.cos(rotation))
* Math.sin(tilt)
+ value.z * Math.cos(tilt), 0) / points.length;
+ value.z, 0) / points.length;
return Object.freeze({
kind,
material,
+6 -6
View File
@@ -501,10 +501,10 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
};
}
// Stage 4 keeps the canonical floor anchor and collision footprint, but the
// screen-facing content sits just above the floor instead of on a tall
// wall-height mast. The wall height is still validated because the same
// placement consumes wall silhouettes built from that physical height.
// The canonical floor anchor and collision footprint stay; the screen-facing
// content stands `visualOffset` above the floor (#713: the wall top for tiles
// and lock badges, 0 for room names). The wall height is still validated
// because this resolver can consume wall silhouettes built from it.
const raisedHeight = visualOffset;
const raisedScene = projectPlanPoint(floorAnchor, raisedHeight, camera);
const owner = input.ownerAlreadyResolved
@@ -1135,9 +1135,9 @@ export function resolveIsoOverlayCollisions(
a.placement.nudgeDistanceCss - b.placement.nudgeDistanceCss
|| isoOverlayCollisionKey(a.kind, a.id).localeCompare(isoOverlayCollisionKey(b.kind, b.id)));
const rot = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
// #713: the floor is not foreshortened, both axes share one inverse scale.
const inverseX = 1 / camera.xyScale;
const inverseY = 1 / (camera.xyScale * Math.cos(tilt));
const inverseY = inverseX;
const cosRot = Math.cos(rot), sinRot = Math.sin(rot);
const cellRange = (bounds: Bounds): readonly [number, number, number, number] => [
+8 -5
View File
@@ -37,7 +37,7 @@ export const ISO_RAISED_OVERLAY_HEIGHT = ISO_OVERLAY_VISUAL_OFFSET;
function finiteCamera(camera: IsoCamera): boolean {
return [camera.rotDeg, camera.tiltDeg, camera.xyScale, camera.zScale,
camera.origin[0], camera.origin[1]].every(Number.isFinite)
&& Math.abs(camera.xyScale) > 1e-12 && Math.abs(Math.cos(camera.tiltDeg * Math.PI / 180)) > 1e-12;
&& Math.abs(camera.xyScale) > 1e-12;
}
export function projectPlanPoint(
@@ -54,9 +54,8 @@ export function unprojectFloorPoint(
if (!finiteCamera(camera) || !Number.isFinite(point[0]) || !Number.isFinite(point[1]))
throw new Error('invalid isometric projection input');
const rot = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
const rx = (point[0] - camera.origin[0]) / camera.xyScale;
const ry = (point[1] - camera.origin[1]) / (camera.xyScale * Math.cos(tilt));
const ry = (point[1] - camera.origin[1]) / camera.xyScale;
return [
camera.origin[0] + rx * Math.cos(rot) + ry * Math.sin(rot),
camera.origin[1] - rx * Math.sin(rot) + ry * Math.cos(rot),
@@ -66,6 +65,10 @@ export function unprojectFloorPoint(
/**
* Canonical plan-plane affine matrix at one logical height. Floor SVG, raised
* plates and individual point projection all use this exact transform.
*
* #713: a vertical oblique projection. The floor keeps the Flat plan (no
* foreshortening), a height moves a point straight up the screen by
* `z × sin(tilt)`: the on-screen wall height of the former 20° camera.
*/
export function isoPlaneMatrix(
zUnits = 0, camera: IsoCamera = ISO_CAMERA,
@@ -76,8 +79,8 @@ export function isoPlaneMatrix(
const tilt = camera.tiltDeg * Math.PI / 180;
const a = camera.xyScale * Math.cos(rot);
const c = -camera.xyScale * Math.sin(rot);
const b = camera.xyScale * Math.sin(rot) * Math.cos(tilt);
const d = camera.xyScale * Math.cos(rot) * Math.cos(tilt);
const b = camera.xyScale * Math.sin(rot);
const d = camera.xyScale * Math.cos(rot);
const e = camera.origin[0] - a * camera.origin[0] - c * camera.origin[1];
const f = camera.origin[1] - b * camera.origin[0] - d * camera.origin[1]
- zUnits * camera.zScale * Math.sin(tilt);
+20 -76
View File
@@ -11,7 +11,7 @@ export { computeIsoSunBeams, renderIsoSunWash } from './iso-sun';
import { nothing, svg, type TemplateResult } from 'lit';
import { clampScale, islandsOf, roomPoly, type SpaceDisplay } from './logic';
import {
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_OVERLAY_VISUAL_OFFSET,
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT,
ISO_RAISED_OVERLAY_HEIGHT, ISO_WALL_HEIGHT,
projectPlanPoint, projectedFrame, type PlanPoint, type ScenePoint,
} from './iso-projection';
@@ -26,8 +26,8 @@ import {
type IsoOpeningGeometryPolicy, type IsoOpeningSurface,
} from './iso-openings';
import {
ISO_OVERLAY_MAX_NUDGE_CSS_PX, isoOverlayCollisionKey, isoRoomSafePoint,
resolveIsoOverlayOwner, resolveIsoOverlayPlacement, resolveIsoOverlayRigidGroups,
ISO_OVERLAY_MAX_NUDGE_CSS_PX, isoRoomSafePoint,
resolveIsoOverlayOwner, resolveIsoOverlayPlacement,
type IsoOverlayPlacement, type IsoOverlayRoom, type IsoRaisedOverlayKind,
type IsoWallSilhouette,
} from './iso-overlays';
@@ -510,7 +510,7 @@ export function createIsoStructuralSource(
wallHeight,
raisedHeight,
floorEdgeHeight,
algorithm: 5,
algorithm: 6, // #713: vertical oblique projection
})}`;
return {
key,
@@ -689,9 +689,9 @@ export interface IsoOverlayFitEnvelopeInput {
}
/**
* Stable fit envelope for screen-facing content. It starts from unnudged
* geometry, then reserves the complete bounded CSS nudge at the fitted scale;
* current zoom can therefore never feed back into the canonical home frame.
* Stable fit envelope for screen-facing content: the structure plus the tiles'
* own visual extent. Since #713 nothing moves at runtime, so no nudge budget is
* reserved and current zoom can never feed back into the canonical home frame.
*/
export function resolveIsoOverlayFitEnvelope(
input: IsoOverlayFitEnvelopeInput,
@@ -699,36 +699,11 @@ export function resolveIsoOverlayFitEnvelope(
const entries = selectedOverlayEntries(input.entries, input.ownerId);
const overlay = rectFromPoints(entries.flatMap((entry) => overlayEntryPoints(entry, false)));
const base = overlay ? unionRect(input.baseBounds, overlay) : input.baseBounds;
const initial = input.targetView(base);
if (!initial || !(initial.w > 0) || !(initial.h > 0)
|| ![initial.x, initial.y, initial.w, initial.h].every(Number.isFinite)) return null;
const stage = input.stageSize;
if (!overlay || !stage || !(stage.width > 0) || !(stage.height > 0))
return { bounds: base, view: initial };
const scaleOf = (view: Rect) => Math.max(view.w / stage.width, view.h / stage.height);
const candidate = (scale: number): { bounds: Rect; view: Rect } | null => {
const pad = ISO_OVERLAY_MAX_NUDGE_CSS_PX * scale;
const padded = { x: overlay.x - pad, y: overlay.y - pad,
w: overlay.w + pad * 2, h: overlay.h + pad * 2 };
const bounds = unionRect(input.baseBounds, padded);
const view = input.targetView(bounds);
return view && [view.x, view.y, view.w, view.h].every(Number.isFinite)
&& view.w > 0 && view.h > 0 ? { bounds, view } : null;
};
let low = 0, high = Math.max(scaleOf(initial), Number.EPSILON), resolved = candidate(high);
for (let iteration = 0; resolved && scaleOf(resolved.view) > high * (1 + 1e-10)
&& iteration < 24; iteration++) {
low = high; high *= 2; resolved = candidate(high);
}
if (!resolved) return null;
for (let iteration = 0; iteration < 40; iteration++) {
const middle = (low + high) / 2;
const next = candidate(middle);
if (!next) return null;
if (scaleOf(next.view) <= middle) { high = middle; resolved = next; }
else low = middle;
}
return resolved;
// #713: markers no longer move at runtime, so fit reserves no nudge budget:
// the frame is the structure plus the markers' own visual extent.
const view = input.targetView(base);
return view && view.w > 0 && view.h > 0
&& [view.x, view.y, view.w, view.h].every(Number.isFinite) ? { bounds: base, view } : null;
}
export interface IsoOverlaySceneInput {
@@ -826,8 +801,10 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
: Math.max(layoutView.w, layoutView.h) / 1000;
const roomRows = isoOverlayRooms(input.space);
const rooms = roomRows.map((row) => row.overlayRoom);
// #713: device tiles and lock badges stand on the wall-top plane, one common
// straight-up shift for every marker; room names stay on the floor where
// Flat puts them. No placement search: markers may meet walls and each other.
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, input.cellCm);
const visualOffset = gridVisualUnits(ISO_OVERLAY_VISUAL_OFFSET, input.cellCm);
const devices = new Map<string, IsoOverlayPlacement>();
const roomPlacements = new Map<RoomCfg, IsoOverlayPlacement>();
const locks = new Map<string, IsoOverlayPlacement>();
@@ -851,6 +828,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
footprintHalfSize: PlanPoint,
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 shapeSignature = [floorAnchor[0], floorAnchor[1],
@@ -897,7 +875,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
? cached.value.placement.nudgeDistanceCss * cached.value.unitsPerPixel / unitsPerPixel
: undefined,
showBorders: true,
wallSilhouettes: input.resolveCollisions === false ? [] : input.wallSilhouettes,
wallSilhouettes: [],
wallGeometryValidated: true,
footprintHalfSize,
wallHeight,
@@ -1022,42 +1000,8 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
return refreshed;
}
const previousEntries = new Map(previous?.entries.map((entry) => [
`${entry.kind}\u0000${entry.id}`, entry,
]) || []);
const collision = mode === 'live' ? resolveIsoOverlayRigidGroups({
items: entries.flatMap((entry) => {
if (entry.kind === 'room-label') return [];
const before = previousEntries.get(`${entry.kind}\u0000${entry.id}`);
const sameShape = !!before
&& layoutHalfSizeOf(before)[0] === layoutHalfSizeOf(entry)[0]
&& layoutHalfSizeOf(before)[1] === layoutHalfSizeOf(entry)[1]
&& before.placement.floorAnchor[0] === entry.placement.floorAnchor[0]
&& before.placement.floorAnchor[1] === entry.placement.floorAnchor[1]
&& (before.placement.owner?.id || '') === (entry.placement.owner?.id || '');
return [{
id: entry.id,
kind: entry.kind,
placement: entry.placement,
screenHalfSize: layoutHalfSizeOf(entry),
...(sameShape ? { nudgeHintCss: [
before!.placement.nudgeScene[0] / unitsPerPixel,
before!.placement.nudgeScene[1] / unitsPerPixel,
] as ScenePoint } : {}),
}];
}),
rooms,
wallSilhouettes: input.wallSilhouettes,
sceneUnitsPerCssPixel: unitsPerPixel,
visualOffset,
}) : { placements: new Map<string, IsoOverlayPlacement>(), residualPairs: [] };
const resolvedEntries = Object.freeze(entries.map((entry) => {
if (entry.kind === 'room-label') return entry;
const placement = collision.placements.get(isoOverlayCollisionKey(entry.kind, entry.id))
|| entry.placement;
return placement === entry.placement ? entry : { ...entry, placement };
}));
// #713: no placement search, so the placed entries are final.
const resolvedEntries = Object.freeze(entries);
const resolvedDevices = new Map<string, IsoOverlayPlacement>();
const resolvedLocks = new Map<string, IsoOverlayPlacement>();
for (const entry of resolvedEntries) {
@@ -1070,7 +1014,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
locks: resolvedLocks,
entries: resolvedEntries,
collisionSignature,
residualPairs: Object.freeze([...collision.residualPairs]),
residualPairs: Object.freeze([]),
};
if (previous && sameOverlayEntries(previous.entries, scene.entries)
&& samePlacementMap(previous.devices, scene.devices)
+3 -4
View File
@@ -64,7 +64,6 @@ interface LiveEditorHost {
/** #521: guides for the gesture in flight; `nothing` while the runtime loads. */
_renderAlignGuides: () => unknown;
_livePos: (device: { id: string }) => { x: number; y: number };
_scenePoint: (point: number[]) => number[];
_renderProjection: string;
_spaceModel: () => SpaceModel | null;
_spaceWalls: WallEntry[];
@@ -309,11 +308,11 @@ const paintDevice = (host: LiveEditorHost, root: ParentNode): void => {
const element = [...root.querySelectorAll<HTMLElement>('[data-hp="device"]')]
.find((candidate) => candidate.dataset.id === drag.id);
if (!device || !element) return;
// #713: the 2.5D floor is the Flat plane, and editors are Flat anyway.
const position = host._livePos(device);
const point = host._scenePoint([position.x, position.y]);
const view = host._viewOr(host._baseVb());
element.style.left = `${((point[0] - view.x) / view.w) * 100}%`;
element.style.top = `${((point[1] - view.y) / view.h) * 100}%`;
element.style.left = `${((position.x - view.x) / view.w) * 100}%`;
element.style.top = `${((position.y - view.y) / view.h) * 100}%`;
};
const makeDecorShapeTransparent = (
+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 \{/,