fix: restore resize pointer gesture

Issue: #293
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 17:35:45 +03:00
parent f2e8fe5e93
commit 6c5163c3c9
19 changed files with 493 additions and 37 deletions
File diff suppressed because one or more lines are too long
+199
View File
@@ -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 });
+45
View File
@@ -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();
+4 -4
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -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
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- Активная ручка Resize теперь действительно следует за указателем на
проблемной планировке второго этажа, а не остаётся неподвижной. Жест считает
смещение от точки нажатия, сохраняет одну непрерывную запись толщины боковой
стены, продолжает работать за пределами ручки благодаря pointer capture и
останавливается на последнем отображаемом preview с явным сообщением вместо
молчаливого бездействия
([#293](https://github.com/Matysh/houseplan-card/issues/293)).
- Все сохраняемые координаты плана теперь приводятся к точному общему узлу
сетки `1/240`, если отличаются от него только невидимым хвостом вычислений.
Авторская off-grid/диагональная геометрия не сдвигается. «Оптимизировать
+22 -2
View File
@@ -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
+23
View File
@@ -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
+48
View File
@@ -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 '
+7 -6
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -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",
+1
View File
@@ -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": "Удалить комнату",
+12
View File
@@ -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
View File
@@ -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;
}
+2 -1
View File
@@ -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
View File
@@ -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);
+21
View File
@@ -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] },
+2 -1
View File
@@ -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\(\)/,