feat: добавить лестницы между этажами (#663)

Прямые и винтовые лестницы получили отдельную модель, инструменты редактора, безопасную межэтажную навигацию, вычитание из чистой площади и плоское отображение в 2.5D.

Issue: #663
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-26 21:00:43 +03:00
parent 781902b3a8
commit d03a68b88a
70 changed files with 2635 additions and 107 deletions
+14 -2
View File
@@ -361,8 +361,14 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* нельзя положить внутрь охраняемого ею чанка. Геометрия и сам рендер остаются
* ленивыми. Факт оставляет 953 Б сверху и 1 047 Б до нижней границы; общий
* бюджет не меняется.
*
* 2026-09-26, #663: 296 000 → 299 700 (замер 298 722). Read-only геометрия
* двух типов лестниц, межэтажный link hit-target и вычитание их footprint из
* чистой площади нужны непосредственно в View. Placement, transform и
* properties вынесены в lazy editor; общий бюджет 301 066 Б не меняется.
* Центр оставляет 978 Б сверху и 1 022 Б до нижней границы полосы.
*/
export const INITIAL_VIEW_GZIP_CEILING = 296_000;
export const INITIAL_VIEW_GZIP_CEILING = 299_700;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
@@ -606,8 +612,14 @@ export const LAZY_FURNITURE_ART_GZIP_CEILING = 17_900;
* контроллер хранит сессионные позиции, ограничивает их геометрией комнаты
* и арбитрирует pointer/pinch/cancel. Центр оставляет 1 012 Б сверху и
* 988 Б до нижней границы полосы.
* - #663: 232 900 → 236 400 (замер 235 443). В ленивый редактор вошёл
* отдельный runtime двух типов лестниц: placement, wall/stair magnet,
* continuous move/resize/rotate, properties dialog и undo/redo. Read-only
* символ и межэтажный переход отделены в eager runtime; первый View не
* загружает эти editor-only ветки. Центр оставляет 957 Б сверху и 1 043 Б
* до нижней границы полосы.
*/
export const LAZY_EDITOR_GZIP_CEILING = 232_900;
export const LAZY_EDITOR_GZIP_CEILING = 236_400;
export const LAZY_GRAPH_CEILING_BAND = 2_000;
/**
+70
View File
@@ -1379,6 +1379,76 @@
"required": false,
"type": "float"
},
"config.spaces[].stairs": {
"required": false
},
"config.spaces[].stairs[]": {
"allowExtra": true
},
"config.spaces[].stairs[].angle": {
"max": 360.0,
"min": -360.0,
"opaque": "<function _finite>",
"required": true
},
"config.spaces[].stairs[].direction": {
"enum": [
"backward",
"clockwise",
"counterclockwise",
"forward"
],
"required": true
},
"config.spaces[].stairs[].id": {
"maxLength": 64,
"minLength": 1,
"required": true,
"type": "str"
},
"config.spaces[].stairs[].kind": {
"enum": [
"spiral",
"straight"
],
"required": true
},
"config.spaces[].stairs[].length": {
"max": 5000.0,
"min": 1e-07,
"opaque": "<function _finite>",
"required": false
},
"config.spaces[].stairs[].radius": {
"max": 5000.0,
"min": 1e-07,
"opaque": "<function _finite>",
"required": false
},
"config.spaces[].stairs[].target_space_id": {
"nullable": true,
"opaque": "Match('^[a-z0-9_-]{1,64}$', msg=None)",
"required": false,
"type": "str"
},
"config.spaces[].stairs[].width": {
"max": 5000.0,
"min": 1e-07,
"opaque": "<function _finite>",
"required": false
},
"config.spaces[].stairs[].x": {
"max": 5000.0,
"min": -5000.0,
"opaque": "<function _finite>",
"required": true
},
"config.spaces[].stairs[].y": {
"max": 5000.0,
"min": -5000.0,
"opaque": "<function _finite>",
"required": true
},
"config.spaces[].title": {
"required": true,
"type": "str"
+1 -1
View File
@@ -4,5 +4,5 @@
"hostRefs": 4880,
"portPrivates": 94,
"harnessPrivates": 100,
"bundleBytes": 2540346
"bundleBytes": 2567812
}
+87
View File
@@ -72,6 +72,91 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
const MUTANT_DEFINITIONS = [
{
id: 'stairs-continuous-transform-snaps-to-lattice',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/coordinate-canonicalization.test.mjs',
because: '#663 AC2/AC9/AC11: a wall-face magnet is continuous and save/load or Optimize '
+ 'must not pull the stair back to a grid node.',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: " scalarFields(stair, ['x', 'y', 'length', 'width', 'radius', 'angle']);\n",
replace: " latticeFields(stair, ['x', 'y', 'length', 'width', 'radius', 'angle']); // mutant\n",
}],
},
{
id: 'stairs-area-does-not-subtract-footprints',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/stairs.test.mjs',
because: '#663 AC4: stair footprints reduce the clean room area once while leaving the '
+ 'visible floor polygon intact.',
patches: [{
file: 'src/stairs.ts',
find: ' return difference(source, ...footprints);\n',
replace: ' return source; // mutant: footprints no longer affect clean area\n',
}],
},
{
id: 'stairs-view-pan-opens-target-floor',
guard: 'node demo/smoke_stairs.mjs',
because: '#663 AC6: a pan or pinch beginning on a stair must not be promoted to the '
+ 'compatibility click that navigates to another floor.',
patches: [{
file: 'src/stairs-view.ts',
find: ' if (!active || this.owner._suppressClick || this.suppressClick\n',
replace: ' if (!active || this.suppressClick\n',
}],
},
{
id: 'stairs-fixed-floor-still-navigates',
guard: 'node demo/smoke_stairs.mjs',
because: '#663 AC8: fixed-floor cards show stairs but never promise or perform navigation.',
patches: [{
file: 'src/stairs-editor-model.ts',
find: " if (fixedFloor) return 'fixed';\n",
replace: ' if (false && fixedFloor) return \'fixed\'; // mutant: fixed-floor guard removed\n',
}],
},
{
id: 'stairs-link-auto-creates-target-object',
guard: 'node demo/smoke_stairs.mjs',
because: '#663 AC5: assigning a target is a one-way link and must not invent a mirrored '
+ 'stair on the destination floor.',
patches: [{
file: 'src/stairs-editor.ts',
find: ' this.write(this.stairs.map((item) => item.id === next.id ? next : item));\n'
+ ' this.owner._recordGeometry(this.owner._t(\'history.stair_edit\'), before);\n',
replace: ' this.write(this.stairs.map((item) => item.id === next.id ? next : item));\n'
+ ' const mirror = this.owner._model.find((item) => item.id === dialog.targetSpaceId);\n'
+ ' if (mirror) mirror.stairs = [...(mirror.stairs || []), { ...next, id: `${next.id}-mirror` }];\n'
+ ' this.owner._recordGeometry(this.owner._t(\'history.stair_edit\'), before);\n',
}],
},
{
id: 'stairs-tread-count-depends-on-render-scale',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/stairs.test.mjs',
because: '#663 AC3: 30 cm is a physical interval; viewport scale may change pixels but '
+ 'must not change the number or placement of treads.',
patches: [{
file: 'src/stairs.ts',
find: ' const treadN = cmToNorm(STAIR_TREAD_CM, cellCm);\n',
replace: ' const treadN = cmToNorm(STAIR_TREAD_CM, cellCm) * NORM_W / scale; // mutant\n',
}],
},
{
id: 'stairs-backend-allows-251-items',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_663_stair_schema_is_discriminated_bounded_and_forward_compatible '
+ 'tests_backend/test_validation.py',
because: '#663 AC13: persisted stair collections are bounded at 250 before render and '
+ 'support/diagnostic paths can receive them.',
patches: [{
file: 'custom_components/houseplan/validation.py',
find: ' vol.Optional("stairs"): vol.All([STAIR_SCHEMA], vol.Length(max=MAX_STAIRS)),\n',
replace: ' vol.Optional("stairs"): vol.All([STAIR_SCHEMA], vol.Length(max=MAX_STAIRS + 1)), # mutant\n',
}],
},
{
id: 'acl-readonly-group-becomes-writer',
guard: 'node scripts/backend-test-guard.mjs '
@@ -6648,6 +6733,7 @@ const MUTANT_DEFINITIONS = [
file: 'src/space-render.ts',
find: ' ${passageGlowTunnels}\n'
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
+ ' ${glowPools}\n ${wallUnion',
replace: ' ${passageGlowTunnels}\n'
+ ' ${!space.bg && !disp.showNames ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
@@ -6656,6 +6742,7 @@ const MUTANT_DEFINITIONS = [
+ ' data-area=${room.area || nothing} x=${center[0]} y=${center[1]}>${room.name}</text>`;\n'
+ ' })}</g>` : nothing}\n'
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
+ ' ${glowPools}\n'
+ ' ${wallUnion',
}],