mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
DEV-B58: one bound, one grid — the canvas border and the snap contract
Two owner reports after v1.57.0, both about coordinates.
=== DEV-B58-01: nothing stops at the old canvas border any more ===
The infinite canvas freed the FRAME and the DRAWING; it did not free the
drag handlers, and both the owner and a user hit that within a day:
"названия комнат и устройства не перетаскиваются дальше старых границ
холста".
Two clamps survived v1.57.0, and the second is the worse one:
* `_pointerMove` (device marker) clamped into `_baseVb()` — the CONTENT
FRAME, with a 0.8 % inset. A marker could never be dragged past the
outline of what was already drawn, so a plan could not be extended by
putting a device where the next room was going to be.
* `_labelMove` (room label) clamped into `_spaceModel().vb` — the
space's STORED `view_box`, which is `[0,0,1,1]` for every plan the
card has ever written. Literally the old square: a room drawn at 2.5
had a name that could not reach its own room.
And one asymmetry: `_decorCommitDraft` and the decor text anchor had no
guard at all, while `_decorMoveUpdate` did — a draft could be born
outside the range the mover then refused to leave.
The rule now is one line: an editor gesture has exactly ONE bound,
`+/-CANVAS_LIMIT`, the same number `validation.py` enforces, and it is a
garbage limit rather than a frame. `clampCanvasR` / `clampCanvasN` in
space-geometry.ts are the only two functions allowed to impose it, and
`_snap()` applies it on the way out, so every gesture that goes through
the snap is bounded by construction.
demo/smoke_drag_bounds.mjs starts from an ORDINARY plan (rooms inside
0..1, so the old clamps really were in the way), drags a marker, a room
name, a decor shape and an opening far past the old square, checks each
arrives, is stored, survives a rebuild and takes the frame with it — and
that a wild drag still parks at exactly 5000 rather than 1e12. Seven of
its eleven facts fail by name on 85263d5.
=== DEV-B58-02: everything strictly on the grid ===
The owner's suspicion first, answered honestly in docs/CANVAS.md §9.2:
THE GRID STEP DID NOT CHANGE. `_gridPitch = NORM_W / GRID_N = 1000/240`,
both constants, independent of the frame, the view, the zoom, `view_box`
and `cell_cm`; `git log -S` shows neither touched since v1.4.0. So the
move to the infinite canvas did not put any existing element between the
nodes. `gridLevels()` changes what is DRAWN, never what is SNAPPED TO.
What WAS off the grid, and is now fixed:
* auto placements. `defaultPositions`, the `spaceCenter` fallback and
an undragged room label used centroids, which are not nodes for an
odd-sized or polygonal room. This is the likeliest thing the owner
was actually looking at.
* `_decorMoveUpdate` snapped the DELTA, which preserves whatever
off-grid offset a shape already had for ever, one step at a time. It
snaps the resulting anchor now, so one drag is enough.
* `snapToGrid`/`snapR` returned 500.00000000000006 for an exact 500 —
the round trip through a non-dyadic pitch. They are bit-identical on
a node now, so "is this on the grid?" stops answering no.
Openings and split points on a wall are deliberately NOT rounded to a
node — a door on a node but off its diagonal wall is broken geometry.
They are WALL-bound: projected onto the wall, then the offset ALONG it
quantised to the same step (`snapToWall({step,length})`,
`snapPointAlongPoly`). On the axis-aligned, grid-drawn walls the editor
itself makes, the two rules give the same point. The centre magnet is
consulted FIRST, so a wall whose middle is not a node can still hold a
centred window (this is what smoke_opening_measure caught).
Shift now means one thing everywhere: suspend the snap for this gesture.
It keeps its two older meanings (no centre magnet, coarse 15° compass).
=== And why an ACTION rather than a silent migration ===
Old plans may hold coordinates between the nodes. The card does not
round them on update. General settings grow a Grid group with
«Выровнять всё по сетке», which first states how many elements will
move and by how much at most, warns that there is no undo, and only then
writes — one config/set plus the layout updates, in one go.
1. A migration moves the user's data without asking. A house plan is a
drawing; the card has no mandate to redraw it on a version bump.
2. Some elements are off-grid ON PURPOSE — a small decor label nudged
next to an icon, a window on a diagonal wall, a plan traced over a
photo whose scale was never a whole number of cells.
3. A silent migration is unattributable: when a room looks 3 cm wrong
the owner cannot tell whether the card did it or they did.
4. An update that rewrites stored geometry cannot be undone by
downgrading the card. A button can simply not be pressed.
`alignAllToGrid()` (src/align-grid.ts) is pure — it copies its input and
returns the new spaces, the new layout and the report — so the dialog
measures and commits the SAME object and cannot promise one thing and do
another. test/align-grid.test.mjs pins what moves, what does not (a
stray opening with no wall in reach stays put), that a rect's FAR corner
lands on a node too, and idempotency: a second run reports moved 0,
changed false, and deep-equals the first. demo/smoke_grid_snap.mjs does
the same through the DOM plus every by-hand placement.
docs/CANVAS.md §9 carries the whole contract; docs/TESTING.md gains
three manual items. i18n en/ru. The backend is untouched — same
coordinates, same schema.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,126 @@
|
||||
// DEV-B58-01 — the infinite canvas reached the FRAME and the drawing, but not
|
||||
// the drag handlers. A device marker was clamped into the CONTENT FRAME (with
|
||||
// a 0.8 % inset), a room label into the space's stored view_box — which, for
|
||||
// every plan the card has ever written, is the old unit square. The owner and a
|
||||
// user hit the same wall: "названия комнат и устройства не перетаскиваются
|
||||
// дальше старых границ холста".
|
||||
//
|
||||
// Every interactive coordinate now has ONE bound: the ±5000 the backend
|
||||
// enforces (docs/CANVAS.md §9). This smoke starts from an ORDINARY plan — one
|
||||
// that lives inside 0..1, so the old clamps really were in the way — and drags
|
||||
// each kind of thing out to 2.5 / 2.2 and beyond.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const NORM_W = 1000;
|
||||
const near = (a, b, eps = 1 / 240) => Math.abs(a - b) <= eps + 1e-9;
|
||||
const onGrid = (v) => Math.abs(v * 240 - Math.round(v * 240)) < 1e-9;
|
||||
|
||||
// the demo plan is an ordinary one: rooms inside the old unit square
|
||||
const b0 = c._baseVb();
|
||||
o.startsFromAnOrdinaryPlan = b0[0] + b0[2] < 1.3 * NORM_W && b0[1] + b0[3] < 1.3 * NORM_W;
|
||||
|
||||
/** Turn a wanted RENDER-unit delta into the clientX/clientY a handler wants. */
|
||||
const mk = (dxu, dyu, sx = 400, sy = 400, extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: sx + (dxu / v.w) * rect.width,
|
||||
clientY: sy + (dyu / v.h) * rect.height,
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
|
||||
// ---- (a) a DEVICE MARKER goes to 2.5 / 2.2 ---------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => !d.virtual);
|
||||
const p0 = c._pos(dev);
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y), dev);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev);
|
||||
await c.updateComplete;
|
||||
const saved = c._layout[dev.id];
|
||||
o.markerLeftTheOldCanvas = near(saved.x, 2.5) && near(saved.y, 2.2);
|
||||
o.markerIsOnTheGrid = onGrid(saved.x) && onGrid(saved.y);
|
||||
|
||||
// ---- (b) it SURVIVES a rebuild ---------------------------------------
|
||||
c._drag = null;
|
||||
c._modelCache = null; c._frame = null; c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const back = c._pos(c._devices.find((d) => d.id === dev.id));
|
||||
o.markerSurvivesRebuild = near(back.x / NORM_W, 2.5) && near(back.y / NORM_W, 2.2);
|
||||
// …and the frame followed it out there instead of pretending it is elsewhere
|
||||
const b1 = c._baseVb();
|
||||
o.frameFollowedTheMarker = b1[0] + b1[2] > 2.4 * NORM_W;
|
||||
|
||||
// ---- (c) a ROOM LABEL, the worse of the two (it clamped to view_box) --
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
|
||||
const lp = c._labelPos(room, c._space);
|
||||
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
|
||||
c._labelMove(mk(2.7 * NORM_W - lp.x, 2.4 * NORM_W - lp.y), room, c._space);
|
||||
c._labelUp(room);
|
||||
await c.updateComplete;
|
||||
const sl = c._layout['rl_' + room.id];
|
||||
o.labelLeftTheOldCanvas = near(sl.x, 2.7) && near(sl.y, 2.4);
|
||||
o.labelIsOnTheGrid = onGrid(sl.x) && onGrid(sl.y);
|
||||
c._drag = null;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const lp2 = c._labelPos(room, c._space);
|
||||
o.labelSurvivesRebuild = near(lp2.x / NORM_W, 2.7) && near(lp2.y / NORM_W, 2.4);
|
||||
|
||||
// ---- (d) DECOR born far out, and moved further still -------------------
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._decorTool = 'rect';
|
||||
c._decorDraft = { kind: 'rect', a: [2.6 * NORM_W, 2.6 * NORM_W], b: [2.9 * NORM_W, 2.8 * NORM_W], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
const sh = c._decorList[c._decorList.length - 1];
|
||||
o.decorBornFarOut = near(sh.x, 2.6) && near(sh.y, 2.6);
|
||||
// the decor mover works in ABSOLUTE svg points, so the gesture is built the
|
||||
// way _decorShapeDown builds it: grab at the shape, release 0.6/0.5 away
|
||||
const atUnits = (X, Y) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
|
||||
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true,
|
||||
});
|
||||
};
|
||||
const grab = c._svgPoint(atUnits(2.6 * NORM_W, 2.6 * NORM_W));
|
||||
c._decorMove = { id: sh.id, start: grab,
|
||||
orig: JSON.parse(JSON.stringify(sh)), pid: 2, moved: false };
|
||||
c._decorMoveUpdate(atUnits(3.2 * NORM_W, 3.1 * NORM_W));
|
||||
await c.updateComplete;
|
||||
const sh2 = c._decorList.find((x) => x.id === sh.id);
|
||||
o.decorMovedFurtherOut = near(sh2.x, 3.2) && near(sh2.y, 3.1);
|
||||
c._decorMove = null;
|
||||
|
||||
// ---- (e) the ONE bound that is left is the backend's ±5000 ------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev2 = c._devices.find((d) => d.id !== dev.id && !d.virtual);
|
||||
const q0 = c._pos(dev2);
|
||||
c._drag = { id: dev2.id, sx: 400, sy: 400, ox: q0.x, oy: q0.y, moved: false };
|
||||
c._pointerMove(mk(9e9, 9e9), dev2);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev2);
|
||||
c._drag = null;
|
||||
o.garbageStillClamped = c._layout[dev2.id].x === 5000 && c._layout[dev2.id].y === 5000;
|
||||
|
||||
// ---- (f) Shift is the only way to land BETWEEN the nodes --------------
|
||||
const dev3 = c._devices.find((d) => d.id !== dev.id && d.id !== dev2.id && !d.virtual);
|
||||
const r0 = c._pos(dev3);
|
||||
const halfStep = NORM_W / 240 / 2;
|
||||
c._drag = { id: dev3.id, sx: 400, sy: 400, ox: r0.x, oy: r0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y, 400, 400, { shiftKey: true }), dev3);
|
||||
c._drag = null;
|
||||
o.shiftPlacesBetweenTheNodes = !onGrid(c._layout[dev3.id].x);
|
||||
|
||||
c._setMode('view');
|
||||
return o;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -40,8 +40,9 @@ const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[
|
||||
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"rows": 14, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun", "About"],
|
||||
"rows": 15, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
|
||||
// + «Выровнять всё по сетке» (docs/CANVAS.md §9)
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun", "Grid", "About"],
|
||||
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
|
||||
"aboutLinks": [
|
||||
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// «Всё строго по сетке» (docs/CANVAS.md §9). Two halves:
|
||||
// 1. every element placed or dragged by hand lands on a node — devices, room
|
||||
// labels, decor, room vertices, resize handles; an opening lands ON ITS
|
||||
// WALL at a whole number of steps along it; Shift is the only way out;
|
||||
// 2. the explicit «Выровнять всё по сетке» action moves a deliberately
|
||||
// detuned plan onto the grid, tells the truth about how much it moves,
|
||||
// and does nothing at all the second time.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const NORM_W = 1000, GRID_N = 240;
|
||||
const PITCH = NORM_W / GRID_N;
|
||||
const onGridN = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
|
||||
const onGridR = (v) => Math.abs(v / PITCH - Math.round(v / PITCH)) < 1e-7;
|
||||
|
||||
/** A click/pointer event at a given RENDER-unit point of the current view. */
|
||||
const at = (X, Y, type = 'pointermove', extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent(type, {
|
||||
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
|
||||
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
// an off-grid target: a third of a step past a node, in both axes
|
||||
const OFF = PITCH / 3;
|
||||
|
||||
// ---- 1a) the snap itself, the one gate every gesture goes through -----
|
||||
const s1 = c._snap([100 + OFF, 200 + OFF]);
|
||||
o.snapRoundsToTheNode = onGridR(s1[0]) && onGridR(s1[1]);
|
||||
const s2 = c._snap([100 + OFF, 200 + OFF], { shiftKey: true });
|
||||
o.shiftSuspendsTheSnap = !onGridR(s2[0]);
|
||||
o.snapAlsoClampsTheGarbage = c._snap([1e12, -1e12])[0] === 5000 * NORM_W;
|
||||
// the step is NORM_W/GRID_N and nothing else — it did NOT change with the
|
||||
// infinite canvas, so no plan's nodes ever moved out from under it
|
||||
o.pitchIsTheCanvasFreeConstant = Math.abs(c._gridPitch - PITCH) < 1e-12;
|
||||
|
||||
// ---- 1b) a DEVICE dropped with the mouse lands on a node --------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => !d.virtual);
|
||||
const p0 = c._pos(dev);
|
||||
const mkDelta = (dxu, dyu, extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: 400 + (dxu / v.w) * rect.width, clientY: 400 + (dyu / v.h) * rect.height,
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mkDelta(137.7 - p0.x + OFF, 251.3 - p0.y + OFF), dev);
|
||||
c._drag = null;
|
||||
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
|
||||
|
||||
// ---- 1c) a ROOM LABEL too ---------------------------------------------
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
|
||||
const lp = c._labelPos(room, c._space);
|
||||
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
|
||||
c._labelMove(mkDelta(311.4 - lp.x + OFF, 402.9 - lp.y + OFF), room, c._space);
|
||||
c._drag = null;
|
||||
o.roomLabelLandsOnANode = onGridN(c._layout['rl_' + room.id].x)
|
||||
&& onGridN(c._layout['rl_' + room.id].y);
|
||||
// …and one that has never been dragged is on a node as well
|
||||
const other = c._spaceModel(c._space).rooms.find((r) => r.name && r.id !== room.id);
|
||||
const op = c._labelPos(other, c._space);
|
||||
o.untouchedLabelIsOnANode = onGridR(op.x) && onGridR(op.y);
|
||||
|
||||
// ---- 1d) DECOR: draft, text anchor, and a move ------------------------
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._decorTool = 'rect';
|
||||
// dispatched for real, so the handler sees a target (it looks for .dshape)
|
||||
sr().querySelector('.stage').dispatchEvent(at(300 + OFF, 300 + OFF, 'pointerdown', { pointerId: 21 }));
|
||||
o.decorDraftStartsOnANode = onGridR(c._decorDraft.a[0]) && onGridR(c._decorDraft.a[1]);
|
||||
c._decorDraft = { ...c._decorDraft, b: c._snap([420 + OFF, 380 + OFF]) };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
const dsh = c._decorList[c._decorList.length - 1];
|
||||
o.decorShapeIsOnNodes = onGridN(dsh.x) && onGridN(dsh.y)
|
||||
&& onGridN(dsh.x + dsh.w) && onGridN(dsh.y + dsh.h);
|
||||
c._decorTool = 'text';
|
||||
sr().querySelector('.stage').dispatchEvent(at(500 + OFF, 500 + OFF, 'pointerdown', { pointerId: 22 }));
|
||||
o.decorTextAnchorIsOnANode = onGridN(c._decorTextDialog.x) && onGridN(c._decorTextDialog.y);
|
||||
c._decorTextDialog = null;
|
||||
// a shape that is ALREADY off the grid is put on it by one drag: the mover
|
||||
// snaps the resulting anchor, not the delta (which used to preserve the drift)
|
||||
const sp = c._curSpaceCfg;
|
||||
sp.decor = [...c._decorList, { id: 'dcOff', kind: 'rect',
|
||||
x: 0.3013, y: 0.4017, w: 0.1, h: 0.05, color: '#889', width: 2 }];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const off = c._decorList.find((x) => x.id === 'dcOff');
|
||||
c._decorMove = { id: 'dcOff', start: c._svgPoint(at(301.3, 401.7)),
|
||||
orig: JSON.parse(JSON.stringify(off)), pid: 9, moved: false };
|
||||
c._decorMoveUpdate(at(351.3, 451.7));
|
||||
await c.updateComplete;
|
||||
const off2 = c._decorList.find((x) => x.id === 'dcOff');
|
||||
o.oneDragPutsADriftedShapeOnTheGrid = onGridN(off2.x) && onGridN(off2.y);
|
||||
c._decorMove = null;
|
||||
sp.decor = sp.decor.filter((x) => x.id !== 'dcOff' && x.id !== dsh.id);
|
||||
|
||||
// ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it ---
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._tool = 'opening';
|
||||
const spm = c._spaceModel(c._space);
|
||||
const wall = (() => {
|
||||
for (const r of spm.rooms) {
|
||||
const poly = r.poly || null;
|
||||
if (!poly) continue;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) > PITCH * 20) return [a, b];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
o.foundAWallToTestWith = !!wall;
|
||||
if (wall) {
|
||||
const [a, b] = wall;
|
||||
const mid = [(a[0] + b[0]) / 2 + 0.37 * PITCH, (a[1] + b[1]) / 2 + 0.4];
|
||||
c._openingClick(mid);
|
||||
const d = c._openingDialog;
|
||||
o.openingPlaced = !!d;
|
||||
if (d) {
|
||||
// exactly on the wall segment…
|
||||
const t = ((d.x - a[0]) * (b[0] - a[0]) + (d.y - a[1]) * (b[1] - a[1]))
|
||||
/ ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
|
||||
const px = a[0] + t * (b[0] - a[0]), py = a[1] + t * (b[1] - a[1]);
|
||||
o.openingSitsOnItsWall = Math.hypot(d.x - px, d.y - py) < 1e-6;
|
||||
// …and at a whole number of steps along it (or at the wall's own centre,
|
||||
// which is what the magnet is for and what the ruler shows)
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
const along = t * len;
|
||||
o.openingIsQuantisedAlongTheWall =
|
||||
Math.abs(along / PITCH - Math.round(along / PITCH)) < 1e-6
|
||||
|| Math.abs(along - len / 2) < 1e-6;
|
||||
}
|
||||
c._openingDialog = null;
|
||||
}
|
||||
|
||||
// ---- 1f) a room VERTEX drawn by hand ----------------------------------
|
||||
c._tool = 'draw'; c._path = [];
|
||||
c._markupClick(at(220 + OFF, 220 + OFF, 'click'));
|
||||
o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]);
|
||||
c._path = []; c._tool = 'draw';
|
||||
|
||||
// ---- 2) «Выровнять всё по сетке» --------------------------------------
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// a deliberately detuned fixture: a third of a step off, everywhere
|
||||
const D = 1 / GRID_N / 3;
|
||||
const FIX = { spaces: [{
|
||||
id: 'g1', title: 'Grid test', view_box: [0, 0, 1, 1],
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'A', area: 'living_room',
|
||||
poly: [[0.2 + D, 0.2], [0.5, 0.2 - D], [0.5 + D, 0.5], [0.2, 0.5]] },
|
||||
{ id: 'r2', name: 'B', area: 'kitchen', x: 0.5, y: 0.2, w: 0.3 + D, h: 0.3 },
|
||||
],
|
||||
openings: [{ id: 'o1', type: 'window', x: 0.3 + D, y: 0.2 + D / 2, angle: 0, length: 0.06 }],
|
||||
decor: [{ id: 'd1', kind: 'line', x1: 0.1 + D, y1: 0.7, x2: 0.9, y2: 0.7 - D }],
|
||||
}], markers: [], settings: {} };
|
||||
const FIXLAY = { d_light1: { s: 'g1', x: 0.3 + D, y: 0.3 }, rl_r1: { s: 'g1', x: 0.25 + D, y: 0.25 } };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(FIX));
|
||||
c._layout = JSON.parse(JSON.stringify(FIXLAY));
|
||||
c._modelCache = null; c._frame = null; c._space = 'g1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
c._openAlignDialog();
|
||||
await c.updateComplete;
|
||||
const dlg = c._alignDialog;
|
||||
o.alignDialogOpens = !!dlg && !!sr().querySelector('.dialogwrap .alignmsg');
|
||||
o.alignCountsWhatMoves = !!dlg && dlg.report.moved > 0 && dlg.report.moved <= dlg.report.total;
|
||||
// half a step of 5 cm cells ≈ 2.5 cm; a third of a step is well under that
|
||||
o.alignPromisesASmallShift = !!dlg && dlg.cm > 0 && dlg.cm < 3;
|
||||
o.alignWarnsAboutNoUndo = (sr().querySelector('.dialogwrap .rhint')?.textContent || '').length > 20;
|
||||
|
||||
// capture what the network is told, then run it
|
||||
const sent = [];
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m.type); return base(m); } };
|
||||
await c._runAlignToGrid();
|
||||
await c.updateComplete;
|
||||
o.alignDialogClosed = c._alignDialog === null;
|
||||
o.alignWroteConfigOnce = sent.filter((t) => t === 'houseplan/config/set').length === 1;
|
||||
o.alignWroteLayout = sent.some((t) => t === 'houseplan/layout/update');
|
||||
|
||||
const g = c._serverCfg.spaces[0];
|
||||
o.alignedRoomPoly = g.rooms[0].poly.every((p) => onGridN(p[0]) && onGridN(p[1]));
|
||||
o.alignedRoomRect = onGridN(g.rooms[1].x) && onGridN(g.rooms[1].x + g.rooms[1].w);
|
||||
o.alignedDecor = onGridN(g.decor[0].x1) && onGridN(g.decor[0].y1)
|
||||
&& onGridN(g.decor[0].x2) && onGridN(g.decor[0].y2);
|
||||
o.alignedLayout = onGridN(c._layout.d_light1.x) && onGridN(c._layout.rl_r1.x);
|
||||
// the opening stayed ON the top wall of r1 rather than being rounded off it
|
||||
const w0 = g.rooms[0].poly[0], w1 = g.rooms[0].poly[1];
|
||||
const tt = ((g.openings[0].x - w0[0]) * (w1[0] - w0[0]) + (g.openings[0].y - w0[1]) * (w1[1] - w0[1]))
|
||||
/ ((w1[0] - w0[0]) ** 2 + (w1[1] - w0[1]) ** 2);
|
||||
o.alignedOpeningStaysOnItsWall = Math.hypot(
|
||||
g.openings[0].x - (w0[0] + tt * (w1[0] - w0[0])),
|
||||
g.openings[0].y - (w0[1] + tt * (w1[1] - w0[1]))) < 1e-9;
|
||||
|
||||
// ---- 2b) …and the second run has nothing to do ------------------------
|
||||
c._openAlignDialog();
|
||||
await c.updateComplete;
|
||||
o.secondRunMovesNothing = c._alignDialog.report.moved === 0;
|
||||
o.secondRunOffersNoButton = !sr().querySelector('.dialogwrap .btn.on');
|
||||
c._alignDialog = null;
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
File diff suppressed because one or more lines are too long
Vendored
+70
-43
File diff suppressed because one or more lines are too long
+136
@@ -305,8 +305,143 @@ do). It fits `core` — the same rectangle the plan opens with. Far
|
||||
objects are reached through the outlier hint's **Show** action, which
|
||||
fits `all`.
|
||||
|
||||
## §9 Drag limits and the snap contract (dev, DEV-B58)
|
||||
|
||||
### 9.1 One bound, and it is the backend's
|
||||
|
||||
v1.57.0 freed the FRAME and the DRAWING, but not the drag handlers. Two
|
||||
of them still clamped, and the owner and a user hit both:
|
||||
|
||||
| Handler | Old clamp | Effect |
|
||||
| --- | --- | --- |
|
||||
| `_pointerMove` (device marker) | `_baseVb()` ± a 0.8 % inset — the CONTENT FRAME | a marker could never be dragged past the outline of what was already drawn, so a plan could not be extended by putting a device where the next room was going to be |
|
||||
| `_labelMove` (room label) | `_spaceModel().vb` — the space's STORED `view_box` | worse: that is `[0,0,1,1]` for every plan the card has ever written, i.e. literally the old square. A room drawn at 2.5 had a name that could not reach its own room |
|
||||
| `_decorCommitDraft` / decor text anchor | *none at all* | asymmetric with `_decorMoveUpdate`, which did clamp — a draft could be born outside the range the mover then refused to leave |
|
||||
|
||||
The rule now: **an editor gesture has exactly one bound, `±CANVAS_LIMIT`
|
||||
(±5000 normalised, ±`SANE_LIMIT` in render units), and it is the same
|
||||
number `validation.py` enforces.** It is a garbage limit — insurance
|
||||
against a stored `1e100` — and never a frame. `clampCanvasR` /
|
||||
`clampCanvasN` (`space-geometry.ts`) are the only two functions that may
|
||||
impose it, and `_snap()` applies `clampCanvasR` on the way out, so every
|
||||
gesture that goes through the snap is bounded by construction.
|
||||
|
||||
Room drawing, split, resize and opening placement had no clamp before
|
||||
and still have none of their own — they inherit it from `_snap()` /
|
||||
`clampCanvasN` at the write.
|
||||
|
||||
### 9.2 The grid step never changed
|
||||
|
||||
`_gridPitch = NORM_W / GRID_N = 1000 / 240`. Both constants; neither
|
||||
depends on the content frame, the view, the zoom, `view_box`, or
|
||||
`cell_cm`. `git log -S` confirms neither has been touched since v1.4.0
|
||||
(the one historical change, `GRID_N` 120 → 240, halved the step, so the
|
||||
old nodes stayed a subset of the new ones and every position was
|
||||
preserved).
|
||||
|
||||
**So the infinite canvas did NOT move any existing element off the
|
||||
grid.** `gridLevels()` (§7) chooses which multiples of that pitch are
|
||||
still legible at the current zoom — it changes what is DRAWN, never what
|
||||
is SNAPPED TO. An element that looks off-grid is off-grid because it was
|
||||
placed by something that never snapped, not because the lattice moved.
|
||||
|
||||
### 9.3 What snaps, and to what
|
||||
|
||||
Two kinds of element, because a door rounded to a grid node while its
|
||||
wall runs diagonally is broken geometry, not a tidy plan:
|
||||
|
||||
**GRID-BOUND — rounded to the nearest node:**
|
||||
|
||||
| Element | Where |
|
||||
| --- | --- |
|
||||
| room vertices (draw tool) | `_markupClick` → `_snap` |
|
||||
| split tool's interior vertices | `_splitClick` → `_snap` |
|
||||
| resize edge drag / corner scale | `_rszMove` → `_snap` |
|
||||
| decor draft endpoints, text anchor | `_decorPointerDown` / `_stagePointerMove` → `_snap` |
|
||||
| decor move | `_decorMoveUpdate` → `_snap` of the resulting ANCHOR |
|
||||
| device markers | `_savePos` |
|
||||
| room labels | `_labelMove` → `_savePos` |
|
||||
| auto-placed markers (`defaultPositions`), the `spaceCenter` fallback, an undragged room label | `snapPt` |
|
||||
|
||||
**WALL-BOUND — projected onto the wall, then the offset ALONG the wall
|
||||
quantised to the same step, measured from the wall's first corner:**
|
||||
|
||||
| Element | Where |
|
||||
| --- | --- |
|
||||
| openings, placed and dragged | `snapToWall(..., { step, length })` |
|
||||
| split tool's points ON a wall | `snapPointAlongPoly` |
|
||||
|
||||
On an axis-aligned wall whose corners are on the grid — every wall the
|
||||
editor itself draws — the two rules give the same point. An opening is
|
||||
also kept inside its wall by half its own length.
|
||||
|
||||
Three things were fixed here besides the new coverage:
|
||||
|
||||
* `_decorMoveUpdate` used to snap the **delta**, which preserves any
|
||||
off-grid offset the shape already had for ever, one step at a time.
|
||||
It snaps the resulting anchor now, so one drag is enough.
|
||||
* `defaultPositions` / `labelPos` / the `spaceCenter` fallback placed
|
||||
auto elements at centroids, which are not nodes for an odd-sized or
|
||||
polygonal room. These were the most likely source of "some elements
|
||||
are between the points" on an untouched plan.
|
||||
* `snapToGrid` and `snapR` now return a value that is already on a node
|
||||
**bit-identical**. The round trip through a non-dyadic pitch
|
||||
(1000/240) otherwise turns an exact `500` into `500.00000000000006`,
|
||||
and "is this on the grid?" starts answering no.
|
||||
|
||||
### 9.4 Shift
|
||||
|
||||
`Shift` suspends the snap for the duration of the gesture, everywhere:
|
||||
`_snap(p, ev)` reads `ev.shiftKey`, `_savePos(..., shift)` takes it, and
|
||||
`snapToWall` is called without `step` when it is held. It keeps its two
|
||||
older meanings too — it opts out of the opening's centre magnet, and it
|
||||
turns the compass to coarse 15° steps.
|
||||
|
||||
### 9.5 «Выровнять всё по сетке» — an ACTION, not a migration
|
||||
|
||||
Existing plans may hold coordinates between the nodes. The card does
|
||||
**not** round them on update. Instead, general settings grow a **Grid**
|
||||
group with one button; it opens a confirmation that states how many
|
||||
elements will move and by how much at most, warns that there is no undo,
|
||||
and only then writes.
|
||||
|
||||
Why an action rather than a silent migration:
|
||||
|
||||
1. It moves the user's data without asking. A house plan is a drawing;
|
||||
the card has no mandate to redraw it on a version bump.
|
||||
2. Some elements are off-grid **on purpose** — a small decor label
|
||||
nudged next to an icon, a window on a diagonal wall, a plan traced
|
||||
over a photo whose scale was never a whole number of cells.
|
||||
3. A silent migration is unattributable. When a room looks 3 cm wrong
|
||||
the owner cannot tell whether the card did it or they did.
|
||||
4. An update that touches stored geometry cannot be rolled back by
|
||||
downgrading the card. An action can simply not be pressed.
|
||||
|
||||
`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it
|
||||
copies its input, never mutates it, and returns the new spaces, the new
|
||||
layout and the report. The dialog therefore measures and commits the
|
||||
**same object** — the numbers it promises cannot differ from what it
|
||||
does. The write is one `config/set` plus the layout updates, in one go.
|
||||
|
||||
Guarantees, all covered by `test/align-grid.test.mjs`:
|
||||
|
||||
* every grid-bound element ends on a node; a rect's FAR corner too (a
|
||||
snapped *size* on an off-grid origin leaves the other side between
|
||||
the nodes);
|
||||
* an opening ends on its wall, at whole steps along it, inside it;
|
||||
* a stray opening with no wall within 6 steps is left exactly where it
|
||||
is rather than teleported;
|
||||
* **idempotent**: a second run reports `moved: 0`, `changed: false`, and
|
||||
returns objects deep-equal to the first run's;
|
||||
* the report's `maxShift` never exceeds half a step diagonally.
|
||||
|
||||
There is no undo, and the dialog says so: the card keeps no snapshot of
|
||||
the previous geometry. Re-running the action does not undo it either —
|
||||
it is a projection, and a projection is not invertible.
|
||||
|
||||
## Every place that assumed the unit square
|
||||
|
||||
|
||||
| Place | Assumption | Decision |
|
||||
| --- | --- | --- |
|
||||
| `contentBounds` envelope `-25 %..125 %` | content outside the square does not count | **removed** — replaced by §4.1 outlier rejection |
|
||||
@@ -321,6 +456,7 @@ fits `all`.
|
||||
| `_stagePointerMove` panned only while `zoom > 1` | below 100% the content already covered the scene, so a drag had nowhere to go | **removed** — §5, panning at every zoom |
|
||||
| `ZOOM_MIN = 0.4` | fraction of the square | `MIN_ZOOM = 1/3` of the content frame (§5) |
|
||||
| `_decorMoveUpdate` clamp `-0.25 .. 1.25` | decor may hang a quarter past the edge | clamp widened to the sane range (`+/-CANVAS_LIMIT`) — corruption insurance, not a frame |
|
||||
| `_pointerMove` clamp to `_baseVb()`, `_labelMove` clamp to `view_box` | a marker/label belongs inside the canvas | **removed** — §9.1; missed in v1.57.0 and reported by the owner |
|
||||
| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card |
|
||||
| `validation.py` `+/-4`, `_EXTENT <= 4`, decor `-1..2`, opening `length <= 1` | the square plus slack | §3 |
|
||||
| `safeViewBox` fallback `[0,0,1,1]` | a broken `view_box` means the square | kept — it is only the last-resort hint (§4) |
|
||||
|
||||
@@ -1113,3 +1113,29 @@ require hands on real hardware — they remain for the human pass.
|
||||
end of the window starts out transparent), and the light fades along the
|
||||
ray, dying out before the far edge. A sun within ~3° of the wall plane
|
||||
(`RAY_MIN_COS`) casts nothing at all [auto: smoke_sun_soft + unit sun.test]
|
||||
- [ ] **Nothing stops at the old canvas border** (owner 2026-08-04, DEV-B58-01:
|
||||
«названия комнат и устройства не перетаскиваются дальше старых границ
|
||||
холста»). On an ORDINARY plan (rooms inside 0..1), in the Devices editor
|
||||
drag a marker far outside the drawing — to about 2.5 / 2.2 normalised. It
|
||||
follows the cursor the whole way, the position is stored, the plan's frame
|
||||
grows to include it, and it is still there after a reload. Repeat with a
|
||||
room NAME in the Plan editor (this one used to stop at the old unit square
|
||||
exactly), with a decor shape in the Background editor (draw it far out,
|
||||
then drag it further), and with an opening on a wall that lives past the
|
||||
old square. The only thing that still stops you is ±5000 — drag wildly and
|
||||
the marker parks there instead of at 1e12 [auto: smoke_drag_bounds]
|
||||
- [ ] **Everything lands on the grid** (owner 2026-08-04, docs/CANVAS.md §9):
|
||||
place a device, a room name, a decor rectangle, a decor text, a room
|
||||
vertex and a resize handle with the mouse — each ends exactly on a grid
|
||||
node, never between two. An opening is the one exception and deliberately
|
||||
so: it stays ON its wall, at a whole number of steps along it. Hold
|
||||
**Shift** while dropping and the element lands where the cursor is,
|
||||
off-grid — that is the only way to get there [auto: smoke_grid_snap]
|
||||
- [ ] **«Выровнять всё по сетке»** (owner 2026-08-04): gear → general settings →
|
||||
**Grid** → the button. On an already tidy plan it says everything is
|
||||
already on the grid and offers no confirm button. On a plan with elements
|
||||
between the nodes it names how many will move and the largest shift in cm,
|
||||
and warns there is no undo. Press it: rooms, decor, markers and room names
|
||||
snap to nodes in ONE write, openings stay on their walls, and pressing the
|
||||
button a second time reports nothing to do. Cancel does nothing at all
|
||||
[auto: smoke_grid_snap + unit test/align-grid.test.mjs]
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* «Выровнять всё по сетке» / «Align everything to the grid» — docs/CANVAS.md §9.
|
||||
*
|
||||
* WHY THIS IS AN ACTION AND NOT A MIGRATION
|
||||
* -----------------------------------------
|
||||
* Every gesture in the editor now lands on the grid, but plans drawn before
|
||||
* that contract existed may hold coordinates between the nodes. The obvious
|
||||
* fix — quietly rounding everything on the next update — is the wrong one:
|
||||
*
|
||||
* 1. It moves the user's data without asking. A house plan is a drawing; the
|
||||
* card has no mandate to redraw it on a version bump.
|
||||
* 2. Some things are off-grid ON PURPOSE — a small decor label nudged next to
|
||||
* an icon, a diagonal wall's window, a plan traced over a photo where the
|
||||
* scale never was a whole number of cells.
|
||||
* 3. A silent migration is unattributable. When a room looks 3 cm wrong the
|
||||
* owner cannot tell whether the card did it or they did.
|
||||
*
|
||||
* So the alignment lives behind an explicit button that first says how many
|
||||
* elements it would move and by how much at most, and only then writes — once,
|
||||
* in a single config+layout operation.
|
||||
*
|
||||
* WALL-BOUND VS GRID-BOUND (the contract this file implements)
|
||||
* -----------------------------------------------------------
|
||||
* Room vertices, decor geometry, device markers and room labels are GRID-BOUND:
|
||||
* they are rounded to the nearest node. Openings are WALL-BOUND: a door that
|
||||
* sits on a grid node but half a metre off its wall is broken geometry, so an
|
||||
* opening is re-projected onto the nearest wall and its offset ALONG that wall
|
||||
* is snapped to the same step. On an axis-aligned wall with grid-aligned
|
||||
* corners the two rules agree exactly.
|
||||
*/
|
||||
|
||||
import { GRID_STEP_N } from './space-geometry';
|
||||
import { snapToWall } from './logic';
|
||||
|
||||
/** Anything closer than this to a node already counts as being on it. */
|
||||
const EPS = GRID_STEP_N * 1e-6;
|
||||
/** How far an opening may be from a wall and still be re-projected onto it. */
|
||||
const WALL_TOL = GRID_STEP_N * 6;
|
||||
|
||||
/** Round a NORMALISED coordinate to the nearest grid node, idempotently.
|
||||
* A value already on a node is returned UNCHANGED (bit for bit), so a second
|
||||
* run of the alignment writes nothing at all. */
|
||||
export function snapN(v: number): number {
|
||||
if (!Number.isFinite(v)) return v;
|
||||
const s = Math.round(v / GRID_STEP_N) * GRID_STEP_N;
|
||||
return Math.abs(s - v) <= EPS ? v : s;
|
||||
}
|
||||
|
||||
export interface AlignReport {
|
||||
/** Elements whose coordinates the run would change. */
|
||||
moved: number;
|
||||
/** Elements examined (rooms, decor shapes, openings, markers, labels). */
|
||||
total: number;
|
||||
/** Largest displacement in NORMALISED units (1 = the plan's width). */
|
||||
maxShift: number;
|
||||
}
|
||||
|
||||
export interface AlignResult {
|
||||
spaces: any[];
|
||||
layout: Record<string, any>;
|
||||
report: AlignReport;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
const dist = (ax: number, ay: number, bx: number, by: number) => Math.hypot(bx - ax, by - ay);
|
||||
|
||||
/**
|
||||
* The whole batch, as a pure function: give it the spaces and the layout, get
|
||||
* back new ones plus the report the confirmation dialog shows. Nothing here
|
||||
* touches the network, so the dialog can call it twice — once to preview, once
|
||||
* to write — and be certain the numbers it promised are the numbers it did.
|
||||
*/
|
||||
export function alignAllToGrid(
|
||||
spacesIn: any[], layoutIn: Record<string, any>,
|
||||
): AlignResult {
|
||||
const spaces = JSON.parse(JSON.stringify(spacesIn || []));
|
||||
const layout: Record<string, any> = JSON.parse(JSON.stringify(layoutIn || {}));
|
||||
let moved = 0, total = 0, maxShift = 0;
|
||||
const note = (d: number) => { if (d > EPS) { moved++; if (d > maxShift) maxShift = d; } };
|
||||
|
||||
for (const sp of spaces) {
|
||||
// ---- rooms: every vertex to the nearest node ----------------------
|
||||
for (const r of sp.rooms || []) {
|
||||
total++;
|
||||
let d = 0;
|
||||
if (r.poly?.length) {
|
||||
r.poly = r.poly.map((p: number[]) => {
|
||||
const q = [snapN(p[0]), snapN(p[1])];
|
||||
d = Math.max(d, dist(p[0], p[1], q[0], q[1]));
|
||||
return q;
|
||||
});
|
||||
} else if (r.x != null && r.y != null) {
|
||||
// a rect keeps its far corner on the grid too, hence w/h are snapped
|
||||
// as corners and not as sizes (a snapped size on an off-grid origin
|
||||
// would leave the other side between the nodes).
|
||||
const x2 = snapN((r.x || 0) + (r.w || 0));
|
||||
const y2 = snapN((r.y || 0) + (r.h || 0));
|
||||
const nx = snapN(r.x), ny = snapN(r.y);
|
||||
d = Math.max(dist(r.x, r.y, nx, ny), dist(r.x + (r.w || 0), r.y + (r.h || 0), x2, y2));
|
||||
r.x = nx; r.y = ny; r.w = Math.max(GRID_STEP_N, x2 - nx); r.h = Math.max(GRID_STEP_N, y2 - ny);
|
||||
}
|
||||
note(d);
|
||||
}
|
||||
|
||||
// ---- decor ---------------------------------------------------------
|
||||
for (const sh of sp.decor || []) {
|
||||
total++;
|
||||
let d = 0;
|
||||
if (sh.kind === 'line') {
|
||||
const a = [snapN(sh.x1), snapN(sh.y1)], b = [snapN(sh.x2), snapN(sh.y2)];
|
||||
d = Math.max(dist(sh.x1, sh.y1, a[0], a[1]), dist(sh.x2, sh.y2, b[0], b[1]));
|
||||
sh.x1 = a[0]; sh.y1 = a[1]; sh.x2 = b[0]; sh.y2 = b[1];
|
||||
} else {
|
||||
const nx = snapN(sh.x), ny = snapN(sh.y);
|
||||
d = dist(sh.x, sh.y, nx, ny);
|
||||
if (sh.w != null && sh.h != null) {
|
||||
const x2 = snapN(sh.x + sh.w), y2 = snapN(sh.y + sh.h);
|
||||
d = Math.max(d, dist(sh.x + sh.w, sh.y + sh.h, x2, y2));
|
||||
sh.w = Math.max(GRID_STEP_N, x2 - nx); sh.h = Math.max(GRID_STEP_N, y2 - ny);
|
||||
}
|
||||
sh.x = nx; sh.y = ny;
|
||||
}
|
||||
note(d);
|
||||
}
|
||||
|
||||
// ---- openings: wall-bound, snapped ALONG the (already aligned) wall --
|
||||
// The very same helper the live editor uses, so the button cannot disagree
|
||||
// with the drag. A stray opening with no wall within WALL_TOL is left
|
||||
// exactly where it is rather than teleported across the plan.
|
||||
for (const o of sp.openings || []) {
|
||||
total++;
|
||||
const q = snapToWall([o.x, o.y], sp.rooms || [], WALL_TOL,
|
||||
{ step: GRID_STEP_N, length: Number(o.length) || 0 });
|
||||
if (!q) continue;
|
||||
const d = dist(o.x, o.y, q.x, q.y);
|
||||
o.x = q.x; o.y = q.y; o.angle = q.angle;
|
||||
note(d);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- layout: device markers AND room labels (rl_<id>) ----------------
|
||||
for (const [k, v] of Object.entries(layout)) {
|
||||
if (!v || typeof v !== 'object') continue;
|
||||
const p: any = v;
|
||||
if (typeof p.x !== 'number' || typeof p.y !== 'number') continue;
|
||||
total++;
|
||||
const nx = snapN(p.x), ny = snapN(p.y);
|
||||
const d = dist(p.x, p.y, nx, ny);
|
||||
layout[k] = { ...p, x: nx, y: ny };
|
||||
note(d);
|
||||
}
|
||||
|
||||
return { spaces, layout, report: { moved, total, maxShift }, changed: moved > 0 };
|
||||
}
|
||||
+224
-43
@@ -15,7 +15,8 @@ import {
|
||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointStrictlyInside, roomsOverlap,
|
||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
|
||||
snapToWall, openingAmount, openingShoulders, interiorPoint, poleOfInaccessibility, subst,
|
||||
snapToWall, snapPointAlongPoly, openingAmount, openingShoulders, interiorPoint,
|
||||
poleOfInaccessibility, subst,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
|
||||
@@ -52,9 +53,11 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import {
|
||||
fitInSquare, contentBounds, spaceModels, contentFrame, contentItems, spaceFrame,
|
||||
spaceCenter, iconUnit, iconCqw, gridLevels, itemOf,
|
||||
MIN_ZOOM, PAN_SLACK, CANVAS_LIMIT, type ContentItem, type Rect,
|
||||
spaceCenter, iconUnit, iconCqw, gridLevels, itemOf, snapPt,
|
||||
MIN_ZOOM, PAN_SLACK, CANVAS_LIMIT, GRID_N, GRID_PITCH, GRID_STEP_N,
|
||||
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
|
||||
} from './space-geometry';
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.57.0';
|
||||
@@ -105,7 +108,6 @@ const unionRect = (a: Rect, b: Rect): Rect => {
|
||||
return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
|
||||
};
|
||||
|
||||
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
|
||||
type MarkupTool = 'draw' | 'merge' | 'split' | 'resize' | 'opening' | 'openwall' | 'delroom';
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
@@ -382,6 +384,13 @@ class HouseplanCard extends LitElement {
|
||||
private _onboardingShown = false; // the auto space dialog is shown once per session
|
||||
|
||||
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
|
||||
/** «Выровнять всё по сетке»: the preview the confirmation shows, plus the
|
||||
* already-computed result so the write cannot differ from the promise. */
|
||||
private _alignDialog: {
|
||||
report: AlignReport; spaces: any[]; layout: Record<string, any>;
|
||||
cm: number; busy: boolean;
|
||||
} | null = null;
|
||||
|
||||
private _settingsDialog: {
|
||||
colors: FillColors; glowRadius: number; bgColor: string | null;
|
||||
/** sun on the plan (docs/SUN.md) */
|
||||
@@ -597,6 +606,7 @@ class HouseplanCard extends LitElement {
|
||||
_infoCard: { state: true },
|
||||
_rulesDialog: { state: true },
|
||||
_settingsDialog: { state: true },
|
||||
_alignDialog: { state: true },
|
||||
_importDialog: { state: true },
|
||||
_markerDialog: { state: true },
|
||||
_zoom: { state: true },
|
||||
@@ -690,6 +700,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._openingInfo) { this._openingInfo = null; return; }
|
||||
if (this._infoCard) { this._infoCard = null; return; }
|
||||
if (this._rulesDialog) { this._rulesDialog = null; return; }
|
||||
if (this._alignDialog) { this._alignDialog = null; return; }
|
||||
if (this._settingsDialog) { this._settingsDialog = null; return; }
|
||||
if (this._markerDialog) { this._markerDialog = null; return; }
|
||||
if (this._openingDialog) { this._openingDialog = null; return; }
|
||||
@@ -1516,7 +1527,9 @@ class HouseplanCard extends LitElement {
|
||||
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
|
||||
}));
|
||||
declump(pts, b, minDist, pad * 0.5);
|
||||
ds.forEach((d, i) => (map[d.id] = pts[i]));
|
||||
// an auto-placed icon lands on a node too (docs/CANVAS.md §9) — the
|
||||
// owner's "everything on the grid" covers what the card places itself
|
||||
ds.forEach((d, i) => (map[d.id] = snapPt(pts[i])));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
@@ -1537,20 +1550,23 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
if (this._defPos[d.id]) return this._defPos[d.id];
|
||||
// the middle of what IS drawn, not of a canvas that has no edges any more
|
||||
return spaceCenter(s);
|
||||
return snapPt(spaceCenter(s));
|
||||
}
|
||||
|
||||
private _savePos(d: DevItem, x: number, y: number): void {
|
||||
private _savePos(d: DevItem, x: number, y: number, shift = false): void {
|
||||
if (this._norm) {
|
||||
// the icon center snaps to the nodes of the same grid as the room markup
|
||||
// The icon center snaps to the nodes of the same grid as the room markup
|
||||
// (docs/CANVAS.md §9). Shift suspends the snap for this one gesture —
|
||||
// the same convention as the opening magnet and the compass.
|
||||
const g = this._gridPitch;
|
||||
const gx = Math.round(x / g) * g;
|
||||
const gy = Math.round(y / g) * g;
|
||||
const gx = shift ? x : Math.round(x / g) * g;
|
||||
const gy = shift ? y : Math.round(y / g) * g;
|
||||
|
||||
const prevK = (this._layout[d.id] as any)?.k;
|
||||
this._layout = {
|
||||
...this._layout,
|
||||
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / NORM_W, ...(prevK ? { k: prevK } : {}) },
|
||||
[d.id]: { s: d.space, x: clampCanvasN(gx / NORM_W), y: clampCanvasN(gy / NORM_W),
|
||||
...(prevK ? { k: prevK } : {}) },
|
||||
};
|
||||
} else {
|
||||
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
|
||||
@@ -2296,7 +2312,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _stagePointerMove(ev: PointerEvent): void {
|
||||
if (this._decorDraft?.pid === ev.pointerId) {
|
||||
this._decorDraft = { ...this._decorDraft, b: this._snap(this._svgPoint(ev)) };
|
||||
this._decorDraft = { ...this._decorDraft, b: this._snap(this._svgPoint(ev), ev) };
|
||||
return;
|
||||
}
|
||||
if (this._decorMove?.pid === ev.pointerId) {
|
||||
@@ -2450,10 +2466,16 @@ class HouseplanCard extends LitElement {
|
||||
this._drag.moved = true;
|
||||
clearTimeout(this._holdTimer);
|
||||
}
|
||||
const m = Math.min(vb[2], vb[3]) * 0.008;
|
||||
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
|
||||
const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, this._drag.oy + dy));
|
||||
this._savePos(d, nx, ny);
|
||||
// DEV-B58-01. This used to be clamped into `vb` — the CONTENT FRAME — with
|
||||
// a 0.8 % margin, which is exactly the "old canvas border" the owner ran
|
||||
// into: a marker could never be dragged past the outline of whatever was
|
||||
// already drawn, so a plan could not be extended by moving a device out to
|
||||
// where the new room was going to be. The plan has no edges any more
|
||||
// (docs/CANVAS.md §9); the only bound is the garbage limit the backend
|
||||
// enforces, and it is the SAME ±5000 on both sides of the wire.
|
||||
const nx = clampCanvasR(this._drag.ox + dx);
|
||||
const ny = clampCanvasR(this._drag.oy + dy);
|
||||
this._savePos(d, nx, ny, ev.shiftKey);
|
||||
}
|
||||
|
||||
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
|
||||
@@ -2511,7 +2533,11 @@ class HouseplanCard extends LitElement {
|
||||
// ================= ROOM MARKUP EDITOR =================
|
||||
|
||||
private get _gridPitch(): number {
|
||||
return NORM_W / GRID_N;
|
||||
// NORM_W / GRID_N and nothing else. The infinite canvas did NOT touch this
|
||||
// (docs/CANVAS.md §9): the step is the same for every plan, at every zoom,
|
||||
// whatever the content frame happens to be — so no existing plan's nodes
|
||||
// ever moved out from under it.
|
||||
return GRID_PITCH;
|
||||
}
|
||||
|
||||
/** cm represented by one grid cell for the current space (default 5). */
|
||||
@@ -2658,9 +2684,20 @@ class HouseplanCard extends LitElement {
|
||||
return this._screenToVb(ev.clientX - r.left, ev.clientY - r.top);
|
||||
}
|
||||
|
||||
private _snap(p: number[]): number[] {
|
||||
/**
|
||||
* THE snap (docs/CANVAS.md §9). Every editor gesture that produces a plan
|
||||
* coordinate goes through here, so "strictly on the grid" is one function
|
||||
* and not a habit. `shift` (the event, or a bare flag) suspends it for the
|
||||
* duration of the gesture — the same escape hatch the opening magnet and the
|
||||
* compass already offered. The canvas clamp rides along: there are no edges
|
||||
* to bump into any more, only the ±5000 the backend refuses to store.
|
||||
*/
|
||||
private _snap(p: number[], shift: boolean | { shiftKey?: boolean } = false): number[] {
|
||||
const off = typeof shift === 'boolean' ? shift : !!shift?.shiftKey;
|
||||
const g = this._gridPitch;
|
||||
return [snapToGrid(p[0], g), snapToGrid(p[1], g)];
|
||||
return off
|
||||
? [clampCanvasR(p[0]), clampCanvasR(p[1])]
|
||||
: [clampCanvasR(snapToGrid(p[0], g)), clampCanvasR(snapToGrid(p[1], g))];
|
||||
}
|
||||
|
||||
private _samePt(a: number[], b: number[]): boolean {
|
||||
@@ -2811,12 +2848,12 @@ class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
if (this._tool === 'split') {
|
||||
this._splitClick(raw);
|
||||
this._splitClick(raw, ev.shiftKey);
|
||||
return;
|
||||
}
|
||||
// draw: clicks on grid points build the outline. Nothing is written to the config
|
||||
// until the contour closes — an abandoned outline leaves no lines behind.
|
||||
const pt = this._snap(raw);
|
||||
const pt = this._snap(raw, ev);
|
||||
const closing = this._path.length >= 3 && this._samePt(pt, this._path[0]);
|
||||
// Island rooms (v1.34.0): drawing INSIDE an existing room is legal — the
|
||||
// contour may become a nested room (a column, an inner room). Partial
|
||||
@@ -2956,7 +2993,7 @@ class HouseplanCard extends LitElement {
|
||||
const plan = g.plan!;
|
||||
const dRaw = (p[0] - plan.a[0]) * plan.n[0] + (p[1] - plan.a[1]) * plan.n[1];
|
||||
// the moved wall LINE lands on the grid, like every drawn wall
|
||||
const sn = this._snap([plan.a[0] + plan.n[0] * dRaw, plan.a[1] + plan.n[1] * dRaw]);
|
||||
const sn = this._snap([plan.a[0] + plan.n[0] * dRaw, plan.a[1] + plan.n[1] * dRaw], ev);
|
||||
let d = (sn[0] - plan.a[0]) * plan.n[0] + (sn[1] - plan.a[1]) * plan.n[1];
|
||||
d = clampEdgeDrag(g.rooms, g.openings, plan, d, this._gridPitch, this._rszOpts());
|
||||
if (d === g.d && g.moved) return;
|
||||
@@ -2968,7 +3005,7 @@ class HouseplanCard extends LitElement {
|
||||
this._rszLive = this._rszEdgeLabels(res, plan);
|
||||
} else {
|
||||
const fixed = g.fixed!;
|
||||
const sn = this._snap(p); // the dragged corner aims at grid nodes
|
||||
const sn = this._snap(p, ev); // the dragged corner aims at grid nodes
|
||||
let k = Math.hypot(sn[0] - fixed[0], sn[1] - fixed[1]) / (g.span0 || 1);
|
||||
k = Math.max(0.05, Math.min(20, k));
|
||||
k = clampRoomScale(g.rooms, g.openings, g.roomId, fixed, k, this._rszOpts());
|
||||
@@ -3169,15 +3206,15 @@ class HouseplanCard extends LitElement {
|
||||
if (onShape) return true; // the shape's own handler deals with it
|
||||
if (t === 'line' || t === 'rect' || t === 'ellipse') {
|
||||
ev.preventDefault();
|
||||
const p = this._snap(this._svgPoint(ev));
|
||||
const p = this._snap(this._svgPoint(ev), ev);
|
||||
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
|
||||
capturePointer(ev);
|
||||
return true;
|
||||
}
|
||||
if (t === 'text') {
|
||||
const p = this._snap(this._svgPoint(ev));
|
||||
const p = this._snap(this._svgPoint(ev), ev);
|
||||
this._decorTextDialog = {
|
||||
x: p[0] / NORM_W, y: p[1] / this._decorH,
|
||||
x: clampCanvasN(p[0] / NORM_W), y: clampCanvasN(p[1] / this._decorH),
|
||||
text: '', size: 'm', color: this._decorStyle.color,
|
||||
};
|
||||
return true;
|
||||
@@ -3196,12 +3233,16 @@ class HouseplanCard extends LitElement {
|
||||
const W = NORM_W, H = this._decorH;
|
||||
const st = this._decorStyle;
|
||||
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||||
// creation had no canvas guard at all while the MOVE did — a draft could be
|
||||
// born outside the range the mover then refused to leave. One limit, both
|
||||
// ends of the gesture (docs/CANVAS.md §9).
|
||||
const cn = clampCanvasN;
|
||||
let shape: any;
|
||||
if (d.kind === 'line') {
|
||||
shape = { id, kind: 'line', x1: d.a[0] / W, y1: d.a[1] / H, x2: d.b[0] / W, y2: d.b[1] / H,
|
||||
color: st.color, width: st.width };
|
||||
shape = { id, kind: 'line', x1: cn(d.a[0] / W), y1: cn(d.a[1] / H),
|
||||
x2: cn(d.b[0] / W), y2: cn(d.b[1] / H), color: st.color, width: st.width };
|
||||
} else {
|
||||
const x = Math.min(d.a[0], d.b[0]) / W, y = Math.min(d.a[1], d.b[1]) / H;
|
||||
const x = cn(Math.min(d.a[0], d.b[0]) / W), y = cn(Math.min(d.a[1], d.b[1]) / H);
|
||||
const w = Math.abs(d.b[0] - d.a[0]) / W, h = Math.abs(d.b[1] - d.a[1]) / H;
|
||||
shape = { id, kind: d.kind, x, y, w, h, color: st.color, width: st.width, fill: st.fill };
|
||||
}
|
||||
@@ -3237,9 +3278,17 @@ class HouseplanCard extends LitElement {
|
||||
private _decorMoveUpdate(ev: PointerEvent): void {
|
||||
const m = this._decorMove!;
|
||||
const p = this._svgPoint(ev);
|
||||
const g = this._gridPitch;
|
||||
let dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
||||
let dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
||||
const o0 = m.orig;
|
||||
// The delta used to be what got snapped, which preserves whatever off-grid
|
||||
// offset the shape already had: a legacy shape at 0.3013 stayed at 0.3013
|
||||
// for ever, one step at a time. Snap the RESULTING ANCHOR instead, so one
|
||||
// drag is enough to put any shape on the grid (docs/CANVAS.md §9). Shift
|
||||
// suspends it, as everywhere else.
|
||||
const ax0 = (o0.kind === 'line' ? o0.x1 : o0.x) * NORM_W;
|
||||
const ay0 = (o0.kind === 'line' ? o0.y1 : o0.y) * this._decorH;
|
||||
const anchor = this._snap([ax0 + (p[0] - m.start[0]), ay0 + (p[1] - m.start[1])], ev);
|
||||
let dx = (anchor[0] - ax0) / NORM_W;
|
||||
let dy = (anchor[1] - ay0) / this._decorH;
|
||||
// audit follow-up L4 gave decor a bounds clamp of -0.25..1.25 — the plan
|
||||
// was a sheet with edges then. It is not any more (docs/CANVAS.md): the
|
||||
// clamp is now the same garbage limit the backend enforces, so decor can
|
||||
@@ -3629,6 +3678,24 @@ class HouseplanCard extends LitElement {
|
||||
if (sh && sh.centered && !shift && (cx !== sh.wallCenter[0] || cy !== sh.wallCenter[1])) {
|
||||
[cx, cy] = sh.wallCenter;
|
||||
sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol);
|
||||
} else if (sh && !shift) {
|
||||
// Not centred: quantise the offset ALONG the wall to the grid step
|
||||
// (docs/CANVAS.md §9.3). Grid-BOUND would lift the opening off a diagonal
|
||||
// wall, so the wall stays the master and the grid only says WHERE on it.
|
||||
// The magnet is consulted first on purpose — a wall whose middle is not a
|
||||
// node must still be able to hold a centred window.
|
||||
const [ax, ay] = sh.wallA, [bx, by] = sh.wallB;
|
||||
const wx = bx - ax, wy = by - ay;
|
||||
const len = Math.hypot(wx, wy);
|
||||
if (len > 0) {
|
||||
const g = this._gridPitch;
|
||||
const half = Math.min(rlen / 2, len / 2);
|
||||
let along = Math.round((((cx - ax) * wx + (cy - ay) * wy) / len) / g) * g;
|
||||
along = Math.max(half, Math.min(len - half, along));
|
||||
cx = ax + (along / len) * wx;
|
||||
cy = ay + (along / len) * wy;
|
||||
sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol) || sh;
|
||||
}
|
||||
}
|
||||
if (!sh) return { x: cx, y: cy, angle: snap.angle, measure: null };
|
||||
const imperial = this.hass?.config?.unit_system?.length === 'mi';
|
||||
@@ -3777,7 +3844,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Split: click the room, then two points on its walls. */
|
||||
private _splitClick(raw: number[]): void {
|
||||
private _splitClick(raw: number[], shift = false): void {
|
||||
const rooms = this._spaceModel().rooms;
|
||||
if (!this._splitSel) {
|
||||
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||||
@@ -3797,10 +3864,14 @@ class HouseplanCard extends LitElement {
|
||||
// any wall (e.g. an accidental one in the middle of the room) is a miss and
|
||||
// gets the toast, not a wall the user never meant. splitRoom() still rejects
|
||||
// any cut that is not a clean wall-to-wall chord.
|
||||
// …and it is still QUANTISED: the offset ALONG the wall moves in whole grid
|
||||
// steps (docs/CANVAS.md §9), which on the axis-aligned, grid-drawn walls the
|
||||
// editor itself makes IS a grid node. Shift opts out, as everywhere else.
|
||||
const eps = this._gridPitch * 0.02;
|
||||
const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
|
||||
const near = closestPointOnBoundary(raw, poly);
|
||||
const wallPt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
|
||||
const raw0 = closestPointOnBoundary(raw, poly);
|
||||
const near = raw0 && !shift ? (snapPointAlongPoly(raw0, poly, this._gridPitch) || raw0) : raw0;
|
||||
const wallPt = raw0 && near && Math.hypot(raw0[0] - raw[0], raw0[1] - raw[1]) <= pull ? near : null;
|
||||
const onWall = !!wallPt && pointOnBoundary(wallPt, poly, eps);
|
||||
const cur = this._splitSel.pts;
|
||||
if (!cur.length) {
|
||||
@@ -3814,7 +3885,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
if (!onWall) {
|
||||
// an interior click adds an intermediate vertex of the cut path
|
||||
const mid = this._snap(raw);
|
||||
const mid = this._snap(raw, shift);
|
||||
if (!ptInside(mid, poly, eps)) {
|
||||
this._showToast(this._t('toast.split_pick_inside'));
|
||||
return;
|
||||
@@ -3855,7 +3926,7 @@ class HouseplanCard extends LitElement {
|
||||
const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed;
|
||||
const cutting = this._tool === 'split' && !!this._splitSel?.pts?.length;
|
||||
if (!drawing && !cutting) return;
|
||||
this._cursorPt = this._snap(this._svgPoint(ev));
|
||||
this._cursorPt = this._snap(this._svgPoint(ev), ev);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3873,9 +3944,9 @@ class HouseplanCard extends LitElement {
|
||||
(o) => Math.hypot(raw[0] - o.rx, raw[1] - o.ry) <= Math.max(o.rlen / 2, eps),
|
||||
);
|
||||
if (hit) return null;
|
||||
const rlen = this._cmToUnits(OPENING_DEFAULT_CM);
|
||||
const snap = snapToWall(raw, this._spaceModel().rooms, eps);
|
||||
if (!snap) return null;
|
||||
const rlen = this._cmToUnits(OPENING_DEFAULT_CM);
|
||||
const r = this._opRuler(snap, rlen, this._opShift);
|
||||
return { x: r.x, y: r.y, angle: r.angle, rlen, measure: r.measure };
|
||||
}
|
||||
@@ -5140,6 +5211,70 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Preview the batch alignment (docs/CANVAS.md §9). Nothing is written here:
|
||||
* the run is pure, so the dialog can show the exact count and the exact
|
||||
* largest shift, and then commit the very object it measured.
|
||||
*/
|
||||
private _openAlignDialog = (): void => {
|
||||
if (!this._norm || !this._serverCfg) return;
|
||||
const r = alignAllToGrid(this._serverCfg.spaces || [], this._layout || {});
|
||||
// the shift in the user's own units: normalised → cells → cm, using the
|
||||
// scale of the first space that has one (they rarely differ, and the
|
||||
// number is an ORDER of magnitude, not a measurement)
|
||||
const cellCm = Number((this._serverCfg.spaces || []).find((x: any) => Number(x?.cell_cm) > 0)?.cell_cm) || 5;
|
||||
const cm = r.report.maxShift * GRID_N * cellCm;
|
||||
this._alignDialog = { report: r.report, spaces: r.spaces, layout: r.layout, cm, busy: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* Commit it: ONE config write and ONE layout write, so a plan that ends up
|
||||
* wrong ends up wrong exactly once and the previous state is a single undo
|
||||
* away — by re-running nothing, because there is no undo. The dialog says so.
|
||||
*/
|
||||
private async _runAlignToGrid(): Promise<void> {
|
||||
const d = this._alignDialog;
|
||||
if (!d || d.busy || !this._serverCfg) return;
|
||||
this._alignDialog = { ...d, busy: true };
|
||||
try {
|
||||
this._serverCfg = { ...this._serverCfg, spaces: d.spaces };
|
||||
this._layout = d.layout;
|
||||
for (const id of Object.keys(d.layout)) this._dirtyPos.add(id);
|
||||
this._modelCache = null;
|
||||
this._frame = null;
|
||||
await this._saveConfigNow();
|
||||
await this._persistLayoutNow();
|
||||
this._alignDialog = null;
|
||||
this.requestUpdate();
|
||||
this._showToast(this._t('gs.align_done', { n: String(d.report.moved) }));
|
||||
} catch (e: any) {
|
||||
if (this._alignDialog) this._alignDialog = { ...this._alignDialog, busy: false };
|
||||
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
/** The debounced layout writer, awaited — the batch must not return before
|
||||
* the positions it promised are actually on their way. */
|
||||
private async _persistLayoutNow(): Promise<void> {
|
||||
if (!this._serverStorage) {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
|
||||
this._dirtyPos.clear();
|
||||
return;
|
||||
}
|
||||
const ids = [...this._dirtyPos];
|
||||
this._dirtyPos.clear();
|
||||
await Promise.all(ids.map((id) => {
|
||||
const pos = this._layout[id];
|
||||
if (!pos) return Promise.resolve();
|
||||
this._sentPos.set(id, pos);
|
||||
return this.hass
|
||||
.callWS({ type: 'houseplan/layout/update', device_id: id, pos })
|
||||
.then((r: any) => this._noteLayoutRev(r))
|
||||
.finally(() => { if (this._sentPos.get(id) === pos) this._sentPos.delete(id); });
|
||||
}));
|
||||
this._cacheSnapshot();
|
||||
}
|
||||
|
||||
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
|
||||
const d = this._settingsDialog!;
|
||||
this._settingsDialog = { ...d, colors: { ...d.colors, [key]: { ...d.colors[key], ...patch } } };
|
||||
@@ -5319,6 +5454,40 @@ class HouseplanCard extends LitElement {
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmation. It states the two numbers the user needs to decide —
|
||||
* HOW MANY elements move and by HOW MUCH at most — and it says plainly that
|
||||
* there is no undo, because there is not: the action is a single batch write
|
||||
* and the card keeps no snapshot of what the plan looked like before.
|
||||
*/
|
||||
private _renderAlignDialog(): TemplateResult {
|
||||
const d = this._alignDialog!;
|
||||
const r = d.report;
|
||||
return html`<div class="menuwrap dialogwrap" @click=${() => (this._alignDialog = null)}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:grid"></ha-icon>${this._t('gs.align_title')}</div>
|
||||
<div class="body">
|
||||
${r.moved === 0
|
||||
? html`<p class="alignmsg">${this._t('gs.align_none')}</p>`
|
||||
: html`
|
||||
<p class="alignmsg">${this._t('gs.align_count', {
|
||||
n: String(r.moved), total: String(r.total),
|
||||
cm: (Math.round(d.cm * 10) / 10).toString(),
|
||||
})}</p>
|
||||
<div class="rhint">${this._t('gs.align_warn')}</div>`}
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._alignDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
${r.moved === 0 ? nothing : html`
|
||||
<button class="btn on" @click=${this._runAlignToGrid} ?disabled=${d.busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('gs.align_run')}
|
||||
</button>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderSettingsDialog(): TemplateResult {
|
||||
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
|
||||
@@ -5419,6 +5588,13 @@ class HouseplanCard extends LitElement {
|
||||
)}
|
||||
</datalist>
|
||||
</div>
|
||||
<label class="dispsection">${this._t('gs.grid_group')}</label>
|
||||
<div class="rhint">${this._t('gs.grid_hint')}</div>
|
||||
<div class="colorrow gsrow">
|
||||
<button class="btn ghost alignall" @click=${this._openAlignDialog}>
|
||||
<ha-icon icon="mdi:grid"></ha-icon>${this._t('gs.align_all')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="dispsection">${this._t('gs.about_group')}</label>
|
||||
<div class="aboutver">${this._t('gs.about_version', { v: CARD_VERSION })}</div>
|
||||
<a class="aboutlink" href="https://github.com/Matysh/houseplan-card" target="_blank" rel="noopener">
|
||||
@@ -5905,6 +6081,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._infoCard ? this._renderInfoCard() : nothing}
|
||||
${this._rulesDialog ? this._renderRulesDialog() : nothing}
|
||||
${this._settingsDialog ? this._renderSettingsDialog() : nothing}
|
||||
${this._alignDialog ? this._renderAlignDialog() : nothing}
|
||||
${this._importDialog ? this._renderImportDialog() : nothing}
|
||||
${this._tip
|
||||
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
|
||||
@@ -6692,7 +6869,9 @@ class HouseplanCard extends LitElement {
|
||||
if (saved && saved.s === spaceId) {
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
const c = this._roomCenter(r);
|
||||
// a label nobody has dragged sits at the room's CENTROID, which is not a
|
||||
// node for an odd-sized or polygonal room — put it on the nearest one
|
||||
const c = this._snap(this._roomCenter(r));
|
||||
return { x: c[0], y: c[1] };
|
||||
}
|
||||
|
||||
@@ -6718,10 +6897,12 @@ class HouseplanCard extends LitElement {
|
||||
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
|
||||
const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h;
|
||||
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true;
|
||||
const m = Math.min(vb[2], vb[3]) * 0.008;
|
||||
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
|
||||
const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, this._drag.oy + dy));
|
||||
this._savePos({ id, space: spaceId } as DevItem, nx, ny);
|
||||
// DEV-B58-01, and worse than the marker's: this clamped to the space's
|
||||
// STORED view_box, which for every existing plan is the old unit square.
|
||||
// A room drawn at 2.5 had a name that could not be dragged to its own room.
|
||||
const nx = clampCanvasR(this._drag.ox + dx);
|
||||
const ny = clampCanvasR(this._drag.oy + dy);
|
||||
this._savePos({ id, space: spaceId } as DevItem, nx, ny, ev.shiftKey);
|
||||
}
|
||||
|
||||
private _labelUp(r: RoomCfg): void {
|
||||
|
||||
+10
-1
@@ -418,5 +418,14 @@
|
||||
"space.sun_off": "Off",
|
||||
"canvas.far_objects": "{n} object(s) far from the plan",
|
||||
"canvas.show_far": "Show",
|
||||
"canvas.home_tip": "The plan is over there — click to fit it"
|
||||
"canvas.home_tip": "The plan is over there — click to fit it",
|
||||
"gs.grid_group": "Grid",
|
||||
"gs.grid_hint": "Everything you draw or drag now lands on the grid. Hold Shift to place something between the nodes on purpose.",
|
||||
"gs.align_all": "Align everything to the grid",
|
||||
"gs.align_title": "Align everything to the grid",
|
||||
"gs.align_none": "Everything is already on the grid — nothing to move.",
|
||||
"gs.align_count": "{n} of {total} elements will move, by at most {cm} cm.",
|
||||
"gs.align_warn": "This rewrites the plan and the device positions in one go. There is no undo: the only way back is to move things by hand. If some elements are deliberately between the nodes, cancel.",
|
||||
"gs.align_run": "Align",
|
||||
"gs.align_done": "Aligned to the grid: {n} elements moved"
|
||||
}
|
||||
|
||||
+10
-1
@@ -418,5 +418,14 @@
|
||||
"space.sun_off": "Выкл",
|
||||
"canvas.far_objects": "Объектов далеко от плана: {n}",
|
||||
"canvas.show_far": "Показать",
|
||||
"canvas.home_tip": "План там — нажмите, чтобы вписать"
|
||||
"canvas.home_tip": "План там — нажмите, чтобы вписать",
|
||||
"gs.grid_group": "Сетка",
|
||||
"gs.grid_hint": "Всё, что вы рисуете и перетаскиваете, ложится строго на сетку. Shift — временно поставить элемент между узлами.",
|
||||
"gs.align_all": "Выровнять всё по сетке",
|
||||
"gs.align_title": "Выровнять всё по сетке",
|
||||
"gs.align_none": "Всё уже на сетке — двигать нечего.",
|
||||
"gs.align_count": "Сдвинется элементов: {n} из {total}, максимум на {cm} см.",
|
||||
"gs.align_warn": "Действие разом перезапишет план и позиции устройств. Отмены нет: вернуть можно только руками. Если какие-то элементы стоят между узлами намеренно — отмените.",
|
||||
"gs.align_run": "Выровнять",
|
||||
"gs.align_done": "Выровнено по сетке: сдвинуто элементов — {n}"
|
||||
}
|
||||
|
||||
+62
-4
@@ -9,9 +9,14 @@ export function lqiColor(lqi: number): string {
|
||||
return `hsl(${Math.round(hue)}, 85%, 55%)`;
|
||||
}
|
||||
|
||||
/** Snap a coordinate to the nearest grid node with step pitch. */
|
||||
/** Snap a coordinate to the nearest grid node with step pitch.
|
||||
* A value already ON a node comes back bit-identical: the round-trip through
|
||||
* a non-dyadic pitch (1000/240) otherwise turns an exact 500 into
|
||||
* 500.00000000000006, and "is it on the grid?" starts answering no. */
|
||||
export function snapToGrid(v: number, pitch: number): number {
|
||||
return Math.round(v / pitch) * pitch;
|
||||
if (!Number.isFinite(v) || !(pitch > 0)) return v;
|
||||
const q = Math.round(v / pitch) * pitch;
|
||||
return Math.abs(q - v) <= pitch * 1e-9 ? v : q;
|
||||
}
|
||||
|
||||
/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
|
||||
@@ -111,8 +116,15 @@ export function roomEdges(rooms: any[]): number[][] {
|
||||
* placed with this stays valid however rooms are later edited — it keeps absolute
|
||||
* coordinates and is not tied to a room id or edge index.
|
||||
*/
|
||||
export interface WallSnapOpts {
|
||||
/** Quantise the offset along the wall to this step (render or config units). */
|
||||
step?: number;
|
||||
/** Length of the thing being placed, so it is kept inside its wall. */
|
||||
length?: number;
|
||||
}
|
||||
|
||||
export function snapToWall(
|
||||
p: number[], rooms: any[], maxDist: number,
|
||||
p: number[], rooms: any[], maxDist: number, opts: WallSnapOpts = {},
|
||||
): { x: number; y: number; angle: number } | null {
|
||||
let best: { x: number; y: number; angle: number } | null = null;
|
||||
let bestD = maxDist;
|
||||
@@ -133,12 +145,58 @@ export function snapToWall(
|
||||
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
if (angle >= 90) angle -= 180;
|
||||
else if (angle < -90) angle += 180;
|
||||
best = { x: q[0], y: q[1], angle };
|
||||
// docs/CANVAS.md §9: an opening is WALL-bound, so it cannot be rounded to
|
||||
// a grid node — that would lift it off a diagonal wall. What IS quantised
|
||||
// is its offset ALONG the wall, in the same step, measured from the
|
||||
// wall's first corner. On an axis-aligned wall whose corners are on the
|
||||
// grid (i.e. every wall the editor itself draws) the two agree exactly.
|
||||
if (opts.step && opts.step > 0) {
|
||||
const len = Math.sqrt(len2);
|
||||
const half = Math.min(Math.max(opts.length || 0, 0) / 2, len / 2);
|
||||
let along = Math.round((t * len) / opts.step) * opts.step;
|
||||
// the wall's own CENTRE wins inside one step of it: that is the editor's
|
||||
// magnet (docs/CANVAS.md §9.3), and a batch alignment must not knock a
|
||||
// deliberately centred window off centre by a couple of centimetres
|
||||
if (Math.abs(t * len - len / 2) <= opts.step / 2) along = len / 2;
|
||||
along = Math.max(half, Math.min(len - half, along));
|
||||
const u = along / len;
|
||||
best = { x: x1 + u * dx, y: y1 + u * dy, angle };
|
||||
} else {
|
||||
best = { x: q[0], y: q[1], angle };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Snap the point to the nearest wall EDGE of a single polygon, quantising the
|
||||
* offset along that edge — the split tool's flavour of the same contract. */
|
||||
export function snapPointAlongPoly(
|
||||
p: number[], poly: number[][], step: number,
|
||||
): number[] | null {
|
||||
let best: number[] | null = null;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const [x1, y1] = poly[i];
|
||||
const [x2, y2] = poly[(i + 1) % poly.length];
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!len2) continue;
|
||||
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const d = Math.hypot(p[0] - (x1 + t * dx), p[1] - (y1 + t * dy));
|
||||
if (d >= bestD) continue;
|
||||
bestD = d;
|
||||
const len = Math.sqrt(len2);
|
||||
const along = step > 0
|
||||
? Math.max(0, Math.min(len, Math.round((t * len) / step) * step))
|
||||
: t * len;
|
||||
const u = along / len;
|
||||
best = [x1 + u * dx, y1 + u * dy];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface OpeningShoulders {
|
||||
/** Endpoints of the wall edge — the ONE room-polygon edge the opening sits on. */
|
||||
wallA: number[];
|
||||
|
||||
+39
-3
@@ -94,6 +94,41 @@ export const CANVAS_LIMIT = 5000;
|
||||
/** The same range in RENDER units. */
|
||||
export const SANE_LIMIT = CANVAS_LIMIT * NORM_W;
|
||||
|
||||
/** Grid points across the plan width — the lattice the editor snaps to.
|
||||
* It is derived from NORM_W alone, so it is the SAME step for every plan and
|
||||
* it did NOT change when the canvas became infinite (docs/CANVAS.md §9). */
|
||||
export const GRID_N = 240;
|
||||
/** One grid step in RENDER units. */
|
||||
export const GRID_PITCH = NORM_W / GRID_N;
|
||||
/** One grid step in NORMALISED units — what the config and the layout store. */
|
||||
export const GRID_STEP_N = 1 / GRID_N;
|
||||
|
||||
/** Snap a RENDER-unit coordinate to the editor's grid (docs/CANVAS.md §9). */
|
||||
export function snapR(v: number): number {
|
||||
if (!Number.isFinite(v)) return v;
|
||||
// integer node index first, then back — dividing by 1000/240 directly turns
|
||||
// an exact 500 into 500.00000000000006 and every equality downstream lies
|
||||
const q = (Math.round((v * GRID_N) / NORM_W) * NORM_W) / GRID_N;
|
||||
return Math.abs(q - v) <= GRID_PITCH * 1e-9 ? v : q;
|
||||
}
|
||||
/** …and a point. Used for AUTO placements (a device with no saved position, a
|
||||
* room label nobody has dragged) so that "everything is on the grid" holds for
|
||||
* what the card puts there itself, not only for what the user drags. */
|
||||
export function snapPt(p: { x: number; y: number }): { x: number; y: number } {
|
||||
return { x: snapR(p.x), y: snapR(p.y) };
|
||||
}
|
||||
|
||||
/** Clamp a RENDER-unit coordinate to the sane canvas range (docs/CANVAS.md §9).
|
||||
* This is the ONLY bound any editor gesture may impose: the plan has no edges
|
||||
* any more, only a garbage limit that mirrors validation.py. */
|
||||
export function clampCanvasR(v: number): number {
|
||||
return Number.isFinite(v) ? Math.min(SANE_LIMIT, Math.max(-SANE_LIMIT, v)) : 0;
|
||||
}
|
||||
/** The same range in NORMALISED units. */
|
||||
export function clampCanvasN(v: number): number {
|
||||
return Number.isFinite(v) ? Math.min(CANVAS_LIMIT, Math.max(-CANVAS_LIMIT, v)) : 0;
|
||||
}
|
||||
|
||||
/** Zoom-out floor: three times the content frame and no further (CANVAS.md §5). */
|
||||
export const MIN_ZOOM = 1 / 3;
|
||||
/** How far past the content frame panning may go, in screens (CANVAS.md §5). */
|
||||
@@ -401,7 +436,7 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
|
||||
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
|
||||
}));
|
||||
declump(pts, b, minDist, pad * 0.5);
|
||||
ds.forEach((d, i) => (map[d.id] = pts[i]));
|
||||
ds.forEach((d, i) => (map[d.id] = snapPt(pts[i])));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -415,7 +450,7 @@ export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos:
|
||||
if (defPos[d.id]) return defPos[d.id];
|
||||
// no saved position, no room to auto-place in: the middle of what IS drawn,
|
||||
// not the middle of a canvas that no longer has edges (docs/CANVAS.md)
|
||||
return spaceCenter(model);
|
||||
return snapPt(spaceCenter(model));
|
||||
}
|
||||
|
||||
/** Saved room-label position (layout key rl_<roomId>) or the room centre. */
|
||||
@@ -424,6 +459,7 @@ export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: Serve
|
||||
if (saved && saved.s === spaceId) {
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
// never dragged: the centroid, put on the nearest node (docs/CANVAS.md §9)
|
||||
const c = roomCenter(r);
|
||||
return { x: c[0], y: c[1] };
|
||||
return snapPt({ x: c[0], y: c[1] });
|
||||
}
|
||||
|
||||
@@ -2002,6 +2002,8 @@ export const cardStyles = css`
|
||||
font-size: var(--fs-m);
|
||||
color: var(--hp-muted);
|
||||
}
|
||||
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
|
||||
.btn.alignall { width: 100%; justify-content: center; }
|
||||
.aboutver {
|
||||
font-size: var(--fs-s);
|
||||
color: var(--hp-muted);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// «Выровнять всё по сетке» (docs/CANVAS.md §9) — the batch that the general
|
||||
// settings offer as an explicit action, never as a silent migration.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { alignAllToGrid, snapN } from '../test-build/align-grid.js';
|
||||
import { GRID_N, GRID_STEP_N } from '../test-build/space-geometry.js';
|
||||
|
||||
const S = GRID_STEP_N; // 1/240 of the plan width
|
||||
const onGrid = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
|
||||
|
||||
/** A plan deliberately knocked off the grid in every direction. */
|
||||
const detuned = () => ({
|
||||
spaces: [{
|
||||
id: 'f1', view_box: [0, 0, 1, 1],
|
||||
rooms: [
|
||||
// a polygon a third of a step off on every vertex
|
||||
{ id: 'r1', name: 'Living', area: 'a1',
|
||||
poly: [[0.2 + S / 3, 0.2], [0.5, 0.2 - S / 3], [0.5 + S / 3, 0.5], [0.2, 0.5]] },
|
||||
// a rect whose far corner is off even though its origin is not
|
||||
{ id: 'r2', name: 'Kitchen', area: 'a2', x: 0.5, y: 0.2, w: 0.3 + S / 3, h: 0.3 },
|
||||
],
|
||||
openings: [
|
||||
// just off the r1 top wall, and not at a whole number of steps along it
|
||||
{ id: 'o1', type: 'window', x: 0.3 + S / 3, y: 0.2 + S / 5, angle: 0, length: 0.06 },
|
||||
],
|
||||
decor: [
|
||||
{ id: 'd1', kind: 'line', x1: 0.1 + S / 3, y1: 0.7, x2: 0.9, y2: 0.7 - S / 4 },
|
||||
{ id: 'd2', kind: 'rect', x: 0.1 + S / 4, y: 0.8, w: 0.2 + S / 3, h: 0.05 },
|
||||
{ id: 'd3', kind: 'text', x: 0.4 + S / 3, y: 0.9, text: 'hi' },
|
||||
],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
});
|
||||
const detunedLayout = () => ({
|
||||
dev1: { s: 'f1', x: 0.3 + S / 3, y: 0.3 },
|
||||
dev2: { s: 'f1', x: 0.35, y: 0.35, k: 1.5 }, // already on a node
|
||||
rl_r1: { s: 'f1', x: 0.25 + S / 2.5, y: 0.25 }, // a room LABEL counts too
|
||||
junk: { s: 'f1' }, // no coordinates: skipped
|
||||
});
|
||||
|
||||
test('snapN is idempotent and leaves a node bit-identical', () => {
|
||||
const node = 12 / GRID_N;
|
||||
assert.equal(snapN(node), node); // untouched, not "re-rounded"
|
||||
const off = node + S / 3;
|
||||
assert.ok(Math.abs(snapN(off) - node) < 1e-12);
|
||||
assert.equal(snapN(snapN(off)), snapN(off));
|
||||
});
|
||||
|
||||
test('alignAllToGrid puts every grid-bound element on a node', () => {
|
||||
const { spaces, layout, report } = alignAllToGrid(detuned().spaces, detunedLayout());
|
||||
const sp = spaces[0];
|
||||
for (const p of sp.rooms[0].poly) { assert.ok(onGrid(p[0])); assert.ok(onGrid(p[1])); }
|
||||
const r2 = sp.rooms[1];
|
||||
assert.ok(onGrid(r2.x) && onGrid(r2.y));
|
||||
assert.ok(onGrid(r2.x + r2.w) && onGrid(r2.y + r2.h)); // the FAR corner, not the size
|
||||
const [l, rc, tx] = sp.decor;
|
||||
assert.ok(onGrid(l.x1) && onGrid(l.y1) && onGrid(l.x2) && onGrid(l.y2));
|
||||
assert.ok(onGrid(rc.x) && onGrid(rc.y) && onGrid(rc.x + rc.w) && onGrid(rc.y + rc.h));
|
||||
assert.ok(onGrid(tx.x) && onGrid(tx.y));
|
||||
assert.ok(onGrid(layout.dev1.x) && onGrid(layout.dev1.y));
|
||||
assert.ok(onGrid(layout.rl_r1.x) && onGrid(layout.rl_r1.y));
|
||||
assert.ok(report.moved > 0 && report.moved <= report.total);
|
||||
});
|
||||
|
||||
test('an opening stays ON its wall — it is wall-bound, not node-bound', () => {
|
||||
const { spaces } = alignAllToGrid(detuned().spaces, {});
|
||||
const o = spaces[0].openings[0];
|
||||
const wall = spaces[0].rooms[0].poly; // the aligned r1
|
||||
// the top edge runs wall[0] → wall[1]; the opening must lie on that segment
|
||||
const [ax, ay] = wall[0], [bx, by] = wall[1];
|
||||
const t = ((o.x - ax) * (bx - ax) + (o.y - ay) * (by - ay)) / ((bx - ax) ** 2 + (by - ay) ** 2);
|
||||
const px = ax + t * (bx - ax), py = ay + t * (by - ay);
|
||||
assert.ok(Math.hypot(o.x - px, o.y - py) < 1e-9, 'opening is off its wall');
|
||||
assert.ok(t > 0 && t < 1, 'opening slid off the end of its wall');
|
||||
});
|
||||
|
||||
test('idempotent: a second run moves nothing and changes nothing', () => {
|
||||
const first = alignAllToGrid(detuned().spaces, detunedLayout());
|
||||
const second = alignAllToGrid(first.spaces, first.layout);
|
||||
assert.equal(second.report.moved, 0);
|
||||
assert.equal(second.changed, false);
|
||||
assert.deepEqual(second.spaces, first.spaces);
|
||||
assert.deepEqual(second.layout, first.layout);
|
||||
});
|
||||
|
||||
test('an already-aligned plan reports nothing to do', () => {
|
||||
const clean = {
|
||||
spaces: [{ id: 'f1', rooms: [{ id: 'r1', poly: [[0.25, 0.25], [0.5, 0.25], [0.5, 0.5], [0.25, 0.5]] }] }],
|
||||
};
|
||||
const r = alignAllToGrid(clean.spaces, { d1: { s: 'f1', x: 0.25, y: 0.5 } });
|
||||
assert.equal(r.report.moved, 0);
|
||||
assert.equal(r.changed, false);
|
||||
assert.ok(r.report.total >= 2); // it still LOOKED at everything
|
||||
});
|
||||
|
||||
test('the report is honest: it counts what moved and the largest shift', () => {
|
||||
const r = alignAllToGrid(detuned().spaces, detunedLayout());
|
||||
// never more than half a step in each axis → at most half a step diagonally… ×√2
|
||||
assert.ok(r.report.maxShift <= S * 0.71 + 1e-12, String(r.report.maxShift / S));
|
||||
assert.ok(r.report.maxShift > 0);
|
||||
// "junk" has no coordinates and is not counted; dev2 is on a node already
|
||||
assert.equal(r.report.total, 2 /*rooms*/ + 3 /*decor*/ + 1 /*opening*/ + 3 /*layout*/);
|
||||
assert.ok(r.report.moved < r.report.total);
|
||||
});
|
||||
|
||||
test('nothing is invented: the input objects are not mutated', () => {
|
||||
const cfg = detuned();
|
||||
const before = JSON.stringify(cfg.spaces);
|
||||
const lay = detunedLayout();
|
||||
const layBefore = JSON.stringify(lay);
|
||||
alignAllToGrid(cfg.spaces, lay);
|
||||
assert.equal(JSON.stringify(cfg.spaces), before);
|
||||
assert.equal(JSON.stringify(lay), layBefore);
|
||||
});
|
||||
|
||||
test('a stray opening with no wall in reach is left exactly where it is', () => {
|
||||
const cfg = detuned();
|
||||
cfg.spaces[0].openings.push({ id: 'o2', type: 'door', x: 3.7, y: 4.2, angle: 0, length: 0.06 });
|
||||
const { spaces } = alignAllToGrid(cfg.spaces, {});
|
||||
const o2 = spaces[0].openings.find((o) => o.id === 'o2');
|
||||
assert.equal(o2.x, 3.7);
|
||||
assert.equal(o2.y, 4.2);
|
||||
});
|
||||
|
||||
test('far-out coordinates align just as well as near ones (infinite canvas)', () => {
|
||||
const cfg = { spaces: [{ id: 'f1', rooms: [
|
||||
{ id: 'r1', poly: [[2.5 + S / 3, 2.2], [3.0, 2.2], [3.0, 2.6], [2.5, 2.6]] }] }] };
|
||||
const { spaces, report } = alignAllToGrid(cfg.spaces, { d: { s: 'f1', x: 2.5 + S / 3, y: 2.2 } });
|
||||
for (const p of spaces[0].rooms[0].poly) { assert.ok(onGrid(p[0])); assert.ok(onGrid(p[1])); }
|
||||
assert.ok(report.moved >= 1);
|
||||
});
|
||||
+2
-1
@@ -15,6 +15,7 @@
|
||||
"src/devices.ts",
|
||||
"src/types.ts",
|
||||
"src/space-geometry.ts",
|
||||
"src/signing.ts"
|
||||
"src/signing.ts",
|
||||
"src/align-grid.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user