mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
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:
@@ -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 });
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user