mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
OPENING DRAG: shoulders measure the ONE room edge under the opening, no collinear merge (owner 2026-08-03)
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:
File diff suppressed because one or more lines are too long
@@ -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
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+6
-5
@@ -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
@@ -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
@@ -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]] }];
|
||||
|
||||
Reference in New Issue
Block a user