HP-1550-01..04: the v1.55.0 resize audit findings, fixed with regressions

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.
This commit is contained in:
Matysh
2026-08-01 18:58:23 +03:00
parent 10927ffaed
commit 84f8bcf0e6
7 changed files with 518 additions and 39 deletions
+210
View File
@@ -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 });
+12 -3
View File
@@ -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();
+42 -8
View File
@@ -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.
+81 -18
View File
@@ -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<string, number[][]>, ops: Record<string, [number, number]>): 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)}></circle>`);
@pointercancel=${(e: PointerEvent) => this._rszPointerCancel(e)}
@lostpointercapture=${(e: PointerEvent) => this._rszPointerCancel(e)}></circle>`);
}
}
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)}></circle>`);
@pointercancel=${(e: PointerEvent) => this._rszPointerCancel(e)}
@lostpointercapture=${(e: PointerEvent) => this._rszPointerCancel(e)}></circle>`);
}
}
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}
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2] * (this._kiosk ? this._kioskScale.icon : 1)) / view.w).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi, disp.fill === 'glow' && !this._markup))}
+116 -10
View File
@@ -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)
+9
View File
@@ -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;
+48
View File
@@ -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
});