From 0d641ffbfdef03786b8690d7af1b31304b8ca8b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 15:18:00 +0300 Subject: [PATCH] feat(iso): the 2.5D floor is the Flat plane, one wall-top rise for tiles (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- demo/benchmark_large_house.mjs | 2 +- demo/golden/matrix.mjs | 2 +- demo/performance/card-contract.mjs | 1 - demo/smoke_iso_flat_parity.mjs | 332 ++++++++++++++++++++++++++++ demo/smoke_isometric_contract.mjs | 19 +- demo/smoke_isometric_live_touch.mjs | 29 ++- demo/smoke_room_fit.mjs | 4 +- docs/CHANGELOG.md | 6 + docs/CHANGELOG.ru.md | 7 + docs/ISOMETRIC.md | 129 ++++++----- docs/USER-GUIDE.md | 18 +- docs/USER-GUIDE.ru.md | 17 +- scripts/mutation-registry.mjs | 79 +++++-- scripts/smoke-links.mjs | 11 + src/houseplan-card.ts | 82 +++---- src/houseplan-editor-runtime.ts | 23 +- src/iso-openings.ts | 5 +- src/iso-overlays.ts | 12 +- src/iso-projection.ts | 13 +- src/iso-scene-render.ts | 96 ++------ src/live-editor.ts | 7 +- test/golden-matrix.test.mjs | 4 +- test/iso-openings.test.mjs | 15 ++ test/iso-projection.test.mjs | 28 ++- test/iso-scene-render.test.mjs | 103 ++++----- test/isometric-contract.test.mjs | 24 +- 26 files changed, 743 insertions(+), 325 deletions(-) create mode 100644 demo/smoke_iso_flat_parity.mjs diff --git a/demo/benchmark_large_house.mjs b/demo/benchmark_large_house.mjs index 23a8cfe4..f3e56460 100644 --- a/demo/benchmark_large_house.mjs +++ b/demo/benchmark_large_house.mjs @@ -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; diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 8e2a84d5..097b2bb6 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -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 } }; diff --git a/demo/performance/card-contract.mjs b/demo/performance/card-contract.mjs index 26847d96..091e1aae 100644 --- a/demo/performance/card-contract.mjs +++ b/demo/performance/card-contract.mjs @@ -34,7 +34,6 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({ '_rszMove', '_rszRooms', '_setMode', - '_scenePoint', '_viewOr', ]), fields: Object.freeze([ diff --git a/demo/smoke_iso_flat_parity.mjs b/demo/smoke_iso_flat_parity.mjs new file mode 100644 index 00000000..dfa330ad --- /dev/null +++ b/demo/smoke_iso_flat_parity.mjs @@ -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); diff --git a/demo/smoke_isometric_contract.mjs b/demo/smoke_isometric_contract.mjs index 49acebbf..c4279263 100644 --- a/demo/smoke_isometric_contract.mjs +++ b/demo/smoke_isometric_contract.mjs @@ -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)); diff --git a/demo/smoke_isometric_live_touch.mjs b/demo/smoke_isometric_live_touch.mjs index 205baf5d..a8e4b8ab 100644 --- a/demo/smoke_isometric_live_touch.mjs +++ b/demo/smoke_isometric_live_touch.mjs @@ -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"]') diff --git a/demo/smoke_room_fit.mjs b/demo/smoke_room_fit.mjs index ee008dd8..a510a54b 100644 --- a/demo/smoke_room_fit.mjs +++ b/demo/smoke_room_fit.mjs @@ -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) || []; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 333a7dad..5e813014 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 59dfe9be..9635cd14 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,13 @@ ## Не выпущено +- В объёмном 2.5D-виде план больше не сдвигается: комнаты, мебель, прочий декор + и названия комнат остаются ровно там же, где на обычном плане, а стены + вырастают из него строго вверх. Все значки устройств и замков поднимаются на + одно и то же расстояние — высоту стен — вместо разных сдвигов у стен. + Включение и выключение объёма и вход в редактор из него сохраняют масштаб и + положение плана на экране + ([#713](https://github.com/Matysh/houseplan-card/issues/713)). - На фоне «Следует за Солнцем» в сумерках и ночью в левом верхнем углу сцены, за планом, теперь видна луна в текущей фазе. Она следует настоящей луне над вашим домом (по координатам дома из Home Assistant), плавно появляется и diff --git a/docs/ISOMETRIC.md b/docs/ISOMETRIC.md index 390de7bb..b5c8d9fb 100644 --- a/docs/ISOMETRIC.md +++ b/docs/ISOMETRIC.md @@ -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) diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 1d119a6a..6ce1672d 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 913abf92..baa0a1b8 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -344,12 +344,17 @@ Escape и закрытие диалога отбрасывают изменен Объёмный вид плана включает администратор один раз: **Общие настройки → Отображение → Объёмный вид плана (2.5D)**. После сохранения Просмотр и киоск во всех пространствах и на всех устройствах сразу показывают стены, двери, окна и -значки устройств с объёмом: значки — приподнятые плитки с мягкой тенью на полу, -из окон на пол ложится мягкий свет по солнцу (если включены солнечные лучи и -задан север). Цвет стен — тот, что задан в общих настройках, одинаковый в -светлой и тёмной теме; мебель и прочий декор выглядят так же, как на обычном -плане. Редакторы всегда плоские. Выключенная опция (по умолчанию) и -**Сбросить** возвращают обычный план. Отдельной кнопки на карточке нет. +значки устройств с объёмом. Сам план не сдвигается: комнаты, мебель, прочий +декор и названия комнат остаются ровно там же, где на обычном плане, а стены +вырастают из него строго вверх. Все значки устройств и замков поднимаются на +одно и то же расстояние — высоту стен — и выглядят приподнятыми плитками с +мягкой тенью; у стены плитка может заходить на неё. Из окон на пол ложится +мягкий свет по солнцу (если включены солнечные лучи и задан север). Цвет стен — +тот, что задан в общих настройках, одинаковый в светлой и тёмной теме. +Переключение опции и вход в редактор из объёмного вида сохраняют масштаб и +положение плана на экране. Редакторы всегда плоские. Выключенная опция (по +умолчанию) и **Сбросить** возвращают обычный план. Отдельной кнопки на карточке +нет. Для разовой проверки размещения Zigbee администратор может включить **Общие настройки → Показывать связи Zigbee при наведении на устройство**. По умолчанию diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 7bd63c1e..8fb161c2 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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; },', diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index 37084344..7d75dbfc 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -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', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 502d3387..6dc21f03 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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 | null = null; private readonly _isoFirstFrame = new IsoFirstFrameState(); // #649/#654 + private _isoEnabledSeen = false; private _projectionShown: 'flat' | 'iso' | null = null; private _isoLightFloors: ReadonlySet | 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"> - - ${renderPaperShapes(paperShapes, 'hp-paper-outline-shapes')} - + ${renderPaperShapes(paperShapes, 'hp-paper-outline-shapes')} ` : 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``); } @@ -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' : ''}`; diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index 138d2937..87f3cb05 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -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; diff --git a/src/iso-openings.ts b/src/iso-openings.ts index 60bf5452..48526de3 100644 --- a/src/iso-openings.ts +++ b/src/iso-openings.ts @@ -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, diff --git a/src/iso-overlays.ts b/src/iso-overlays.ts index 726bd7fa..08cb1207 100644 --- a/src/iso-overlays.ts +++ b/src/iso-overlays.ts @@ -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] => [ diff --git a/src/iso-projection.ts b/src/iso-projection.ts index b2512e59..49601deb 100644 --- a/src/iso-projection.ts +++ b/src/iso-projection.ts @@ -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); diff --git a/src/iso-scene-render.ts b/src/iso-scene-render.ts index ff1ff870..504e919c 100644 --- a/src/iso-scene-render.ts +++ b/src/iso-scene-render.ts @@ -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(); const roomPlacements = new Map(); const locks = new Map(); @@ -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(), 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(); const resolvedLocks = new Map(); 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) diff --git a/src/live-editor.ts b/src/live-editor.ts index 91e07fdb..f5c48e96 100644 --- a/src/live-editor.ts +++ b/src/live-editor.ts @@ -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('[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 = ( diff --git a/test/golden-matrix.test.mjs b/test/golden-matrix.test.mjs index 50558b49..a6e83fea 100644 --- a/test/golden-matrix.test.mjs +++ b/test/golden-matrix.test.mjs @@ -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', diff --git a/test/iso-openings.test.mjs b/test/iso-openings.test.mjs index 2b7076c5..145d7df9 100644 --- a/test/iso-openings.test.mjs +++ b/test/iso-openings.test.mjs @@ -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'); +}); diff --git a/test/iso-projection.test.mjs b/test/iso-projection.test.mjs index 39e83358..b1860b29 100644 --- a/test/iso-projection.test.mjs +++ b/test/iso-projection.test.mjs @@ -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)); diff --git a/test/iso-scene-render.test.mjs b/test/iso-scene-render.test.mjs index 22071425..bdeef12a 100644 --- a/test/iso-scene-render.test.mjs +++ b/test/iso-scene-render.test.mjs @@ -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-отсечение учитывает зазор безопасности', () => { diff --git a/test/isometric-contract.test.mjs b/test/isometric-contract.test.mjs index 4a4a4228..256d3558 100644 --- a/test/isometric-contract.test.mjs +++ b/test/isometric-contract.test.mjs @@ -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, //); + assert.doesNotMatch(card, /isoFloorMatrixCss|unprojectFloorPoint/); assert.match(card, /\$\{litCache\(iso && isoLayers\?\.structural \? svg` { - 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 \{/,