fix: учесть направление ступеней и защитить совместимость (#663)

Issue: #663
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-27 01:14:46 +03:00
parent 3f8aae838f
commit d64338224d
5 changed files with 74 additions and 5 deletions
+2
View File
@@ -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)).
+4 -2
View File
@@ -14,8 +14,10 @@
стены или другой лестнице; её габарит вычитается из чистой площади комнаты,
но не вырезает видимый пол. Необязательная односторонняя ссылка безопасно
переводит на выбранный этаж в Просмотре, а после жеста, при битой ссылке и в
карточке с закреплённым этажом переход не срабатывает. Целевой этаж
автоматически не меняется; в первой версии 2.5D лестница остаётся плоской
карточке с закреплённым этажом переход не срабатывает. При любом направлении
подъёма полные ступени отсчитываются от нижней границы, а короткий остаток
остаётся у верхней. Целевой этаж автоматически не меняется; в первой версии
2.5D лестница остаётся плоской
на полу
([#663](https://github.com/Matysh/houseplan-card/issues/663)).
+39
View File
@@ -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 '
+4 -3
View File
@@ -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);
+25
View File
@@ -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');
});