From e4b4b0e6cda3eed899fa3cb2d1e285cd4ae67024 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:33:22 +0300 Subject: [PATCH] fix(radar): show the room contour in the on-plan setup (#774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-plan radar setup multiplied SpaceModel room contours by 1000 a second time. They are render units already, so the outline was drawn at 40000… far outside the fixed "0 0 1000 1000" viewBox, and the administrator placed mount, heading and references on an empty field. Dropping the multiplier alone is not enough: the fixed board also cut every room drawn outside the historical square, and the pointer mapping ignored the letterbox and clamped presses to [0,1]. The setup now frames the plan with the main card's content frame (docs/CANVAS.md §4, the same items through the editor host port). It opens on core and switches to all when core would cut the configured room; the stored view_box remains the empty-content fallback. The frame is computed once per session. One pure projection serves every layer: the contour is drawn as is, mount, heading, pending/reference marks and the live trail go through plan units x NORM_W, and a press maps back through xMidYMid meet to unclamped plan units, a press in the letterbox placing nothing. Stored radar data, the calibration solve, Apply/Save and View are unchanged. Tests: a unit table for the projection and the frame (offset frame, letterbox, negative and >1 plan units, outlier core -> all, empty-content fallback) and an extended smoke_radar_setup (a room outside [0,1] with view_box [0,0,1,1], vertices and bbox, known presses -> draft and DOM, frame stability across presses, live trail and resize, Apply, Cancel, Escape and reopen, a room without contour, an unknown room). Five unit-guarded mutants are registered. Issue: #774 User-Visible: yes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- demo/smoke_radar_setup.mjs | 350 +++++++++++++++++++++++++++++++- docs/CANVAS.md | 6 + docs/CHANGELOG.md | 7 + docs/CHANGELOG.ru.md | 7 + scripts/mutation-registry.mjs | 57 ++++++ src/editors/radar-section.ts | 7 +- src/houseplan-editor-runtime.ts | 3 + src/radar-setup.ts | 84 ++++++-- test/radar-setup.test.mjs | 221 +++++++++++++++++++- 9 files changed, 719 insertions(+), 23 deletions(-) diff --git a/demo/smoke_radar_setup.mjs b/demo/smoke_radar_setup.mjs index 013befe0..a66ec705 100644 --- a/demo/smoke_radar_setup.mjs +++ b/demo/smoke_radar_setup.mjs @@ -96,4 +96,352 @@ const out = await page.evaluate(async () => { }; }); -await finish(browser, checkAll(out)); +// #774: the on-plan setup shows the real contour of the selected room, and the +// contour, mount, heading, pending/reference marks and the live trail share ONE +// projection: content frame (docs/CANVAS.md §4) → SVG viewBox → xMidYMid meet. +// Plan units below are the stored ones (render = ×1000). Every expected number +// is computed here, from the fixture and the measured SVG rectangle — never by +// asking the product code. +const POLY = { + // an ordinary non-rectangular room inside the historical square + r_l: [[0.1, 0.2], [0.7, 0.2], [0.7, 0.45], [0.4, 0.45], [0.4, 0.6], [0.1, 0.6]], + // a room entirely outside [0,1], negative on both axes + r_out: [[-0.55, -0.4], [-0.15, -0.4], [-0.15, -0.1], [-0.35, -0.1], [-0.55, -0.25]], + // a room entirely past 1 on both axes + r_far: [[1.1, 0.8], [1.45, 0.8], [1.45, 1.15], [1.1, 1.15]], +}; +const SAVED_774 = { + version: 1, enabled: true, show_live: false, profile: 'cartesian_v1', + sources: { slots: [{ id: 'target_1', x_entity: 'sensor.living_temp', + y_entity: 'sensor.living_temp', unit: 'cm' }] }, + room_id: 'r_l', + mount: { installation_id: 'smoke-774', x: 0.2, y: 0.3, heading_deg: 45, range_cm: 600, fov_deg: 120 }, + calibration: { method: 'two_point', mirror: false, cell_cm: 5, rms_cm: 4, + refs: [{ plan: { x: 0.3, y: 0.3 }, local_cm: { x: 0, y: 100 } }, + { plan: { x: 0.25, y: 0.4 }, local_cm: { x: 80, y: 60 } }] }, +}; +const FIXTURE_774 = { + spaces: [{ + id: 'rs', title: 'Radar setup', view_box: [0, 0, 1, 1], cell_cm: 5, plan_url: null, plan_aspect: null, + rooms: [ + { id: 'r_l', name: 'L room', area: 'zx_l', poly: POLY.r_l }, + { id: 'r_out', name: 'Outside', area: 'zx_out', poly: POLY.r_out }, + { id: 'r_far', name: 'Far', area: 'zx_far', poly: POLY.r_far }, + // a legacy rectangle: a room WITHOUT a contour polygon + { id: 'r_rect', name: 'No contour', area: 'zx_rect', x: 0.75, y: 0.25, w: 0.15, h: 0.15 }, + ], + }], + // made-up areas: no demo device lands here, the radar is the only marker + markers: [{ id: 'm_radar', binding: 'device:d_motion', area: 'zx_l', radar: SAVED_774 }], + settings: { filter_seeded: true }, +}; +// The radar's icon stands outside every room, so a frame built from the rooms +// alone (a truncated set of inputs) is NOT the frame the card opens on. +const LAYOUT_774 = { m_radar: { s: 'rs', x: -0.6, y: 1.2 } }; +// Content bbox: x -600..1450 (2050), y -400..1200 (1600); 5 % of 2050 = 102.5. +// Five items, no outlier (docs/CANVAS.md §4.1) → core = all. +const FRAME_774 = [-702.5, -502.5, 2255, 1805]; + +const out774 = await page.evaluate(async ({ fixture, layout, poly, frame, saved }) => { + const hp = window.__hpTest; + const card = window.__card; + const root = () => card.shadowRoot || card.renderRoot; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const result = {}; + const savedRadar = () => JSON.stringify(card._serverCfg.markers.find((m) => m.id === 'm_radar')?.radar); + const setup = () => root().querySelector('.radarsetup'); + const svgOf = () => setup()?.querySelector('svg') || null; + const viewBoxOf = (svg) => (svg?.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number); + const pointsOf = (value) => (value || '').trim().split(/\s+/).filter(Boolean) + .map((pair) => pair.split(',').map(Number)); + const contourOf = (svg) => pointsOf(svg?.querySelector('polygon.room')?.getAttribute('points')); + const render = (pts) => pts.map(([x, y]) => [x * 1000, y * 1000]); + const samePts = (a, b, eps = 1e-6) => a.length === b.length + && a.every((p, i) => Math.abs(p[0] - b[i][0]) <= eps && Math.abs(p[1] - b[i][1]) <= eps); + const near = (a, b, eps) => !!a && !!b && Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps; + const within = (pts, vb) => pts.length > 0 && pts.every(([x, y]) => x >= vb[0] && y >= vb[1] + && x <= vb[0] + vb[2] && y <= vb[1] + vb[3]); + const sameFrame = (a, b) => a.length === 4 && a.every((v, i) => Math.abs(v - b[i]) < 1e-9); + // xMidYMid meet, written out independently of src/radar-setup.ts + const screen = (svg) => { + const rect = svg.getBoundingClientRect(); + const [vx, vy, vw, vh] = viewBoxOf(svg); + const k = Math.min(rect.width / vw, rect.height / vh); + const ox = rect.left + (rect.width - vw * k) / 2; + const oy = rect.top + (rect.height - vh * k) / 2; + return { rect, k, ox, oy, client: ([px, py]) => [ox + (px * 1000 - vx) * k, oy + (py * 1000 - vy) * k] }; + }; + const press = (svg, [x, y]) => svg.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, clientX: x, clientY: y, pointerId: 1, isPrimary: true, + })); + const centre = (el) => { + const r = el?.getBoundingClientRect(); + return r ? [r.left + r.width / 2, r.top + r.height / 2] : null; + }; + const markOf = (cls) => svgOf()?.querySelector(`g.${cls} circle`) || null; + const marksAt = (cls) => [...(svgOf()?.querySelectorAll(`g.${cls}`) || [])] + .map((g) => g.getAttribute('transform')); + const choose = async (select, value) => { + select.value = value; + select.dispatchEvent(new Event('change', { bubbles: true })); + await hp.settled(); + }; + const roomSelect = () => root().querySelector('#radar-room'); + const configure = async () => { + root().querySelector('.radargroup button ha-icon[icon="mdi:map-marker-radius"]') + ?.closest('button')?.click(); + await hp.settled(); + return svgOf(); + }; + const toast = () => root().querySelector('[data-hp="toast"]')?.textContent?.trim() || ''; + const markerDialog = () => root().querySelector('[data-hp="dialog"][data-kind="marker"]'); + const acceptConfirm = async (confirm) => { + const buttons = [...(confirm?.querySelectorAll('.danger-confirm-footer button') + || confirm?.shadowRoot?.querySelectorAll('.danger-confirm-footer button') || [])]; + buttons.at(-1)?.click(); + await hp.settled(); + }; + const closeMarker = async () => { + if (!markerDialog()) return false; + const { confirm } = await hp.close(markerDialog(), { via: 'escape' }); + if (confirm) await acceptConfirm(confirm); + // a second confirmation (the dialog's own) may follow the setup's + const again = root().querySelector('[data-hp="dialog"][data-kind="confirm"]'); + if (again) await acceptConfirm(again); + return !markerDialog(); + }; + + // Live diagnostics: the demo's subscribeMessage is a no-op; this one hands the + // setup subscription's callback to the smoke so it can push real snapshots. + const connection = card.hass.connection; + const realSubscribe = connection.subscribeMessage; + const live = { push: null, subscribed: 0, released: 0 }; + connection.subscribeMessage = async (callback, message) => { + if (message?.type !== 'houseplan/radar/setup/subscribe') return () => {}; + live.subscribed += 1; + live.push = callback; + return () => { live.released += 1; }; + }; + const realNow = Date.now; + let skew = 0; + Date.now = () => realNow.call(Date) + skew; + // Every config write the card sends, as the radar block it would persist. The + // editor may write unrelated housekeeping on its own; only a changed radar + // block before the ordinary Save is a leak (AC4). + const hass = card.hass; + const realCallWS = hass.callWS; + const radarWrites = []; + hass.callWS = async (message) => { + if (message?.type === 'houseplan/config/set') { + radarWrites.push(JSON.stringify(message.config?.markers?.find((m) => m.id === 'm_radar')?.radar)); + } + return realCallWS(message); + }; + try { + await hp.setServerConfig(fixture); + await hp.setLayout(layout); + if (card._space !== 'rs') await hp.switchSpace('rs'); + await hp.setMode('devices'); + await hp.openMarkerDialog('m_radar'); + const saved0 = savedRadar(); + const persisted = () => savedRadar() === saved0 && radarWrites.every((radar) => radar === saved0); + result.fixtureLoaded = saved0 === JSON.stringify(saved) && !!roomSelect(); + + // -------------------------------------------------- AC1: room outside [0,1] + await choose(roomSelect(), 'r_out'); + let svg = await configure(); + result.setupOpensForOutsideRoom = !!svg; + const vb = viewBoxOf(svg); + result.frameIsContentDerived = sameFrame(vb, frame); + // the main card frames the same space with the very same numbers + const cardFrame = card._frameOf().rect; + result.frameMatchesMainCard = sameFrame(vb, [cardFrame.x, cardFrame.y, cardFrame.w, cardFrame.h]); + const contourOut = contourOf(svg); + result.outsideContourScaledOnce = samePts(contourOut, render(poly.r_out)); + result.outsideContourFitsViewBox = within(contourOut, vb); + + // -------------------------------------------------- AC2/AC3: one projection + let view = screen(svg); + result.squareSvgLetterboxes = Math.abs(view.rect.width - view.rect.height) < 1 + && view.oy - view.rect.top > 10; + press(svg, [view.rect.left + view.rect.width / 2, view.rect.top + (view.oy - view.rect.top) / 2]); + await hp.settled(); + result.letterboxPressIgnored = !markOf('mount') + && card._editorRuntime._radarSetup.active?.mount === null; + const MOUNT = [-0.3, -0.25]; // inside r_out: negative plan units + const AHEAD = [0.5, -0.25]; // due east of the mount → heading 90° + press(svg, view.client(MOUNT)); + await hp.settled(); + press(svg, view.client(AHEAD)); + await hp.settled(); + const state = () => card._editorRuntime._radarSetup.active; + result.mountInPlanUnits = near(state()?.mount, MOUNT, 1e-9) + && state()?.draft.mountX === '-300' && state()?.draft.mountY === '-250' + && state()?.draft.heading === '90'; + result.mountMarkOnClick = near(centre(markOf('mount')), view.client(MOUNT), 1); + const line = svgOf().querySelector('line.heading'); + result.headingLineInSceneUnits = !!line + && near([+line.getAttribute('x1'), +line.getAttribute('y1')], [-300, -250], 1e-6) + && near([+line.getAttribute('x2'), +line.getAttribute('y2')], [500, -250], 1e-6); + const polygonBox = svgOf().querySelector('polygon.room').getBoundingClientRect(); + const outMin = view.client([-0.55, -0.4]); + const outMax = view.client([-0.15, -0.1]); + result.contourOnScreenWhereProjected = Math.abs(polygonBox.left - outMin[0]) < 1.5 + && Math.abs(polygonBox.top - outMin[1]) < 1.5 && Math.abs(polygonBox.right - outMax[0]) < 1.5 + && Math.abs(polygonBox.bottom - outMax[1]) < 1.5; + await sleep(0); + result.coordinateProfileSubscribed = live.subscribed === 1 && typeof live.push === 'function'; + + // live trail: 600 cm ahead, then 60 cm to its right (heading 90, cell 5 → 1200 cm/unit) + live.push?.({ local_targets: [{ slot: 'target_1', x_cm: 0, y_cm: 600 }], frame: { health: 'ok' } }); + live.push?.({ local_targets: [{ slot: 'target_1', x_cm: 60, y_cm: 600 }], frame: { health: 'ok' } }); + await hp.settled(); + const trail = pointsOf(svgOf().querySelector('polyline.trail')?.getAttribute('points')); + result.trailInSameProjection = samePts(trail, [[200, -250], [200, -200]], 1e-6); + result.frameStableAfterClicksAndLive = sameFrame(viewBoxOf(svgOf()), frame); + + // reference 1 at x > 1, y < 0: exactly 1800 cm ahead of the mount + const REF1 = [1.2, -0.25]; + press(svgOf(), view.client(REF1)); + await hp.settled(); + result.pendingMarkOnClick = near(state()?.pendingPlan, REF1, 1e-9) + && near(centre(markOf('pending')), view.client(REF1), 1); + const capture = async (local) => { + setup().querySelector('.row button.btn:not(.ghost):not(.on)')?.click(); + await hp.settled(); + skew += 10_500; // countdown over + const t0 = Date.now() / 1000; + for (const dt of [0, 1.2, 2.4]) { + live.push?.({ local_targets: [{ slot: 'target_1', x_cm: local[0], y_cm: local[1], + reported_at: t0 + dt }], frame: { health: 'ok' } }); + } + skew += 5_000; // capture window over + await sleep(450); // the 200 ms setup tick closes it + await hp.settled(); + }; + await capture([0, 1800]); + result.referenceOneCaptured = state()?.phase === 'reference_2' + && near(state()?.refs?.[0]?.plan, REF1, 1e-9) + && near(centre(markOf('reference')), view.client(REF1), 1); + + // resize: only the screen matrix changes; the scene and the stored points do not + const before = { vb: svgOf().getAttribute('viewBox'), marks: [...marksAt('mount'), ...marksAt('reference')] }; + svgOf().style.width = '300px'; + await hp.settled(); + view = screen(svgOf()); + result.resizeChangedScreen = Math.abs(view.rect.width - 300) < 1; + result.resizeKeepsFrameAndScene = svgOf().getAttribute('viewBox') === before.vb + && JSON.stringify([...marksAt('mount'), ...marksAt('reference')]) === JSON.stringify(before.marks); + result.resizeMovesMarksWithMatrix = near(centre(markOf('mount')), view.client(MOUNT), 1) + && near(centre(markOf('reference')), view.client(REF1), 1); + // reference 2 placed through the NEW matrix: 1200 cm right and 1200 cm ahead + const REF2 = [0.7, 0.75]; + press(svgOf(), view.client(REF2)); + await hp.settled(); + result.clickAfterResizeLands = near(state()?.pendingPlan, REF2, 1e-9) + && near(centre(markOf('pending')), view.client(REF2), 1); + await capture([1200, 1200]); + result.twoPointSolved = state()?.phase === 'solved' && state()?.solved?.mirror === false + && Math.abs(state()?.solved?.headingDeg - 90) < 1e-6; + result.frameStableThroughSession = sameFrame(viewBoxOf(svgOf()), frame); + + // -------------------------------------------------- AC4: Apply ≠ Save + setup().querySelector('.row button.btn.on')?.click(); + await hp.settled(); + const applied = card._markerDialog?.radar; + const refs = applied?.calibrationOverride?.refs || []; + result.applyReturnsPlanUnits = !setup() && applied?.mountX === '-300' && applied?.mountY === '-250' + && applied?.heading === '90' && applied?.mirror === false + && applied?.calibrationOverride?.method === 'two_point' && refs.length === 2 + && near([refs[0].plan.x, refs[0].plan.y], REF1, 1e-9) + && near([refs[1].plan.x, refs[1].plan.y], REF2, 1e-9) + && refs[0].local_cm.x === 0 && refs[0].local_cm.y === 1800; + result.applyReleasesSubscription = live.released === 1; + result.applyDoesNotPersist = persisted(); + result.closeWithoutSaveDiscards = await closeMarker() && persisted(); + + // -------------------------------------------------- reopen: saved calibration untouched + await hp.openMarkerDialog('m_radar'); + result.reopenShowsSavedCalibration = JSON.stringify(card._markerDialog?.radar?.original) === saved0 + && roomSelect()?.value === 'r_l'; + // AC1: the ordinary non-rectangular room, same frame + svg = await configure(); + const contourL = contourOf(svg); + result.ordinaryContourScaledOnce = samePts(contourL, render(poly.r_l)); + result.ordinaryContourFitsViewBox = within(contourL, viewBoxOf(svg)); + result.ordinaryRoomSameFrame = sameFrame(viewBoxOf(svg), frame); + // Escape with a dirty setup: confirmation, nothing written + view = screen(svg); + press(svg, view.client([0.2, 0.3])); + await hp.settled(); + result.escapeWithDirtySetupDiscards = !!markOf('mount') && await closeMarker() && persisted(); + + // -------------------------------------------------- AC5: no contour / unknown room + await hp.openMarkerDialog('m_radar'); + await choose(roomSelect(), 'r_rect'); + svg = await configure(); + result.noContourWarns = toast() === card._t('radar.no_contour'); + result.noContourNoInventedOutline = !!svg && contourOf(svg).length === 0 + && sameFrame(viewBoxOf(svg), frame); + setup().querySelector('.radarsetup-head .iconbtn')?.click(); + await hp.settled(); + result.untouchedCancelCloses = !setup(); + await closeMarker(); + await hp.setServerConfig((cfg) => { + cfg.markers.find((m) => m.id === 'm_radar').radar.room_id = 'r_gone'; + return cfg; + }); + await hp.openMarkerDialog('m_radar'); + svg = await configure(); + result.unknownRoomIsAnError = !svg && toast() === card._t('radar.invalid'); + await closeMarker(); + + // -------------------------------------------------- AC2: stored view_box does not frame content + await hp.setServerConfig((cfg) => { + cfg.spaces[0].view_box = [5, 5, 2, 2]; + cfg.markers.find((m) => m.id === 'm_radar').radar.room_id = 'r_l'; + return cfg; + }); + await hp.openMarkerDialog('m_radar'); + svg = await configure(); + result.storedViewBoxIgnoredWithContent = sameFrame(viewBoxOf(svg), frame); + setup().querySelector('.radarsetup-head .iconbtn')?.click(); + await hp.settled(); + await closeMarker(); + + // -------------------------------------------------- AC1: the outlier room (core → all) + await hp.setServerConfig((cfg) => { + cfg.spaces[0].rooms.push({ id: 'r_remote', name: 'Remote', area: 'zx_remote', + poly: [[40, 40], [40.5, 40], [40.5, 40.5], [40, 40.5]] }); + return cfg; + }); + await hp.openMarkerDialog('m_radar'); + svg = await configure(); + result.mainMassRoomKeepsCore = sameFrame(viewBoxOf(svg), frame); + setup().querySelector('.radarsetup-head .iconbtn')?.click(); + await hp.settled(); + await choose(roomSelect(), 'r_remote'); + svg = await configure(); + const remote = contourOf(svg); + // all: x -600..40500 (41100), y -400..40500 (40900); 5 % of 41100 = 2055 + result.outlierRoomOpensOnAll = sameFrame(viewBoxOf(svg), [-2655, -2455, 45210, 45010]) + && samePts(remote, [[40000, 40000], [40500, 40000], [40500, 40500], [40000, 40500]]) + && within(remote, viewBoxOf(svg)); + setup().querySelector('.radarsetup-head .iconbtn')?.click(); + await hp.settled(); + await closeMarker(); + result.scenarioCompleted = true; + } catch (error) { + // keep the named results gathered so far; the failure itself is reported too + result.scenarioCompleted = `stopped: ${error?.message || error}`; + } finally { + Date.now = realNow; + hass.callWS = realCallWS; + connection.subscribeMessage = realSubscribe; + if (markerDialog()) await closeMarker().catch(() => {}); + await hp.setMode('view').catch(() => {}); + } + return result; +}, { fixture: FIXTURE_774, layout: LAYOUT_774, poly: POLY, frame: FRAME_774, saved: SAVED_774 }); + +await finish(browser, checkAll({ ...out, ...out774 })); diff --git a/docs/CANVAS.md b/docs/CANVAS.md index 8c3b0b1e..a4a4aabf 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -206,6 +206,12 @@ not only in the viewBox: §6's `iconUnit` runs the same vote over the rooms. One notion of "the plan", or a stray the frame had just thrown out came back as icons ninety times too big (audit DEV-2C947-03). +The radar **Configure on plan** setup (#774) frames the plan with the same +items and opens on `core`, with one exception: when `core` would cut the +contour of the room being configured it opens on `all`, so the vote can never +hide the room the user is placing the sensor in. The frame is fixed for the +setup session; the stored `view_box` still counts only when nothing is drawn. + ### §4.3 The frame in an editor Inside an editor the frame only ever **grows** (`unionRect` with the previous diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e60e34f1..a96b5b38 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,13 @@ - In kiosk mode, holding the plan to open **This screen's sizes** no longer downloads the editor, and a poor connection no longer shows a "Could not load the editor" notice there ([#763](https://github.com/Matysh/houseplan-card/issues/763)). +- Presence radar: **Configure on plan** now shows the outline of the selected + room. It used to be drawn far outside the setup area, so the mount point, + direction and reference positions were placed on an empty field. The area + now frames the plan the same way the card does, including rooms drawn outside + the original square, and every mark lands exactly where it is clicked + relative to the outline. Saved radar settings are unchanged + ([#774](https://github.com/Matysh/houseplan-card/issues/774)). ## v1.80.0-beta.6 — 2026-10-06 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 6947e736..2636b9c6 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -17,6 +17,13 @@ - В режиме киоска долгое нажатие, открывающее **«Размеры на этом экране»**, больше не загружает редактор, а при плохой сети не показывает сообщение «Не удалось загрузить редактор» ([#763](https://github.com/Matysh/houseplan-card/issues/763)). +- Радар присутствия: в **«Настроить на плане»** теперь виден контур выбранной + комнаты. Раньше он рисовался далеко за пределами области настройки, и точку + установки, направление и опорные позиции приходилось ставить на пустом поле. + Теперь область показывает план так же, как карточка, включая комнаты за + пределами исходного квадрата, а каждая отметка ложится ровно туда, куда + нажали относительно контура. Сохранённые настройки радаров не меняются + ([#774](https://github.com/Matysh/houseplan-card/issues/774)). ## v1.80.0-beta.6 — 2026-10-06 diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 471c2f09..2daaaeae 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -719,6 +719,63 @@ const MUTANT_DEFINITIONS = [ replace: ' && JSON.stringify(original.sources) === JSON.stringify(sources)', }], }, + { + id: 'radar-setup-contour-scaled-twice', + guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs', + because: '#774 AC1: SpaceModel room contours are render units already; multiplying them by ' + + '1000 again draws the outline at 40000… far outside the setup viewBox, so the field ' + + 'looks empty while mount and heading still appear', + patches: [{ + file: 'src/radar-setup.ts', + find: "map((point) => `${point[0]},${point[1]}`)", + replace: "map((point) => `${point[0] * 1000},${point[1] * 1000}`)", + }], + }, + { + id: 'radar-setup-pointer-clamped-to-unit-square', + guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs', + because: '#774 AC2: the canvas has no edges; clamping a press to [0,1] silently moves a mount ' + + 'or reference placed in a room outside the historical square and changes the physical ' + + 'meaning of the stored calibration', + patches: [{ + file: 'src/radar-setup.ts', + find: ' ? [x / NORM_W, y / NORM_W] : null;', + replace: ' ? [Math.max(0, Math.min(1, x / NORM_W)), Math.max(0, Math.min(1, y / NORM_W))] : null;', + }], + }, + { + id: 'radar-setup-frame-from-stored-view-box', + guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs', + because: '#774 AC1/AC2 (SPEC-REVIEW-774-r1 H1): view_box is only the empty-content hint; ' + + 'framing the setup with it hides a room drawn outside [0,1] although its contour is right', + patches: [{ + file: 'src/radar-setup.ts', + find: ' const frame = !core || !all ? spaceFrame(space)', + replace: ' const frame = !core || !all || space.vb ? spaceFrame({ ...space, rooms: [], stairs: [], bg: null })', + }], + }, + { + id: 'radar-setup-room-left-to-outlier-vote', + guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs', + because: '#774 AC1: the outlier vote may drop the very room being configured from core; ' + + 'without the switch to all its contour is cut off the setup frame', + patches: [{ + file: 'src/radar-setup.ts', + find: ' && core.x + core.w >= own.maxX && core.y + core.h >= own.maxY) ? all : core;', + replace: ' && core.x + core.w >= own.maxX && core.y + core.h >= own.maxY) ? core : core;', + }], + }, + { + id: 'radar-setup-trail-in-plan-units', + guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs', + because: '#774 AC3: a layer outside the shared projection (the live trail in raw plan units) ' + + 'sits 1000× off the contour and marks it is meant to be compared with', + patches: [{ + file: 'src/radar-setup.ts', + find: "trail.map((entry) => view.scene(entry.point).join(','))", + replace: "trail.map((entry) => entry.point.join(','))", + }], + }, { id: 'household-marker-drops-keyboard-reach', guard: 'node demo/smoke_household_journeys.mjs', diff --git a/src/editors/radar-section.ts b/src/editors/radar-section.ts index 9a309338..03934b64 100644 --- a/src/editors/radar-section.ts +++ b/src/editors/radar-section.ts @@ -7,7 +7,8 @@ import { recognizeRadar, type RadarEditorDraft, type RadarHassLike, } from '../radar-editor'; -import type { RadarSetupController } from '../radar-setup'; +import { radarSetupFrame, type RadarSetupController } from '../radar-setup'; +import type { ContentItem } from '../space-geometry'; import type { DevItem, SpaceModel } from '../types'; import { toggleRow } from './form-kit'; @@ -35,6 +36,9 @@ export interface RadarSectionOptions { hass: RadarHass; spaceModelById(id: string | null | undefined): SpaceModel | null | undefined; currentSpace(): SpaceModel | null | undefined; + /** The main card's content-frame items of a space (docs/CANVAS.md §4): the + * on-plan setup frames the same plan, without drawing those layers (#774). */ + contentItems(space: SpaceModel): ReadonlyArray; position(device: DevItem): { x: number; y: number }; updateDialog(patch: Partial<{ radar: RadarEditorDraft | null; @@ -197,6 +201,7 @@ export function renderRadarSection( } if (!options.setup.begin( device.marker?.id || device.id, draft, room, space.cellCm || 5, options.configRev, + radarSetupFrame(space, room, options.contentItems(space)), )) options.toast(options.t('radar.invalid')); }; const changeInstallation = () => configureOnPlan({ diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index 7b856868..6e5c6fd3 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -509,6 +509,8 @@ export interface HouseplanEditorHostPort { _confirmDanger: (request: HpConfirmRequest) => Promise; _cancelDangerConfirm: () => void; _config: CardConfig | undefined; + /** #774: the content-frame items the card frames a space with (docs/CANVAS.md §4). */ + _contentItems: (space: SpaceModel) => import('./space-geometry').ContentItem[]; _contourClosed: boolean; _curSpaceCfg: any; _currentModeVisual: (mode?: HouseplanMode) => ModeVisualState | null; @@ -11986,6 +11988,7 @@ public _renderRadarSection(d: NonNullable this.host._spaceModelById(id), currentSpace: () => this.host._spaceModel(), + contentItems: (space) => this.host._contentItems(space), position: (item) => this.host._pos(item), updateDialog: (patch) => { if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, ...patch }; diff --git a/src/radar-setup.ts b/src/radar-setup.ts index 52a78092..da0888be 100644 --- a/src/radar-setup.ts +++ b/src/radar-setup.ts @@ -7,6 +7,9 @@ import { projectRadarLocal, radarMedianSample, solveRadarTwoPoint, } from './radar-geometry'; import type { RadarEditorDraft } from './radar-editor'; +import { + NORM_W, contentFrame, itemOf, spaceFrame, type ContentItem, type Rect, +} from './space-geometry'; type RadarDiscardKey = 'radar.discard_setup_title' | 'radar.discard_setup' | 'btn.close' | 'btn.cancel'; @@ -15,7 +18,7 @@ export const radarDiscardRequest = (t: (key: RadarDiscardKey) => string) => ({ title: t('radar.discard_setup_title'), message: t('radar.discard_setup'), confirmLabel: t('btn.close'), cancelLabel: t('btn.cancel'), }); -import type { MarkerRadar, RoomCfg } from './types'; +import type { MarkerRadar, RoomCfg, SpaceModel } from './types'; const COUNTDOWN_MS = 10_000; const CAPTURE_MS = 5_000; @@ -47,6 +50,7 @@ interface ActiveSetup { markerId: string; draft: RadarEditorDraft; room: RoomCfg; + projection: RadarSetupProjection; cellCm: number; configRev: number; phase: SetupPhase; @@ -74,13 +78,55 @@ export interface RadarSetupHost { confirmDiscard(): Promise; } -function pointFromEvent(event: PointerEvent): Point | null { - const rect = (event.currentTarget as SVGSVGElement).getBoundingClientRect(); - if (!(rect.width > 0 && rect.height > 0)) return null; - return [ - Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), - Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)), - ]; +/** + * The wizard's camera (#774, docs/CANVAS.md §4): the content frame the main + * card opens on, from the same items (`items` is the card's own content list, + * a superset of the space model's). `core` normally; `all` when the core would + * cut the contour of the room being configured, so an outlier vote can never + * hide it. The stored view_box is read only through `spaceFrame` — i.e. only + * when nothing at all is drawn. Absolute render units, fixed for the session. + */ +export function radarSetupFrame( + space: SpaceModel, room: RoomCfg, items: ReadonlyArray, +): Rect { + const { core, all } = contentFrame(items); + const own = room.poly && room.poly.length >= 3 ? itemOf(room.poly) : null; + const frame = !core || !all ? spaceFrame(space) + : own && !(core.x <= own.minX && core.y <= own.minY + && core.x + core.w >= own.maxX && core.y + core.h >= own.maxY) ? all : core; + return [frame.x, frame.y, frame.w, frame.h].every(Number.isFinite) && frame.w > 0 && frame.h > 0 + ? frame : { x: 0, y: 0, w: NORM_W, h: NORM_W }; +} + +export type RadarSetupProjection = ReturnType; + +/** + * The ONE projection of every wizard layer (#774). Plan units (what is stored) + * times NORM_W are absolute render units, and those ARE the SVG user units, + * because the viewBox is the frame itself. Room contours are render units + * already and are drawn untouched. The screen side mirrors + * preserveAspectRatio="xMidYMid meet"; a press in the letterbox is no point. + * Never clamped: the canvas has no edges (docs/CANVAS.md). + */ +export function radarSetupProjection(frame: Rect) { + return { + frame, + viewBox: `${frame.x} ${frame.y} ${frame.w} ${frame.h}`, + /** Glyph scale that keeps marker sizes what they were on the 1000-unit board. */ + glyph: Math.max(frame.w, frame.h) / NORM_W, + scene: (point: Point): Point => [point[0] * NORM_W, point[1] * NORM_W], + plan( + clientX: number, clientY: number, + rect: { left: number; top: number; width: number; height: number }, + ): Point | null { + const k = Math.min(rect.width / frame.w, rect.height / frame.h); + if (!(k > 0 && Number.isFinite(k))) return null; + const x = frame.x + (clientX - rect.left - (rect.width - frame.w * k) / 2) / k; + const y = frame.y + (clientY - rect.top - (rect.height - frame.h * k) / 2) / k; + return x >= frame.x && x <= frame.x + frame.w && y >= frame.y && y <= frame.y + frame.h + ? [x / NORM_W, y / NORM_W] : null; + }, + }; } function headingBetween(mount: Point, target: Point): number { @@ -105,13 +151,16 @@ export class RadarSetupController { public constructor(private readonly host: RadarSetupHost) {} + /** `frame` is `radarSetupFrame(...)`, computed once by the caller at open. */ public begin( markerId: string, draft: RadarEditorDraft, room: RoomCfg, cellCm: number, configRev: number, + frame: Rect, ): boolean { if (!this.host.configFromDraft(draft, cellCm)) return false; this.reset(); this.active = { - markerId, draft, room, cellCm, configRev, phase: 'mount', mount: null, + markerId, draft, room, projection: radarSetupProjection(frame), + cellCm, configRev, phase: 'mount', mount: null, headingPoint: null, pendingPlan: null, refs: [], samples: [], ambiguous: false, trail: [], }; this.host.requestUpdate(); @@ -155,7 +204,8 @@ export class RadarSetupController { private choosePoint(event: PointerEvent): void { const state = this.active; - const point = pointFromEvent(event); + const point = state?.projection.plan(event.clientX, event.clientY, + (event.currentTarget as Element).getBoundingClientRect()); if (!state || !point || event.isPrimary === false || state.phase === 'capture' || state.phase === 'checking') return; state.error = undefined; @@ -390,7 +440,9 @@ export class RadarSetupController { public render(): TemplateResult | typeof nothing { const state = this.active; if (!state) return nothing; - const polygon = (state.room.poly || []).map((point) => `${point[0] * 1000},${point[1] * 1000}`).join(' '); + const view = state.projection; + // SpaceModel contours are render units already (space-geometry.ts): drawn as is. + const polygon = (state.room.poly || []).map((point) => `${point[0]},${point[1]}`).join(' '); const markers = [ ...(state.mount ? [{ point: state.mount, cls: 'mount', label: 'R' }] : []), ...state.refs.map((ref, index) => ({ point: ref.plan, cls: 'reference', label: String(index + 1) })), @@ -406,17 +458,17 @@ export class RadarSetupController { - this.choosePoint(event)} @pointercancel=${() => this.interrupt()}> ${trailGroups.map((trail) => trail.length > 1 ? svg` `${entry.point[0] * 1000},${entry.point[1] * 1000}`).join(' ')}>` : nothing)} + points=${trail.map((entry) => view.scene(entry.point).join(',')).join(' ')}>` : nothing)} ${state.mount && state.headingPoint ? svg`` : nothing} + x1=${view.scene(state.mount)[0]} y1=${view.scene(state.mount)[1]} + x2=${view.scene(state.headingPoint)[0]} y2=${view.scene(state.headingPoint)[1]}>` : nothing} ${markers.map((marker) => svg` + transform="translate(${view.scene(marker.point).join(' ')}) scale(${view.glyph})"> ${marker.label}`)}

${this.host.t('radar.desktop_hint')}

diff --git a/test/radar-setup.test.mjs b/test/radar-setup.test.mjs index 097a5c34..f5a90229 100644 --- a/test/radar-setup.test.mjs +++ b/test/radar-setup.test.mjs @@ -2,7 +2,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { radarConfigFromDraft } from '../test-build/radar-editor.js'; -import { RadarSetupController } from '../test-build/radar-setup.js'; +import { + RadarSetupController, radarSetupFrame, radarSetupProjection, +} from '../test-build/radar-setup.js'; +import { contentFrame, itemOf } from '../test-build/space-geometry.js'; + +/** The historical 1000-unit board: plan units ×1000, no offset. */ +const BOARD = { x: 0, y: 0, w: 1000, h: 1000 }; const draft = () => ({ original: null, enabled: true, showLive: true, profile: 'presence_v1', roomId: 'living', @@ -27,7 +33,7 @@ test('on-plan installation changes only the editor draft until ordinary Save', ( }); assert.equal(controller.begin('radar', draft(), { id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1], [0, 1]], - }, 5, 7), true); + }, 5, 7, BOARD), true); assert.equal(controller.isDirty(), false, 'an untouched setup may close synchronously'); controller.choosePoint(pointer(250, 400)); assert.equal(controller.isDirty(), true, 'placing the mount makes the setup discard-sensitive'); @@ -50,7 +56,7 @@ test('cancelling calibration releases its draft subscription exactly once', asyn }); assert.equal(controller.begin('radar', draft(), { id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]], - }, 5, 7), true); + }, 5, 7, BOARD), true); let calls = 0; controller.unsubscribe = () => { calls += 1; }; assert.equal(await controller.cancel(), true); @@ -68,7 +74,7 @@ test('dirty calibration stays open when discard confirmation is rejected', async }); assert.equal(controller.begin('radar', draft(), { id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]], - }, 5, 7), true); + }, 5, 7, BOARD), true); controller.choosePoint(pointer(250, 400)); let cleanups = 0; controller.unsubscribe = () => { cleanups += 1; }; @@ -86,7 +92,7 @@ test('calibration reports bad reference placement separately from a measurement }); assert.equal(controller.begin('radar', draft(), { id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]], - }, 5, 7), true); + }, 5, 7, BOARD), true); const plan = (x, y) => [.5 + x / 1200, .5 - y / 1200]; controller.active.mount = [.5, .5]; controller.active.refs = [ @@ -103,3 +109,208 @@ test('calibration reports bad reference placement separately from a measurement controller.solve(); assert.equal(controller.active.error, 'radar.bad_fit'); }); + +// --------------------------------------------------------------------------- +// #774: one projection for the on-plan setup. The oracles below are computed +// by hand from the frame, the SVG rectangle and xMidYMid meet — never by the +// production function — so an offset, a scale, a clamp or a frame-relative +// normalisation slipping into the code changes a number here. + +const near = (actual, expected, message) => { + assert.ok(actual, `${message}: expected a point, got ${actual}`); + assert.ok(Math.abs(actual[0] - expected[0]) < 1e-9 && Math.abs(actual[1] - expected[1]) < 1e-9, + `${message}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`); +}; + +/** Values of a lit template (recursively) whose static part ends with `suffix`. */ +const templateValues = (result, suffix) => { + const found = []; + const walk = (node) => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (!node || typeof node !== 'object' || !Array.isArray(node.strings)) return; + node.values.forEach((value, index) => { + if (node.strings[index].replace(/\s+/g, ' ').endsWith(suffix)) found.push(value); + walk(value); + }); + }; + walk(result); + return found; +}; + +// Offset, non-square frame in a square 400 px SVG at (10, 20): the scale is +// 400 / 2000 = 0.2, the frame is 200 px tall, so 100 px of letterbox lie above +// (client y 20..120) and below (320..420) the projected viewBox. +const WIDE = { x: -500, y: 200, w: 2000, h: 1000 }; +const SQUARE = { left: 10, top: 20, width: 400, height: 400 }; + +test('#774 projection: screen → absolute render units → plan units, letterbox honoured', () => { + const view = radarSetupProjection(WIDE); + assert.equal(view.viewBox, '-500 200 2000 1000'); + assert.equal(view.glyph, 2, 'marker glyphs keep their 1000-unit board size'); + const table = [ + // client point expected plan units (null = ignored) + [[60, 220], [-0.25, 0.7], 'negative x inside an offset frame'], + [[390, 300], [1.4, 1.1], 'both axes past 1, never clamped'], + [[10, 120], [-0.5, 0.2], 'top-left corner of the projected viewBox is inside'], + [[410, 320], [1.5, 1.2], 'bottom-right corner of the projected viewBox is inside'], + [[200, 60], null, 'upper letterbox'], + [[200, 380], null, 'lower letterbox'], + [[200, 119], null, 'one pixel above the projected viewBox'], + [[5, 200], null, 'left of the SVG rectangle'], + ]; + for (const [[x, y], expected, label] of table) { + const actual = view.plan(x, y, SQUARE); + if (expected) near(actual, expected, label); + else assert.equal(actual, null, label); + } + // The historical board is the identity case, and a wide rectangle letterboxes sideways. + const board = radarSetupProjection(BOARD); + near(board.plan(250, 400, { left: 0, top: 0, width: 1000, height: 1000 }), [0.25, 0.4], 'board'); + const wideRect = { left: 0, top: 0, width: 600, height: 300 }; + near(board.plan(150, 0, wideRect), [0, 0], 'left edge of a sideways letterbox'); + near(board.plan(300, 150, wideRect), [0.5, 0.5], 'centre of a sideways letterbox'); + assert.equal(board.plan(100, 150, wideRect), null, 'sideways letterbox'); + for (const rect of [ + { left: 0, top: 0, width: 0, height: 400 }, + { left: 0, top: 0, width: 400, height: 0 }, + { left: 0, top: 0, width: Number.NaN, height: 400 }, + ]) assert.equal(view.plan(100, 100, rect), null, `degenerate rect ${JSON.stringify(rect)}`); + assert.deepEqual(view.scene([-0.25, 1.4]), [-250, 1400], 'plan → render is ×NORM_W, unclamped'); +}); + +const L_ROOM = { id: 'r_l', name: 'L', poly: [ + [100, 200], [700, 200], [700, 450], [400, 450], [400, 600], [100, 600]] }; +const OUT_ROOM = { id: 'r_out', name: 'Out', poly: [ + [-550, -400], [-150, -400], [-150, -100], [-350, -100], [-550, -250]] }; +const spaceOf = (rooms, vb = [0, 0, 1000, 1000]) => ({ + id: 's', title: 'S', cellCm: 5, vb, bg: null, rooms, stairs: [], +}); +const itemsOf = (rooms, devices = []) => [ + ...rooms.map((room) => itemOf(room.poly)), + ...devices.map(([x, y]) => ({ minX: x, minY: y, maxX: x, maxY: y })), +]; + +test('#774 frame: the content frame, not the stored view_box, frames a room outside [0,1]', () => { + const rooms = [L_ROOM, OUT_ROOM]; + const items = itemsOf(rooms, [[300, 300]]); + // bbox x -550..700 (1250), y -400..600 (1000); 5 % of the longer side = 62.5 + const expected = { x: -612.5, y: -462.5, w: 1375, h: 1125 }; + for (const room of rooms) { + assert.deepEqual(radarSetupFrame(spaceOf(rooms), room, items), expected, room.id); + } + const frame = radarSetupFrame(spaceOf(rooms), OUT_ROOM, items); + const own = itemOf(OUT_ROOM.poly); + assert.ok(frame.x <= own.minX && frame.y <= own.minY + && frame.x + frame.w >= own.maxX && frame.y + frame.h >= own.maxY, + 'the whole contour of the room outside [0,1] fits the frame'); + assert.ok(own.maxX < 0 && own.maxY < 0, 'premise: the stored unit view_box would cut it entirely'); + for (const vb of [[5000, 5000, 2000, 2000], [-3, 7, 0.5, 9]]) { + assert.deepEqual(radarSetupFrame(spaceOf(rooms, vb), L_ROOM, items), expected, + `replacing the stored view_box ${vb} does not move a content frame`); + } +}); + +test('#774 frame: the configured room is never left to the outlier vote (core → all)', () => { + const square = (id, x, y, side = 100) => ({ id, name: id, poly: [ + [x, y], [x + side, y], [x + side, y + side], [x, y + side]] }); + const near4 = [square('a', 400, 400), square('b', 500, 400), square('c', 400, 500), + square('d', 500, 500)]; + const far = square('far', 40000, 40000, 500); + const rooms = [...near4, far]; + const items = itemsOf(rooms); + const { core, all, outliers } = contentFrame(items); + assert.equal(outliers, 1, 'premise: the vote rejects the far room'); + assert.deepEqual(core, { x: 390, y: 390, w: 220, h: 220 }); + assert.deepEqual(all, { x: -1605, y: -1605, w: 44110, h: 44110 }); + assert.ok(core.x + core.w < 40000, 'core excludes the far room'); + assert.deepEqual(radarSetupFrame(spaceOf(rooms), far, items), all, 'the far room opens on all'); + assert.deepEqual(radarSetupFrame(spaceOf(rooms), near4[0], items), core, + 'a room inside the main mass keeps the ordinary opening frame'); +}); + +test('#774 frame: the stored view_box is only the empty-content fallback', () => { + const room = { id: 'gone', name: 'No contour' }; + assert.deepEqual(radarSetupFrame(spaceOf([], [300, -200, 1500, 900]), room, []), + { x: 300, y: -200, w: 1500, h: 900 }, 'a valid offset hint is used verbatim'); + for (const vb of [[0, 0, 0, 1000], [0, 0, -5, 10], undefined]) { + assert.deepEqual(radarSetupFrame(spaceOf([], vb), room, []), BOARD, + `invalid hint ${JSON.stringify(vb)} → the standard unit frame`); + } +}); + +const coordinateDraft = () => ({ + ...draft(), profile: 'cartesian_v1', roomId: 'r_out', + xEntities: ['sensor.x'], yEntities: ['sensor.y'], swapXY: [false], xSigns: [1], ySigns: [1], + slotPresenceEntities: [''], distanceEntities: [''], angleEntities: [''], +}); +const press = (x, y, rect = SQUARE) => ({ + clientX: x, clientY: y, currentTarget: { getBoundingClientRect: () => rect }, +}); + +test('#774 every layer goes through the one projection; the frame stays put', () => { + let applied = null; + const controller = new RadarSetupController({ + hass: () => ({}), requestUpdate() {}, t: (key) => key, + configFromDraft: radarConfigFromDraft, apply: (next) => { applied = next; }, + confirmDiscard: async () => true, + }); + assert.equal(controller.begin('radar', coordinateDraft(), OUT_ROOM, 5, 7, WIDE), true); + const frame = controller.active.projection.frame; + controller.choosePoint(press(200, 60)); + assert.equal(controller.active.mount, null, 'a press in the letterbox places nothing'); + assert.equal(controller.active.phase, 'mount'); + controller.choosePoint(press(60, 220)); + near(controller.active.mount, [-0.25, 0.7], 'mount'); + controller.choosePoint(press(390, 220)); + near(controller.active.headingPoint, [1.4, 0.7], 'heading point'); + assert.equal(controller.active.draft.mountX, '-250'); + assert.equal(controller.active.draft.mountY, '700'); + assert.equal(controller.active.draft.heading, '90'); + assert.equal(controller.active.phase, 'reference_1'); + controller.choosePoint(press(60, 300)); + near(controller.active.pendingPlan, [-0.25, 1.1], 'pending reference'); + // Live diagnostics: 120 cm straight ahead (east) of the mount, then 60 cm to its right. + controller.receive({ local_targets: [{ slot: 'target_1', x_cm: 0, y_cm: 120 }] }); + controller.receive({ local_targets: [{ slot: 'target_1', x_cm: 60, y_cm: 120 }] }); + assert.equal(controller.active.projection.frame, frame, 'clicks and live data keep the session frame'); + const view = controller.render(); + assert.deepEqual(templateValues(view, ' pair.split(',').map(Number)); + near(points[0], [-150, 700], 'trail sample 1 (120 cm ahead = +0.1 plan)'); + near(points[1], [-150, 750], 'trail sample 2 (60 cm right = +0.05 plan)'); + + // Presence profile: Apply hands back plain plan units, unclamped, and nothing else. + assert.equal(controller.begin('radar', draft(), OUT_ROOM, 5, 7, WIDE), true); + controller.choosePoint(press(60, 220)); + controller.choosePoint(press(390, 220)); + controller.apply(); + assert.equal(applied.mountX, '-250'); + assert.equal(applied.mountY, '700'); + assert.equal(applied.heading, '90'); + assert.deepEqual(radarConfigFromDraft(applied, 5).mount.x, -0.25); + assert.deepEqual(radarConfigFromDraft(applied, 5).mount.y, 0.7); +}); + +test('#774 a room without a contour draws no invented outline', () => { + const controller = new RadarSetupController({ + hass: () => ({}), requestUpdate() {}, t: (key) => key, + configFromDraft: radarConfigFromDraft, apply() {}, confirmDiscard: async () => true, + }); + const room = { id: 'living', name: 'Legacy', x: 100, y: 100, w: 300, h: 200 }; + const space = spaceOf([room]); + const frame = radarSetupFrame(space, room, [itemOf([[100, 100], [400, 300]])]); + assert.equal(controller.begin('radar', draft(), room, 5, 7, frame), true); + assert.deepEqual(templateValues(controller.render(), 'class="room" points='), [''], + 'the camera frame is not substituted for the missing polygon'); +});