OPENING DRAG: shoulders measure the ONE room edge under the opening, no collinear merge (owner 2026-08-03)
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 1m24s
Validate / backend (push) Failing after 5m25s
Validate / smoke (push) Failing after 7m57s

Owner: 'not like that — the whole wall is counted now, only the wall of ONE
room must count'. openingShoulders no longer merges collinear touching edges
of neighbouring rooms into a physical run: the wall is exactly the room-
polygon edge the opening is snapped to. Selection mirrors snapToWall
(nearest collinear edge, first in roomEdges order on a tie), so the ruler
always measures the same edge the drag snapped to; the center tick/magnet
now targets that edge's middle. Unit tests flipped to the new contract plus
a staggered shared-wall case; smoke_opening_measure recalculated for r1's
own edge 40..550 and grew a shared-wall scenario (both failed on the old
build, green now); docs/TESTING.md wording updated.
This commit is contained in:
Matysh
2026-08-03 12:21:02 +03:00
parent 5e5c06f126
commit b70153769a
7 changed files with 148 additions and 61 deletions
File diff suppressed because one or more lines are too long
+74 -21
View File
@@ -2,9 +2,11 @@
// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
// along the wall), and when the opening's center hits the wall's center a
// perpendicular dashed tick appears and the center magnet-snaps (Shift opts
// out). Real pointer events; the numbers are checked against the geometry:
// r1+r2 top edges merge into one wall 40..960 (center 500), cell = 5 cm,
// grid pitch = 1000/240, so cm = units * 1.2 and the wall is 11.04 m.
// out). The wall is ONE room's edge (owner: "only the wall of one room") —
// r2's collinear top edge never merges into r1's, so the ruler runs 40..550
// (center 295), not the old merged 40..960. Real pointer events; the numbers
// are checked against the geometry: cell = 5 cm, grid pitch = 1000/240, so
// cm = units * 1.2 and r1's top wall (510 u) is 6.12 m.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
@@ -41,14 +43,16 @@ await page.mouse.move(sx, sy);
await page.mouse.down();
await page.mouse.move(t1x, sy, { steps: 5 });
await settle();
// center 460 -> edges 420/500; shoulders 420-40=380 u (4.56 m) and 960-500=460 u (5.52 m)
// center 460 -> edges 420/500; shoulders measured on r1's OWN edge 40..550:
// 420-40=380 u (4.56 m) and 550-500=50 u (0.60 m) — the old merged build
// showed 960-500=460 u (5.52 m) on the right
{
const [a, b] = await nums();
check('two_badges_visible', (await badges()).length, 2);
check('badge_left_4_56', near(a, 4.56, 0.04));
check('badge_right_5_52', near(b, 5.52, 0.04));
check('badge_right_0_60', near(a, 0.6, 0.04));
check('badge_left_4_56', near(b, 4.56, 0.04));
// exact invariant, immune to pointer rounding: shoulders + opening = wall
check('badges_sum_to_wall', near(a + b, 11.04 - 0.96, 0.021));
check('badges_sum_to_wall', near(a + b, 6.12 - 0.96, 0.021));
}
check('no_tick_off_center', await tick(), 0);
@@ -56,22 +60,22 @@ check('no_tick_off_center', await tick(), 0);
const [t2x] = await screenPt(360, 140);
await page.mouse.move(t2x, sy, { steps: 3 });
await settle();
// center 360 -> shoulders 320-40=280 u (3.36 m) and 960-400=560 u (6.72 m)
// center 360 -> shoulders 320-40=280 u (3.36 m) and 550-400=150 u (1.80 m)
{
const [a, b] = await nums();
check('badge_left_updates_3_36', near(a, 3.36, 0.04));
check('badge_right_updates_6_72', near(b, 6.72, 0.04));
check('badge_right_updates_1_80', near(a, 1.8, 0.04));
check('badge_left_updates_3_36', near(b, 3.36, 0.04));
}
// ---------- near the wall center: tick + magnet ----------
const [t3x] = await screenPt(498.7, 140);
// ---------- near the OWN edge center (40+550)/2 = 295: tick + magnet ----------
const [t3x] = await screenPt(293.7, 140);
await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('tick_at_center', await tick(), 1);
check('magnet_snaps_center', await page.evaluate(() =>
window.__card._curSpaceCfg.openings[0].x), 0.5);
// both shoulders equal EXACTLY after the snap: (920 - 80) / 2 = 420 u -> 5.04 m
check('badges_equal_at_center', await badges(), ['5.04 m', '5.04 m']);
check('magnet_snaps_center', near(await page.evaluate(() =>
window.__card._curSpaceCfg.openings[0].x), 0.295, 1e-6));
// both shoulders equal after the snap: (510 - 80) / 2 = 215 u -> 2.58 m
check('badges_equal_at_center', await badges(), ['2.58 m', '2.58 m']);
// the tick is perpendicular to the wall (horizontal wall -> a vertical dash)
const geom = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opcentertick');
@@ -82,7 +86,7 @@ const geom = await page.evaluate(() => {
dashed: dashed !== 'none' && dashed !== '' };
});
check('tick_perpendicular_at_wall_center', !!geom
&& near(geom.x1, 500, 1e-6) && near(geom.x2, 500, 1e-6) && near(geom.y2 - geom.y1, 30, 1e-6));
&& near(geom.x1, 295, 1e-6) && near(geom.x2, 295, 1e-6) && near(geom.y2 - geom.y1, 30, 1e-6));
check('tick_dashed', !!geom && geom.dashed);
// ---------- release: everything disappears, the snapped x is committed ----------
@@ -90,14 +94,14 @@ await page.mouse.up();
await settle();
check('badges_gone_after_drop', (await badges()).length, 0);
check('tick_gone_after_drop', await tick(), 0);
check('committed_x_center', await page.evaluate(() =>
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.5);
check('committed_x_center', near(await page.evaluate(() =>
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
// ---------- Shift disables the magnet (badges stay) ----------
// park the opening off-center first: a drag that returns to within 3 px of its
// own starting point is filtered by the tap threshold, so the Shift approach
// must start away from the center
const [cx0] = await screenPt(500, 140);
const [cx0] = await screenPt(295, 140);
await page.mouse.move(cx0, sy);
await page.mouse.down();
await page.mouse.move(t2x, sy, { steps: 3 });
@@ -109,11 +113,60 @@ await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('no_tick_with_shift', await tick(), 0);
const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
check('no_magnet_with_shift', near(freeX, 0.4987, 0.004) && freeX !== 0.5);
check('no_magnet_with_shift', near(freeX, 0.2937, 0.004) && freeX !== 0.295);
check('badges_still_there_with_shift', (await badges()).length, 2);
await page.mouse.up();
await page.keyboard.up('Shift');
await settle();
check('badges_gone_final', (await badges()).length, 0);
// ---------- opening on a SHARED wall of two rooms: the own room's edge ----------
// staggered fragments on the boundary x=550: r1's right edge 140..460 (its
// room is made the short one), r2's left edge 140..580. The snap resolves to
// r1's edge (nearest, first in roomEdges order on the tie), so the shoulders
// and the center come from 140..460 — the old build measured the merged
// 140..580 union
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.rooms = [
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.46], [0.04, 0.46]] },
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.58], [0.55, 0.58]] },
];
sp.openings = [{ id: 'op1', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await settle();
const [vx0, vy0] = await screenPt(550, 300);
const [, vy1] = await screenPt(550, 360);
await page.mouse.move(vx0, vy0);
await page.mouse.down();
await page.mouse.move(vx0, vy1, { steps: 3 });
await settle();
// center 360 -> edges 320/400; r1's edge 140..460: 320-140=180 u (2.16 m) and
// 460-400=60 u (0.72 m); the merged build showed 580-400=180 u (2.16 m) below
{
const [a, b] = await nums();
check('shared_wall_two_badges', (await badges()).length, 2);
check('shared_wall_bottom_0_72', near(a, 0.72, 0.04));
check('shared_wall_top_2_16', near(b, 2.16, 0.04));
}
check('shared_wall_no_tick_off_center', await tick(), 0);
await page.mouse.up();
await settle();
// approach the OWN edge center (140+460)/2 = 300 — NOT the merged 360
const [, vy2] = await screenPt(550, 301.3);
await page.mouse.move(vx0, vy1);
await page.mouse.down();
await page.mouse.move(vx0, vy2, { steps: 3 });
await settle();
check('shared_wall_tick_at_own_center', await tick(), 1);
check('shared_wall_magnet_y_0_3', near(await page.evaluate(() =>
window.__card._curSpaceCfg.openings[0].y), 0.3, 1e-6));
// equal shoulders on the own edge: (320 - 80) / 2 = 120 u -> 1.44 m
check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
await page.mouse.up();
await settle();
await finish(browser, { done: true });
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -5
View File
@@ -122,11 +122,12 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings]
- [ ] Opening drag rulers (2026-08-03): while an opening is dragged, a measure
badge on EACH shoulder shows the along-the-wall distance from the wall end
(collinear touching edges count as one wall) to the nearest opening edge,
live; at the wall's center (±half a grid step) a perpendicular dashed tick
appears and the center magnet-snaps — Shift disables the magnet; badges
and tick vanish on release [auto: smoke_opening_measure + unit
openingShoulders]
to the nearest opening edge, live; the wall is ONE room's edge — the edge
the opening is snapped to — so a neighbouring room's collinear edge is
never merged in; at that edge's center (±half a grid step) a
perpendicular dashed tick appears and the center magnet-snaps — Shift
disables the magnet; badges and tick vanish on release
[auto: smoke_opening_measure + unit openingShoulders]
## Onboarding ★
+22 -23
View File
@@ -140,7 +140,7 @@ export function snapToWall(
}
export interface OpeningShoulders {
/** Endpoints of the wall run (maximal chain of touching collinear edges). */
/** Endpoints of the wall edge — the ONE room-polygon edge the opening sits on. */
wallA: number[];
wallB: number[];
/** Distance (render units) from each wall end to the NEAREST opening edge, >= 0. */
@@ -149,7 +149,7 @@ export interface OpeningShoulders {
/** Midpoint of each shoulder — where a measure badge sits. */
midA: number[];
midB: number[];
/** Center of the wall run. */
/** Center of the wall edge. */
wallCenter: number[];
/** The opening's center coincides with the wall center (within tol). */
centered: boolean;
@@ -159,40 +159,39 @@ export interface OpeningShoulders {
* Shoulders of an opening being dragged along a wall: distances from the
* opening's edges to the ends of the wall it sits on, plus the "centered on the
* wall" flag (owner 2026-08-03: live ruler badges + a perpendicular dashed tick
* when the opening is exactly in the middle). The wall is the maximal run of
* COLLINEAR touching room edges through the opening's center — roomEdges splits
* a physical wall wherever a neighbouring room corners into it, and a user
* thinks in whole walls, not derived fragments. Works for angled walls too:
* everything is measured along the wall's direction. Returns null when the
* center does not lie on any wall.
* when the opening is exactly in the middle). The wall is ONE room-polygon
* edge — exactly the edge the opening is snapped to (owner 2026-08-03:
* «only the wall of one room», collinear edges of a NEIGHBOURING room are
* never merged in). Selection mirrors snapToWall: among the edges collinear
* with the opening's line, the one nearest to its center wins, first in
* roomEdges order on a tie — so the ruler always measures the same edge the
* drag snapped to. Works for angled walls too: everything is measured along
* the wall's direction. Returns null when the center does not lie on any wall.
*/
export function openingShoulders(
center: number[], angleDeg: number, lengthPx: number, rooms: any[], tol: number, eps = 1.0,
): OpeningShoulders | null {
const rad = (angleDeg * Math.PI) / 180;
const dir = [Math.cos(rad), Math.sin(rad)];
// edges lying on the opening's wall line → intervals of t along dir (t=0 at the center)
const intervals: Array<[number, number]> = [];
// edges lying on the opening's wall line → pick the ONE nearest to the
// center along that line (t=0 at the center). Same tie-break as snapToWall:
// strictly-nearer wins, so on a tie (shared-boundary fragments, a junction
// vertex) the first edge in roomEdges order is used — the very edge the
// drag snapped to. NO merging of collinear runs across rooms.
let wall: [number, number] | null = null;
let bestD = eps;
for (const e of roomEdges(rooms)) {
const pts = [[e[0], e[1]], [e[2], e[3]]];
const off = (p: number[]) => Math.abs(dir[0] * (p[1] - center[1]) - dir[1] * (p[0] - center[0]));
if (off(pts[0]) > eps || off(pts[1]) > eps) continue; // not collinear with the wall line
const t0 = (pts[0][0] - center[0]) * dir[0] + (pts[0][1] - center[1]) * dir[1];
const t1 = (pts[1][0] - center[0]) * dir[0] + (pts[1][1] - center[1]) * dir[1];
intervals.push([Math.min(t0, t1), Math.max(t0, t1)]);
const lo = Math.min(t0, t1), hi = Math.max(t0, t1);
const d = lo > 0 ? lo : hi < 0 ? -hi : 0; // distance from the center to the edge, along the line
if (d < bestD) { bestD = d; wall = [lo, hi]; }
}
if (!intervals.length) return null;
// merge into the contiguous run containing t=0 (the opening's center)
intervals.sort((a, b) => a[0] - b[0]);
let run: [number, number] | null = null;
for (const iv of intervals) {
if (!run) { run = [iv[0], iv[1]]; continue; }
if (iv[0] <= run[1] + eps) run[1] = Math.max(run[1], iv[1]);
else if (run[1] < -eps) run = [iv[0], iv[1]]; // run ended before the center: start over
else break; // the run already covers the center; a gap follows — stop
}
if (!run || run[0] > eps || run[1] < -eps) return null; // center is not on this run
const [tMin, tMax] = run;
if (!wall) return null; // center is not on any edge of this direction
const [tMin, tMax] = wall;
const half = lengthPx / 2;
const sideA = Math.max(0, -half - tMin);
const sideB = Math.max(0, tMax - half);
+43 -9
View File
@@ -1015,21 +1015,30 @@ test('openingShoulders: centered flag obeys the tolerance', () => {
assert.equal(openingShoulders([298, 140], 0, 80, rooms, 2).centered, false, 'outside tol');
});
test('openingShoulders: collinear touching edges merge into one physical wall', () => {
// two rooms side by side: the top edges 40..550 and 550..960 are ONE wall
test('openingShoulders: only the OWN room edge counts, no merging with a neighbour', () => {
// two rooms side by side: the top edges 40..550 (r1) and 550..960 (r2) are
// collinear and touch at x=550, but they belong to DIFFERENT rooms — the
// ruler measures ONLY the edge the opening sits on (owner 2026-08-03:
// «only the wall of one room»)
const rooms = [
{ id: 'r1', poly: [[40, 140], [550, 140], [550, 580], [40, 580]] },
{ id: 'r2', poly: [[550, 140], [960, 140], [960, 580], [550, 580]] },
];
const sh = openingShoulders([200, 140], 0, 80, rooms, 2);
assert.deepEqual(sh.wallA, [40, 140]);
assert.deepEqual(sh.wallB, [960, 140], 'run extends across the room boundary');
assert.equal(sh.sideB, 960 - 240);
// the middle of the MERGED run: (40+960)/2 = 500
assert.equal(openingShoulders([500, 140], 0, 80, rooms, 2).centered, true);
assert.equal(openingShoulders([295, 140], 0, 80, rooms, 2).centered, false,
'the center of a fragment is not the center of the wall');
// the interior shared wall x=550 (vertical, angle 90) is its own run
assert.deepEqual(sh.wallB, [550, 140], 'stops at the own corner, never runs into r2');
assert.equal(sh.sideB, 550 - 240);
// the middle of r1's OWN edge: (40+550)/2 = 295
assert.equal(openingShoulders([295, 140], 0, 80, rooms, 2).centered, true,
'centered on the own edge');
assert.equal(openingShoulders([500, 140], 0, 80, rooms, 2).centered, false,
'the center of the old merged run means nothing now');
// an opening on r2's fragment measures r2's edge
const far = openingShoulders([700, 140], 0, 80, rooms, 2);
assert.deepEqual(far.wallA, [550, 140]);
assert.deepEqual(far.wallB, [960, 140]);
assert.equal(openingShoulders([755, 140], 0, 80, rooms, 2).centered, true);
// the interior shared wall x=550 is ONE deduped edge shared by both rooms
const v = openingShoulders([550, 300], 90, 80, rooms, 2);
assert.deepEqual(v.wallA, [550, 140]);
assert.deepEqual(v.wallB, [550, 580]);
@@ -1037,6 +1046,31 @@ test('openingShoulders: collinear touching edges merge into one physical wall',
assert.equal(openingShoulders([550, 360], 90, 80, rooms, 2).centered, true);
});
test('openingShoulders: shared wall of two rooms uses the snapped room edge', () => {
// staggered fragments on the shared line x=550: r1's right edge 140..400,
// r2's left edge 300..580. The snap tie-break (nearest edge, first in
// roomEdges order on a tie — exactly what snapToWall picks during the
// drag) resolves to r1's edge, so shoulders and center come from 140..400,
// never from the merged 140..580 union
const rooms = [
{ id: 'r1', poly: [[40, 140], [550, 140], [550, 400], [40, 400]] },
{ id: 'r2', poly: [[550, 300], [960, 300], [960, 580], [550, 580]] },
];
const sh = openingShoulders([550, 350], 90, 80, rooms, 2);
assert.deepEqual(sh.wallA, [550, 140]);
assert.deepEqual(sh.wallB, [550, 400], "r1's own end, not r2's 580");
assert.equal(sh.sideA, 310 - 140);
assert.equal(sh.sideB, 400 - 390);
assert.deepEqual(sh.wallCenter, [550, 270]);
assert.equal(sh.centered, false);
// below r1's edge the same line belongs to r2 alone
const low = openingShoulders([550, 500], 90, 80, rooms, 2);
assert.deepEqual(low.wallA, [550, 300]);
assert.deepEqual(low.wallB, [550, 580]);
assert.equal(openingShoulders([550, 440], 90, 80, rooms, 2).centered, true,
'centered on r2 own edge (300+580)/2');
});
test('openingShoulders: angled wall measures along the wall direction', () => {
// 3-4-5 triangle: hypotenuse-ish edge (0,0)->(300,400), length 500, angle 53.13°
const rooms = [{ id: 't', poly: [[0, 0], [300, 400], [0, 400]] }];