mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
fix: учесть направление ступеней и защитить совместимость (#663)
Issue: #663 User-Visible: yes
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
стены или другой лестнице; её габарит вычитается из чистой площади комнаты,
|
||||
но не вырезает видимый пол. Необязательная односторонняя ссылка безопасно
|
||||
переводит на выбранный этаж в Просмотре, а после жеста, при битой ссылке и в
|
||||
карточке с закреплённым этажом переход не срабатывает. Целевой этаж
|
||||
автоматически не меняется; в первой версии 2.5D лестница остаётся плоской
|
||||
карточке с закреплённым этажом переход не срабатывает. При любом направлении
|
||||
подъёма полные ступени отсчитываются от нижней границы, а короткий остаток
|
||||
остаётся у верхней. Целевой этаж автоматически не меняется; в первой версии
|
||||
2.5D лестница остаётся плоской
|
||||
на полу
|
||||
([#663](https://github.com/Matysh/houseplan-card/issues/663)).
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user