From d64338224da8606bf2b346906b8f6f2a9bd0a15e Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 27 Sep 2026 01:14:46 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=83=D1=87=D0=B5=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D1=82=D1=83=D0=BF=D0=B5=D0=BD=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20(#663)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #663 User-Visible: yes --- docs/CHANGELOG.md | 2 ++ docs/CHANGELOG.ru.md | 6 ++++-- scripts/mutation-registry.mjs | 39 +++++++++++++++++++++++++++++++++++ src/stairs.ts | 7 ++++--- test/stairs.test.mjs | 25 ++++++++++++++++++++++ 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ffc3edf3..c9b52a44 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,8 @@ footprint is deducted from clean room area without cutting the visible floor. An optional one-way target turns a stair into safe floor navigation in View, while gestures, broken targets and fixed-floor cards remain inert. + In either ascent direction, full treads are counted from the lower edge and + the short remainder stays at the upper edge. The target floor is never changed automatically, and the first 2.5D version stays flat on the floor plane ([#663](https://github.com/Matysh/houseplan-card/issues/663)). diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index eff6ab2a..d94b9fb3 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -14,8 +14,10 @@ стены или другой лестнице; её габарит вычитается из чистой площади комнаты, но не вырезает видимый пол. Необязательная односторонняя ссылка безопасно переводит на выбранный этаж в Просмотре, а после жеста, при битой ссылке и в - карточке с закреплённым этажом переход не срабатывает. Целевой этаж - автоматически не меняется; в первой версии 2.5D лестница остаётся плоской + карточке с закреплённым этажом переход не срабатывает. При любом направлении + подъёма полные ступени отсчитываются от нижней границы, а короткий остаток + остаётся у верхней. Целевой этаж автоматически не меняется; в первой версии + 2.5D лестница остаётся плоской на полу ([#663](https://github.com/Matysh/houseplan-card/issues/663)). diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 235b74c1..4ad64fd9 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -156,6 +156,45 @@ const MUTANT_DEFINITIONS = [ replace: ' const treadN = cmToNorm(STAIR_TREAD_CM, cellCm) * NORM_W / scale; // mutant\n', }], }, + { + id: 'stairs-broken-targets-become-active', + guard: 'node demo/smoke_stairs.mjs', + because: '#663 AC7: missing, self and deleted targets must stay visible and repairable, ' + + 'but must never navigate or hide their repair warning.', + patches: [{ + file: 'src/stairs-editor-model.ts', + find: " if (!stair.target_space_id) return 'missing';\n" + + " if (stair.target_space_id === currentSpaceId) return 'self';\n" + + " return spaceIds.has(stair.target_space_id) ? 'active' : 'deleted';\n", + replace: " return 'active'; // mutant: every broken target looks navigable\n", + }], + }, + { + id: 'stairs-legacy-config-materializes-empty-collection', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern=legacy-no-stairs-config test/stairs.test.mjs', + because: '#663 AC11: reading or optimizing a legacy space must not add an empty stairs ' + + 'collection to a configuration that never had the feature.', + patches: [{ + file: 'src/plan-optimizer.ts', + find: ' const beforeSpaces = clone(config.spaces || []);\n', + replace: ' const beforeSpaces = clone(config.spaces || []);\n' + + ' for (const space of config.spaces || []) space.stairs ??= []; // mutant\n', + }], + }, + { + id: 'stairs-import-skips-target-space-remap', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_663_full_import_repairs_stair_floor_target_by_exact_space_map ' + + 'tests_backend/test_ha_import_export.py', + because: '#663 AC11: a full import must remap a stair target through the exact space-id ' + + 'map instead of retaining the source document id.', + patches: [{ + file: 'custom_components/houseplan/import_export.py', + find: ' if mapped is not None:\n stair["target_space_id"] = mapped\n', + replace: ' if mapped is not None:\n stair["target_space_id"] = target # mutant\n', + }], + }, { id: 'stairs-backend-allows-251-items', guard: 'node scripts/backend-test-guard.mjs ' diff --git a/src/stairs.ts b/src/stairs.ts index 23ad5916..7f13f6e3 100644 --- a/src/stairs.ts +++ b/src/stairs.ts @@ -148,17 +148,18 @@ export function stairRenderGeometry( const outline = stairOutline(stair, scale); const treadN = cmToNorm(STAIR_TREAD_CM, cellCm); if (stair.kind === 'straight') { + const forward = stair.direction === 'forward'; const count = Math.max(0, Math.floor(stair.length / treadN)); const treads: StairLine[] = []; for (let index = 1; index <= count; index++) { - const x = -stair.length / 2 + index * treadN; - if (x >= stair.length / 2 - 1e-10) break; + const x = (forward ? -1 : 1) * stair.length / 2 + + (forward ? 1 : -1) * index * treadN; + if (forward ? x >= stair.length / 2 - 1e-10 : x <= -stair.length / 2 + 1e-10) break; treads.push({ a: worldPoint(stair, x, -stair.width / 2, scale), b: worldPoint(stair, x, stair.width / 2, scale), }); } - const forward = stair.direction === 'forward'; const fromX = (forward ? -0.3 : 0.3) * stair.length; const toX = (forward ? 0.3 : -0.3) * stair.length; const from = worldPoint(stair, fromX, 0, scale); diff --git a/test/stairs.test.mjs b/test/stairs.test.mjs index 388cd8c6..8d282236 100644 --- a/test/stairs.test.mjs +++ b/test/stairs.test.mjs @@ -71,6 +71,16 @@ test('#663 straight stair treads keep exact 30 cm intervals and top remainder', 'zoom changes only pixels, never the physical tread count'); assert.equal(zoomed.treads[1].a[0] - zoomed.treads[0].a[0], 12.5, 'the same 30 cm interval scales with the symbol, not with viewport zoom'); + + const backward = stairRenderGeometry(straight({ direction: 'backward' }), 5); + assert.equal(backward.treads.length, geometry.treads.length); + for (let index = 1; index < backward.treads.length; index++) { + assert.equal(backward.treads[index].a[0] - backward.treads[index - 1].a[0], -25); + } + assert.equal(backward.treads[0].a[0], 595, + 'backward starts its full intervals at the opposite lower edge'); + assert.equal(backward.treads.at(-1).a[0], 395, + 'the short remainder stays before the backward top edge'); }); test('#663 spiral stair uses one turn with 30 cm travel-line spacing', () => { @@ -182,3 +192,18 @@ test('#663 Optimize preserves continuous authored stair transforms exactly', () const result = optimizePlans(config, {}); assert.deepEqual(result.config.spaces[0].stairs[0], authored); }); + +test('#663 legacy-no-stairs-config never materializes an empty stair collection', () => { + const legacy = { + model_version: 10, + spaces: [{ + id: 'legacy', title: 'Legacy', cell_cm: 5, view_box: [0, 0, 1, 1], + rooms: [], wall_segments: [], + }], + markers: [], settings: {}, + }; + const before = structuredClone(legacy); + const result = optimizePlans(legacy, {}); + assert.equal(Object.hasOwn(result.config.spaces[0], 'stairs'), false); + assert.deepEqual(legacy, before, 'Optimize remains immutable for the caller'); +});