From 84f8bcf0e69222dce2c9c0a284ccf68897322789 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 1 Aug 2026 18:58:23 +0300 Subject: [PATCH] HP-1550-01..04: the v1.55.0 resize audit findings, fixed with regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 01 (high): the live resize preview no longer touches _serverCfg — it lives in the _rszPreview overlay served to renders via _curSpaceCfg/_renderCfg, so a debounced write queued from a previous edit can never carry mid-drag geometry to the server; commit happens once, on pointerup; Esc just drops the overlay. 03: pointercancel/lostpointercapture take the cancel path (no commit, no undo step, no write) for edge and corner handles alike. 04: in the resize tool the wall handles own the hit test — the transparent .op-hit is inert and the resize layer renders above the openings; a door at a wall midpoint no longer shadows the handle, other tools unchanged. 02: the 30 cm floor is orientation-independent — minSpanClearance (band sweep of the moved stretch) for wall drags, minPolyWidth (calipers) for the scale frame; already-thin rooms may improve, never worsen. --- demo/smoke_resize_audit_1550.mjs | 210 +++++++++++++++++++++++++++++++ demo/smoke_room_resize.mjs | 15 ++- docs/RESIZE.md | 50 ++++++-- src/houseplan-card.ts | 99 ++++++++++++--- src/resize.ts | 126 +++++++++++++++++-- src/styles.ts | 9 ++ test/resize.test.mjs | 48 +++++++ 7 files changed, 518 insertions(+), 39 deletions(-) create mode 100644 demo/smoke_resize_audit_1550.mjs diff --git a/demo/smoke_resize_audit_1550.mjs b/demo/smoke_resize_audit_1550.mjs new file mode 100644 index 00000000..3a715b62 --- /dev/null +++ b/demo/smoke_resize_audit_1550.mjs @@ -0,0 +1,210 @@ +// The v1.55.0 resize audit (HP-1550-01/-03/-04), numeric regressions: +// 01 — a debounced config write pending from a PREVIOUS edit must never carry +// the live resize preview to the server; Esc leaves server === snapshot; +// a normal pointerup produces exactly one write; +// 03 — pointercancel aborts the drag (no commit, no undo step, no write), +// for wall handles and corner handles alike; lostpointercapture after +// a cancel must not double-fire; +// 04 — a door at the exact midpoint of a wall must not shadow the resize +// handle: the handle wins the hit test, the wall drags WITH the door, +// and no opening-edit drag starts in the resize tool. +import { launch, check, finish } from './serve.mjs'; +const { page, browser } = await launch(); + +const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg)); +const restore = () => page.evaluate((s) => { + const c = window.__card; + c._serverCfg = JSON.parse(s); + c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = []; + if ('_rszPreview' in c) c._rszPreview = null; + c._cfgEpoch++; c.requestUpdate(); + return c.updateComplete && true; +}, snap); +const enter = (tool) => page.evaluate((t) => { + const c = window.__card; + if (!c._markup) c._setMode('plan'); + c._tool = t; c._rszSel = null; c.requestUpdate(); + return c.updateComplete && true; +}, tool); +const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res)))); +const screenPt = (x, y) => page.evaluate(([x, y]) => { + const c = window.__card; + const stage = c.renderRoot.querySelector('.stage'); + const r = stage.getBoundingClientRect(); + const svg = stage.querySelector('svg'); + const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number); + return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height]; +}, [x, y]); +// geometry the SERVER would get (the mutable config _writeConfig reads) +const roomSrv = (id) => page.evaluate((id) => { + const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id); + return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null; +}, id); +// geometry AS RENDERED (includes the live preview overlay when a drag is on) +const roomLive = (id) => page.evaluate((id) => { + const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id); + return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null; +}, id); + +// spy on every config write with a deep copy taken AT CALL TIME — exactly what +// the backend would persist (a later fix of the mutable object must not hide it) +await page.evaluate(() => { + const c = window.__card; + window.__writes = []; + const orig = c.hass.callWS.bind(c.hass); + c.hass = { ...c.hass, callWS: async (m) => { + if (m.type === 'houseplan/config/set') window.__writes.push(JSON.parse(JSON.stringify(m.config))); + return orig(m); + } }; + return c.updateComplete && true; +}); +const writes = () => page.evaluate(() => window.__writes.length); +const writeWallAt = (i, x) => page.evaluate(([i, x]) => { + const w = window.__writes[i]; + const r1 = w.spaces.find((s) => s.id === 'f1').rooms.find((r) => r.id === 'r1'); + return r1.poly.some((p) => Math.abs(p[0] - x) < 0.011); +}, [i, x]); + +// ================= HP-1550-01: pending write vs live preview ================= +await enter('resize'); +await settle(); +// precompute every screen point so drag 2 starts well inside the 500 ms window +const [h550x, h550y] = await screenPt(550, 360); +const [h600x] = await screenPt(600, 360); +const [h650x] = await screenPt(650, 360); +const [h700x] = await screenPt(700, 360); +// drag 1 commits r1's right wall 0.55 → 0.60: a write enters the debounce queue +await page.mouse.move(h550x, h550y); +await page.mouse.down(); +await page.mouse.move(h600x, h550y, { steps: 3 }); +await page.mouse.up(); +// drag 2 starts IMMEDIATELY (pending save still queued): preview 0.60 → 0.70, HOLD +await page.mouse.move(h600x, h550y); +await page.mouse.down(); +await page.mouse.move(h700x, h550y, { steps: 3 }); +await settle(); +check('01_committed_wall', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 0.011); +check('01_preview_live', Math.abs((await roomLive('r1'))[1][0] - 0.70) < 0.011); +// … the queued write fires while the handle is still held (>500 ms) +await page.waitForTimeout(800); +check('01_pending_write_fired', (await writes()) >= 1); +// Esc abandons the drag +await page.keyboard.press('Escape'); +await settle(); +await page.mouse.up(); +await page.waitForTimeout(800); // any stray debounced write would have fired +const nw = await writes(); +for (let i = 0; i < nw; i++) { + check(`01_write_${i}_no_preview_coords`, await writeWallAt(i, 0.70), false); + check(`01_write_${i}_has_committed`, await writeWallAt(i, 0.60), true); +} +check('01_server_geom_is_snapshot', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 1e-6); +check('01_local_geom_is_snapshot', Math.abs((await roomLive('r1'))[1][0] - 0.60) < 1e-6); +// a normal pointerup still produces EXACTLY ONE write +{ + const before = await writes(); + await page.mouse.move(h600x, h550y); + await page.mouse.down(); + await page.mouse.move(h650x, h550y, { steps: 3 }); + await settle(); + await page.mouse.up(); + await page.waitForTimeout(800); + check('01_single_write_per_commit', (await writes()) - before, 1); + check('01_commit_reached_server', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011); +} +await restore(); + +// ================= HP-1550-03: pointercancel = abort, not commit ============= +await enter('resize'); +await settle(); +{ + const w0 = await writes(); + const [ax, ay] = await screenPt(550, 360); + const [bx] = await screenPt(650, 360); + await page.mouse.move(ax, ay); + await page.mouse.down(); + await page.mouse.move(bx, ay, { steps: 3 }); + await settle(); + check('03_preview_moved', Math.abs((await roomLive('r1'))[1][0] - 0.65) < 0.011); + // the system interrupts the stream (app switch, palm rejection) + await page.evaluate(() => { + const c = window.__card; + const pid = c._rszDrag.pid; + const el = c.renderRoot.querySelector('.rszhandle'); + el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true })); + // the lostpointercapture that follows a cancel must not double-fire + el.dispatchEvent(new PointerEvent('lostpointercapture', { pointerId: pid, bubbles: true })); + }); + await settle(); + check('03_snapshot_back_live', Math.abs((await roomLive('r1'))[1][0] - 0.55) < 1e-6); + check('03_server_untouched', Math.abs((await roomSrv('r1'))[1][0] - 0.55) < 1e-6); + check('03_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0); + check('03_drag_cleared', await page.evaluate(() => window.__card._rszDrag === null)); + await page.waitForTimeout(800); + check('03_no_pending_write', (await writes()) - w0, 0); + await page.mouse.up(); +} +await restore(); +// the corner (scale) handle takes the same abort path +await enter('resize'); +await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; }); +await settle(); +{ + const w0 = await writes(); + const [ax, ay] = await screenPt(550, 860); + const [bx, by] = await screenPt(448, 804); // ≈ k=0.8 about (40,580) + await page.mouse.move(ax, ay); + await page.mouse.down(); + await page.mouse.move(bx, by, { steps: 3 }); + await settle(); + check('03_corner_preview', Math.abs((await roomLive('r4'))[2][0] - 0.55) > 0.02); + await page.evaluate(() => { + const c = window.__card; + const pid = c._rszDrag.pid; + const el = c.renderRoot.querySelector('.rszcorner') || c.renderRoot.querySelector('.rszhandle'); + el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true })); + }); + await settle(); + check('03_corner_snapshot_back', Math.abs((await roomLive('r4'))[2][0] - 0.55) < 1e-6); + check('03_corner_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0); + await page.waitForTimeout(800); + check('03_corner_no_write', (await writes()) - w0, 0); + await page.mouse.up(); +} +await restore(); + +// ============ HP-1550-04: a door mid-wall must not shadow the handle ========= +await enter('resize'); +await page.evaluate(() => { + const c = window.__card; + const sp = c._serverCfg.spaces.find((s) => s.id === 'f1'); + // exactly at the midpoint of r1's right wall (y 0.14..0.58 → 0.36) + sp.openings = [{ id: 'opm', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.08 }]; + c._cfgEpoch++; c.requestUpdate(); + return c.updateComplete && true; +}); +await settle(); +{ + const [hx, hy] = await screenPt(550, 360); + const [tx] = await screenPt(650, 360); + // the REAL hit test: the topmost interactive element must be the handle + const top = await page.evaluate(([x, y]) => { + const el = window.__card.shadowRoot.elementFromPoint(x, y); + return el ? el.getAttribute('class') || '' : ''; + }, [hx, hy]); + check('04_handle_wins_hit_test', String(top).includes('rszhandle')); + await page.mouse.move(hx, hy); + await page.mouse.down(); + await page.mouse.move(tx, hy, { steps: 4 }); + await settle(); + check('04_no_opening_drag', await page.evaluate(() => !window.__card._opDrag)); + check('04_wall_drag_started', await page.evaluate(() => !!window.__card._rszDrag)); + await page.mouse.up(); + await settle(); + check('04_wall_moved', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011); + const op = await page.evaluate(() => window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0]); + check('04_door_travelled_with_wall', Math.abs(op.x - 0.65) < 0.011); +} +await restore(); + +await finish(browser, { done: true }); diff --git a/demo/smoke_room_resize.mjs b/demo/smoke_room_resize.mjs index 4ba5e2f1..c8a774b9 100644 --- a/demo/smoke_room_resize.mjs +++ b/demo/smoke_room_resize.mjs @@ -8,7 +8,7 @@ const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg)) const restore = () => page.evaluate((s) => { const c = window.__card; c._serverCfg = JSON.parse(s); - c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = []; + c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = []; c._rszPreview = null; c._cfgEpoch++; c.requestUpdate(); return c.updateComplete && true; }, snap); @@ -25,6 +25,12 @@ const roomPolyN = (id) => page.evaluate((id) => { const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id); return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null; }, id); +// mid-drag geometry AS RENDERED: HP-1550-01 moved the live preview out of +// _serverCfg into the _rszPreview overlay (_curSpaceCfg serves it to renders) +const roomPolyLive = (id) => page.evaluate((id) => { + const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id); + return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null; +}, id); const openingOf = (id) => page.evaluate((id) => (window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings || []).find((o) => o.id === id) || null, id); // screen position of a render-unit point (the svg viewBox fills the stage exactly) @@ -77,8 +83,11 @@ const mid = await labels(); check('drag_badges_visible', mid.length >= 6); // 3 wall lengths + 3 areas (r1, r2, r3) check('drag_badges_have_area', mid.filter((t) => t.includes('m²')).length >= 3); check('drag_badges_have_len', mid.some((t) => /\d\.\d\d m$/.test(t))); -const midR1 = await roomPolyN('r1'); +const midR1 = await roomPolyLive('r1'); check('preview_moves_r1', Math.abs(midR1[1][0] - 0.6) < 0.011); // halfway ≈ 0.60 (snapped) +// HP-1550-01: the preview must NOT leak into the config a queued write reads +const midSrv = await roomPolyN('r1'); +check('preview_not_in_servercfg', Math.abs(midSrv[1][0] - 0.55) < 1e-9); await page.mouse.move(tx, hy, { steps: 4 }); await settle(); const end = await labels(); @@ -219,7 +228,7 @@ await settle(); await page.mouse.down(); await page.mouse.move(bx, ay, { steps: 4 }); await settle(); - const midPoly = await roomPolyN('r1'); + const midPoly = await roomPolyLive('r1'); check('esc_preview_moved', Math.abs(midPoly[1][0] - 0.70) < 0.011); await page.keyboard.press('Escape'); await settle(); diff --git a/docs/RESIZE.md b/docs/RESIZE.md index 574977b1..21f63780 100644 --- a/docs/RESIZE.md +++ b/docs/RESIZE.md @@ -29,6 +29,14 @@ before — no handles, no new hit areas. converted through `roomPoly` and are **saved back as `poly`**. - The moved wall position snaps to the drawing grid (`snapToGrid`, same pitch as the draw tool). +- **Handles own the hit test** (HP-1550-04): inside the resize tool + openings are not editable — their transparent hit area is inert + (`pointer-events: none`) and the resize layer renders above the + openings, so a door sitting exactly at the midpoint of a wall can + never shadow that wall's handle. A drag of such a wall carries the + door along through the normal anchor pipeline; clicking over a door + falls through to room picking. Every other Plan tool keeps the + openings interactive exactly as before. ## Shared walls — ALWAYS together @@ -49,9 +57,17 @@ drag. On commit collinear leftovers are simplified away 1. **Minimum size** — neither the own room nor a shrinking neighbour may get thinner than ~30 cm (`MIN_ROOM_CM`, expressed in canvas - units through `cell_cm`). Measured as the normal clearance between - the moved stretch and any parallel opposite wall with overlapping - projection. Rooms that are ALREADY thinner keep their clearance + units through `cell_cm`). The measure is orientation-independent + (HP-1550-02): for a wall drag it is the smallest perpendicular + distance from the moved stretch to any part of the room boundary + inside the band the stretch sweeps along its normal — vertices and + crossing walls count whether parallel or not (a triangle's apex, a + slanted obstacle), while collinear remainders and the step corners + a T-junction inserts at the very ends of the stretch do not. For + the scale frame it is the TRUE minimum width of the outline + (rotating calipers over the convex hull) scaled by `k` — the + axis-aligned bbox is never consulted, so rotation cannot hide the + short side. Rooms that are ALREADY thinner keep their clearance (the drag may improve it, never worsen it). 2. **Self-intersection** — a wall never passes through the opposite side; the outline must stay a simple polygon with its orientation @@ -97,8 +113,23 @@ While a handle is being dragged: live; when a shared wall is dragged — the areas of BOTH rooms (owner picked «стены + площадь»); - Esc cancels the current drag and puts the original geometry back; -- releasing the handle commits: one write through the standard - debounced `_saveConfig` path. +- releasing the handle (pointerup) commits: one write through the + standard debounced `_saveConfig` path; +- `pointercancel` / `lostpointercapture` (the system interrupted the + stream: app switch, palm rejection) takes the CANCEL path, never the + commit path — snapshot geometry back, no undo step, no write + (HP-1550-03). + +## Preview vs commit (HP-1550-01) + +The live drag preview never touches the shared `_serverCfg`: it lives +in a separate overlay (`_rszPreview`) that `_curSpaceCfg`/`_renderCfg` +substitute into every render. Config writes are serialized and read +`_serverCfg` at the moment they run (HP-1454-03), so a debounced write +still queued from a previous edit can fire mid-drag — with the overlay +it carries only committed geometry. The overlay moves into the real +config exactly once, on pointerup; a cancel (Esc, pointercancel) just +drops the overlay, leaving nothing to restore and nothing to write. ## Undo @@ -124,6 +155,9 @@ and Ctrl+Z/⌘Z pops it while the tool is active. ## Geometry home All pure geometry lives in `src/resize.ts` (edge normals, edge move, -shared-span search and vertex insertion, all stops, the scale clamp, -area formatting) under node:test units in `test/resize.test.mjs`; -`src/houseplan-card.ts` only wires pointers, preview, badges and undo. +shared-span search and vertex insertion, all stops — including the +orientation-independent `minSpanClearance` band measure and the +`minPolyWidth` calipers width — the scale clamp, area formatting) +under node:test units in `test/resize.test.mjs`; +`src/houseplan-card.ts` only wires pointers, the preview overlay, +badges and undo. diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 0301a15e..e6caebbd 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -46,7 +46,7 @@ import { cardStyles } from './styles'; import { fitInSquare, contentBounds, spaceModels } from './space-geometry'; import { langOf, t, type I18nKey } from './i18n'; -const CARD_VERSION = '1.55.0'; +const CARD_VERSION = '1.55.1'; const LS_KEY = 'houseplan_card_layout_v1'; const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_ZOOM = 'houseplan_card_zoom_v1'; @@ -237,6 +237,8 @@ class HouseplanCard extends LitElement { changed: string[]; } | null = null; private _rszUndo: { space: string; snap: string }[] = []; + /** HP-1550-01: the live resize preview, kept OUT of _serverCfg (see _rszApplyPreview). */ + private _rszPreview: { space: string; sp: any } | null = null; private _rszLive: { x: number; y: number; text: string; area?: boolean }[] | null = null; private _path: number[][] = []; // current outline (render units, vertices snapped to the grid) private _cursorPt: number[] | null = null; @@ -745,6 +747,7 @@ class HouseplanCard extends LitElement { private _buildModel(): SpaceModel[] { if (!this._serverCfg) return []; + // HP-1550-01: the model renders the preview overlay, not the raw config // ONE model builder for both cards. This used to be a hand-copied twin of // spaceModels(), and the twin missed the legacy-store fallbacks the shared // one gained (safeViewBox, normRect) — the same broken store rendered fine @@ -753,7 +756,7 @@ class HouseplanCard extends LitElement { // is memoized on the config fingerprint, so a signed url baked in here // would freeze BEFORE the signature arrives and the plan would never load // (bug found 2026-07-27). _display() is called at render time instead. - const cfg = this._serverCfg; + const cfg = this._renderCfg!; return spaceModels(cfg).map((m, i) => { const raw = (cfg.spaces[i] as any)?.plan_url; return m.bg && raw ? { ...m, bg: { ...m.bg, href: raw } } : m; @@ -1903,9 +1906,24 @@ class HouseplanCard extends LitElement { } private get _curSpaceCfg(): any { + // HP-1550-01: while a resize drag is live, every render-path reader sees + // the preview overlay; _writeConfig deliberately reads _serverCfg instead, + // so a queued write can never pick the preview up. + const pv = this._rszPreview; + if (pv && pv.space === this._space) return pv.sp; return this._serverCfg?.spaces.find((s: any) => s.id === this._space); } + /** The config AS RENDERED: _serverCfg with the live resize preview substituted in. */ + private get _renderCfg(): ServerConfig | null { + const pv = this._rszPreview; + if (!pv || !this._serverCfg) return this._serverCfg; + return { + ...this._serverCfg, + spaces: this._serverCfg.spaces.map((s: any) => (s.id === pv.space ? pv.sp : s)), + }; + } + private get _spaceH(): number { const sp = this._curSpaceCfg; return NORM_W; // square canvas @@ -2002,6 +2020,7 @@ class HouseplanCard extends LitElement { this._rszSel = null; this._rszDrag = null; this._rszLive = null; + this._rszPreview = null; this._rszUndo = []; this._tip = null; this._decorDraft = null; @@ -2232,7 +2251,8 @@ class HouseplanCard extends LitElement { } private _rszRestore(snap: string): void { - const sp = this._curSpaceCfg; + // undo restores are commits: they target the REAL config, never the overlay + const sp = this._serverCfg?.spaces.find((s: any) => s.id === this._space); if (!sp) return; const s = JSON.parse(snap); sp.rooms = s.rooms; @@ -2241,16 +2261,25 @@ class HouseplanCard extends LitElement { this.requestUpdate(); } - /** Live preview: the candidate outlines go straight into the space config (normalized), - * based on the immutable pre-drag snapshot — walls, fills, labels and openings all - * follow in the same render. Nothing is WRITTEN until the handle is released. */ + /** Live preview of the candidate geometry, based on the immutable pre-drag snapshot — + * walls, fills, labels and openings all follow in the same render. + * + * HP-1550-01: the preview must NEVER touch _serverCfg. It used to be written + * into the shared mutable space config, and the serialized write chain + * (HP-1454-03) reads `_serverCfg` AT THE MOMENT a write runs — so a debounced + * write still queued from a previous edit carried the mid-drag preview to the + * server before pointerup, and an Esc after that left the abandoned geometry + * persisted (reload resurrected it). Flushing the queue before the drag would + * not close it: the queued write reads the mutable config later anyway. The + * live geometry therefore lives in the `_rszPreview` overlay; _curSpaceCfg / + * _renderCfg feed it to every render, and only _rszUp moves it into the real + * config — the single point where a resize becomes visible to _writeConfig. */ private _rszApplyPreview(polys: Record, ops: Record): void { const g = this._rszDrag; - const sp = this._curSpaceCfg; - if (!g || !sp) return; - const s = JSON.parse(g.snap); - sp.rooms = s.rooms; - sp.openings = s.openings; + const real = this._serverCfg?.spaces.find((s: any) => s.id === this._space); + if (!g || !real) return; + const s = JSON.parse(g.snap); // fresh deep copies every move — free to mutate + const sp = { ...real, rooms: s.rooms, openings: s.openings }; const H = this._spaceH; for (const [id, poly] of Object.entries(polys)) { const r = sp.rooms.find((x: any) => x.id === id); @@ -2264,6 +2293,7 @@ class HouseplanCard extends LitElement { o.x = c[0] / NORM_W; o.y = c[1] / H; } + this._rszPreview = { space: this._space, sp }; this._cfgEpoch++; } @@ -2337,16 +2367,24 @@ class HouseplanCard extends LitElement { const g = this._rszDrag; if (!g || g.pid !== ev.pointerId) return; ev.stopPropagation(); + const preview = this._rszPreview; this._rszDrag = null; this._rszLive = null; + this._rszPreview = null; // the overlay is gone either way; renders read the real config again const changed = g.moved && (g.kind === 'edge' ? Math.abs(g.d) > 1e-9 : Math.abs(g.k - 1) > 1e-9); - if (!changed) { - this._rszRestore(g.snap); + if (!changed || !preview) { + // HP-1550-01: nothing to restore — the preview never touched the config + this._cfgEpoch++; + this.requestUpdate(); return; } - // commit: clean the collinear leftovers of T-inserts, then ONE undo step + ONE write + // commit: the preview moves into the REAL config in one step (the only point + // where _writeConfig can see a resize), collinear T-insert leftovers cleaned, + // then ONE undo step + ONE write const sp = this._curSpaceCfg; if (sp) { + sp.rooms = preview.sp.rooms; + sp.openings = preview.sp.openings; for (const id of g.changed) { const r = sp.rooms.find((x: any) => x.id === id); if (r?.poly) r.poly = simplifyPoly(r.poly, 1e-9); @@ -2366,7 +2404,23 @@ class HouseplanCard extends LitElement { if (!g) return; this._rszDrag = null; this._rszLive = null; - this._rszRestore(g.snap); + // HP-1550-01/-03: a cancel just drops the overlay — the real config was + // never touched, so there is nothing to restore, no undo step and no write + this._rszPreview = null; + this._cfgEpoch++; + this.requestUpdate(); + } + + /** HP-1550-03: pointercancel / lostpointercapture is an ABORT, not a release — + * the system interrupted the stream (app switch, palm rejection), so the drag + * takes the cancel path: snapshot geometry, no undo step, no write. The pid + * guard also absorbs the lostpointercapture that follows a normal pointerup + * or a pointercancel (the drag is already gone — no double cancel/commit). */ + private _rszPointerCancel(ev: PointerEvent): void { + const g = this._rszDrag; + if (!g || g.pid !== ev.pointerId) return; + ev.stopPropagation(); + this._rszCancelDrag(); } /** Ctrl+Z in the resize tool: one handle release = one undo step (docs/RESIZE.md). */ @@ -2427,7 +2481,8 @@ class HouseplanCard extends LitElement { @pointerdown=${(e: PointerEvent) => this._rszEdgeDown(e, r.id, i)} @pointermove=${(e: PointerEvent) => this._rszMove(e)} @pointerup=${(e: PointerEvent) => this._rszUp(e)} - @pointercancel=${(e: PointerEvent) => this._rszUp(e)}>`); + @pointercancel=${(e: PointerEvent) => this._rszPointerCancel(e)} + @lostpointercapture=${(e: PointerEvent) => this._rszPointerCancel(e)}>`); } } const sel = this._rszSel ? rooms.find((r) => r.id === this._rszSel) : null; @@ -2440,7 +2495,8 @@ class HouseplanCard extends LitElement { @pointerdown=${(e: PointerEvent) => this._rszCornerDown(e, sel.id, [cx, cy], [fx, fy] as [number, number])} @pointermove=${(e: PointerEvent) => this._rszMove(e)} @pointerup=${(e: PointerEvent) => this._rszUp(e)} - @pointercancel=${(e: PointerEvent) => this._rszUp(e)}>`); + @pointercancel=${(e: PointerEvent) => this._rszPointerCancel(e)} + @lostpointercapture=${(e: PointerEvent) => this._rszPointerCancel(e)}>`); } } return svg`${parts}`; @@ -2869,6 +2925,10 @@ class HouseplanCard extends LitElement { /** Drag an opening along the walls (view mode): it re-snaps continuously. */ private _opPointerDown(ev: PointerEvent, o: OpeningCfg): void { if (this._mode !== 'plan') return; + // HP-1550-04: in the resize tool the wall handles own the geometry — a door + // in the middle of a wall must neither swallow the handle nor start its own + // drag (it travels with the wall through the resize pipeline instead) + if (this._tool === 'resize') return; ev.preventDefault(); ev.stopPropagation(); try { @@ -2914,6 +2974,9 @@ class HouseplanCard extends LitElement { /** Click: the status card (delayed so a double click can cancel it). */ private _opClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void { + // HP-1550-04: in the resize tool a click over an opening falls through to + // the stage (room picking) instead of opening the editor dialog + if (this._mode === 'plan' && this._tool === 'resize') return; ev.stopPropagation(); if (this._opDrag?.moved) return; // that click was the tail of a drag // openings are inert outside Plan mode (owner's decision: View must not @@ -4653,8 +4716,8 @@ class HouseplanCard extends LitElement { ${this._renderOpenWalls(disp)} ${this._editing ? this._renderAlignGuides() : nothing} ${this._markup ? this._renderMarkupLayer(vb) : nothing} - ${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing} ${this._renderOpenings(disp)} + ${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
${devs.map((d) => this._renderDevice(d, view, showLqi, disp.fill === 'glow' && !this._markup))} diff --git a/src/resize.ts b/src/resize.ts index 160e1f60..eac81b87 100644 --- a/src/resize.ts +++ b/src/resize.ts @@ -223,6 +223,110 @@ export function minParallelClearance( return best; } +/** + * HP-1550-02: orientation-independent clearance of the moved stretches. + * + * minParallelClearance only saw PARALLEL opposite walls, so a triangle (no + * parallel wall at all) reported Infinity and the 30 cm floor was simply off — + * the base could be dragged to a 5-unit sliver. This measure looks at the whole + * band the span sweeps along its normal: every vertex strictly inside the band + * and every edge crossing the band interior counts with its perpendicular + * distance from the span line. Two exclusions keep it honest: + * - anything ON the span line (offset ≤ eps) is the span itself, a collinear + * wall remainder or a T-insert — not an opposite obstacle; + * - the band ENDS (projection ≤ eps or ≥ L − eps) are excluded, so the + * |d|-long step edge a T-junction inserts at the very end of the span does + * not read as a paper-thin room on every small drag. + * Offsets cannot change sign inside the band (that would cross the span — + * polyIsSimple already rejected it), so an edge's minimum lies at a clip bound. + * Infinity still means «nothing opposite at all» (e.g. growing outward). + */ +export function minSpanClearance( + poly: number[][], spans: [number[], number[]][], eps = 1e-6, +): number { + let best = Infinity; + for (const [a, b] of spans) { + const ab = sub(b, a); + const L = len2d(ab); + if (L < eps) continue; + const u = [ab[0] / L, ab[1] / L]; + const soff = (p: number[]) => (p[0] - a[0]) * u[1] - (p[1] - a[1]) * u[0]; + const tOf = (p: number[]) => (p[0] - a[0]) * u[0] + (p[1] - a[1]) * u[1]; + const lo = eps, hi = L - eps; + for (const v of poly) { + const o = Math.abs(soff(v)); + if (o <= eps) continue; + const tv = tOf(v); + if (tv <= lo || tv >= hi) continue; + if (o < best) best = o; + } + for (let j = 0; j < poly.length; j++) { + const q1 = poly[j], q2 = poly[(j + 1) % poly.length]; + const o1 = soff(q1), o2 = soff(q2); + if (Math.abs(o1) <= eps || Math.abs(o2) <= eps) continue; // attached to the moving wall + const t1 = tOf(q1), t2 = tOf(q2); + const tlo = Math.max(lo, Math.min(t1, t2)); + const thi = Math.min(hi, Math.max(t1, t2)); + if (thi - tlo <= eps) continue; // casts no shadow on the span interior + const dt = t2 - t1; + if (Math.abs(dt) < eps) { // perpendicular-ish edge fully inside the band + best = Math.min(best, Math.abs(o1), Math.abs(o2)); + continue; + } + const offAt = (tt: number) => Math.abs(o1 + ((tt - t1) / dt) * (o2 - o1)); + best = Math.min(best, offAt(tlo), offAt(thi)); + } + } + return best; +} + +/** Convex hull (monotone chain) — only the width measure below needs it. */ +function convexHull(pts: number[][]): number[][] { + const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]); + if (p.length < 3) return p; + const cross = (o: number[], a: number[], b: number[]) => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + const lower: number[][] = []; + for (const pt of p) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop(); + lower.push(pt); + } + const upper: number[][] = []; + for (let i = p.length - 1; i >= 0; i--) { + const pt = p[i]; + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop(); + upper.push(pt); + } + lower.pop(); upper.pop(); + return lower.concat(upper); +} + +/** + * HP-1550-02: the TRUE minimum width of a polygon — rotating calipers over the + * convex hull (the min over hull edge directions of the perpendicular extent). + * The axis-aligned bbox lied under rotation: a 500×100 rectangle turned 45° has + * a ≈424×424 bbox, so a 0.1 scale slid the real 100-side down to 10 unchecked. + * A similarity scales every distance by k, which makes k·minPolyWidth exact — + * and a concave room is judged by its overall silhouette, so a small notch + * that takes no part in the operation cannot veto a legal scale. + */ +export function minPolyWidth(poly: number[][]): number { + const h = convexHull(poly); + if (h.length < 3) return 0; + let best = Infinity; + for (let i = 0; i < h.length; i++) { + const a = h[i], b = h[(i + 1) % h.length]; + const e = sub(b, a); + const L = len2d(e); + if (L < 1e-12) continue; + const u = [e[0] / L, e[1] / L]; + let w = 0; + for (const pt of h) w = Math.max(w, Math.abs((pt[0] - a[0]) * u[1] - (pt[1] - a[1]) * u[0])); + if (w < best) best = w; + } + return Number.isFinite(best) ? best : 0; +} + /** * Do two outlines ILLEGALLY share floor area? `roomsOverlap` alone misses the * «slide-over» case: equal-height rectangles overlapping horizontally have all @@ -336,13 +440,15 @@ export function validateEdgeDrag( if (!polyIsSimple(np)) return false; const s0 = signedArea(r.poly), s1 = signedArea(np); if (Math.abs(s1) < eps || s0 * s1 <= 0) return false; - // minimum size: normal clearance of the moved stretches; a room already - // thinner keeps its clearance (improving is allowed, worsening is not) + // minimum size: orientation-independent clearance of the moved stretches + // (HP-1550-02 — the parallel-walls-only measure left triangles and other + // non-parallel geometry without the 30 cm floor); a room already thinner + // keeps its clearance (improving is allowed, worsening is not) const oldSpans: [number[], number[]][] = id === plan.roomId ? [[plan.a, plan.b]] : sharedSpansWith(r.poly, plan.a, plan.b, eps); - const cOld = minParallelClearance(r.poly, oldSpans, eps); - const cNew = minParallelClearance(np, res.movedSpans[id] || [], eps); + const cOld = minSpanClearance(r.poly, oldSpans, eps); + const cNew = minSpanClearance(np, res.movedSpans[id] || [], eps); if (cNew < Math.min(minDim, cOld) - eps) return false; // every pre-existing room relationship must SURVIVE the drag: an island // stays an island (a jump fully past a thin island crosses no edge, so @@ -437,12 +543,12 @@ export function validateRoomScale( const otherPolys = rooms.filter((r) => r.id !== roomId).map((r) => r.poly); const res = applyRoomScale(room, openings, otherPolys, fixed, k, eps * 2); const np = res.poly; - // minimum size: the bbox side (a similarity cannot self-intersect) - const xs = np.map((p) => p[0]), ys = np.map((p) => p[1]); - const minSide = Math.min(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)); - const xs0 = room.poly.map((p) => p[0]), ys0 = room.poly.map((p) => p[1]); - const minSide0 = Math.min(Math.max(...xs0) - Math.min(...xs0), Math.max(...ys0) - Math.min(...ys0)); - if (minSide < Math.min(minDim, minSide0) - eps) return false; + // minimum size: a similarity scales every distance by exactly k, so the TRUE + // minimum width of the original scales to k·w0 (HP-1550-02 — the axis-aligned + // bbox side let a rotated rectangle shrink its real short side unchecked); + // an already-thin room keeps the improve-only rule + const w0 = minPolyWidth(room.poly); + if (w0 * k < Math.min(minDim, w0) - eps) return false; // the neighbour is a wall to hit: pre-existing nesting must survive, // everything else must not gain shared area or become nested (engulfing a // foreign room via scale is a stop, not a new island) diff --git a/src/styles.ts b/src/styles.ts index 6602ec0b..ee024214 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -262,6 +262,15 @@ export const cardStyles = css` .stage.markup .op-hit:active { cursor: grabbing; } + /* HP-1550-04: in the resize tool the wall handles own the hit test — the + transparent .op-hit of a door at the midpoint of a wall used to sit ON + TOP of the handle and made that wall ungrabbable for both rooms. + Openings are not editable in this tool (they ride along with the wall), + so their hit area goes fully inert; every other Plan tool is untouched. */ + .stage.markup.tool-resize .op-hit { + pointer-events: none; + cursor: default; + } .oplock { pointer-events: none; /* inert while editing; clickable in View (rule below) */ position: absolute; diff --git a/test/resize.test.mjs b/test/resize.test.mjs index d6d3ad17..83489cf3 100644 --- a/test/resize.test.mjs +++ b/test/resize.test.mjs @@ -208,3 +208,51 @@ test('zero drag is always valid and clamps to zero', () => { assert.equal(validateEdgeDrag(rooms, [], plan, 0, OPTS), true); assert.equal(clampEdgeDrag(rooms, [], plan, 0, STEP, OPTS), 0); }); + +// ================= HP-1550-02 (the v1.55.0 audit): the 30 cm floor must be +// orientation-independent — triangles have no parallel opposite wall and a +// rotated rectangle hides its true short side from the axis-aligned bbox. + +test('HP-1550-02: triangle — the min-size stop holds without a parallel opposite wall', () => { + // base 300 wide, apex 300 above it; drag the base toward the apex + const T = { id: 'T', poly: [[0, 0], [300, 0], [150, 300]] }; + const rooms = [T]; + const plan = planEdgeDrag(rooms, 'T', 0); + assert.deepEqual(plan.n.map((v) => Math.round(v) + 0), [0, -1]); // outward = away from the apex + assert.equal(validateEdgeDrag(rooms, [], plan, -295, OPTS), false); // height 5 < 25 + const d = clampEdgeDrag(rooms, [], plan, -295, STEP, OPTS); + const height = 300 - Math.abs(d); + assert.ok(height >= OPTS.minDim - OPTS.eps, `triangle squeezed to height ${height}`); +}); + +test('HP-1550-02: rotated rectangle — scale stops at the TRUE short side, not the bbox', () => { + // 500×100 rectangle rotated 45°: its axis-aligned bbox is ≈424×424, so the + // bbox measure let k=0.1 slip through while the real short side became 10 + const c45 = Math.SQRT1_2; + const rot = ([x, y]) => [x * c45 - y * c45, x * c45 + y * c45]; + const P = [[0, 0], [500, 0], [500, 100], [0, 100]].map(rot); + const rooms = [{ id: 'S', poly: P }]; + const fixed = [P[0][0], P[0][1]]; + assert.equal(validateRoomScale(rooms, [], 'S', fixed, 0.1, OPTS), false); // side 10 < 25 + const k = clampRoomScale(rooms, [], 'S', fixed, 0.1, OPTS); + assert.ok(k * 100 >= OPTS.minDim - OPTS.eps, `short side shrank to ${k * 100}`); +}); + +test('HP-1550-02: concave room — a non-parallel obstacle stops the drag', () => { + // a "roof" vertex dips into the room at (200,120); the bottom wall dragged up + // has NO parallel opposite wall, yet must stop ~30 cm short of the dip + const C = { id: 'C', poly: [[0, 0], [400, 0], [400, 200], [200, 120], [0, 200]] }; + const rooms = [C]; + const plan = planEdgeDrag(rooms, 'C', 0); + assert.equal(validateEdgeDrag(rooms, [], plan, -115, OPTS), false); // 5 from the dip + assert.equal(validateEdgeDrag(rooms, [], plan, -95, OPTS), true); // 25 — the floor + closeTo(clampEdgeDrag(rooms, [], plan, -115, STEP, OPTS), -95); +}); + +test('HP-1550-02: an already-thin triangle may improve but never worsen', () => { + const T = { id: 'T', poly: [[0, 0], [300, 0], [150, 20]] }; // height 20 < 25 already + const rooms = [T]; + const plan = planEdgeDrag(rooms, 'T', 0); + assert.equal(validateEdgeDrag(rooms, [], plan, -5, OPTS), false); // 20 → 15: worse + assert.equal(validateEdgeDrag(rooms, [], plan, 50, OPTS), true); // 20 → 70: better +});