mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
File diff suppressed because one or more lines are too long
@@ -0,0 +1,199 @@
|
||||
// #293: exercise the real browser pointer pipeline on the tracked second-floor
|
||||
// fixture. The card is recreated so the fixture enters through config/get,
|
||||
// exactly as it does in Home Assistant; no private Resize method is invoked.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/real-plan-second-floor.json', import.meta.url),
|
||||
'utf8',
|
||||
));
|
||||
const { page, browser } = await launch({ width: 1180, height: 920 });
|
||||
|
||||
await page.evaluate(async (space) => {
|
||||
const previous = window.__card;
|
||||
const hass = window.__mkHass();
|
||||
const callWS = hass.callWS.bind(hass);
|
||||
window.__resizeWrites = [];
|
||||
hass.callWS = async (message) => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
return { config: { spaces: [structuredClone(space)], markers: [], settings: {} }, rev: 1, can_write: true };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
if (message.type === 'houseplan/config/set') {
|
||||
window.__resizeWrites.push(structuredClone(message));
|
||||
return { ok: true, rev: 2 };
|
||||
}
|
||||
return callWS(message);
|
||||
};
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan' });
|
||||
previous.remove();
|
||||
document.getElementById('host').appendChild(card);
|
||||
window.__card = card;
|
||||
card.hass = hass;
|
||||
}, fixture.space);
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const card = window.__card;
|
||||
return card?._booting === false
|
||||
&& card._serverCfg?.spaces?.[0]?.id === 'real-second-floor'
|
||||
&& card._space === 'real-second-floor';
|
||||
}, { timeout: 9000 });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card._setMode('plan');
|
||||
await card.updateComplete;
|
||||
const button = [...card.renderRoot.querySelectorAll('button')]
|
||||
.find((entry) => entry.textContent?.trim() === 'Resize');
|
||||
button?.click();
|
||||
await card.updateComplete;
|
||||
});
|
||||
await page.waitForFunction(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length > 0);
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__resizePointerId = null;
|
||||
window.__card.renderRoot.addEventListener('pointerdown', (event) => {
|
||||
if (event.target?.classList?.contains('rszhandle')) window.__resizePointerId = event.pointerId;
|
||||
}, true);
|
||||
});
|
||||
|
||||
const target = await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const handles = [...card.renderRoot.querySelectorAll('.rszhandle')];
|
||||
const handle = handles.find((entry) => entry.getAttribute('aria-disabled') === 'false'
|
||||
&& Math.abs(Number(entry.getAttribute('cx')) - 400) < 1
|
||||
&& Math.abs(Number(entry.getAttribute('cy')) - 529.166667) < 2);
|
||||
if (!handle) return null;
|
||||
const svg = handle.ownerSVGElement;
|
||||
const screen = (x, y) => {
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = x; point.y = y;
|
||||
const mapped = point.matrixTransform(handle.getScreenCTM());
|
||||
return [mapped.x, mapped.y];
|
||||
};
|
||||
const cx = Number(handle.getAttribute('cx'));
|
||||
const cy = Number(handle.getAttribute('cy'));
|
||||
const rect = handle.getBoundingClientRect();
|
||||
return {
|
||||
start: [rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||
end: screen(cx + 10 * (1000 / 240), cy),
|
||||
mappedStart: screen(cx, cy),
|
||||
count: handles.length,
|
||||
};
|
||||
});
|
||||
|
||||
const settle = () => page.evaluate(() => new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const persistedGeometry = () => page.evaluate(() => {
|
||||
const space = window.__card._serverCfg.spaces.find((entry) => entry.id === 'real-second-floor');
|
||||
return JSON.stringify({
|
||||
rooms: space.rooms,
|
||||
openings: space.openings || [],
|
||||
walls: space.walls || [],
|
||||
open_spans: space.open_spans || [],
|
||||
});
|
||||
});
|
||||
const sharedX = () => page.evaluate(() => {
|
||||
const space = window.__card._serverCfg.spaces.find((entry) => entry.id === 'real-second-floor');
|
||||
const a = space.rooms.find((room) => room.id === 'room-a');
|
||||
const b = space.rooms.find((room) => room.id === 'room-b');
|
||||
return [a.poly[2][0], a.poly[3][0], b.poly[2][0], b.poly[3][0]];
|
||||
});
|
||||
const domHasSharedX = (x) => page.evaluate((wanted) =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')]
|
||||
.some((entry) => Math.abs(Number(entry.getAttribute('cx')) - wanted) < 1), x);
|
||||
|
||||
check('resize_pointer.fixture_loaded', await page.evaluate(() =>
|
||||
window.__card._serverCfg.spaces[0].id), 'real-second-floor');
|
||||
check('resize_pointer.fixture_is_current_server_space', await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const current = card._serverCfg.spaces.find((space) => space.id === card._space);
|
||||
return current?.rooms?.length === 8
|
||||
&& current.rooms.find((room) => room.id === 'room-a')?.poly?.[2]?.[0] === 0.4;
|
||||
}), true);
|
||||
check('resize_pointer.target_enabled', !!target, true);
|
||||
if (target) {
|
||||
const before = await persistedGeometry();
|
||||
const writesBefore = await page.evaluate(() => window.__resizeWrites.length);
|
||||
const beforeWalls = await page.evaluate(() => {
|
||||
const walls = window.__card._serverCfg.spaces[0].walls || [];
|
||||
return { count: walls.length, cms: walls.map((wall) => wall.cm).sort((a, b) => a - b) };
|
||||
});
|
||||
await page.mouse.move(...target.start);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(...target.end, { steps: 8 });
|
||||
await settle();
|
||||
check('resize_pointer.dom_preview_ten_steps', await domHasSharedX(400 + 10 * (1000 / 240)), true);
|
||||
check('resize_pointer.preview_not_persisted', await persistedGeometry(), before);
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const expected = 0.4 + 10 / 240;
|
||||
check('resize_pointer.both_rooms_commit_ten_steps',
|
||||
(await sharedX()).every((value) => Math.abs(value - expected) < 1e-9), true);
|
||||
check('resize_pointer.one_history_command', await page.evaluate(() =>
|
||||
window.__card._geometryHistory.size), 1);
|
||||
await page.waitForTimeout(650);
|
||||
check('resize_pointer.one_atomic_write', await page.evaluate(() => window.__resizeWrites.length), writesBefore + 1);
|
||||
check('resize_pointer.wall_metadata_preserved', await page.evaluate(() => {
|
||||
const walls = window.__card._serverCfg.spaces[0].walls || [];
|
||||
return { count: walls.length, cms: walls.map((wall) => wall.cm).sort((a, b) => a - b) };
|
||||
}), beforeWalls);
|
||||
|
||||
await page.keyboard.press('Control+z');
|
||||
await settle();
|
||||
check('resize_pointer.undo_byte_exact', await persistedGeometry(), before);
|
||||
|
||||
// The second gesture leaves the circle by much more than its hit radius.
|
||||
// Pointer capture must keep the real browser stream alive; Esc then cancels
|
||||
// the already-visible overlay without a second persistence write.
|
||||
await page.mouse.move(...target.start);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(target.start[0] + 60, target.start[1], { steps: 12 });
|
||||
await settle();
|
||||
check('resize_pointer.capture_beyond_handle', await domHasSharedX(400), false);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
check('resize_pointer.escape_restores_config', await persistedGeometry(), before);
|
||||
await page.waitForTimeout(650);
|
||||
check('resize_pointer.escape_zero_extra_write', await page.evaluate(() => window.__resizeWrites.length), writesBefore + 2);
|
||||
|
||||
// A different pointer cannot take over an active drag. Losing capture for
|
||||
// the owning pointer is an abort, never a commit of the visible preview.
|
||||
await page.mouse.move(...target.start);
|
||||
await page.mouse.down();
|
||||
await page.evaluate(({ x, y }) => {
|
||||
const card = window.__card;
|
||||
const handle = [...card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')]
|
||||
.find((entry) => Math.abs(Number(entry.getAttribute('cx')) - 400) < 1);
|
||||
handle?.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true,
|
||||
pointerId: Number(window.__resizePointerId) + 100,
|
||||
clientX: x + 100,
|
||||
clientY: y,
|
||||
}));
|
||||
}, { x: target.start[0], y: target.start[1] });
|
||||
await settle();
|
||||
check('resize_pointer.unrelated_pointer_ignored', await domHasSharedX(400), true);
|
||||
await page.mouse.move(target.start[0] + 30, target.start[1], { steps: 6 });
|
||||
await settle();
|
||||
check('resize_pointer.preview_before_capture_loss', await domHasSharedX(400), false);
|
||||
await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const handle = [...card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')][0];
|
||||
handle?.dispatchEvent(new PointerEvent('lostpointercapture', {
|
||||
bubbles: true,
|
||||
pointerId: Number(window.__resizePointerId),
|
||||
}));
|
||||
});
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
check('resize_pointer.capture_loss_restores_dom', await domHasSharedX(400), true);
|
||||
check('resize_pointer.capture_loss_restores_config', await persistedGeometry(), before);
|
||||
await page.waitForTimeout(650);
|
||||
check('resize_pointer.capture_loss_zero_extra_write', await page.evaluate(() => window.__resizeWrites.length), writesBefore + 2);
|
||||
}
|
||||
|
||||
await finish(browser, { done: true });
|
||||
@@ -242,23 +242,68 @@ await enter();
|
||||
await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
card.__resizePreflight = card._checkSpacePhysicalGeometry;
|
||||
card.__resizeShowToast = card._showToast;
|
||||
card.__resizeRejectToasts = 0;
|
||||
card._checkSpacePhysicalGeometry = () => ({ ok: false, status: 'failed' });
|
||||
card._showToast = function resizeRejectToast(message) {
|
||||
if (/last safe position|последн/i.test(message)) card.__resizeRejectToasts++;
|
||||
return card.__resizeShowToast.call(card, message);
|
||||
};
|
||||
});
|
||||
const preflightBefore = JSON.stringify(await roomPoly('preflight'));
|
||||
const [px, py] = await screenPt(400, 250);
|
||||
const [preflightX] = await screenPt(500, 250);
|
||||
await pointer('pointerdown', px, py, { cx: 400, cy: 250, pointerId: 80 });
|
||||
await pointer('pointermove', preflightX, py, { pointerId: 80 });
|
||||
await pointer('pointermove', preflightX + 20, py, { pointerId: 80 });
|
||||
await settle();
|
||||
check('safe_resize.preflight_visible_reason', await page.evaluate(() =>
|
||||
/last safe position|последн/i.test(window.__card._toast)), true);
|
||||
check('safe_resize.preflight_reason_once', await page.evaluate(() =>
|
||||
window.__card.__resizeRejectToasts), 1);
|
||||
await pointer('pointerup', preflightX, py, { pointerId: 80 });
|
||||
await settle();
|
||||
await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
card._checkSpacePhysicalGeometry = card.__resizePreflight;
|
||||
card._showToast = card.__resizeShowToast;
|
||||
delete card.__resizePreflight;
|
||||
delete card.__resizeShowToast;
|
||||
delete card.__resizeRejectToasts;
|
||||
});
|
||||
check('safe_resize.preflight_no_commit', JSON.stringify(await roomPoly('preflight')), preflightBefore);
|
||||
check('safe_resize.preflight_zero_write', await page.evaluate(() => window.__card._geometryHistory.size), 0);
|
||||
|
||||
// The candidate may become invalid after a valid preview (for example a
|
||||
// concurrent structural update). Pointerup repeats the common preflight and
|
||||
// must not trust the fact that an overlay was shown.
|
||||
await setRooms([rect('commit-preflight', 100, 100, 400, 400)]);
|
||||
await enter();
|
||||
const commitPreflightBefore = JSON.stringify(await roomPoly('commit-preflight'));
|
||||
const [cpx, cpy] = await screenPt(400, 250);
|
||||
const [commitPreflightX] = await screenPt(500, 250);
|
||||
await pointer('pointerdown', cpx, cpy, { cx: 400, cy: 250, pointerId: 84 });
|
||||
await pointer('pointermove', commitPreflightX, cpy, { pointerId: 84 });
|
||||
await settle();
|
||||
check('safe_resize.commit_preflight_preview_exists',
|
||||
Math.abs((await edgeX('commit-preflight', 1, true)) - 500) < 6, true);
|
||||
await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
card.__resizeCommitPreflight = card._checkSpacePhysicalGeometry;
|
||||
card._checkSpacePhysicalGeometry = () => ({ ok: false, status: 'failed' });
|
||||
});
|
||||
await pointer('pointerup', commitPreflightX, cpy, { pointerId: 84 });
|
||||
await settle();
|
||||
await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
card._checkSpacePhysicalGeometry = card.__resizeCommitPreflight;
|
||||
delete card.__resizeCommitPreflight;
|
||||
});
|
||||
check('safe_resize.commit_preflight_no_commit',
|
||||
JSON.stringify(await roomPoly('commit-preflight')), commitPreflightBefore);
|
||||
check('safe_resize.commit_preflight_zero_write',
|
||||
await page.evaluate(() => window.__card._geometryHistory.size), 0);
|
||||
|
||||
// pointercancel follows the abort path: no persistence, history or hidden save.
|
||||
await setRooms([rect('solo', 100, 100, 400, 400)]);
|
||||
await enter();
|
||||
|
||||
Vendored
+4
-4
File diff suppressed because one or more lines are too long
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- An enabled Resize handle now follows the real pointer on the affected
|
||||
second-floor layout instead of staying inert. The gesture measures movement
|
||||
from the press point, preserves one continuous side-wall thickness record,
|
||||
keeps working outside the handle through pointer capture, and stops at the
|
||||
last renderable preview with an explicit message instead of silently doing
|
||||
nothing ([#293](https://github.com/Matysh/houseplan-card/issues/293)).
|
||||
- Every saved plan coordinate now converges to the exact shared `1/240` grid
|
||||
value when it differs only by an invisible floating-point tail. Authored
|
||||
off-grid/diagonal geometry is preserved. “Optimize plans” reports the number
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Активная ручка Resize теперь действительно следует за указателем на
|
||||
проблемной планировке второго этажа, а не остаётся неподвижной. Жест считает
|
||||
смещение от точки нажатия, сохраняет одну непрерывную запись толщины боковой
|
||||
стены, продолжает работать за пределами ручки благодаря pointer capture и
|
||||
останавливается на последнем отображаемом preview с явным сообщением вместо
|
||||
молчаливого бездействия
|
||||
([#293](https://github.com/Matysh/houseplan-card/issues/293)).
|
||||
- Все сохраняемые координаты плана теперь приводятся к точному общему узлу
|
||||
сетки `1/240`, если отличаются от него только невидимым хвостом вычислений.
|
||||
Авторская off-grid/диагональная геометрия не сдвигается. «Оптимизировать
|
||||
|
||||
+22
-2
@@ -105,8 +105,17 @@ from it; `_serverCfg` is untouched until pointerup. The overlay contains rooms,
|
||||
openings, re-keyed wall thickness/open spans and byte-equivalent partitions,
|
||||
drafts, columns, decor and plan transform.
|
||||
|
||||
Pointer displacement is measured from the exact pointerdown position and
|
||||
projected onto the moving wall's immutable normal. It does not depend on which
|
||||
part of the handle was pressed. The handle captures the owning pointer, ignores
|
||||
other pointer ids and continues receiving movement outside its visible hit
|
||||
area. `pointercancel` or `lostpointercapture` aborts the gesture.
|
||||
|
||||
The renderer consumes that exact overlay, so fills, masonry, openings, labels
|
||||
and measurements show one candidate. Its production wall/floor result is cached
|
||||
and measurements show one candidate. Before an overlay becomes visible it must
|
||||
pass the same fail-closed physical-geometry barrier as persistence. A rejected
|
||||
candidate leaves the wall at the last safe visible position and shows one
|
||||
localized explanation per gesture. Its production wall/floor result is cached
|
||||
for the preview cfg epoch. Pointerup reuses that exact result (or computes it if
|
||||
the release happened before a frame) and then re-runs the pure invariants.
|
||||
|
||||
@@ -122,6 +131,12 @@ with zero Undo entries and zero writes.
|
||||
snapshot to the fixed-topology candidate. Physical centimetre values and open
|
||||
span count must survive; the production geometry check is fail-closed.
|
||||
|
||||
When the two owners of a shared moving seam split one physically continuous
|
||||
side-wall record at their meeting point, the mapped atoms are joined back only
|
||||
if their endpoints still meet exactly and their directions remain collinear.
|
||||
This preserves one record and its thickness for the continuous wall without
|
||||
undoing the lossless split required by a genuinely partial or angled move.
|
||||
|
||||
Ordinary openings centred on the moving wall translate by the same vector.
|
||||
Their type, angle, length and compatibility fields stay unchanged. Hosted
|
||||
partition openings never move with a room wall. Openings on the two side walls
|
||||
@@ -153,6 +168,10 @@ building the preview frame itself.
|
||||
- `demo/smoke_room_resize.mjs`: production bundle pointer handlers,
|
||||
preview/commit/Undo, disabled accessibility, real fixture topology,
|
||||
production-preflight failure and cancellation;
|
||||
- `demo/smoke_resize_pointer_real_plan.mjs`: the tracked second-floor fixture
|
||||
entering through `config/get`, real browser mouse events, ten-grid-step live
|
||||
preview and atomic commit, wall metadata, Undo, pointer capture outside the
|
||||
handle, foreign-pointer isolation, Escape and capture-loss cancellation;
|
||||
- `test/resize-optimize.test.mjs` and
|
||||
`demo/smoke_resize_outer_reconciliation.mjs`: an exact outer-wall partition
|
||||
blocks a zero-range handle, Optimize safely rehosts its windows and removes
|
||||
@@ -161,7 +180,8 @@ building the preview frame itself.
|
||||
- `demo/benchmark_safe_resize.mjs`: same-run pointer and cached pointerup budgets;
|
||||
- `demo/benchmark_safe_resize_render.mjs`: warm 20-room/80-handle layer p95
|
||||
and exactly one geometry snapshot per rendered frame;
|
||||
- mutation gate: eligibility, third-room, topology, side ownership, jamb and
|
||||
- mutation gate: eligibility, third-room, topology, side ownership, jamb,
|
||||
pointer displacement/capture, shared-seam coalescing, preview rejection and
|
||||
commit-preflight bypass mutants.
|
||||
|
||||
Targeted light/dark golden scenes cover enabled/disabled handles, opening/corner
|
||||
|
||||
@@ -32,6 +32,29 @@
|
||||
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
|
||||
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
|
||||
|
||||
## Resize: реальный pointer pipeline (#293)
|
||||
|
||||
- [ ] `demo/smoke_resize_pointer_real_plan.mjs` загружает tracked fixture
|
||||
второго этажа обычным `houseplan/config/get`, включает Resize кнопкой и
|
||||
двигает доступную общую стену только реальными `page.mouse` событиями.
|
||||
Прямые вызовы приватных resize-методов в этом smoke запрещены source
|
||||
guard-юнитом.
|
||||
- [ ] На десятом шаге сетки обе комнаты имеют видимый preview, а server config
|
||||
ещё байт-в-байт исходный. Pointerup создаёт одну history-команду и одну
|
||||
запись; wall count и набор толщин сохраняются; Ctrl+Z возвращает исходную
|
||||
геометрию.
|
||||
- [ ] Pointer capture продолжает жест более чем в 60 px от хэндла, чужой
|
||||
pointer id игнорируется, а Escape и `lostpointercapture` возвращают DOM и
|
||||
config без дополнительной записи.
|
||||
- [ ] Невозможная физическая preview-геометрия останавливает стену на последней
|
||||
безопасной позиции и показывает один локализованный toast за жест. Отдельно
|
||||
проверяется отказ финального preflight без commit/history.
|
||||
- [ ] Мутанты `resize-pointer-delta-zeroed`,
|
||||
`resize-shared-seam-not-coalesced`, `resize-pointer-capture-removed`,
|
||||
`resize-preview-reject-silent` и
|
||||
`safe-resize-commit-preflight-bypassed` обязаны красить соответствующие
|
||||
unit/production smoke guards.
|
||||
|
||||
## Decor composition order (#231)
|
||||
|
||||
- [ ] All five decor kinds render in one `.decorlayer` after opaque room/data
|
||||
|
||||
@@ -294,6 +294,54 @@ export const MUTANTS = [
|
||||
replace: ' const candidateValid = !!preview;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'resize-pointer-delta-zeroed',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="#293 pointer displacement" test/resize.test.mjs',
|
||||
because: 'an enabled handle must turn real pointer travel into a signed wall displacement; '
|
||||
+ 'returning zero recreates the active-but-inert user report from #293',
|
||||
patches: [{
|
||||
file: 'src/resize.ts',
|
||||
find: ' return (current[0] - start[0]) * normal[0]',
|
||||
replace: ' return (current[0] - start[0]) * 0',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'resize-shared-seam-not-coalesced',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="moving a shared seam" test/wall-thickness.test.mjs',
|
||||
because: 'two owners moving one internal seam must keep the continuous side wall as one '
|
||||
+ 'record; atomising it changes metadata cardinality and silently rejects every preview (#293)',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' if (previous && closePoint(previous[1], atom[0])\n'
|
||||
+ ' && collinearForward(previous, atom)) {',
|
||||
replace: ' if (false && previous && closePoint(previous[1], atom[0])\n'
|
||||
+ ' && collinearForward(previous, atom)) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'resize-pointer-capture-removed',
|
||||
guard: 'node demo/smoke_resize_pointer_real_plan.mjs',
|
||||
because: 'the real pointer must keep driving the gesture after it leaves the small SVG handle; '
|
||||
+ 'without capture the visible preview freezes as soon as the cursor exits (#293)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' capturePointer(ev);\n const plan = resolution.plan;',
|
||||
replace: ' // mutant: pointer capture removed\n const plan = resolution.plan;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'resize-preview-reject-silent',
|
||||
guard: 'node demo/smoke_room_resize.mjs',
|
||||
because: 'an unexpected runtime preflight rejection must explain why an enabled handle '
|
||||
+ 'stopped instead of restoring the original silent no-op (#293)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: " this._showToast(this._t('resize.preview_failed'));",
|
||||
replace: ' // mutant: reject remains silent',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'resize-labels-show-centreline',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
@@ -65,12 +65,13 @@ export const SMOKE_LINKS = [
|
||||
{
|
||||
symbols: [
|
||||
'resolveSafeResize', 'applySafeResize', 'validateSafeResize', 'clampSafeResize',
|
||||
'SafeResizePlan', 'SafeResizeResolution', 'SafeResizeReason', 'SafeResizeObstacle',
|
||||
'safeResizePointerDisplacement', 'SafeResizePlan', 'SafeResizeResolution',
|
||||
'SafeResizeReason', 'SafeResizeObstacle',
|
||||
],
|
||||
smokes: ['smoke_room_resize.mjs'],
|
||||
because: 'the #277 production-bundle scenario drives the private pointer controller and '
|
||||
+ 'observes fixed topology, the first-corner clamp, disabled accessibility, one Undo and '
|
||||
+ 'zero-write cancellation; pure safe-resize helpers are tree-shaken behind that controller',
|
||||
smokes: ['smoke_room_resize.mjs', 'smoke_resize_pointer_real_plan.mjs'],
|
||||
because: 'the #277 synthetic scenario covers fixed topology, first-corner clamp and disabled '
|
||||
+ 'accessibility, while #293 recreates the card from config/get and drives real browser mouse '
|
||||
+ 'events on the tracked second-floor plan; pure helpers are hidden behind both controllers',
|
||||
},
|
||||
{
|
||||
symbols: ['reconcileCoincidentPartitions', 'CoincidentPartitionResult'],
|
||||
@@ -126,7 +127,7 @@ export const SMOKE_LINKS = [
|
||||
'rekeyWallsAfterMove', 'WallEntry', 'angleClose', 'clampWallCm',
|
||||
'closePoint', 'distToSeg', 'keyOf', 'pointAt', 'segAngle', 'wallDir', 'wallEntry',
|
||||
],
|
||||
smokes: ['smoke_resize_wall_thickness.mjs'],
|
||||
smokes: ['smoke_resize_wall_thickness.mjs', 'smoke_resize_pointer_real_plan.mjs'],
|
||||
because: 'the real pointer-handler smoke proves that a partial overlap is split losslessly '
|
||||
+ 'through live preview, commit, opening movement and Undo, but the browser bundle does '
|
||||
+ 'not expose the pure transformer name to the scenario',
|
||||
|
||||
+40
-15
@@ -42,6 +42,7 @@ import {
|
||||
} from './logic';
|
||||
import {
|
||||
resolveSafeResize, applySafeResize, clampSafeResize, validateSafeResize,
|
||||
safeResizePointerDisplacement,
|
||||
polyIsSimple, areaM2, formatArea, MIN_ROOM_CM,
|
||||
type SafeOpeningIn, type SafeResizeObstacle, type SafeResizeOptions,
|
||||
type SafeResizePlan, type SafeResizeReason, type SafeResizeResolution,
|
||||
@@ -1610,6 +1611,7 @@ class HouseplanCard extends LitElement {
|
||||
private _rszSel: string | null = null;
|
||||
private _rszDrag: {
|
||||
pid: number;
|
||||
start: [number, number];
|
||||
roomId: string;
|
||||
plan: SafeResizePlan;
|
||||
opts: SafeResizeOptions;
|
||||
@@ -1619,6 +1621,7 @@ class HouseplanCard extends LitElement {
|
||||
moved: boolean;
|
||||
d: number;
|
||||
changed: string[];
|
||||
rejectNotified: boolean;
|
||||
} | null = null;
|
||||
private _rszEligibilityCache: {
|
||||
key: string;
|
||||
@@ -8464,10 +8467,13 @@ class HouseplanCard extends LitElement {
|
||||
* config — the single point where a resize becomes visible to _writeConfig. */
|
||||
private _rszApplyPreview(
|
||||
polys: Record<string, number[][]>, ops: Record<string, [number, number]>,
|
||||
): boolean {
|
||||
): { ok: true } | {
|
||||
ok: false;
|
||||
reason: 'missing-context' | 'wall-metadata' | 'open-span-metadata' | 'physical-geometry';
|
||||
} {
|
||||
const g = this._rszDrag;
|
||||
const real = this._serverCfg?.spaces.find((s: any) => s.id === this._space);
|
||||
if (!g || !real || !this._serverCfg) return false;
|
||||
if (!g || !real || !this._serverCfg) return { ok: false, reason: 'missing-context' };
|
||||
const s = JSON.parse(g.snap); // fresh deep copies every move — free to mutate
|
||||
const sp: any = {
|
||||
...real,
|
||||
@@ -8532,29 +8538,41 @@ class HouseplanCard extends LitElement {
|
||||
.sort((a: number, b: number) => a - b);
|
||||
const beforeWallCms = wallThicknesses(s.walls || []);
|
||||
const afterWallCms = wallThicknesses(sp.walls || []);
|
||||
if (JSON.stringify(beforeWallCms) !== JSON.stringify(afterWallCms)
|
||||
|| (s.open_spans || []).length !== ((sp as any).open_spans || []).length) return false;
|
||||
if (JSON.stringify(beforeWallCms) !== JSON.stringify(afterWallCms)) {
|
||||
return { ok: false, reason: 'wall-metadata' };
|
||||
}
|
||||
if ((s.open_spans || []).length !== ((sp as any).open_spans || []).length) {
|
||||
return { ok: false, reason: 'open-span-metadata' };
|
||||
}
|
||||
if (!this._rszSpaceCandidateRenderable(this._space, sp)) {
|
||||
return { ok: false, reason: 'physical-geometry' };
|
||||
}
|
||||
this._rszPreview = { space: this._space, sp };
|
||||
this._cfgEpoch++;
|
||||
return true;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Final fail-closed check for the exact overlay through the common barrier. */
|
||||
private _rszCandidateRenderable(preview: { space: string; sp: any } | null): boolean {
|
||||
if (!preview || preview.space !== this._space || this._rszPreview !== preview
|
||||
|| !this._serverCfg) return false;
|
||||
/** Fail-closed check for one exact candidate through the common barrier. */
|
||||
private _rszSpaceCandidateRenderable(spaceId: string, sp: any): boolean {
|
||||
if (!this._serverCfg) return false;
|
||||
try {
|
||||
const candidate = {
|
||||
...this._serverCfg,
|
||||
spaces: this._serverCfg.spaces.map((space: any) =>
|
||||
space.id === preview.space ? preview.sp : space),
|
||||
space.id === spaceId ? sp : space),
|
||||
} as ServerConfig;
|
||||
return this._checkSpacePhysicalGeometry(candidate, preview.space).ok;
|
||||
return this._checkSpacePhysicalGeometry(candidate, spaceId).ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Final identity guard for the exact overlay already shown to the user. */
|
||||
private _rszCandidateRenderable(preview: { space: string; sp: any } | null): boolean {
|
||||
return !!preview && preview.space === this._space && this._rszPreview === preview
|
||||
&& this._rszSpaceCandidateRenderable(preview.space, preview.sp);
|
||||
}
|
||||
|
||||
private _rszEdgeDown(ev: PointerEvent, roomId: string, edge: number): void {
|
||||
if (this._tool !== 'resize' || this._rszDrag) return;
|
||||
ev.stopPropagation();
|
||||
@@ -8567,10 +8585,12 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
capturePointer(ev);
|
||||
const plan = resolution.plan;
|
||||
const start = this._svgPoint(ev);
|
||||
this._rszDrag = {
|
||||
pid: ev.pointerId, roomId, plan, opts: this._rszOptsFor(plan.a, plan.b),
|
||||
pid: ev.pointerId, start: [start[0], start[1]], roomId, plan,
|
||||
opts: this._rszOptsFor(plan.a, plan.b),
|
||||
rooms, openings: this._rszOpenings(), snap: this._rszSnapshot(),
|
||||
moved: false, d: 0, changed: [...plan.roomIds],
|
||||
moved: false, d: 0, changed: [...plan.roomIds], rejectNotified: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8595,7 +8615,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const p = this._svgPoint(ev);
|
||||
const plan = g.plan;
|
||||
const dRaw = (p[0] - plan.a[0]) * plan.n[0] + (p[1] - plan.a[1]) * plan.n[1];
|
||||
const dRaw = safeResizePointerDisplacement(g.start, p, plan.n);
|
||||
// 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 wanted = (sn[0] - plan.a[0]) * plan.n[0] + (sn[1] - plan.a[1]) * plan.n[1];
|
||||
@@ -8605,7 +8625,8 @@ class HouseplanCard extends LitElement {
|
||||
const previousLive = this._rszLive;
|
||||
const previousD = g.d;
|
||||
let res = applySafeResize(g.rooms, g.openings, plan, d);
|
||||
if (!this._rszApplyPreview(res.polys, res.openings)) {
|
||||
const previewResult = this._rszApplyPreview(res.polys, res.openings);
|
||||
if (!previewResult.ok) {
|
||||
// Persistence metadata is part of the geometry transaction. If wall or
|
||||
// virtual-span rekeying would be lossy, keep the last complete preview:
|
||||
// the pointer visibly stops there and pointerup can commit only that
|
||||
@@ -8613,6 +8634,10 @@ class HouseplanCard extends LitElement {
|
||||
this._rszPreview = previousPreview;
|
||||
this._rszLive = previousLive;
|
||||
g.d = previousD;
|
||||
if (!g.rejectNotified) {
|
||||
g.rejectNotified = true;
|
||||
this._showToast(this._t('resize.preview_failed'));
|
||||
}
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"resize.disabled.opening-conflict": "An opening on this wall cannot be moved safely",
|
||||
"resize.disabled.invalid-geometry": "This wall cannot be resized without changing the plan structure",
|
||||
"resize.commit_failed": "Resize was cancelled because the final plan did not pass the geometry check",
|
||||
"resize.preview_failed": "Resize stopped at the last safe position because the plan geometry could not be preserved",
|
||||
"markup.opening": "Opening",
|
||||
"markup.boundary": "Boundary",
|
||||
"markup.delete_room": "Delete room",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"resize.disabled.opening-conflict": "Проём на этой стене нельзя безопасно переместить",
|
||||
"resize.disabled.invalid-geometry": "Эту стену нельзя переместить без изменения структуры плана",
|
||||
"resize.commit_failed": "Изменение размера отменено: итоговый план не прошёл проверку геометрии",
|
||||
"resize.preview_failed": "Стена остановлена в последней безопасной позиции: геометрию плана нельзя сохранить без потерь",
|
||||
"markup.opening": "Проём",
|
||||
"markup.boundary": "Граница",
|
||||
"markup.delete_room": "Удалить комнату",
|
||||
|
||||
@@ -126,6 +126,18 @@ const add2 = (p: number[], d: number[]) => [p[0] + d[0], p[1] + d[1]];
|
||||
const dot = (p: number[], q: number[]) => p[0] * q[0] + p[1] * q[1];
|
||||
const len2d = (p: number[]) => Math.hypot(p[0], p[1]);
|
||||
|
||||
/** Signed pointer travel along the immutable wall normal (#293). The click
|
||||
* point itself is the origin: clicking near either jamb must not add the
|
||||
* wall's absolute position to the gesture. */
|
||||
export function safeResizePointerDisplacement(
|
||||
start: number[], current: number[], normal: [number, number],
|
||||
): number {
|
||||
if (![start?.[0], start?.[1], current?.[0], current?.[1], normal?.[0], normal?.[1]]
|
||||
.every(Number.isFinite)) return 0;
|
||||
return (current[0] - start[0]) * normal[0]
|
||||
+ (current[1] - start[1]) * normal[1];
|
||||
}
|
||||
|
||||
function signedArea(poly: number[][]): number {
|
||||
let s = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
|
||||
+30
-3
@@ -641,6 +641,7 @@ export function rekeyWallsAfterMove(
|
||||
.sort((a, b) => a - b)
|
||||
.filter((value, index, list) => index === 0
|
||||
|| Math.abs(value - list[index - 1]) * wallLen > exactEps);
|
||||
const mappedAtoms: [number[], number[]][] = [];
|
||||
for (let i = 0; i + 1 < bounds.length; i++) {
|
||||
const lo = bounds[i], hi = bounds[i + 1];
|
||||
if ((hi - lo) * wallLen <= exactEps) continue;
|
||||
@@ -649,7 +650,7 @@ export function rekeyWallsAfterMove(
|
||||
const candidates = overlaps.filter((overlap) =>
|
||||
mid >= overlap.lo - 1e-12 && mid <= overlap.hi + 1e-12);
|
||||
if (!candidates.length) {
|
||||
pushExact(a, b, w.cm);
|
||||
mappedAtoms.push([a, b]);
|
||||
continue;
|
||||
}
|
||||
const first: [number[], number[]] = [
|
||||
@@ -661,8 +662,34 @@ export function rekeyWallsAfterMove(
|
||||
});
|
||||
// Conflicting room transforms are invalid planner input. Preserve the
|
||||
// source atom rather than selecting by array order or losing masonry.
|
||||
if (conflict) pushExact(a, b, w.cm);
|
||||
else pushExact(first[0], first[1], w.cm);
|
||||
mappedAtoms.push(conflict ? [a, b] : first);
|
||||
}
|
||||
|
||||
// Two rooms on opposite sides of one shared seam contribute separate
|
||||
// side-edge moves. Moving the seam changes their meeting point, but the
|
||||
// physical wall covering both side edges is still one straight,
|
||||
// continuous record. Reassemble only atoms that meet exactly and stay
|
||||
// collinear; a real partial perpendicular move still leaves disjoint or
|
||||
// angled atoms and therefore keeps the lossless split from #253.
|
||||
const collinearForward = (left: [number[], number[]], right: [number[], number[]]): boolean => {
|
||||
const ldx = left[1][0] - left[0][0], ldy = left[1][1] - left[0][1];
|
||||
const rdx = right[1][0] - right[0][0], rdy = right[1][1] - right[0][1];
|
||||
const leftLength = Math.hypot(ldx, ldy);
|
||||
if (leftLength <= exactEps || ldx * rdx + ldy * rdy <= 0) return false;
|
||||
return Math.abs(ldx * rdy - ldy * rdx) / leftLength <= exactEps;
|
||||
};
|
||||
const coalesced: [number[], number[]][] = [];
|
||||
for (const atom of mappedAtoms) {
|
||||
const previous = coalesced[coalesced.length - 1];
|
||||
if (previous && closePoint(previous[1], atom[0])
|
||||
&& collinearForward(previous, atom)) {
|
||||
previous[1] = atom[1];
|
||||
} else {
|
||||
coalesced.push([[...atom[0]], [...atom[1]]]);
|
||||
}
|
||||
}
|
||||
for (const [a, b] of coalesced) {
|
||||
pushExact(a, b, w.cm);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ test('#277 every stable disabled reason and commit failure is localized RU/EN',
|
||||
|
||||
test('#277 a lossy persistence rekey stops at the last complete preview', () => {
|
||||
assert.match(card, /JSON\.stringify\(beforeWallCms\) !== JSON\.stringify\(afterWallCms\)/);
|
||||
assert.match(card, /if \(!this\._rszApplyPreview\(res\.polys, res\.openings\)\)/);
|
||||
assert.match(card, /const previewResult = this\._rszApplyPreview\(res\.polys, res\.openings\)/);
|
||||
assert.match(card, /if \(!previewResult\.ok\)/);
|
||||
assert.match(card, /this\._rszPreview = previousPreview/);
|
||||
assert.match(card, /g\.d = previousD/);
|
||||
});
|
||||
|
||||
+19
-1
@@ -8,7 +8,7 @@ import {
|
||||
validateEdgeDrag, clampEdgeDrag, applyRoomScale, validateRoomScale,
|
||||
clampRoomScale, areaM2, formatArea, MIN_ROOM_CM,
|
||||
resolveSafeResize, applySafeResize, validateSafeResize, clampSafeResize,
|
||||
safeResizeCachedDeltaCount,
|
||||
safeResizeCachedDeltaCount, safeResizePointerDisplacement,
|
||||
} from '../test-build/resize.js';
|
||||
import { roomPoly } from '../test-build/logic.js';
|
||||
import fs from 'node:fs';
|
||||
@@ -264,6 +264,24 @@ test('HP-1550-02: an already-thin triangle may improve but never worsen', () =>
|
||||
|
||||
const SAFE = { minDim: 25, eps: 0.1, movingHalf: 10, obstacles: [] };
|
||||
|
||||
test('#293 pointer displacement is relative to the click and only follows the normal', () => {
|
||||
const normal = [1, 0];
|
||||
for (const start of [[50, 100], [5, 100], [95, 100]]) {
|
||||
assert.equal(safeResizePointerDisplacement(start, [start[0] + 40, start[1]], normal), 40);
|
||||
assert.equal(safeResizePointerDisplacement(start, [start[0], start[1] + 60], normal), 0);
|
||||
}
|
||||
assert.equal(safeResizePointerDisplacement([50, 100], [90, 100], [-1, 0]), -40);
|
||||
assert.equal(safeResizePointerDisplacement([NaN, 0], [10, 0], normal), 0);
|
||||
});
|
||||
|
||||
test('#293 real-plan smoke cannot bypass the production pointer pipeline', () => {
|
||||
const source = fs.readFileSync(new URL('../demo/smoke_resize_pointer_real_plan.mjs', import.meta.url), 'utf8');
|
||||
assert.match(source, /page\.mouse\.down\(\)/);
|
||||
assert.match(source, /real-plan-second-floor\.json/);
|
||||
assert.doesNotMatch(source, /\._rsz(?:Move|ApplyPreview|Up)\s*\(/);
|
||||
assert.doesNotMatch(source, /\b(?:applySafeResize|clampSafeResize|validateSafeResize)\s*\(/);
|
||||
});
|
||||
|
||||
test('#277 non-shared: exactly two existing vertices move and topology stays fixed', () => {
|
||||
const rooms = [A()];
|
||||
const resolution = resolveSafeResize(rooms, [], 'A', 1, SAFE);
|
||||
|
||||
@@ -586,6 +586,27 @@ test('issue 253 equivalent shared-room transforms apply once and conflicts fail
|
||||
assert.deepEqual([conflictA[0].a, conflictA[0].b], old);
|
||||
});
|
||||
|
||||
test('issue 293 moving a shared seam keeps one continuous side-wall record', () => {
|
||||
const wall = setWallThickness([], [0, 0], [2, 0], 20, pitch);
|
||||
const next = rekeyWallsAfterMove(
|
||||
wall,
|
||||
[[[0, 0], [1, 0]], [[2, 0], [1, 0]]],
|
||||
[[[0, 0], [1.2, 0]], [[2, 0], [1.2, 0]]],
|
||||
pitch,
|
||||
);
|
||||
assert.equal(next.length, 1, 'a moving internal seam must not atomise one physical wall');
|
||||
assert.deepEqual([next[0].a, next[0].b], [[0, 0], [2, 0]]);
|
||||
assert.equal(next[0].cm, 20);
|
||||
|
||||
const bent = rekeyWallsAfterMove(
|
||||
wall,
|
||||
[[[0, 0], [1, 0]], [[2, 0], [1, 0]]],
|
||||
[[[0, 0], [1, 0.005]], [[2, 0], [1, 0.005]]],
|
||||
pitch,
|
||||
);
|
||||
assert.equal(bent.length, 2, 'meeting atoms with different directions must remain losslessly split');
|
||||
});
|
||||
|
||||
test('issue 253 key collisions never erase different exact or legacy records', () => {
|
||||
const exact = [
|
||||
{ key: wallKey([-1, 0], [1, 0], pitch), cm: 20, a: [-1, 0], b: [1, 0] },
|
||||
|
||||
@@ -110,7 +110,8 @@ test('#278 production source routes physical writers through one barrier and dec
|
||||
assert.match(source, /_commitPhysicalGeometry\([\s\S]{0,160}history\.split_room/);
|
||||
assert.match(source, /_recordGeometry\(this\._t\('history\.decor_edit'/);
|
||||
assert.doesNotMatch(source, /_commitPhysicalGeometry\(this\._t\('history\.decor_/);
|
||||
assert.match(source, /this\._checkSpacePhysicalGeometry\(candidate, preview\.space\)/);
|
||||
assert.match(source, /this\._rszSpaceCandidateRenderable\(preview\.space, preview\.sp\)/);
|
||||
assert.match(source, /this\._checkSpacePhysicalGeometry\(candidate, spaceId\)\.ok/);
|
||||
assert.match(source, /if \(physicalChanged\)[\s\S]{0,900}_pendingPhysicalWrites\.set\(state\.spaceId/,
|
||||
'physical Undo/Redo must retain the deferred-write barrier');
|
||||
assert.match(source, /if \(configChanged\)[\s\S]{0,300}_pendingPhysicalWrites\.clear\(\)/,
|
||||
|
||||
Reference in New Issue
Block a user