mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
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:
@@ -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,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 } };
|
||||
|
||||
@@ -34,7 +34,6 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_rszMove',
|
||||
'_rszRooms',
|
||||
'_setMode',
|
||||
'_scenePoint',
|
||||
'_viewOr',
|
||||
]),
|
||||
fields: Object.freeze([
|
||||
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
@@ -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) || [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- В объёмном 2.5D-виде план больше не сдвигается: комнаты, мебель, прочий декор
|
||||
и названия комнат остаются ровно там же, где на обычном плане, а стены
|
||||
вырастают из него строго вверх. Все значки устройств и замков поднимаются на
|
||||
одно и то же расстояние — высоту стен — вместо разных сдвигов у стен.
|
||||
Включение и выключение объёма и вход в редактор из него сохраняют масштаб и
|
||||
положение плана на экране
|
||||
([#713](https://github.com/Matysh/houseplan-card/issues/713)).
|
||||
- На фоне «Следует за Солнцем» в сумерках и ночью в левом верхнем углу сцены,
|
||||
за планом, теперь видна луна в текущей фазе. Она следует настоящей луне над
|
||||
вашим домом (по координатам дома из Home Assistant), плавно появляется и
|
||||
|
||||
+64
-65
@@ -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
@@ -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
@@ -344,12 +344,17 @@ Escape и закрытие диалога отбрасывают изменен
|
||||
Объёмный вид плана включает администратор один раз: **Общие настройки →
|
||||
Отображение → Объёмный вид плана (2.5D)**. После сохранения Просмотр и киоск во
|
||||
всех пространствах и на всех устройствах сразу показывают стены, двери, окна и
|
||||
значки устройств с объёмом: значки — приподнятые плитки с мягкой тенью на полу,
|
||||
из окон на пол ложится мягкий свет по солнцу (если включены солнечные лучи и
|
||||
задан север). Цвет стен — тот, что задан в общих настройках, одинаковый в
|
||||
светлой и тёмной теме; мебель и прочий декор выглядят так же, как на обычном
|
||||
плане. Редакторы всегда плоские. Выключенная опция (по умолчанию) и
|
||||
**Сбросить** возвращают обычный план. Отдельной кнопки на карточке нет.
|
||||
значки устройств с объёмом. Сам план не сдвигается: комнаты, мебель, прочий
|
||||
декор и названия комнат остаются ровно там же, где на обычном плане, а стены
|
||||
вырастают из него строго вверх. Все значки устройств и замков поднимаются на
|
||||
одно и то же расстояние — высоту стен — и выглядят приподнятыми плитками с
|
||||
мягкой тенью; у стены плитка может заходить на неё. Из окон на пол ложится
|
||||
мягкий свет по солнцу (если включены солнечные лучи и задан север). Цвет стен —
|
||||
тот, что задан в общих настройках, одинаковый в светлой и тёмной теме.
|
||||
Переключение опции и вход в редактор из объёмного вида сохраняют масштаб и
|
||||
положение плана на экране. Редакторы всегда плоские. Выключенная опция (по
|
||||
умолчанию) и **Сбросить** возвращают обычный план. Отдельной кнопки на карточке
|
||||
нет.
|
||||
|
||||
Для разовой проверки размещения Zigbee администратор может включить **Общие
|
||||
настройки → Показывать связи Zigbee при наведении на устройство**. По умолчанию
|
||||
|
||||
@@ -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; },',
|
||||
|
||||
@@ -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
@@ -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' : ''}`;
|
||||
|
||||
@@ -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
@@ -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
@@ -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] => [
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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-отсечение учитывает зазор безопасности', () => {
|
||||
|
||||
@@ -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 \{/,
|
||||
|
||||
Reference in New Issue
Block a user