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
+1
View File
@@ -186,6 +186,7 @@ concurrent clients, but avoid editing the same object in two browsers at once.
- [Full user guide](docs/USER-GUIDE.md)
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
- [Stairs and floor links](docs/STAIRS.md)
- [Background editor](docs/DECOR-EDITOR.md)
- [Robot vacuums](docs/VACUUM.md)
- [Touch support](docs/TOUCH-SUPPORT.md)
@@ -158,6 +158,12 @@ def canonicalize_config_geometry(config: Any) -> Any:
_lattice_fields(decor, ("x", "y"))
_scalar_fields(decor, ("scale", "angle"))
for stair in _records(space.get("stairs")):
# #663: wall/stair magnet placement is continuous like furniture.
# Scalar cleanup removes JSON tails without moving an authored
# physical-face contact onto the plan grid.
_scalar_fields(stair, ("x", "y", "length", "width", "radius", "angle"))
# Legacy-only read boundary: v9 drafts are canonicalized before the
# wall-model migration turns their edges into current partitions.
for draft in _records(space.get("room_drafts")):
@@ -33,6 +33,7 @@ async def async_get_config_entry_diagnostics(
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
"partitions": len(s.get("partitions", [])),
"wall_columns": len(s.get("wall_columns", [])),
"stairs": len(s.get("stairs", [])),
}
for s in config.get("spaces", [])
],
+31 -3
View File
@@ -73,7 +73,7 @@ _LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_PLAN_ONLY_DASH = "—"
_IMPORT_ID_NAMESPACES = {
"space", "room", "marker", "partition", "wall", "opening", "decor", "column",
"space", "room", "marker", "partition", "wall", "opening", "decor", "column", "stair",
}
_MAX_IMPORT_LINEAGE_DEPTH = 16
_REPORT_EXAMPLE_LIMIT = 24
@@ -294,6 +294,14 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
projected["decor"] = [
_project_plan_only_decor(shape) for shape in space.get("decor") or []
]
if "stairs" in space:
projected["stairs"] = [
_pick_fields(stair, (
"id", "kind", "x", "y", "angle", "direction",
"target_space_id", "length", "width", "radius",
))
for stair in space.get("stairs") or []
]
return projected
@@ -742,6 +750,7 @@ def _counts(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, int]:
"markers": len(config.get("markers") or []),
"openings": sum(len(sp.get("openings") or []) for sp in spaces),
"decor": sum(len(sp.get("decor") or []) for sp in spaces),
"stairs": sum(len(sp.get("stairs") or []) for sp in spaces),
"layout": len(layout),
}
@@ -1132,6 +1141,14 @@ def _repair_target_space_refs(
for space in spaces:
space_id = str(space.get("id", "?"))
for stair in space.get("stairs") or []:
target = stair.get("target_space_id") if isinstance(stair, dict) else None
mapped = replace(
f"{space_id}:{stair.get('id', '?')}", "stair.target_space_id",
target, resolve_space,
)
if mapped is not None:
stair["target_space_id"] = mapped
for room in space.get("rooms") or []:
room_id = str(room.get("id", "?"))
values = room.get("open_to")
@@ -1322,7 +1339,7 @@ def build_space_merge(
(sp,), sp.get("rooms") or [],
sp.get("partitions") or [], sp.get("wall_segments") or [],
sp.get("wall_columns") or [],
sp.get("openings") or [], sp.get("decor") or [],
sp.get("openings") or [], sp.get("decor") or [], sp.get("stairs") or [],
)
for item in collection
if isinstance(item, dict)
@@ -1338,7 +1355,7 @@ def build_space_merge(
(incoming_space,), incoming_space.get("rooms") or [],
incoming_space.get("partitions") or [], incoming_space.get("wall_segments") or [],
incoming_space.get("wall_columns") or [],
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
incoming_space.get("openings") or [], incoming_space.get("decor") or [], incoming_space.get("stairs") or [],
)
for item in collection
if isinstance(item, dict)
@@ -1362,6 +1379,7 @@ def build_space_merge(
("rooms", "room"), ("partitions", "partition"),
("wall_segments", "wall"),
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
("stairs", "stair"),
):
for item in space.get(collection) or []:
if isinstance(item, dict) and item.get("id") is not None:
@@ -1405,6 +1423,16 @@ def build_space_merge(
reference_report, "incoming", "opening.host",
str(opening.get("id", "?")), old_host_id,
)
# A one-space export cannot carry a trustworthy external floor target.
# Self-links are invalid too because the imported source gets a new id.
for stair in space.get("stairs") or []:
target = stair.get("target_space_id") if isinstance(stair, dict) else None
if target is not None:
stair["target_space_id"] = None
_report_remap(
reference_report, "incoming", "stair.target_space_id",
str(stair.get("id", "?")), str(target),
)
space["id"] = new_space_id
space["title"] = _unique_title(
str(space.get("title") or old_space_id), current_config.get("spaces") or []
@@ -374,6 +374,17 @@ def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[
out["decor"] = [
_project_decor(ids, item) for item in space.get("decor") or [] if isinstance(item, dict)
]
out["stairs"] = []
for item in space.get("stairs") or []:
if not isinstance(item, dict):
continue
projected = {"id": ids.get("stair", item.get("id"))}
projected.update(_copy_keys(item, (
"kind", "x", "y", "angle", "direction", "length", "width", "radius",
)))
if item.get("target_space_id"):
projected["target_space_id"] = ids.get("space", item.get("target_space_id"))
out["stairs"].append(projected)
out["open_spans"] = [
{"a": _point(item.get("a")), "b": _point(item.get("b"))}
for item in space.get("open_spans") or [] if isinstance(item, dict)
@@ -436,6 +447,7 @@ def _summary(config: object, layout: object) -> dict[str, Any]:
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
"openings": dict(sorted(kinds.items())),
"decor": dict(sorted(decor_kinds.items())),
"stairs": sum(len(space.get("stairs") or []) for space in spaces if isinstance(space, dict)),
"markers": {
"total": len(markers),
"lifecycle": dict(sorted(lifecycles.items())),
@@ -31,6 +31,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
"stairs": sum(len(s.get("stairs", [])) for s in config.get("spaces", [])),
"markers": len(config.get("markers", [])),
"layout_entries": len(layout_raw.get("layout", {})),
}
+54 -1
View File
@@ -1198,6 +1198,7 @@ MAX_ROOMS = 400
MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_STAIRS = 250
# v8 atomises room boundaries. The 2 MiB wire cap is the practical bound; this
# structural cap mirrors MAX_ROOMS * MAX_POLY_POINTS without depending on the
# later constant declaration.
@@ -1532,6 +1533,57 @@ DECOR_SCHEMA = vol.Any(
)
def _strict_stair(value: dict) -> dict:
"""A discriminated stair record carries dimensions for exactly one kind."""
if value["kind"] == "straight":
if "radius" in value:
raise vol.Invalid("radius is allowed only for spiral stairs")
if value["direction"] not in ("forward", "backward"):
raise vol.Invalid("straight stair direction must be forward or backward")
else:
if "length" in value or "width" in value:
raise vol.Invalid("length and width are allowed only for straight stairs")
if value["direction"] not in ("clockwise", "counterclockwise"):
raise vol.Invalid("spiral stair direction must be clockwise or counterclockwise")
return value
def _stair_dimensions(value: dict) -> dict:
"""Require the dimension fields owned by the selected stair kind."""
complete = (
value["kind"] == "straight" and "length" in value and "width" in value
or value["kind"] == "spiral" and "radius" in value
)
if not complete:
raise vol.Invalid("stair kind requires its dimensions")
return value
STAIR_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("kind"): vol.In(["straight", "spiral"]),
vol.Required("x"): _NORM,
vol.Required("y"): _NORM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Required("direction"): vol.In([
"forward", "backward", "clockwise", "counterclockwise",
]),
vol.Optional("target_space_id"): vol.Any(
None, vol.All(str, vol.Match(SPACE_ID_RE.pattern))
),
vol.Optional("length"): _FURN_SIZE,
vol.Optional("width"): _FURN_SIZE,
vol.Optional("radius"): _FURN_SIZE,
},
extra=vol.ALLOW_EXTRA,
),
_stair_dimensions,
_strict_stair,
)
def _wall_endpoints_pair(entry: dict) -> dict:
"""Exact wall endpoints are useful only as a complete a/b pair."""
if ("a" in entry) != ("b" in entry):
@@ -1673,7 +1725,7 @@ OPENING_HOST_SCHEMA = vol.Any(PARTITION_OPENING_HOST_SCHEMA, WALL_OPENING_HOST_S
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
seen: set[str] = set()
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns", "wall_segments"):
for key in ("rooms", "openings", "decor", "stairs", "room_drafts", "partitions", "wall_columns", "wall_segments"):
for item in value.get(key, []):
item_id = item.get("id")
if not item_id:
@@ -1768,6 +1820,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
vol.Optional("stairs"): vol.All([STAIR_SCHEMA], vol.Length(max=MAX_STAIRS)),
vol.Optional("openings"): vol.All([
vol.Schema(
{
@@ -439,7 +439,7 @@ def _thickness(space: dict[str, Any], atom: dict[str, Any], previous: dict[str,
def _non_catalog_ids(space: dict[str, Any]) -> set[str]:
result: set[str] = set()
for name in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for name in ("rooms", "openings", "decor", "stairs", "room_drafts", "partitions", "wall_columns"):
for item in space.get(name) or []:
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]:
result.add(item["id"])
@@ -661,7 +661,7 @@ def _migrate_room_drafts_to_partitions(space: dict[str, Any]) -> tuple[int, int]
return 0, 0
used = {
str(item["id"])
for name in ("rooms", "openings", "decor", "partitions",
for name in ("rooms", "openings", "decor", "stairs", "partitions",
"wall_columns", "wall_segments")
for item in space.get(name) or []
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
@@ -1848,6 +1848,10 @@ def _space_delete_candidate(
item for item in candidate_config.get("spaces") or []
if item.get("id") != space_id
]
for remaining_space in candidate_config["spaces"]:
for stair in remaining_space.get("stairs") or []:
if isinstance(stair, dict) and stair.get("target_space_id") == space_id:
stair["target_space_id"] = None
for marker in candidate_config.get("markers") or []:
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
+7 -2
View File
@@ -40,6 +40,7 @@ const requiresIsoStructuralBuildCounter = requiresIsometric
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
const requiresStairs = existsSync(resolve(targetRoot, 'src/stairs.ts'));
const fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture();
const fixtureCounts = stage3Dense ? fixture.counts : LARGE_HOUSE_COUNTS;
const sourceSha = (() => {
@@ -100,7 +101,7 @@ try {
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, profile,
requiresIsoStructuralBuildCounter, requiresStairs, profile,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -400,6 +401,10 @@ try {
await until(() => card._booting === false);
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
await frame();
if (requiresStairs
&& card.renderRoot.querySelectorAll('[data-hp="stair"]').length !== fixture.counts.stairs) {
throw new Error(`${profile} did not render the bounded maximum stair collection`);
}
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const bootDiag = {
updates: card.__diag.updates,
@@ -1219,7 +1224,7 @@ try {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, profile,
requiresIsoStructuralBuildCounter, requiresStairs, profile,
});
// #520: диагностика печатается в лог прогона и в запись не попадает.
const { bootDiag, ...measured } = row;
+20
View File
@@ -12,6 +12,7 @@ const OPENING_COUNT = 100;
const PARTITION_COUNT = 60;
const COLUMN_COUNT = 40;
const DECOR_COUNT = 500;
const STAIR_COUNT = 250;
const round = (value) => Number(value.toFixed(6));
@@ -119,6 +120,23 @@ const makeDecor = (floor, count) => Array.from({ length: count }, (_, index) =>
};
});
const makeStairs = (floor, count) => Array.from({ length: count }, (_, index) => {
const column = index % 25;
const row = Math.floor(index / 25);
const common = {
id: `perf-stair-${floor}-${index}`,
x: round(0.025 + column * 0.039),
y: round(0.03 + row * 0.1),
angle: (index % 8) * 45,
target_space_id: 'perf-floor-2',
};
return index % 2 === 0
? { ...common, kind: 'straight', direction: index % 4 ? 'forward' : 'backward',
length: 0.12, width: 0.045 }
: { ...common, kind: 'spiral', direction: index % 4 ? 'clockwise' : 'counterclockwise',
radius: 0.05 };
});
const entityKinds = [
['light', 'on'],
['switch', 'off'],
@@ -188,6 +206,7 @@ export const LARGE_HOUSE_COUNTS = Object.freeze({
partitions: PARTITION_COUNT,
columns: COLUMN_COUNT,
decor: DECOR_COUNT,
stairs: STAIR_COUNT,
});
export const makeLargeHouseFixture = () => {
@@ -228,6 +247,7 @@ export const makeLargeHouseFixture = () => {
partitions: makePartitions(floor, rooms, partitionCount),
wall_columns: makeColumns(floor, rooms, columnCount),
decor: makeDecor(floor, decorCount),
...(floor === 0 ? { stairs: makeStairs(floor, STAIR_COUNT) } : {}),
};
});
const runtime = makeRuntime(spaces);
+35
View File
@@ -727,6 +727,18 @@ export function prepareGoldenFixture(scenario) {
}
space.decor = structuredClone(scenario.decorOverride);
}
if (scenario.stairsOverride) {
const space = requireSpace();
const known = new Set();
for (const stair of scenario.stairsOverride) {
if (!stair?.id || known.has(stair.id))
throw new Error(`golden stairsOverride has missing/duplicate id: ${stair?.id || '<empty>'}`);
if (!['straight', 'spiral'].includes(stair.kind))
throw new Error(`golden stairsOverride has unknown kind: ${stair.kind}`);
known.add(stair.id);
}
space.stairs = structuredClone(scenario.stairsOverride);
}
if (scenario.openingGeometry) {
const space = requireSpace();
const opening = (space.openings || []).find(
@@ -1538,6 +1550,17 @@ export async function prepareGoldenScenario(page, scenario) {
throw new Error(`golden furniture transform frame is incomplete: ${scenario.id}`);
}
}
if (scenario.stairSelection) {
const target = card.renderRoot.querySelector(
`[data-hp="stair"][data-id="${CSS.escape(scenario.stairSelection)}"] .hp-stair-hit`,
);
target?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await card.updateComplete;
await frame();
if (!target || !card.renderRoot.querySelector(
`[data-hp="stair"][data-id="${CSS.escape(scenario.stairSelection)}"].selected`,
)) throw new Error(`golden stair selection missing: ${scenario.id}`);
}
if (scenario.editorTray) {
let expectedKind = '';
if (scenario.editorTray === 'plan-selection') {
@@ -2347,6 +2370,18 @@ export async function prepareGoldenScenario(page, scenario) {
});
}
}
if (scenario.hoverStair) {
const point = await page.evaluate((id) => {
const stair = window.__goldenCard?.renderRoot?.querySelector(
`[data-hp="stair"][data-id="${CSS.escape(id)}"] .hp-stair-hit`,
);
if (!stair) return null;
const rect = stair.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}, scenario.hoverStair);
if (!point) throw new Error(`golden hover stair missing: ${scenario.hoverStair}`);
await page.mouse.move(point.x, point.y);
}
return result;
}
+30 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 64;
export const GOLDEN_MATRIX_VERSION = 65;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -203,7 +203,36 @@ const decorLayerProbes = {
],
};
const stairLayerFixture = [
{ id: 'golden-stair-straight-large', kind: 'straight', x: 0.25, y: 0.27,
length: 0.28, width: 0.10, angle: 0, direction: 'forward',
target_space_id: 'golden-lighting' },
{ id: 'golden-stair-straight-small', kind: 'straight', x: 0.72, y: 0.27,
length: 0.14, width: 0.065, angle: 45, direction: 'backward',
target_space_id: 'golden-lighting' },
{ id: 'golden-stair-spiral-large', kind: 'spiral', x: 0.25, y: 0.73,
radius: 0.10, angle: 15, direction: 'clockwise',
target_space_id: 'golden-lighting' },
{ id: 'golden-stair-spiral-small', kind: 'spiral', x: 0.72, y: 0.73,
radius: 0.06, angle: 210, direction: 'counterclockwise',
target_space_id: 'golden-lighting' },
];
export const GOLDEN_SCENARIOS = Object.freeze([
// #663 AC3/AC10/AC12: both stair geometries, both rise directions and
// small/large footprints across themes, flat/2.5D and interaction states.
{ id: 'stairs-flat-normal-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
stairsOverride: stairLayerFixture, language: 'en', theme: 'light',
viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'stairs-flat-hover-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
stairsOverride: stairLayerFixture, hoverStair: 'golden-stair-straight-large',
language: 'ru', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'stairs-flat-selected-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
stairsOverride: stairLayerFixture, stairSelection: 'golden-stair-spiral-large',
language: 'en', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'stairs-isometric-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
projection: 'iso', stairsOverride: stairLayerFixture,
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
// #486 AC7: the custom HA panel owns the full viewport. These three focused
// frames pin its populated View, its Plan-editor chrome and the deliberately
// inert read-only first-run state without duplicating the card matrix below.
+341
View File
@@ -0,0 +1,341 @@
// #663: straight and spiral stairs are plan-editor objects, clean View links
// between spaces, and flat floor content in 2.5D. Exercise only public DOM
// hooks and the harness facade for writes; card internals are read-only oracles.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1100, height: 850 });
const out = await page.evaluate(async () => {
const card = window.__card;
const hp = window.__hpTest;
const root = () => card.renderRoot;
const settled = () => hp.settled();
const settleCamera = async () => {
for (let guard = 0; card._cameraTransition?.active && guard < 90; guard++)
await new Promise((resolve) => requestAnimationFrame(resolve));
await settled();
};
const spaceCfg = (id) => card._serverCfg.spaces.find((space) => space.id === id);
const stairs = (id = 'f1') => spaceCfg(id)?.stairs || [];
const stairNode = (id) => root().querySelector(`[data-hp="stair"][data-id="${id}"]`);
const planSvg = () => root().querySelector('.plan-svg');
const stage = () => root().querySelector('.stage');
const screen = ([x, y]) => {
const point = new DOMPoint(x, y).matrixTransform(planSvg().getScreenCTM());
return { clientX: point.x, clientY: point.y };
};
const clickPlan = async (point) => {
stage().dispatchEvent(new MouseEvent('click', {
...screen(point), bubbles: true, composed: true, cancelable: true, button: 0,
}));
await settled();
};
const pointer = (target, type, point, id, extra = {}) => target.dispatchEvent(new PointerEvent(type, {
...screen(point), bubbles: true, composed: true, cancelable: true,
pointerId: id, pointerType: extra.pointerType || 'mouse',
button: type === 'pointerdown' ? 0 : -1, isPrimary: extra.isPrimary ?? true,
shiftKey: !!extra.shiftKey,
}));
const chooseStair = async (kind) => {
await hp.setTool('stairs');
const item = root().querySelector(`[data-hp="tray"] [data-group-item="${kind}"]`);
item?.click();
await settled();
return !!item;
};
const setDialogSelect = (select, value) => {
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
};
const openStairDialog = async (id) => {
stairNode(id)?.dispatchEvent(new MouseEvent('dblclick', {
bubbles: true, composed: true, cancelable: true,
}));
await settled();
return root().querySelector('[data-hp="dialog"][data-kind="stairs"]');
};
const saveDialog = async (dialog) => {
dialog?.querySelector('.dialog-action-footer .btn.on')?.click();
await settled();
};
const activeSpace = () => root().querySelector('[data-hp="space-tab"][aria-current="page"]')
?.getAttribute('data-id');
const closeTo = (a, b, tolerance = 1e-5) => Math.abs(a - b) <= tolerance;
const result = {};
await hp.setServerConfig((config) => {
for (const space of config.spaces) delete space.stairs;
const first = config.spaces.find((space) => space.id === 'f1');
first.partitions = [...(first.partitions || []), {
id: 'stair-smoke-wall', a: [0.15, 0.60], b: [0.85, 0.60], cm: 20,
}];
return config;
});
await hp.switchSpace('f1');
await hp.setMode('plan');
result.straightToolExists = await chooseStair('straight');
await clickPlan([400, 650]);
const straight = stairs().find((stair) => stair.kind === 'straight');
result.straightCreatedOnlyOnCurrentFloor = !!straight && stairs('garden').length === 0;
result.wallMagnetUsesPhysicalFace = !!straight
&& closeTo(Math.abs(straight.y * 1000 - 600), straight.width * 500 + (20 / 5) * (1000 / 240) / 2, 2)
&& closeTo(((straight.angle % 180) + 180) % 180, 0);
result.spiralToolExists = await chooseStair('spiral');
await clickPlan([580, 650]);
const spiral = stairs().find((stair) => stair.kind === 'spiral');
result.spiralCreated = !!spiral && stairs().length === 2;
result.stairMagnetTouchesOtherFootprint = !!straight && !!spiral
&& closeTo(Math.abs(spiral.x - straight.x) * 1000,
(straight.length / 2 + spiral.radius) * 1000, 0.5);
result.renderedTypesAndDirections = !!root().querySelector(
'[data-hp="stair"][data-kind="straight"] .hp-stair-arrow',
) && !!root().querySelector('[data-hp="stair"][data-kind="spiral"] .hp-stair-arrow');
// Select and move the straight stair through its actual hit target.
const start = { x: straight.x, y: straight.y };
const straightHit = stairNode(straight.id)?.querySelector('.hp-stair-hit');
straightHit?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await settled();
pointer(straightHit, 'pointerdown', [start.x * 1000, start.y * 1000], 6631);
pointer(stage(), 'pointermove', [start.x * 1000 - 180, start.y * 1000 - 170], 6631);
pointer(stage(), 'pointerup', [start.x * 1000 - 180, start.y * 1000 - 170], 6631);
await settled();
const moved = stairs().find((stair) => stair.id === straight.id);
result.moveUsesContinuousTransform = !!moved
&& (!closeTo(moved.x, start.x) || !closeTo(moved.y, start.y));
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, cancelable: true,
}));
await settled();
const undone = stairs().find((stair) => stair.id === straight.id);
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true,
}));
await settled();
const redone = stairs().find((stair) => stair.id === straight.id);
result.moveUndoRedo = !!undone && !!redone
&& closeTo(undone.x, start.x) && closeTo(undone.y, start.y)
&& closeTo(redone.x, moved.x) && closeTo(redone.y, moved.y);
// Resize and Shift-rotate through the visible handles.
stairNode(straight.id)?.querySelector('.hp-stair-hit')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await settled();
const beforeResize = stairs().find((stair) => stair.id === straight.id);
const resize = stairNode(straight.id)?.querySelector('.hp-stair-resize');
const resizePoint = [Number(resize?.getAttribute('cx')), Number(resize?.getAttribute('cy'))];
pointer(resize, 'pointerdown', resizePoint, 6632);
pointer(stage(), 'pointermove', [resizePoint[0] - 35, resizePoint[1] - 25], 6632);
pointer(stage(), 'pointerup', [resizePoint[0] - 35, resizePoint[1] - 25], 6632);
await settled();
const resized = stairs().find((stair) => stair.id === straight.id);
result.resizeIsContinuous = !!beforeResize && !!resized
&& (!closeTo(beforeResize.length, resized.length) || !closeTo(beforeResize.width, resized.width));
const rotate = stairNode(straight.id)?.querySelector('.hp-stair-rotate');
const rotatePoint = [Number(rotate?.getAttribute('cx')), Number(rotate?.getAttribute('cy'))];
pointer(rotate, 'pointerdown', rotatePoint, 6633);
pointer(stage(), 'pointermove', [rotatePoint[0] + 47, rotatePoint[1] + 19], 6633, { shiftKey: true });
pointer(stage(), 'pointerup', [rotatePoint[0] + 47, rotatePoint[1] + 19], 6633, { shiftKey: true });
await settled();
const rotated = stairs().find((stair) => stair.id === straight.id);
result.shiftRotationSnaps45 = !!rotated && closeTo(rotated.angle / 45, Math.round(rotated.angle / 45));
// Properties switch kind without changing identity, then switch it back and link Garden.
let dialog = await openStairDialog(straight.id);
const selects = dialog ? [...dialog.querySelectorAll('select')] : [];
if (selects[0]) setDialogSelect(selects[0], 'spiral');
await saveDialog(dialog);
const converted = stairs().find((stair) => stair.id === straight.id);
dialog = await openStairDialog(straight.id);
const restoredSelects = dialog ? [...dialog.querySelectorAll('select')] : [];
if (restoredSelects[0]) setDialogSelect(restoredSelects[0], 'straight');
if (restoredSelects[1]) setDialogSelect(restoredSelects[1], 'garden');
await saveDialog(dialog);
const linked = stairs().find((stair) => stair.id === straight.id);
result.kindSwitchKeepsIdentityAndLink = converted?.kind === 'spiral'
&& linked?.kind === 'straight' && linked.target_space_id === 'garden';
await hp.setMode('view');
const linkedNode = stairNode(straight.id);
result.validLinkIsAccessible = linkedNode?.getAttribute('role') === 'link'
&& linkedNode?.getAttribute('data-target-state') === 'active';
// A pan ending on the stair owns its compatibility click and cannot navigate.
const linkedCenter = [linked.x * 1000, linked.y * 1000];
pointer(linkedNode, 'pointerdown', linkedCenter, 6634, { pointerType: 'touch' });
pointer(stage(), 'pointermove', [linkedCenter[0] + 60, linkedCenter[1] + 20], 6634, { pointerType: 'touch' });
pointer(stage(), 'pointerup', [linkedCenter[0] + 60, linkedCenter[1] + 20], 6634, { pointerType: 'touch' });
linkedNode?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
result.panClickSuppressed = activeSpace() === 'f1';
await new Promise((resolve) => setTimeout(resolve, 0));
pointer(linkedNode, 'pointerdown', linkedCenter, 6635, { pointerType: 'touch' });
pointer(stage(), 'pointermove', [linkedCenter[0] + 35, linkedCenter[1] + 45], 6635, { pointerType: 'touch' });
pointer(stage(), 'pointercancel', [linkedCenter[0] + 35, linkedCenter[1] + 45], 6635, { pointerType: 'touch' });
linkedNode?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
result.pointerCancelClickSuppressed = activeSpace() === 'f1';
await new Promise((resolve) => setTimeout(resolve, 0));
// Cancellation without any movement must also disarm the compatibility
// click; it cannot rely on the pan path having set _suppressClick.
pointer(stairNode(straight.id)?.querySelector('.hp-stair-hit'),
'pointerdown', linkedCenter, 6642, { pointerType: 'touch' });
pointer(stage(), 'pointercancel', linkedCenter, 6642, { pointerType: 'touch' });
stairNode(straight.id)?.querySelector('.hp-stair-hit')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }),
);
result.cancelledTapClickSuppressed = activeSpace() === 'f1';
// A motionless long press is still a gesture, not a floor-link activation.
pointer(stairNode(straight.id)?.querySelector('.hp-stair-hit'),
'pointerdown', linkedCenter, 6636, { pointerType: 'touch' });
await new Promise((resolve) => setTimeout(resolve, 650));
pointer(stage(), 'pointerup', linkedCenter, 6636, { pointerType: 'touch' });
stairNode(straight.id)?.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true, cancelable: true,
}));
result.longPressClickSuppressed = activeSpace() === 'f1';
await new Promise((resolve) => setTimeout(resolve, 0));
// The shared multi-touch barrier owns delayed compatibility clicks even
// when the first finger started on this interactive child.
pointer(stairNode(straight.id)?.querySelector('.hp-stair-hit'),
'pointerdown', linkedCenter, 6637, { pointerType: 'touch' });
pointer(stage(), 'pointerdown', [linkedCenter[0] + 80, linkedCenter[1] + 30], 6638, {
pointerType: 'touch', isPrimary: false,
});
pointer(stage(), 'pointermove', [linkedCenter[0] + 110, linkedCenter[1] + 45], 6638, {
pointerType: 'touch', isPrimary: false,
});
pointer(stage(), 'pointerup', [linkedCenter[0] + 110, linkedCenter[1] + 45], 6638, {
pointerType: 'touch', isPrimary: false,
});
pointer(stage(), 'pointerup', linkedCenter, 6637, { pointerType: 'touch' });
stairNode(straight.id)?.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true, cancelable: true,
}));
result.pinchClickSuppressed = activeSpace() === 'f1';
// A new pointer sequence is the shared guard's explicit re-arm signal.
pointer(stage(), 'pointerdown', [40, 40], 6640);
pointer(stage(), 'pointerup', [40, 40], 6640);
// Store a user view on Garden through public controls, return, then navigate by stair.
await hp.switchSpace('garden');
root().querySelector('[data-hp="zoom-in"]')?.click();
await settleCamera();
const remembered = planSvg().getAttribute('viewBox');
await hp.switchSpace('f1');
const cleanNode = stairNode(straight.id);
pointer(cleanNode?.querySelector('.hp-stair-hit'), 'pointerdown', linkedCenter, 6639);
pointer(stage(), 'pointerup', linkedCenter, 6639);
cleanNode?.querySelector('.hp-stair-hit')?.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true, cancelable: true,
}));
await settleCamera();
result.cleanClickNavigatesAndRestoresView = activeSpace() === 'garden'
&& planSvg().getAttribute('viewBox') === remembered;
result.targetFloorGetsNoAutomaticStair = !root().querySelector('[data-hp="stair"]')
&& stairs('garden').length === 0;
// The circular variant uses the same real hit surface and navigation path.
await hp.setServerConfig((config) => {
config.spaces.find((space) => space.id === 'f1').stairs
.find((stair) => stair.id === spiral.id).target_space_id = 'garden';
return config;
});
await hp.switchSpace('f1');
const spiralNode = stairNode(spiral.id);
const spiralCenter = [spiral.x * 1000, spiral.y * 1000];
pointer(spiralNode?.querySelector('.hp-stair-hit'), 'pointerdown', spiralCenter, 6641);
pointer(stage(), 'pointerup', spiralCenter, 6641);
spiralNode?.querySelector('.hp-stair-hit')?.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true, cancelable: true,
}));
await settleCamera();
result.spiralCleanClickNavigates = activeSpace() === 'garden';
// Missing/self/deleted targets remain visible, repairable and inert.
await hp.setServerConfig((config) => {
const source = config.spaces.find((space) => space.id === 'f1');
source.stairs.find((stair) => stair.id === linked.id).target_space_id = null;
return config;
});
await hp.switchSpace('f1');
let broken = stairNode(straight.id);
broken?.querySelector('.hp-stair-hit')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
result.missingTargetIsVisibleAndInert = activeSpace() === 'f1'
&& broken?.getAttribute('data-target-state') === 'missing'
&& broken?.getAttribute('role') === 'img';
await hp.setServerConfig((config) => {
config.spaces.find((space) => space.id === 'f1').stairs
.find((stair) => stair.id === linked.id).target_space_id = 'f1';
return config;
});
broken = stairNode(straight.id);
broken?.querySelector('.hp-stair-hit')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
result.selfTargetIsVisibleAndInert = activeSpace() === 'f1'
&& broken?.getAttribute('data-target-state') === 'self'
&& broken?.getAttribute('role') === 'img';
await hp.setServerConfig((config) => {
config.spaces.find((space) => space.id === 'f1').stairs
.find((stair) => stair.id === linked.id).target_space_id = '__deleted__';
return config;
});
broken = stairNode(straight.id);
broken?.querySelector('.hp-stair-hit')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await settled();
result.deletedTargetIsVisibleAndInert = activeSpace() === 'f1'
&& broken?.getAttribute('data-target-state') === 'deleted'
&& broken?.getAttribute('role') === 'img';
await hp.setMode('plan');
dialog = await openStairDialog(straight.id);
result.brokenTargetHasRepairWarning = !!dialog?.querySelector('.hint.warn')
&& [...dialog.querySelectorAll('option')].every((option) => option.value !== '__deleted__');
await hp.close(dialog, { via: 'cancel' });
result.dialogCancelCloses = !root().querySelector('[data-hp="dialog"][data-kind="stairs"]');
// A fixed-floor card keeps the same object visible but never presents a link.
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan', icon_size: 3.4, floor: 'f1' });
await settled();
await hp.setMode('view');
const fixed = stairNode(straight.id);
fixed?.querySelector('.hp-stair-hit')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await settled();
result.fixedFloorIsVisibleAndInert = activeSpace() === 'f1'
&& fixed?.getAttribute('data-target-state') === 'fixed'
&& fixed?.getAttribute('role') === 'img';
// Restore a valid link and prove that the same flat symbol/hit target is
// projected with the floor in 2.5D and still performs its View action.
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan', icon_size: 3.4 });
await hp.setServerConfig((config) => {
const source = config.spaces.find((space) => space.id === 'f1');
source.stairs.find((stair) => stair.id === linked.id).target_space_id = 'garden';
return config;
});
await hp.switchSpace('f1');
await hp.setVolumetricView(true);
const isoStair = stairNode(straight.id);
result.flatIsoSymbolSharesFloorProjection = stage().classList.contains('projection-iso')
&& !!isoStair?.closest('.iso-floor-scene')
&& !!isoStair?.querySelector('.hp-stair-hit');
isoStair?.querySelector('.hp-stair-hit')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await settled();
result.isoHitTargetNavigates = activeSpace() === 'garden';
return result;
});
checkAll(out);
await finish(browser, out);
+29 -2
View File
@@ -26,6 +26,11 @@ houseplan-card/
│ ├─ houseplan-panel.ts # HA sidebar/app-bar host for the same full card
│ ├─ editor-runtime-loader.ts # lazy loader: dedupe, retry and build handshake
│ ├─ houseplan-editor-runtime.ts # Plan/Devices/Background composition root
│ ├─ stairs-view.ts # eager read-only stair symbols, links and gesture guards
│ ├─ stairs-editor.ts # lazy Plan stair tools, transforms and properties
│ ├─ stairs-editor-model.ts # pure editor transforms, snapping and target-state helpers
│ ├─ stairs.ts # stair model, render geometry and area math
│ ├─ clean-floor.ts # shared room-floor subtraction including stair footprints
│ ├─ decor-image-editor.ts # lazy Background/Furniture image palette, upload and properties controller
│ ├─ houseplan-onboarding-runtime.ts # first-space/import dialogs, independent of editor
│ ├─ iso-scene-render.ts # lazy 2.5D scene/runtime boundary (settings.volumetric_view, #649)
@@ -656,7 +661,7 @@ not be added to an individual sink.
"rooms":[{"id","name","area","poly|x/y/w/h","wall_ids":[…],"settings"}],
"wall_segments":[{"id","a","b","cm","owners":[…]}],
"partitions":[…], "wall_columns":[…],
"openings":[…], "decor":[…], "settings":{…} }],
"openings":[…], "decor":[…], "stairs":[…], "settings":{…} }],
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden","removed",
"name","icon","display","controls","is_light","glow_color","tap_action",
"room_id","pdfs",… }],
@@ -940,7 +945,7 @@ designer handoff are attached to issue #570.
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/column/merge/split/resize/opening/
wallthick/delroom), `_path` (the current outline,
stairs/wallthick/delroom), `_path` (the current outline,
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
@@ -1125,6 +1130,28 @@ While drawing, the length of the current segment follows the cursor (`_fmtLen`
per-space `cell_cm` — canonical centimetres represented by one grid cell; new
spaces use 1 cm or 2.54 cm/1 inch, while missing legacy values fall back to 5 cm.
### Stairs (#663)
Stairs are a separate Plan entity, not decor. The lazy `StairEditorRuntime`
owns the straight/spiral tool group, selection, continuous
move/resize/rotation, wall/stair magnet and properties. The eager
`StairViewRuntime` owns only read-only symbols, guarded navigation and gesture
suppression, so opening a plan does not load the editor graph. The root card
owns lifecycle composition, shared Plan history/persistence and stage pointer
terminals. `stairs.ts` is the pure boundary for the discriminated model, exact
30 cm tread geometry, cached render projection and footprint containment;
`stairs-editor-model.ts` contains the pure transform, target-state and
footprint-to-footprint snapping helpers used by the editor.
`spaces[].stairs[]` is optional and capped at 250 records. A record exists on
one space only; `target_space_id` is a one-way navigation reference and never
creates target geometry. Straight footprints are rotated rectangles; spiral
footprints are circles. `clean-floor.ts` subtracts their geometric overlap
from the same clean-area result used by room cards and summary metrics, without
cutting the painted floor or changing walls/light/vacuum. PDF and full/static
renderers consume the same stair geometry. The 2.5D scene keeps the SVG object
on its floor plane with no height or shadow. See `docs/STAIRS.md`.
## Editor chrome and contextual controls
Every editor uses one stable primary `.editbar`. Its `.editbar-tools` contains
+21 -9
View File
@@ -16,7 +16,7 @@ coordinate system.
## Principle
1. **Coordinates keep their meaning.** Rooms, openings, decor and
1. **Coordinates keep their meaning.** Rooms, openings, decor, stairs and
device positions are still stored normalised. `1.0` is still the
same distance it always was; `cell_cm` still ties a grid cell to
real centimetres. **No data migration.** An existing plan opens as
@@ -56,8 +56,10 @@ Negative zero becomes positive zero.
The lattice allow-list covers room outlines/extents, exact wall endpoints,
opening `x/y`, decor origins/sizes/endpoints, drafts, partitions, columns, open
spans and layout `x/y`. Angles, opening length/host `t`, decor scale and
backdrop transforms retain the nine-decimal scalar contract. The traversal
spans and layout `x/y`. Angles, opening length/host `t`, decor scale, backdrop
transforms and every stair transform field retain the nine-decimal scalar
contract. Stairs are deliberately excluded even when a value is near a grid
node: their continuous wall-face magnet position is authored data. The traversal
deliberately excludes `cell_cm`, `plan_aspect`, `view_box`,
physical centimetre fields, colours/opacities/live values, presentation scales
and vacuum affine calibration. Unknown/future numeric fields round-trip
@@ -113,6 +115,7 @@ the exact target atomically without exposing a default-fit frame.
| `_GEOM` (room x/y, poly points, opening x/y, `view_box` origin) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
| `_EXTENT` (room w/h, `view_box` w/h) | `0.001 .. 4` | `0.001 .. 5000` | size — strictly positive |
| `_NORM` (decor x/y/w/h) | `-1 .. 2` | `-5000 .. 5000` | coordinate |
| stair `x/y`, `length/width/radius` | — | `-5000 .. 5000`; sizes positive | continuous transform |
| opening `length` | `0.001 .. 1` | `0.001 .. 5000` | size — strictly positive |
`+/-5000` is **garbage insurance, not a frame**. At the historical compatibility
@@ -132,6 +135,7 @@ tested. Input is a list of **items**, one per drawn/placed object:
* the backdrop image rectangle, when the space has one;
* every opening (door/window/gate) end-to-end segment;
* every decor shape;
* every stair, using its complete rotated rectangle or circular footprint;
* every device the layout actually places in this space **and that the card
actually draws** — a HIDDEN device (docs/FILTERING.md) is not content: the
frame is presentation, and an object nobody can see must not decide what the
@@ -276,7 +280,7 @@ produce a zero-sized SVG `viewBox`.
in the decision — `_clampView` alone says how far you may walk.
* **Who owns the pointer.** A drag pans only when it starts on empty
scene: the room-resize handles, device badges, openings, room labels
and the decor shapes take the pointer first (`_stagePointerDown`
stairs and the decor shapes take the pointer first (`_stagePointerDown`
bails out on them), and a drawing tool that consumes the press —
decor line/rect/ellipse/text — bails out too. Two fingers are always
a pinch, never a pan. On a **kiosk** screen at swipe zoom (`≤ 1`,
@@ -526,10 +530,18 @@ exception to positional quantisation: it is continuous in both modes, with
Shift selecting independent axes, while Shift on its rotation handle snaps to
45°. Furniture placement and movement remain grid-bound.
Stairs use the continuous branch of this contract for their complete
transform. Move and resize stay off-grid when authored there; Shift snaps only
rotation to 45°. Wall magnet resolves the visible physical face (including
half-thickness) and stair magnet resolves outer footprint-to-footprint contact.
Neither save/load nor Optimize may replace that contact with a nearby lattice
node.
### 9.5 «Оптимизировать планы» — explicit whole-plan maintenance
Existing and imported plans may still hold coordinates between the
nodes. New editor operations cannot create more. General settings contain
Existing and imported plans may still hold grid-bound coordinates between the
nodes. Ordinary grid-bound editor operations do not create more; explicitly
continuous objects are exempt. General settings contain
a **Plan maintenance** group whose action previews and then repairs old
data through all current passes: model upgrades, mandatory grid
alignment, exact open-span canonicalisation and wall-interval compaction.
@@ -585,9 +597,9 @@ either store changes, commits both revisions, and retains one snapshot.
has changed since the optimization. A crash between store writes is
completed from the intent on the next integration setup.
The grid pass deliberately excludes the complete transform of `furniture` and
uploaded `image` decor. Their position, size and rotation are continuously
authored values (#383), so changing even one of those fields would make
The grid pass deliberately excludes the complete transform of `furniture`,
uploaded `image` decor and `spaces[].stairs[]`. Their position, size and
rotation are continuously authored values (#383, #663), so changing even one of those fields would make
Optimize create debt from a normal editor operation. Other decor kinds and
storage-level numeric canonicalization keep their existing grid contract
(#477).
+10 -1
View File
@@ -2,6 +2,16 @@
## Unreleased
- The Plan editor can now place straight and one-turn spiral stair objects with
physical sizes, automatic 30 cm treads and an ascent arrow. Stairs move,
resize, rotate and snap continuously to wall faces or each other; their
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.
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)).
## v1.78.0-beta.5 — 2026-09-26
- In the 2.5D plan the metrics row under a room name (temperature, humidity,
@@ -22,7 +32,6 @@
footer button as the only clickable control. The regression came with the
overlapping-marker fix in v1.76.0-beta.2
([#664](https://github.com/Matysh/houseplan-card/issues/664)).
## v1.78.0-beta.4 — 2026-09-26
- The main toolbar is steadier and more compact: its editor close **×** now
+11 -1
View File
@@ -8,6 +8,17 @@
## Не выпущено
- В редакторе плана появились отдельные прямые и винтовые лестницы с физическими
размерами, автоматическим шагом ступеней 30 см и стрелкой подъёма. Лестницу
можно плавно перемещать, менять её размер, поворачивать и магнитить к грани
стены или другой лестнице; её габарит вычитается из чистой площади комнаты,
но не вырезает видимый пол. Необязательная односторонняя ссылка безопасно
переводит на выбранный этаж в Просмотре, а после жеста, при битой ссылке и в
карточке с закреплённым этажом переход не срабатывает. Целевой этаж
автоматически не меняется; в первой версии 2.5D лестница остаётся плоской
на полу
([#663](https://github.com/Matysh/houseplan-card/issues/663)).
## v1.78.0-beta.5 — 2026-09-26
- На объёмном плане (2.5D) строка показателей под названием комнаты
@@ -28,7 +39,6 @@
единственный кликабельный элемент — кнопка в подвале. Регрессия пришла с
исправлением перекрытий маркеров в v1.76.0-beta.2
([#664](https://github.com/Matysh/houseplan-card/issues/664)).
## v1.78.0-beta.4 — 2026-09-26
- Основная панель стала компактнее и перестала дёргаться: крестик **×**
+22
View File
@@ -112,6 +112,28 @@ An older frontend ignores the field and shows Flat. An older backend preserves
it through the unknown-settings policy. Full backup/import carries `settings`
whole, so the value survives.
## Stairs (#663)
`spaces[].stairs[]` is an optional bounded (250 records per space)
discriminated collection. `kind: "straight"` stores positive `length` and
`width` plus `direction: "forward" | "backward"`; `kind: "spiral"` stores a
positive `radius` plus `direction: "clockwise" | "counterclockwise"`. Both
variants carry stable `id`, continuous normalized centre `x/y`, scalar
`angle`, and an optional nullable `target_space_id`.
Stair transforms follow furniture's continuous contract. Config writes apply
only the nine-decimal scalar cleanup to position, size and angle; they do not
use near-lattice snapping, and Optimize does not move them. Full backup,
support data and diagnostics retain the bounded records. Full import remaps a
known target space id and clears a target that was not imported; single-space
transfer cannot invent a link to an external floor. Deleting a target space
clears incoming links without deleting the source stair.
The collection is additive and requires no model/store version migration. A
current frontend renders and edits it; a legacy frontend ignores the objects.
The current backend preserves valid records through unrelated writes, while a
backend predating the field must not be used to edit a newer configuration.
## Sun-ray window face (#577)
`settings.sun_ray_origin` is an optional global enum: `inner` or `outer`.
+2 -2
View File
@@ -92,7 +92,7 @@ Composition remains SVG-first:
3. screen-facing HTML overlays.
The floor keeps the same nodes and order for paper/backdrop, room fills/hover,
Glow/spill, sun, decor/furniture, opening symbols and vacuum path/outline. Stage
Glow/spill, sun, decor/furniture, flat stair symbols, opening symbols and vacuum path/outline. Stage
1 does not add a second light source/layer. Markers and room cards intentionally
remain above walls without geometric occlusion.
@@ -248,7 +248,7 @@ search. There is no painted plate, long tether, ground dot or per-marker
shadow. The original screen-facing HTML root remains the only hit, focus,
tooltip and action target, and selection/hover cannot invalidate the placement
cache. Vacuum, Glow/spill, SUN, room fills/hover, arbitrary decor,
furniture/backdrop and every persisted coordinate remain on `z=0`.
furniture/backdrop, stairs and every persisted coordinate remain on `z=0`.
Room names remain screen-facing and lose stroke, text shadow, drop shadow and
halo. Iso uses `#303936` on a light presentation and `#f2f0e8` on a dark one;
+1
View File
@@ -11,6 +11,7 @@
| [Полное руководство пользователя](USER-GUIDE.ru.md) | Пользователи и администраторы Home Assistant | Установка, первая настройка, пространства, комнаты, стены, проёмы, устройства, визуальные состояния, заливки, подложка, солнце, пылесосы, киоск, обслуживание и диагностика |
| [Радары присутствия](RADAR.md) | Пользователи, администраторы и интеграторы | Поддерживаемые профили, привязка источников, установка и калибровка, статусы данных, приватность и диагностика |
| [Редактор подложки и декора](DECOR-EDITOR.md) | Пользователи, тестировщики и разработчики | Инструменты, единое выделение и трансформации, физические размеры, магнит, Undo/Redo, поведение картинки-подложки и совместимость старых полей |
| [Лестницы и переходы между этажами](STAIRS.md) | Пользователи, тестировщики и разработчики | Прямые и винтовые лестницы, размеры, направление подъёма, магнит, связь этажей, площадь и ограничения 2.5D |
| [GitHub Issues](https://github.com/Matysh/houseplan-card/issues) и [Project v2](https://github.com/users/Matysh/projects/1) | Владелец, разработчики и контрибьюторы | Единственный актуальный backlog: задачи, приоритеты, решения и статусы выполнения |
Старые тематические документы в этом каталоге остаются инженерными спецификациями и историей решений. При расхождении пользовательского описания с интерфейсом текущей версии приоритет имеет новое руководство, а при расхождении с фактическим поведением — код текущей версии.
+93
View File
@@ -0,0 +1,93 @@
# Stairs
Stairs are plan-level navigation objects. They show the physical place and
direction of an ascent on one space and can link that space to one other House
Plan space. The link is deliberately one-way: adding or editing a stair never
creates or changes anything on the target floor.
## User contract
The Plan editor has one **Stairs** group with **Straight** and **Spiral** tools.
A click places the chosen default; the object remains selected. Drag moves it,
the visible handles resize it, and the upper handle rotates it. Straight stairs
have corner and one-axis edge handles. A spiral stair remains a circle and has
one radial resize handle. Rotation is continuous; holding `Shift` snaps to the
nearest multiple of 45 degrees. `Esc` cancels an active transform or clears the
selection; Delete/Backspace removes the selected stair; the ordinary Plan
Undo/Redo history covers create, edit, transform and delete.
Double click opens properties. A straight stair stores positive length and
width; a spiral stair stores a positive radius. The dialog also selects the
rise direction, rotation and an optional target space. A target cannot be the
current space. A missing, self or deleted target leaves the stair visible and
editable but shows a repair warning and makes View activation a no-op.
In an ordinary multi-space card, a clean click/tap or keyboard activation on a
valid stair switches to the target tab and restores that floor's remembered
camera. Pan, pinch, long press, swipe and pointer cancellation do not navigate.
In a card configured with `floor`, stairs are visible but inert. The target
floor receives no automatic stair, highlight or camera centring.
## Geometry and drawing
Both variants use the same continuous transform contract as furniture: their
authored position, size and angle are not grid-quantised on save or by
**Optimize plans**. A stair near a wall snaps to the visible physical wall face;
a straight stair also becomes parallel to that face. Any straight/circular pair
of stairs can snap footprint-to-footprint. The other stair is never modified or
linked by the magnet.
Straight tread lines are perpendicular to the rise axis and start at the lower
edge every 30 cm. A remainder shorter than 30 cm stays at the upper edge.
Spiral stairs make one full turn; radial treads use the same 30 cm physical step
measured on the travel line at two thirds of the radius. Their direction is
clockwise or counter-clockwise when viewed from above. The arrow always means
physical ascent, not the direction of navigation between named tabs.
The footprint overlap is removed from clean room floor area exactly once. It
does not cut the visible floor, change room or wall geometry, create a light
occluder, affect Glow/sun/vacuum, or participate in Optimize. In 2.5D the first
version remains a flat symbol projected with the floor/decor plane; it has no
height, risers, railings or shadows.
## Persisted model
Each space may contain `stairs: []`, bounded to 250 valid records:
```json
{
"id": "stair-main",
"kind": "straight",
"x": 0.42,
"y": 0.55,
"angle": 90,
"length": 0.24,
"width": 0.10,
"direction": "forward",
"target_space_id": "floor-2"
}
```
`kind:"spiral"` replaces `length`/`width` with `radius` and uses direction
`clockwise` or `counterclockwise`. Coordinates and sizes use the normalized
plan coordinate system; physical labels are derived through `cell_cm`.
`target_space_id` is nullable. Unknown sibling fields survive validation and
round trips for forward compatibility. Full backup/import/export, one-space
transfer, plan-only transfer, diagnostics and support packages preserve the
same records. When a complete backup removes a target space its incoming stair
links are cleared; a one-space transfer cannot invent an external target.
## Implementation boundary
- `src/stairs.ts` owns validation, drawing geometry and area-subtraction
primitives.
- `src/stairs-view.ts` is the eager read-only boundary for symbols, guarded
navigation and touch/pointer gesture suppression.
- `src/stairs-editor-model.ts` owns pure Plan transforms, target state and
stair-to-stair magnet math; `src/stairs-editor.ts` owns the lazy Plan UI and
properties.
- `src/clean-floor.ts` and `src/summary-panel-metrics.ts` consume the same
footprint subtraction for room cards and summary totals; PDF rendering uses
the same stair outline/tread geometry without changing the room floor paint.
- Backend schemas and transfer/support surfaces treat `stairs` as a bounded,
additive space collection. Old configs without it remain unchanged.
+1
View File
@@ -98,6 +98,7 @@ Everything in this table is **public API**.
| Wall body (thickness) | `path` (SVG) | `wall` | `data-id` = segment key, `data-kind` = `shared` \| `outer` | `.wallbody` |
| Independent partition | `path` (SVG editor hit target) | `partition` | `data-id` = partition id, `data-kind` = `partition` | `.physical-hit` |
| Wall column | `path` / `circle` (SVG editor hit target) | `wall-column` | `data-id` = column id, `data-kind` = `square` \| `circle` | `.physical-hit` |
| Stair | `g` (SVG) | `stair` | `data-id` = stair id, `data-kind` = `straight` \| `spiral`, `data-target-state` = `active` \| `missing` \| `self` \| `deleted` \| `fixed` | `.hp-stair` |
| Decor shape | `line` / `rect` / `ellipse` / `text` (SVG) | `decor` | `data-id` = shape id, `data-kind` = `line` \| `rect` \| `ellipse` \| `text` | `.dshape` (`.dtext` on text); persisted colour/alpha are inline SVG attributes and therefore win over weak CSS selectors |
| Furniture | `path` (SVG) | `decor` | `data-id` = shape id, `data-kind` = `furniture`, `data-symbol` = the symbol id (`sofa`, `toilet`, …) | `.dshape .dfurn` |
| Floor / space tab | `button` (HTML, header) | `space-tab` | `data-id` = space id | `.tab` |
+7
View File
@@ -37,6 +37,8 @@ On phones, tablets, wall panels and HA Companion apps, the ordinary View must:
tabs, zoom and one gear whose menu holds every other header action, each
item a 44 px target (#616);
- support convenient pan, pinch zoom and space switching;
- treat a linked stair as a floor-navigation target only after a clean tap;
pan, pinch, long press, swipe and cancellation tails must stay inert;
- fit the whole plan after two clean taps on free scene background, while one
tap on a room keeps the immediate room-fit action;
- provide a touch path for essential information that desktop exposes through
@@ -158,6 +160,11 @@ Desktop `Shift` bypasses that offer and constrains drawing to an exact 45° ray;
touch has no separate modifier gesture. A pan, pinch, cancellation or second
pointer never accepts a face, applies a small-gap repair or creates a room.
Stair placement and transforms remain best-effort editor interactions on
touch, but the safety floor is strict: cancellation and navigation gestures do
not create, move, save or follow a stair. A long press in View does not activate
its target floor, and the next deliberate tap is re-armed immediately.
During a View/editor visual transition the moving stage is inert while the
header tabs remain available. A pinch, cancelled pointer or synthetic click
cannot operate stale geometry; leaving the editor is always a single safe
+11
View File
@@ -594,6 +594,7 @@ shape change it is retained only while it remains inside the room.
| Walls | Continuous wall chain; offers rooms when it closes faces and finishes open chains as independent walls | Only a confirmed room has area | Positive thickness blocks light; zero thickness follows the space's dashed/solid policy | Partial room overlap is rejected; there is no separate Partition or Boundary drawing tool |
| Column | Square or circular support | Does not change area | Blocks light inside its shape | One shape/size/rotation; not a wall or room |
| Opening | Door, window or gate | Does not change area | Door/gate passage follows state; window may cast sun | Must fit completely on a suitable wall segment |
| Stairs | Straight flight or one-turn spiral with automatic 30 cm treads and an ascent arrow | Subtracts only its footprint overlap from clean area | No effect on Glow, sun or walls | Lives on this floor only; a valid optional target makes it a View link |
Other operations edit existing geometry:
@@ -605,6 +606,16 @@ Other operations edit existing geometry:
| Thickness | Changes one span or every wall of a room, including zero-thickness walls |
| Delete room | Deletes the room after choosing whether its exclusive physical walls remain; shared walls always remain |
Choose **Stairs → Straight** or **Stairs → Spiral**, then click the plan. A
selected stair moves and resizes smoothly like furniture; `Shift` snaps its
rotation to 45°. It snaps to the visible physical face of a wall or flush to
another stair, and **Optimize plans** preserves that exact continuous
transform. Double click opens size, direction, angle and target-floor
properties. In View a clean activation switches to the valid target and
restores that floor's saved view; gesture tails, broken links and fixed-floor
cards do nothing. The target floor is never changed automatically. In 2.5D the
symbol is flat on the floor. See [Stairs](STAIRS.md).
![Selected partition and its Plan context tray](images/05-plan-context-tray.png)
Deleting a room also clears that exact room from direct device assignments and
+12
View File
@@ -779,6 +779,7 @@ Undo оптимизации.
| Стены | Непрерывную цепочку стен; при замыкании предлагает комнаты, при выходе сохраняет независимые стены | Площадь появляется только у подтверждённой комнаты | Положительная толщина блокирует свет; нулевая следует настройке пространства | Частичное перекрытие комнат запрещено; отдельных инструментов «Перегородка» и «Граница» нет |
| Колонна | Квадратную или круглую опору | Не меняет площадь комнаты | Блокирует свет внутри своей формы | Имеет одну форму/размер/поворот, но не является стеной или комнатой |
| Проём | Дверь, окно, открытый проём или ворота на стене комнаты либо законченном независимом отрезке | Не меняет площадь | Дверь/ворота пропускают свет между полами, открытый проём — всегда; только наружное окно комнаты может давать солнечный луч | Должен целиком помещаться на одном подходящем отрезке стены |
| Лестница | Прямой марш или один оборот винтовой лестницы с автоматическим шагом ступеней 30 см и стрелкой подъёма | Вычитает из чистой площади только пересечение своего габарита | Не влияет на Glow, солнце и стены | Существует только на этом этаже; необязательная корректная цель превращает её в ссылку в Просмотре |
Остальные операции меняют уже созданную геометрию:
@@ -790,6 +791,17 @@ Undo оптимизации.
| Толщина | Задаёт толщину 0–100 см выбранному участку или всем стенам комнаты |
| Удалить комнату | Открывает выбор: удалить комнату и оставить её физические стены либо удалить комнату вместе с ними; общие стены остаются всегда |
Выберите **Лестница → Прямая** или **Лестница → Винтовая**, затем кликните по
плану. Выбранная лестница перемещается и меняет размер плавно, как мебель;
`Shift` привязывает поворот к 45°. Магнит ставит её к видимой физической грани
стены или вплотную к другой лестнице, а **Оптимизировать планы** сохраняет этот
continuous-transform без привязки к сетке. Двойной клик открывает размеры,
направление подъёма, угол и целевой этаж. В Просмотре чистый клик переходит на
корректную цель и восстанавливает сохранённый вид этажа; хвост жеста, битая
ссылка и карточка с закреплённым этажом ничего не делают. На целевом этаже
объект автоматически не создаётся. В 2.5D символ остаётся плоским на полу.
Подробный контракт — в [документе о лестницах](STAIRS.md).
При варианте **«Удалить комнату, оставить стены»** эксклюзивные участки
положительной толщины становятся обычными независимыми перегородками с той же
толщиной, а их двери/окна перепривязываются к ним. Нулевые участки сохраняются
+5
View File
@@ -269,6 +269,11 @@ layer you cannot see is a layer you cannot edit.
- Door, window, gate and passage may be hosted by one finished independent wall
segment. Drafts and columns are never opening targets. Missing hosts fail
dark and expose a rebind action only in Plan.
- **Stairs** places a straight flight or a one-turn spiral as a separate Plan
object. It uses furniture-like continuous move/resize/rotation and physical
wall/stair magnet rather than the grid-bound wall contract. Its optional
one-way target is edited in properties; in View only a clean activation of a
valid target switches floors. The target floor is never modified.
- **Select** is the only mode in which these objects intercept input. It offers
rigid grid-bound drag, double-click/tap properties, Delete, and a rotate
handle for square columns (5° steps; Shift is free). Draft Delete removes the
+7
View File
@@ -43,6 +43,7 @@
"rules",
"settings",
"space",
"stairs",
"summary",
"support",
"vacuum"
@@ -156,6 +157,11 @@
"since": "1.59.0-beta.3",
"audience": ["styling", "test"]
},
"stair": {
"elements": ["g"],
"since": "1.78.0-beta.5",
"audience": ["styling", "test"]
},
"support": {
"elements": ["button"],
"since": "1.73.0-beta.7",
@@ -193,6 +199,7 @@
"resize",
"select",
"split",
"stairs",
"text",
"wall-thickness"
]
+12 -12
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": null,
"sourceFingerprint": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceFingerprint": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"captureScriptSha256": "e2d14d89de793011615662af7c83c433768d4761f6cec7f0b3860c797494299e",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "1bde1bff8d4ab5f197320b54ef57186b3c07cf2121d5414ada63ce357826bfa8"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "f9dd5b54f86b814c00bc84149af84485f052b103cdcc9263b5c50bf5d32c69d6"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "aab4811bba3afa6cc849cb9d3989d126b8c327b69a2f8f853e55fc077e0e9a1b"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "e106ff9db6c96adb08beba12cc299a88ac5fa47c079be316f185e5657bc656f0"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "9b0db923fa7c8aff783b5c7050ee5b410b1b04df81a9e48bbc8111a7d0d47e76"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "198c084c0a45aa3afdf79181b80d8bb0347c95f3248b4fac9e05d69eb8a35f8f"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "e9b88d65dca74133646aa849d47bc09ad85eb8b16a168c4df6930e8f5856b5f5"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "44beadd61365ac1730c1efde846e528bfe92d8935fd167f1b6bf3bbad6af8f33"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "d47988b03e6a078508b27b5612b6eafe8ecc33c891b589ddda1cc7c71f43b7c8"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "4ec8c6df671742b90ca55222917076c47c5035a6df0587282e60873c0ce5f64e"
},
"pdf-export": {
@@ -125,7 +125,7 @@
},
"theme": "light",
"language": "en",
"sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"sourceSha256": "fcc2617295be1d37facbd00dea5df81fc5d3d11605b97f23da563a25df22425e",
"imageSha256": "ad3d735135b180ce015c9cda07fdcec08d7c8707abe0665734ef7b450a8a7093"
}
},
+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',
}],
+11
View File
@@ -0,0 +1,11 @@
/**
* Dependency-free coordinate constants shared by low-level geometry modules.
* Keep this leaf free of model/render imports: modules such as stairs are
* consumed by space-geometry itself and must not form an ESM initialisation
* cycle through that higher-level facade.
*/
export const NORM_W = 1000;
export const CANVAS_LIMIT = 5000;
export const GRID_N = 240;
export const GRID_PITCH = NORM_W / GRID_N;
export const GRID_STEP_N = 1 / GRID_N;
+2 -2
View File
@@ -27,13 +27,13 @@ export const lruWrite = <K, V>(cache: Map<K, V>, key: K, value: V, limit: number
};
export type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize'
| 'opening' | 'wallthick' | 'delroom';
| 'opening' | 'stairs' | 'wallthick' | 'delroom';
export type DecorTool = 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text'
| 'furniture' | 'image' | 'erase';
const MARKUP_TOOLS = new Set<MarkupTool>([
'select', 'draw', 'column', 'merge', 'split', 'resize',
'opening', 'wallthick', 'delroom',
'opening', 'stairs', 'wallthick', 'delroom',
]);
/** Warm viewport may contain a tool token written by an older bundle. */
+51
View File
@@ -0,0 +1,51 @@
import { floorMinusBodies, geometryArea, polyclipPathD } from './physical-geometry';
import { geometryAreaMinusStairs } from './stairs';
import type { RoomCfg, SpaceModel } from './types';
import { lruRead, lruWrite } from './card-runtime';
import type { Geom } from 'polyclip-ts';
export type CleanFloorResult = {
floor: number[][];
geom: Geom | null;
path: string;
area: number;
};
export function cleanFloorForRoom(input: {
room: RoomCfg;
floor: number[][];
space?: SpaceModel;
configEpoch: number;
resizePreview: boolean;
cache: Map<string, CleanFloorResult>;
physicalBodies(space: SpaceModel): number[][][];
}): CleanFloorResult {
const { room, floor, space } = input;
if (!space) return {
floor, geom: null, path: '', area: geometryArea([[[...floor, floor[0]]]]),
};
const roomKey = room.id || `#${space.rooms.indexOf(room)}`;
const key = `${space.id}|${input.configEpoch}|${roomKey}`;
if (!input.resizePreview) {
const cached = lruRead(input.cache, key);
if (cached.hit) return cached.value;
}
const xs = floor.map((point) => point[0]);
const ys = floor.map((point) => point[1]);
const box = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
const candidates = input.physicalBodies(space).filter((body) => {
const bx = body.map((point) => point[0]);
const by = body.map((point) => point[1]);
return Math.max(...bx) >= box[0] && Math.min(...bx) <= box[2]
&& Math.max(...by) >= box[1] && Math.min(...by) <= box[3];
});
const geom = candidates.length ? floorMinusBodies(floor, candidates) : null;
const result = {
floor,
geom,
path: geom ? polyclipPathD(geom) : '',
area: geometryAreaMinusStairs(geom || [[[...floor, floor[0]]]], space.stairs),
};
if (!input.resizePreview) lruWrite(input.cache, key, result, 600);
return result;
}
+7
View File
@@ -357,6 +357,13 @@ export function canonicalizeConfigGeometryInPlace<T>(config: T): T {
scalarFields(decor, ['scale', 'angle']);
}
}
for (const stair of records(space.stairs)) {
// #663 stairs inherit furniture's continuous transform contract. A wall
// face may be an arbitrary half-thickness away from the grid, so only
// remove insignificant JSON noise; never pull the magnet result onto a
// lattice node.
scalarFields(stair, ['x', 'y', 'length', 'width', 'radius', 'angle']);
}
// Legacy-only read boundary; current v10 documents reject this field.
for (const draft of records(space.room_drafts)) latticePoints(draft.points);
+37 -37
View File
@@ -116,7 +116,7 @@ import {
} from './config-reload-authority';
import type { OptimisticAttempt } from './serialized-write-queue';
import {
COLUMN_MAX_CM, directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings,
COLUMN_MAX_CM, directionalOccluders, floorMinusBodies, geometryOuterRings,
polyclipPathD, physicalBodyParts, type PartitionOpeningCut,
} from './physical-geometry';
import { partitionOpeningFace, resolvePartitionOpeningCompat } from './partition-openings';
@@ -265,6 +265,8 @@ import {
type HaBindingStatus, type HaRegistrySnapshot,
} from './ha-binding-status';
import type { DecorShape, DecorStyle } from './editors/decor/types';
import { StairViewRuntime, type StairViewHostPort } from './stairs-view';
import { cleanFloorForRoom, type CleanFloorResult } from './clean-floor';
import {
DECOR_ASSETS_API_VERSION, decorAssetIds, projectDecorImage,
resolveDecorAssets, type DecorAsset,
@@ -430,6 +432,7 @@ interface SpaceGeometryState {
partitions?: PartitionCfg[];
wall_columns?: WallColumnCfg[];
decor?: DecorShape[];
stairs?: import('./stairs').Stair[];
plan_transform: {
plan_x?: number; plan_y?: number; plan_scale?: number;
plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number;
@@ -1675,6 +1678,7 @@ export class HouseplanCard extends LitElement {
return this._mode === 'plan';
}
private _tool: MarkupTool = 'draw';
private _stairsView = new StairViewRuntime(this as unknown as StairViewHostPort);
/** UX-04: one named, 50-step command history for every plan-geometry tool. */
private _geometryHistory = new CommandStack<SpaceGeometryState>(50);
/** #74: independent session-local history for manual device placements. */
@@ -1783,9 +1787,7 @@ export class HouseplanCard extends LitElement {
/** Light cuts are type/floor-specific and differ from drawn masonry, but HA
* state ticks must not rebuild independent-wall topology. */
private _lightPhysicalBodiesCache: { key: string; all: number[][][] } | null = null;
private _cleanFloorCache = new Map<string, {
floor: number[][]; geom: any; path: string; area: number;
}>();
private _cleanFloorCache = new Map<string, CleanFloorResult>();
private _innerContourCache = new Map<string, number[][] | null>();
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
private readonly _glowRuntimeHost: GlowRuntimeHost = {
@@ -2891,6 +2893,10 @@ export class HouseplanCard extends LitElement {
}
if (!this._markup) return;
if ((undo || redo) && inField) return; // keep native text-field history
if ((e.key === 'Delete' || e.key === 'Backspace')
&& !inField && !inEditorSecondary && this._editorRuntime?.stairs.deleteSelected()) {
e.preventDefault(); return;
}
if ((e.key === 'Delete' || e.key === 'Backspace') && this._physicalSel
&& !inField && !inEditorSecondary) {
e.preventDefault();
@@ -2904,6 +2910,7 @@ export class HouseplanCard extends LitElement {
}
if (undo) {
e.preventDefault();
if (this._editorRuntime?.stairs.undoActiveDrag()) return;
if (this._resize?.dragging) {
this._rszCancelDrag();
return;
@@ -2932,6 +2939,7 @@ export class HouseplanCard extends LitElement {
return;
}
if (e.key !== 'Escape') return;
if (this._editorRuntime?.stairs.escape()) { e.preventDefault(); return; }
if (this._physicalDrag || this._physicalRotate) {
e.preventDefault();
this._cancelPhysicalGesture();
@@ -6732,6 +6740,7 @@ export class HouseplanCard extends LitElement {
}
private _stagePointerMove(ev: PointerEvent): void {
if (this._editorRuntime?.stairs.pointerMove(ev)) return;
if (this._physicalRotate?.pid === ev.pointerId) {
this._physicalRotateMove(ev);
return;
@@ -6902,6 +6911,8 @@ export class HouseplanCard extends LitElement {
}
}
}
this._stairsView.pointerUp(ev);
if (this._editorRuntime?.stairs.pointerUp(ev)) return;
if (this._physicalDrag?.pid === ev.pointerId) {
this._physicalUp(ev);
return;
@@ -7431,7 +7442,8 @@ export class HouseplanCard extends LitElement {
void this._requestMode(mode, animate);
return;
}
return this._editorRuntime._setMode(mode, animate);
this._editorRuntime.stairs.beforeModeChange(mode); this._editorRuntime._setMode(mode, animate);
this._editorRuntime.stairs.afterModeChange(); this._stairsView.clearGesture();
}
/** Prime the Draw thickness field to 15 cm once per Plan session. */
@@ -7696,8 +7708,10 @@ export class HouseplanCard extends LitElement {
this._decorMove = null;
this._dtDrag = null;
this._bdDrag = null;
this._stairsView.clearGesture();
return;
}
this._editorRuntime.stairs.clearGesture();
return this._editorRuntimeOrThrow()._clearGeometryGesture();
}
@@ -7707,6 +7721,8 @@ export class HouseplanCard extends LitElement {
this._flushHa();
this._editorRuntime?._cancelPointerMove('markup-hover');
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; this._doubleFit.clear();
this._stairsView.pointerCancel(ev);
if (this._editorRuntime?.stairs.pointerCancel(ev)) return;
if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
@@ -7714,6 +7730,9 @@ export class HouseplanCard extends LitElement {
this._panStart = null;
this._panLock = null;
this._swipeStart = null;
// Keep the compatibility click from this cancelled pointer blocked, but
// do not poison the next deliberate gesture indefinitely.
if (this._suppressClick) setTimeout(() => (this._suppressClick = false), 0);
}
if (this._pointers.size === 0) this._finishViewportGesture();
}
@@ -8580,7 +8599,7 @@ export class HouseplanCard extends LitElement {
role: 'tool', invoke: () => this._activateOpeningPlacement('gate'),
},
],
}];
}, this._editorRuntimeOrThrow().stairs.toolbarGroup()];
}
/** Stable target identity + current config epoch + operation revision. */
@@ -8591,6 +8610,8 @@ export class HouseplanCard extends LitElement {
if (this._mode === 'plan') {
const sel = this._physicalSel;
if (sel) return `${base}:selection:${sel.kind}:${sel.id}`;
const stairKey = this._editorRuntime?.stairs.selectionKey();
if (stairKey) return `${base}:${stairKey}`;
return `${base}:tool:${this._tool}:${this._path.length}`;
}
if (this._mode === 'decor') {
@@ -8618,7 +8639,7 @@ export class HouseplanCard extends LitElement {
|| this._infoCard || this._rulesDialog || this._settingsDialog || this._supportDialog
|| this._alignDialog || this._importDialog || this._kioskDialog || this._summary?.blocksOtherDialogs()
|| this._backupExportDialog || this._backupImportDialog
|| this._wallDialog);
|| this._wallDialog || this._editorRuntime?.stairs.dialogOpen);
}
private _renderEditorSecondary(): TemplateResult | typeof nothing {
@@ -9474,37 +9495,13 @@ export class HouseplanCard extends LitElement {
* rooms which touch only a small subset of independent bodies. */
private _cleanFloor(
room: RoomCfg, floor: number[][], space: SpaceModel | undefined = this._spaceModel(),
): { floor: number[][]; geom: any; path: string; area: number } {
if (!space) {
return {
floor, geom: null, path: '',
area: geometryArea([[[...floor, floor[0]]]]),
};
}
const roomKey = room.id || `#${space.rooms.indexOf(room)}`;
const key = `${space.id}|${this._cfgEpoch}|${roomKey}`;
if (!this._resize?.preview) {
const cached = lruRead(this._cleanFloorCache, key);
if (cached.hit) return cached.value;
}
const xs = floor.map((p) => p[0]), ys = floor.map((p) => p[1]);
const box = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
const candidates = this._physicalBodiesR(space).filter((body) => {
const bx = body.map((p) => p[0]), by = body.map((p) => p[1]);
return Math.max(...bx) >= box[0] && Math.min(...bx) <= box[2]
&& Math.max(...by) >= box[1] && Math.min(...by) <= box[3];
): CleanFloorResult {
return cleanFloorForRoom({
room, floor, space, configEpoch: this._cfgEpoch,
resizePreview: !!this._resize?.preview,
cache: this._cleanFloorCache,
physicalBodies: (model) => this._physicalBodiesR(model),
});
const geom = candidates.length ? floorMinusBodies(floor, candidates) : null;
const result = {
floor,
geom,
path: geom ? polyclipPathD(geom) : '',
area: geom ? geometryArea(geom) : geometryArea([[[...floor, floor[0]]]]),
};
if (!this._resize?.preview) {
lruWrite(this._cleanFloorCache, key, result, 600);
}
return result;
}
/** Effective auto icon for a binding selected but not saved yet. */
@@ -11069,6 +11066,8 @@ export class HouseplanCard extends LitElement {
hide_decor visual-only: the decor editor must always paint
stored shapes so they remain editable. */}
${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer(undefined, view)}
${this._mode === 'plan' && this._editorRuntime
? this._editorRuntime.stairs.renderLayer() : this._stairsView.renderLayer()}
${glowLayerVisible ? this._renderGlowLayer(space, disp, view) : nothing}
${this._renderSunRays(space)}
${this._editing ? svg`<g class="hp-editor-only-layer"
@@ -11206,6 +11205,7 @@ export class HouseplanCard extends LitElement {
${this._decorShapeDialog ? this._editorRuntime ? this._renderDecorShapeDialog() : nothing : nothing}
${this._backdropDialog ? this._editorRuntime ? this._renderBackdropDialog() : nothing : nothing}
${this._decorEraseConfirm ? this._editorRuntime ? this._renderDecorEraseConfirm() : nothing : nothing}
${this._editorRuntime?.stairs.dialogOpen ? this._editorRuntime.stairs.renderDialog() : nothing}
${this._spaceDialog
? (this._onboardingRuntime || this._editorRuntime) ? this._renderSpaceDialog() : nothing
: nothing}
+13 -12
View File
@@ -222,7 +222,7 @@ import {
type SupportDialogState,
type SupportPreview,
} from './support-feedback';
import { renderBackdropGuard, renderPlanBackdropGuard, stagePlanFile, uploadPlanFile } from './backdrop-pick';
import { renderBackdropGuard, renderPlanBackdropGuard, stagePlanFile, uploadPlanFile } from './backdrop-pick'; import { StairEditorRuntime, type StairEditorHostPort } from './stairs-editor';
import { CommandStack } from './command-stack';
import type { DeviceLayout, DevicePositionState } from './device-position-history';
import { contentFingerprint } from './visual-continuity';
@@ -333,12 +333,10 @@ const NORM_W = 1000; // side of the render space — the canvas is square (v1.48
/** #313: one Thickness-tool hit — a room interval or independent masonry. */
type WallThickSource = { kind: 'room' }
| { kind: 'partition'; id: string };
type WallThickHit = {
a: number[]; b: number[]; roomId: string; segs: number[][];
open: boolean; cm: number; source: WallThickSource;
};
type WallThickHit = { a: number[]; b: number[]; roomId: string; segs: number[][];
open: boolean; cm: number; source: WallThickSource };
type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'wallthick' | 'delroom';
type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'stairs' | 'wallthick' | 'delroom';
type WallFaceCandidate = WallGraphFace & {
split?: { roomId: string; mainPoly: number[][]; newPoly: number[][] };
consumeAllActive?: boolean;
@@ -375,7 +373,7 @@ interface SpaceGeometryState {
open_spans?: OpenSpanEntry[];
partitions?: PartitionCfg[];
wall_columns?: WallColumnCfg[];
decor?: DecorShape[];
decor?: DecorShape[]; stairs?: import('./stairs').Stair[];
plan_transform: {
plan_x?: number; plan_y?: number; plan_scale?: number;
plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number;
@@ -803,6 +801,7 @@ export { EDITOR_LANGUAGE_RUNTIME }; // #627: see src/i18n/editor-language.ts
export class HouseplanEditorRuntime {
public readonly languageRuntime = EDITOR_LANGUAGE_RUNTIME;
public readonly stairs = new StairEditorRuntime(this.host as unknown as StairEditorHostPort);
private _junctionBaselineCache = new WeakMap<object, {
spaceId: string; fingerprint: string; violations: JunctionLimitViolation[];
}>();
@@ -1224,6 +1223,7 @@ public _activateMarkupTool(tool: MarkupTool): void {
else this.host._resize.reset();
}
this.host._tool = tool;
if (tool !== 'select' && tool !== 'stairs') this.stairs.clearSelection();
if (tool === 'resize') this.host._resize.selectRoom(null);
if (tool === 'wallthick') this.host._wallDialog = null;
}
@@ -1567,7 +1567,7 @@ public _geometrySnapshotFromConfig(config: any, spaceId: string): SpaceGeometryS
...(Array.isArray((sp as any).wall_columns)
? { wall_columns: copy((sp as any).wall_columns) }
: {}),
...(Array.isArray(sp.decor) ? { decor: copy(sp.decor) } : {}),
...(Array.isArray(sp.decor) ? { decor: copy(sp.decor) } : {}), ...(Array.isArray(sp.stairs) ? { stairs: copy(sp.stairs) } : {}),
plan_transform,
};
}
@@ -1609,7 +1609,7 @@ public _restoreGeometryStateInConfig(
}
}
const assign = (key: 'openings' | 'walls' | 'wall_segments' | 'open_spans'
| 'partitions' | 'wall_columns' | 'decor', value: unknown): void => {
| 'partitions' | 'wall_columns' | 'decor' | 'stairs', value: unknown): void => {
if (value !== undefined) (sp as any)[key] = copy(value);
else if (!(preserveIdentityHints && key === 'wall_segments')) delete (sp as any)[key];
};
@@ -1620,7 +1620,7 @@ public _restoreGeometryStateInConfig(
assign('open_spans', state.open_spans);
assign('partitions', state.partitions);
assign('wall_columns', state.wall_columns);
assign('decor', state.decor);
assign('decor', state.decor); assign('stairs', state.stairs);
if (preserveIdentityHints) {
for (const opening of sp.openings || []) {
const old: any = oldOpenings.get(opening.id);
@@ -2008,7 +2008,7 @@ public _stagePointerCancel(ev: PointerEvent): void {
if (this.host._pointers.size < 2) this.host._pinchStart = null;
if (this.host._pointers.size === 0) {
this.host._panStart = null;
this.host._panLock = null;
this.host._panLock = null; if (this['host']._suppressClick) setTimeout(() => (this['host']._suppressClick = false), 0);
}
if (this.host._viewportGestureDirty && this.host._pointers.size === 0) {
this.host._viewportGestureDirty = false;
@@ -2231,7 +2231,7 @@ public _markupClick(ev: MouseEvent): void {
if (path.some((n) => n?.classList?.contains?.('physical-hit'))) return;
const raw = this._svgPoint(ev);
if (this.host._tool === 'select') {
this.host._physicalSel = null;
this.host._physicalSel = null; this.stairs.clearSelection();
return;
}
if (this.host._tool === 'resize') {
@@ -2266,6 +2266,7 @@ public _markupClick(ev: MouseEvent): void {
this._columnClick(raw);
return;
}
if (this['host']._tool === 'stairs') { this.stairs.placeAt(raw); return; }
// Walls: every completed segment is immediately an ordinary independent wall.
this.host._wallRepairDiagnostic = null;
const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey);
+23
View File
@@ -30,6 +30,28 @@
"title.markup_column": "Säule: Klicken Sie auf einen Rasterpunkt, um eine quadratische Säule zu platzieren",
"markup.partition": "Trennwand",
"markup.column": "Säule",
"markup.stairs": "Treppe",
"stairs.straight": "Gerade",
"stairs.spiral": "Spindeltreppe",
"stairs.properties": "Treppeneigenschaften",
"stairs.type": "Typ",
"stairs.length": "Länge",
"stairs.width": "Breite",
"stairs.radius": "Radius",
"stairs.rotation": "Drehung",
"stairs.direction": "Aufstiegsrichtung",
"stairs.forward": "Vorwärts",
"stairs.backward": "Rückwärts",
"stairs.clockwise": "Im Uhrzeigersinn",
"stairs.counterclockwise": "Gegen den Uhrzeigersinn",
"stairs.target": "Führt zu Etage",
"stairs.no_target": "Nicht ausgewählt",
"stairs.target_warning": "Wählen Sie eine andere vorhandene Etage für die Navigation.",
"stairs.fixed_floor": "In einer Karte mit fester Etage ist die Navigation deaktiviert.",
"history.stair_add": "Treppe hinzufügen",
"history.stair_edit": "Treppe ändern",
"history.stair_move": "Treppe verschieben",
"history.stair_delete": "Treppe löschen",
"markup.hint_column": "Klicken Sie auf einen Rasterpunkt, um eine quadratische Säule zu platzieren",
"history.wall_segment": "Wandsegment hinzufügen",
"history.column_add": "Säule hinzufügen",
@@ -281,6 +303,7 @@
"space.copy_error_partitions_limit": "Dieser Bereich enthält zu viele Wände zum Kopieren.",
"space.copy_error_openings_limit": "Dieser Bereich enthält zu viele Öffnungen zum Kopieren.",
"space.copy_error_decor_limit": "Dieser Bereich enthält zu viele Hintergrundobjekte zum Kopieren.",
"space.copy_error_stairs_limit": "Dieser Bereich enthält zu viele Treppen zum Kopieren.",
"space.copy_error_columns_limit": "Dieser Bereich enthält zu viele Säulen zum Kopieren.",
"space.copy_error_opening_host": "Eine Öffnung ist keiner Wand mehr zugeordnet. Korrigieren oder löschen Sie sie vor dem Kopieren.",
"space.copy_error_geometry": "Die Kopie würde eine unsichere Wandgeometrie erzeugen. Korrigieren Sie den Quellbereich.",
+23
View File
@@ -30,6 +30,28 @@
"title.markup_column": "Column: click a grid point to place a square column",
"markup.partition": "Partition",
"markup.column": "Column",
"markup.stairs": "Stairs",
"stairs.straight": "Straight",
"stairs.spiral": "Spiral",
"stairs.properties": "Stair properties",
"stairs.type": "Type",
"stairs.length": "Length",
"stairs.width": "Width",
"stairs.radius": "Radius",
"stairs.rotation": "Rotation",
"stairs.direction": "Ascent direction",
"stairs.forward": "Forward",
"stairs.backward": "Backward",
"stairs.clockwise": "Clockwise",
"stairs.counterclockwise": "Counterclockwise",
"stairs.target": "Leads to floor",
"stairs.no_target": "Not selected",
"stairs.target_warning": "Select an existing different floor to enable navigation.",
"stairs.fixed_floor": "Navigation is disabled in a fixed-floor card.",
"history.stair_add": "Add stairs",
"history.stair_edit": "Change stairs",
"history.stair_move": "Move stairs",
"history.stair_delete": "Delete stairs",
"markup.hint_column": "click a grid point to place a square column",
"history.wall_segment": "Add wall segment",
"history.column_add": "Add column",
@@ -281,6 +303,7 @@
"space.copy_error_partitions_limit": "This space has too many walls to copy.",
"space.copy_error_openings_limit": "This space has too many openings to copy.",
"space.copy_error_decor_limit": "This space has too many background objects to copy.",
"space.copy_error_stairs_limit": "This space has too many stairs to copy.",
"space.copy_error_columns_limit": "This space has too many columns to copy.",
"space.copy_error_opening_host": "One of the openings is no longer attached to a wall. Fix or delete it before copying.",
"space.copy_error_geometry": "The copy would create unsafe wall geometry. Fix the source space and try again.",
+23
View File
@@ -30,6 +30,28 @@
"title.markup_column": "Colonne : cliquez sur un point de la grille pour placer une colonne carrée",
"markup.partition": "Cloison",
"markup.column": "Colonne",
"markup.stairs": "Escalier",
"stairs.straight": "Droit",
"stairs.spiral": "Hélicoïdal",
"stairs.properties": "Propriétés de l’escalier",
"stairs.type": "Type",
"stairs.length": "Longueur",
"stairs.width": "Largeur",
"stairs.radius": "Rayon",
"stairs.rotation": "Rotation",
"stairs.direction": "Sens de montée",
"stairs.forward": "Avant",
"stairs.backward": "Arrière",
"stairs.clockwise": "Sens horaire",
"stairs.counterclockwise": "Sens antihoraire",
"stairs.target": "Mène à l’étage",
"stairs.no_target": "Non sélectionné",
"stairs.target_warning": "Sélectionnez un autre étage existant pour activer la navigation.",
"stairs.fixed_floor": "La navigation est désactivée dans une carte à étage fixe.",
"history.stair_add": "Ajouter un escalier",
"history.stair_edit": "Modifier l’escalier",
"history.stair_move": "Déplacer l’escalier",
"history.stair_delete": "Supprimer l’escalier",
"markup.hint_column": "cliquez sur un point de la grille pour placer une colonne carrée",
"history.wall_segment": "Ajouter un segment de mur",
"history.column_add": "Ajouter une colonne",
@@ -281,6 +303,7 @@
"space.copy_error_partitions_limit": "Cet espace contient trop de murs pour être copié.",
"space.copy_error_openings_limit": "Cet espace contient trop d’ouvertures pour être copié.",
"space.copy_error_decor_limit": "Cet espace contient trop d’objets d’arrière-plan pour être copié.",
"space.copy_error_stairs_limit": "Cet espace contient trop d’escaliers pour être copié.",
"space.copy_error_columns_limit": "Cet espace contient trop de colonnes pour être copié.",
"space.copy_error_opening_host": "Une ouverture n’est plus rattachée à un mur. Corrigez-la ou supprimez-la avant la copie.",
"space.copy_error_geometry": "La copie créerait une géométrie de murs non sûre. Corrigez l’espace source.",
+23
View File
@@ -30,6 +30,28 @@
"title.markup_column": "Колонна: кликните точку сетки для квадратной колонны",
"markup.partition": "Перегородка",
"markup.column": "Колонна",
"markup.stairs": "Лестница",
"stairs.straight": "Прямая",
"stairs.spiral": "Винтовая",
"stairs.properties": "Свойства лестницы",
"stairs.type": "Тип",
"stairs.length": "Длина",
"stairs.width": "Ширина",
"stairs.radius": "Радиус",
"stairs.rotation": "Поворот",
"stairs.direction": "Направление подъёма",
"stairs.forward": "Вперёд",
"stairs.backward": "Назад",
"stairs.clockwise": "По часовой стрелке",
"stairs.counterclockwise": "Против часовой стрелки",
"stairs.target": "Ведёт на этаж",
"stairs.no_target": "Не выбран",
"stairs.target_warning": "Выберите другой существующий этаж, чтобы включить переход.",
"stairs.fixed_floor": "В карточке с фиксированным этажом переход отключён.",
"history.stair_add": "Добавить лестницу",
"history.stair_edit": "Изменить лестницу",
"history.stair_move": "Переместить лестницу",
"history.stair_delete": "Удалить лестницу",
"markup.hint_column": "кликните точку сетки, чтобы поставить квадратную колонну",
"history.wall_segment": "Добавление отрезка стены",
"history.column_add": "Добавление колонны",
@@ -281,6 +303,7 @@
"space.copy_error_partitions_limit": "В этом пространстве слишком много стен для копирования.",
"space.copy_error_openings_limit": "В этом пространстве слишком много проёмов для копирования.",
"space.copy_error_decor_limit": "В этом пространстве слишком много элементов подложки для копирования.",
"space.copy_error_stairs_limit": "В этом пространстве слишком много лестниц для копирования.",
"space.copy_error_columns_limit": "В этом пространстве слишком много колонн для копирования.",
"space.copy_error_opening_host": "Один из проёмов больше не привязан к стене. Исправьте или удалите его перед копированием.",
"space.copy_error_geometry": "Копия создаст небезопасную геометрию стен. Исправьте исходное пространство и повторите попытку.",
+29 -6
View File
@@ -5,7 +5,7 @@ import { canonicalFurnitureId } from '../furniture-id';
import { formatArea } from '../area-format';
import { roomPoly } from '../logic';
import {
floorMinusBodies, geometryAllRings, geometryArea, geometryOuterRings, physicalBodyParts,
floorMinusBodies, geometryAllRings, geometryOuterRings, physicalBodyParts,
pointInPhysicalGeometry,
} from '../physical-geometry';
import {
@@ -13,6 +13,9 @@ import {
type GeometryOpeningProjection,
} from '../plan-geometry-preflight';
import { labelPos, GRID_PITCH, GRID_STEP_N, NORM_W } from '../space-geometry';
import {
cachedStairRenderGeometry, geometryAreaMinusStairs, stairOutline, type Stair,
} from '../stairs';
import type { ServerConfig, SpaceModel } from '../types';
import {
innerContourForRoom, openingInnerFaceOffset, wallBodiesGeometry,
@@ -62,6 +65,7 @@ export interface PdfRawSpace {
cell_cm?: unknown;
walls?: WallEntry[];
decor?: DecorShape[];
stairs?: Stair[];
settings?: Record<string, unknown>;
[key: string]: unknown;
}
@@ -301,13 +305,10 @@ function preparePdfScene(input: PdfSceneInput): PreparedPdfScene {
const localBodies = !Number.isFinite(cachedArea)
? bodiesOverlappingRing(contour, built.extras) : [];
const candidates = localBodies.length ? floorMinusBodies(contour, localBodies) : null;
const cleanGeometry = candidates || [[[...contour, contour[0]]]];
const areaUnits = Number.isFinite(cachedArea) && (cachedArea as number) >= 0
? cachedArea as number
: candidates ? geometryArea(candidates) : Math.abs(
contour.reduce((sum, point, index) => {
const next = contour[(index + 1) % contour.length];
return sum + point[0] * next[1] - next[0] * point[1];
}, 0) / 2);
: geometryAreaMinusStairs(cleanGeometry, input.space.stairs);
roomAreas.set(room, areaUnits);
}
const architectureRings = built.geometry.status === 'failed-core'
@@ -315,6 +316,7 @@ function preparePdfScene(input: PdfSceneInput): PreparedPdfScene {
: built.geometry.components.flatMap((component) => geometryAllRings(component.geom));
const allBounds = [...architectureRings, ...built.zero.lines.map((line) => [[line[0], line[1]], [line[2], line[3]]])];
if (input.options.decor) allBounds.push(...decorBounds(input.rawSpace, true));
allBounds.push(...input.space.stairs.map((stair) => stairOutline(stair)));
for (const raster of input.rasters || []) allBounds.push(rotatedRectRing(
raster.x, raster.y, raster.drawWidth, raster.drawHeight, raster.angle,
));
@@ -460,6 +462,27 @@ function buildPdfCandidate(
}
}
// Stairs are plan geometry, not optional decor. Keep their PDF symbol and
// physical area semantics aligned with the two on-screen renderers.
const stairMatrix = {
a: pointPerUnit, b: 0, c: 0, d: pointPerUnit,
e: left - bounds.minX * pointPerUnit,
f: top - bounds.minY * pointPerUnit,
};
for (const stair of input.space.stairs) {
const geometry = cachedStairRenderGeometry(stair, built.cellCm);
commands.push({
kind: 'path', rings: [geometry.outline.map(pt)], stroke: INK, width: 0.25 * MM,
});
for (const tread of geometry.treads) commands.push({
kind: 'line', points: [pt(tread.a), pt(tread.b)], stroke: INK, width: 0.2 * MM,
});
commands.push({
kind: 'vector', ops: transformSvgPath(geometry.arrowPath, stairMatrix),
stroke: INK, width: 0.3 * MM,
});
}
if (input.options.dimensions && built.geometry.roomGeom) {
for (const [ringIndex, ring] of geometryOuterRings(built.geometry.roomGeom).entries()) {
const epsilon = dimensionEpsilonUnits(cmPerUnit);
+1
View File
@@ -31,6 +31,7 @@ const ERROR_KEYS: Record<SpaceCopyErrorCode, I18nKey> = {
partitions_limit: 'space.copy_error_partitions_limit',
openings_limit: 'space.copy_error_openings_limit',
decor_limit: 'space.copy_error_decor_limit',
stairs_limit: 'space.copy_error_stairs_limit',
columns_limit: 'space.copy_error_columns_limit',
opening_host_missing: 'space.copy_error_opening_host',
opening_host_unknown: 'space.copy_error_opening_host',
+6
View File
@@ -6,6 +6,7 @@ export const SPACE_COPY_LIMITS = Object.freeze({
openings: 500,
decor: 1000,
columns: 500,
stairs: 250,
});
export type SpaceCopyErrorCode =
@@ -16,6 +17,7 @@ export type SpaceCopyErrorCode =
| 'openings_limit'
| 'decor_limit'
| 'columns_limit'
| 'stairs_limit'
| 'opening_host_missing'
| 'opening_host_unknown'
| 'geometry_unsafe';
@@ -113,11 +115,13 @@ export function createSpaceCopyCandidate(
const openings = geometryList(source.openings);
const decor = geometryList(source.decor);
const columns = geometryList(source.wall_columns);
const stairs = geometryList(source.stairs);
const partitionCount = wallSegments.length + sourcePartitions.length;
if (partitionCount > SPACE_COPY_LIMITS.partitions) throw new SpaceCopyError('partitions_limit');
if (openings.length > SPACE_COPY_LIMITS.openings) throw new SpaceCopyError('openings_limit');
if (decor.length > SPACE_COPY_LIMITS.decor) throw new SpaceCopyError('decor_limit');
if (columns.length > SPACE_COPY_LIMITS.columns) throw new SpaceCopyError('columns_limit');
if (stairs.length > SPACE_COPY_LIMITS.stairs) throw new SpaceCopyError('stairs_limit');
let serial = 0;
const nextId = (prefix: string): string => `${prefix}${seed}${(serial++).toString(36)}`;
@@ -164,6 +168,7 @@ export function createSpaceCopyCandidate(
});
const copiedDecor = decor.map((item) => ({ ...clone(item), id: nextId('cd') }));
const copiedColumns = columns.map((item) => ({ ...clone(item), id: nextId('cc') }));
const copiedStairs = stairs.map((item) => ({ ...clone(item), id: nextId('cs') }));
const newSpaceId = `s${seed}`;
if (spaces.some((item) => item.id === newSpaceId)) throw new SpaceCopyError('source_invalid');
@@ -181,6 +186,7 @@ export function createSpaceCopyCandidate(
if (copiedOpenings.length) copiedSpace.openings = copiedOpenings;
if (copiedDecor.length) copiedSpace.decor = copiedDecor;
if (copiedColumns.length) copiedSpace.wall_columns = copiedColumns;
if (copiedStairs.length) copiedSpace.stairs = copiedStairs;
const config = clone(input);
config.spaces.splice(sourceIndex + 1, 0, copiedSpace);
+8 -7
View File
@@ -8,8 +8,10 @@ import type { ServerConfig, SpaceModel, RoomCfg, DevItem, OpeningCfg } from './t
import { boxCorners, normalizeAngle } from './editors/decor/geometry';
import { canonicalColumnAngle } from './physical-geometry';
import { gridVisualScale } from './grid-scale';
import { stairList, stairOutline } from './stairs';
import { CANVAS_LIMIT, GRID_N, GRID_PITCH, NORM_W } from './canvas-constants';
export const NORM_W = 1000; // side of the render space — the canvas is square
export { CANVAS_LIMIT, GRID_N, GRID_PITCH, GRID_STEP_N, NORM_W } from './canvas-constants';
export type StaticPassageOpening = OpeningCfg & {
type: 'passage'; rx: number; ry: number; rlen: number;
@@ -184,6 +186,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
cm: Number(c.cm),
...(c.shape === 'circle' ? {} : { angle: canonicalColumnAngle(c.angle) }),
})),
stairs: stairList(s.stairs),
} as SpaceModel;
});
}
@@ -194,18 +197,12 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
/** Sane coordinate range in NORMALISED units — mirrors validation.py.
* Not a frame: insurance against a stored 1e100 (HP-1500-03/HP-1501-01). */
export const CANVAS_LIMIT = 5000;
/** The same range in RENDER units. */
export const SANE_LIMIT = CANVAS_LIMIT * NORM_W;
/** Grid points across the plan width — the lattice the editor snaps to.
* It is derived from NORM_W alone, so it is the SAME step for every plan and
* it did NOT change when the canvas became infinite (docs/CANVAS.md §9). */
export const GRID_N = 240;
/** One grid step in RENDER units. */
export const GRID_PITCH = NORM_W / GRID_N;
/** One grid step in NORMALISED units — what the config and the layout store. */
export const GRID_STEP_N = 1 / GRID_N;
/** Snap a RENDER-unit coordinate to the editor's grid (docs/CANVAS.md §9). */
export function snapR(v: number): number {
@@ -328,6 +325,10 @@ export function contentItems(
): ContentItem[] {
const out: ContentItem[] = [];
for (const r of space.rooms || []) { const it = roomItem(r); if (it) out.push(it); }
for (const stair of space.stairs || []) {
const item = itemOf(stairOutline(stair));
if (item) out.push(item);
}
// The backdrop image is ONE OF the objects of the space, exactly like a room
// (docs/BACKDROP.md §4): cropping to the outlined rooms would hide the parts
// of the picture nobody has drawn over yet, and — since v1.58.0 — the
+21
View File
@@ -60,6 +60,7 @@ import { geometryOpenings } from './plan-geometry-preflight';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import { projectDecorImage } from './decor-assets';
import type { DecorShape } from './editors/decor/types';
import { cachedStairRenderGeometry, stairOutline } from './stairs';
import {
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
@@ -381,6 +382,14 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
minX: Math.min(line[0], line[2]), minY: Math.min(line[1], line[3]),
maxX: Math.max(line[0], line[2]), maxY: Math.max(line[1], line[3]),
});
for (const stair of space.stairs) {
const outline = stairOutline(stair);
const xs = outline.map((point) => point[0]), ys = outline.map((point) => point[1]);
if (xs.length) placed.push({
minX: Math.min(...xs), minY: Math.min(...ys),
maxX: Math.max(...xs), maxY: Math.max(...ys),
});
}
// Resolve architectural opening hosts before framing: tight house mode must
// include their maximum painted envelope without consulting live state.
@@ -893,6 +902,17 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
${renderOpeningVisibleGeometry(spec)}
</g>`;
});
const stairShapes = space.stairs.map((stair) => {
const geometry = cachedStairRenderGeometry(stair, cellCm);
return svg`<g class="hp-stair" data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
data-target-state="fixed"
aria-hidden="true" pointer-events="none">
<polygon class="hp-stair-outline" points=${geometry.outline.map((point) => point.join(',')).join(' ')}></polygon>
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
</g>`;
});
return html`
<div class="hp-static-stage${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}"
@@ -941,6 +961,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: nothing}
${passageGlowTunnels}
<g class="decorlayer" pointer-events="none">${decorImages}</g>
<g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>
${glowPools}
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
+104
View File
@@ -0,0 +1,104 @@
import { GRID_N, NORM_W } from './canvas-constants';
import type { Stair } from './stairs';
export const STAIR_MIN_N = 0.0005;
const finite = (value: unknown): number | null => {
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
const cmToNorm = (cm: number, cellCm: number): number => (
Number(cm) / ((Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N)
);
const normToCm = (value: number, cellCm: number): number => (
Number(value) * (Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N
);
export function normalizeStairAngle(value: unknown): number {
const number = finite(value) ?? 0;
const normalized = ((number % 360) + 360) % 360;
return Number(normalized.toFixed(6));
}
export function defaultStair(
kind: Stair['kind'], x: number, y: number, cellCm: number, id: string,
): Stair {
if (kind === 'spiral') return {
id, kind, x: x / NORM_W, y: y / NORM_W, angle: 0,
direction: 'clockwise', radius: cmToNorm(90, cellCm), target_space_id: null,
};
return {
id, kind, x: x / NORM_W, y: y / NORM_W, angle: 0,
direction: 'forward', length: cmToNorm(240, cellCm), width: cmToNorm(100, cellCm),
target_space_id: null,
};
}
export function convertStairKind(stair: Stair, kind: Stair['kind']): Stair {
if (stair.kind === kind) return { ...stair };
if (stair.kind === 'straight') return {
id: stair.id, kind: 'spiral', x: stair.x, y: stair.y, angle: stair.angle,
direction: stair.direction === 'backward' ? 'counterclockwise' : 'clockwise',
radius: Math.max(stair.length, stair.width) / 2,
target_space_id: stair.target_space_id ?? null,
};
return {
id: stair.id, kind: 'straight', x: stair.x, y: stair.y, angle: stair.angle,
direction: stair.direction === 'counterclockwise' ? 'backward' : 'forward',
length: stair.radius * 2, width: stair.radius * 2,
target_space_id: stair.target_space_id ?? null,
};
}
const supportAlong = (stair: Stair, dx: number, dy: number, scale: number): number => {
if (stair.kind === 'spiral') return stair.radius * scale;
const bearing = stair.angle * Math.PI / 180;
const ux = Math.cos(bearing), uy = Math.sin(bearing);
const vx = -uy, vy = ux;
return Math.abs(dx * ux + dy * uy) * stair.length * scale / 2
+ Math.abs(dx * vx + dy * vy) * stair.width * scale / 2;
};
/** Snap one independently movable footprint flush to the nearest other stair. */
export function snapStairToStairs(
stair: Stair,
center: readonly number[],
others: readonly Stair[],
reach: number,
scale = NORM_W,
): [number, number] {
let best: { x: number; y: number; gap: number } | null = null;
const moved = { ...stair, x: center[0] / scale, y: center[1] / scale } as Stair;
for (const other of others) {
if (other.id === stair.id) continue;
const ox = other.x * scale, oy = other.y * scale;
const dx0 = center[0] - ox, dy0 = center[1] - oy;
const distance = Math.hypot(dx0, dy0);
if (!(distance > 1e-8)) continue;
const dx = dx0 / distance, dy = dy0 / distance;
const target = supportAlong(moved, dx, dy, scale)
+ supportAlong(other, dx, dy, scale);
const gap = Math.abs(distance - target);
if (gap <= reach && (!best || gap < best.gap)) {
best = { x: ox + dx * target, y: oy + dy * target, gap };
}
}
return best ? [best.x, best.y] : [center[0], center[1]];
}
export function stairPhysicalSizeCm(stair: Stair, cellCm: number): number[] {
return stair.kind === 'straight'
? [normToCm(stair.length, cellCm), normToCm(stair.width, cellCm)]
: [normToCm(stair.radius, cellCm)];
}
export function stairTargetState(
stair: Stair, currentSpaceId: string, spaceIds: ReadonlySet<string>, fixedFloor: boolean,
): 'active' | 'missing' | 'self' | 'deleted' | 'fixed' {
if (fixedFloor) return 'fixed';
if (!stair.target_space_id) return 'missing';
if (stair.target_space_id === currentSpaceId) return 'self';
return spaceIds.has(stair.target_space_id) ? 'active' : 'deleted';
}
+528
View File
@@ -0,0 +1,528 @@
import { html, nothing, svg, type TemplateResult } from 'lit';
import { resizeFurnitureTransform } from './furniture';
import { FURN_WALL_CELLS, snapFurnitureToWall } from './furniture-placement';
import {
furnitureWallSurfacesFor, type FurnitureWallSurfaceSource,
} from './furniture-wall-surface';
import { strictNumber, type MarkupTool } from './card-runtime';
import type { EditorToolbarGroup } from './editor-secondary';
import type { I18nKey } from './i18n';
import { clampCanvasN, NORM_W } from './space-geometry';
import {
convertStairKind, defaultStair, normalizeStairAngle, snapStairToStairs,
stairPhysicalSizeCm, stairTargetState, STAIR_MIN_N,
} from './stairs-editor-model';
import {
cachedStairRenderGeometry, MAX_STAIRS_PER_SPACE, stairList, type Stair,
} from './stairs';
import type { SpaceModel } from './types';
import { cmToField, fieldToCm } from './wall-thickness';
type StairDialog = {
id: string;
kind: Stair['kind'];
length: string;
width: string;
radius: string;
angle: string;
direction: Stair['direction'];
targetSpaceId: string;
};
type StairDrag = {
pid: number;
id: string;
mode: 'move' | 'resize' | 'rotate';
resizeX: -1 | 0 | 1;
resizeY: -1 | 0 | 1;
start: number[];
original: Stair;
before: unknown;
moved: boolean;
};
/** Narrow internal seam owned by HouseplanCard. The cast at construction keeps
* these implementation details private to the card while this module keeps the
* stairs feature out of the already size-limited card core. */
export interface StairEditorHostPort {
hass: unknown;
_mode: 'view' | 'plan' | 'devices' | 'decor';
_tool: MarkupTool;
_curSpaceCfg: unknown;
_cfgEpoch: number;
_cleanFloorCache: Map<unknown, unknown>;
_modelCache: unknown;
_gridPitch: number;
_cellCm: number;
_imperial: boolean;
_model: SpaceModel[];
_space: string;
_hasFixedFloor: boolean;
_suppressClick: boolean;
requestUpdate(): void;
_showToast(message: string): void;
_t(key: I18nKey, vars?: Record<string, string | number>): string;
_geometrySnapshot(): unknown;
_recordGeometry(name: string, before: unknown): void;
_saveConfigDebounced(): void;
_cmToUnits(cm: number): number;
_svgPoint(event: MouseEvent): number[];
_tabClick(spaceId: string): void;
_activateMarkupTool(tool: MarkupTool): void;
}
export class StairEditorRuntime {
private preset: Stair['kind'] = 'straight';
private selectedId: string | null = null;
private dialog: StairDialog | null = null;
private drag: StairDrag | null = null;
public constructor(private readonly owner: StairEditorHostPort) {}
public get selected(): string | null { return this.selectedId; }
public get dialogOpen(): boolean { return this.dialog !== null; }
public get dragging(): boolean { return this.drag !== null; }
public clearSelection(): void {
this.selectedId = null;
this.owner.requestUpdate();
}
public selectionKey(): string | null {
return this.selectedId ? `selection:stair:${this.selectedId}` : null;
}
private get stairs(): Stair[] {
return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs);
}
private write(stairs: Stair[]): void {
const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null;
if (!space) return;
if (stairs.length) space.stairs = stairs;
else delete space.stairs;
this.owner._cfgEpoch++;
this.owner._cleanFloorCache.clear();
this.owner._modelCache = null;
this.owner.requestUpdate();
}
private withMagnet(stair: Stair, center: readonly number[]): Stair {
let cx = center[0], cy = center[1], angle = stair.angle;
const depth = (stair.kind === 'straight' ? stair.width : stair.radius * 2) * NORM_W;
const wall = snapFurnitureToWall(
cx, cy, depth,
furnitureWallSurfacesFor(this.owner as unknown as FurnitureWallSurfaceSource),
this.owner._gridPitch * FURN_WALL_CELLS, 0, [cx, cy],
);
if (wall) {
cx = wall.cx;
cy = wall.cy;
if (stair.kind === 'straight') angle = wall.angle;
}
const moved = { ...stair, x: cx / NORM_W, y: cy / NORM_W, angle } as Stair;
[cx, cy] = snapStairToStairs(
moved, [cx, cy], this.stairs, this.owner._gridPitch * FURN_WALL_CELLS,
);
return {
...stair, x: clampCanvasN(cx / NORM_W), y: clampCanvasN(cy / NORM_W), angle,
} as Stair;
}
public activatePlacement(kind: Stair['kind']): void {
this.preset = kind;
this.selectedId = null;
this.owner._activateMarkupTool('stairs');
}
public placeAt(point: number[]): void {
const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null;
if (!space || this.stairs.length >= MAX_STAIRS_PER_SPACE) {
this.owner._showToast(this.owner._t('toast.physical_limit'));
return;
}
const before = this.owner._geometrySnapshot();
const id = `stair-${crypto.randomUUID?.()
|| `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`;
const stair = this.withMagnet(
defaultStair(this.preset, point[0], point[1], this.owner._cellCm, id), point,
);
this.write([...this.stairs, stair]);
this.selectedId = id;
this.owner._recordGeometry(this.owner._t('history.stair_add'), before);
this.owner._saveConfigDebounced();
}
private openDialog(stair: Stair): void {
const sizes = stairPhysicalSizeCm(stair, this.owner._cellCm);
this.dialog = {
id: stair.id,
kind: stair.kind,
length: cmToField(stair.kind === 'straight' ? sizes[0] : 100, this.owner._imperial),
width: cmToField(stair.kind === 'straight' ? sizes[1] : 100, this.owner._imperial),
radius: cmToField(stair.kind === 'spiral' ? sizes[0] : 90, this.owner._imperial),
angle: String(stair.angle),
direction: stair.direction,
targetSpaceId: stair.target_space_id || '',
};
this.owner.requestUpdate();
}
private updateDialog(patch: Partial<StairDialog>): void {
if (!this.dialog) return;
this.dialog = { ...this.dialog, ...patch };
this.owner.requestUpdate();
}
private saveDialog(): void {
const dialog = this.dialog;
const current = this.stairs.find((item) => item.id === dialog?.id);
if (!dialog || !current) return;
const lengthCm = fieldToCm(dialog.length, this.owner._imperial);
const widthCm = fieldToCm(dialog.width, this.owner._imperial);
const radiusCm = fieldToCm(dialog.radius, this.owner._imperial);
const angle = strictNumber(dialog.angle);
if (angle == null || (dialog.kind === 'straight'
? !(lengthCm && widthCm) : !radiusCm)) return;
const before = this.owner._geometrySnapshot();
let next = convertStairKind(current, dialog.kind);
if (next.kind === 'straight') next = {
...next,
length: Math.max(STAIR_MIN_N, this.owner._cmToUnits(lengthCm!) / NORM_W),
width: Math.max(STAIR_MIN_N, this.owner._cmToUnits(widthCm!) / NORM_W),
direction: dialog.direction === 'backward' ? 'backward' : 'forward',
};
else next = {
...next,
radius: Math.max(STAIR_MIN_N, this.owner._cmToUnits(radiusCm!) / NORM_W),
direction: dialog.direction === 'counterclockwise' ? 'counterclockwise' : 'clockwise',
};
next = {
...next,
angle: normalizeStairAngle(angle),
target_space_id: dialog.targetSpaceId || null,
} as Stair;
this.write(this.stairs.map((item) => item.id === next.id ? next : item));
this.owner._recordGeometry(this.owner._t('history.stair_edit'), before);
this.owner._saveConfigDebounced();
this.dialog = null;
}
private delete(id: string): void {
const before = this.owner._geometrySnapshot();
this.write(this.stairs.filter((item) => item.id !== id));
this.selectedId = null;
this.dialog = null;
this.owner._recordGeometry(this.owner._t('history.stair_delete'), before);
this.owner._saveConfigDebounced();
}
public deleteSelected(): boolean {
if (!this.selectedId) return false;
this.delete(this.selectedId);
return true;
}
private pointerDown(
event: PointerEvent,
stair: Stair,
mode: 'move' | 'resize' | 'rotate',
resizeX: -1 | 0 | 1 = 1,
resizeY: -1 | 0 | 1 = 1,
): void {
if (this.owner._mode !== 'plan'
|| (this.owner._tool !== 'select' && this.owner._tool !== 'stairs')) return;
event.preventDefault();
event.stopPropagation();
this.selectedId = stair.id;
this.drag = {
pid: event.pointerId,
id: stair.id,
mode,
resizeX,
resizeY,
start: this.owner._svgPoint(event as unknown as MouseEvent),
original: structuredClone(stair),
before: this.owner._geometrySnapshot(),
moved: false,
};
try { (event.currentTarget as Element).setPointerCapture(event.pointerId); }
catch { /* synthetic event */ }
this.owner.requestUpdate();
}
public pointerMove(event: PointerEvent): boolean {
const drag = this.drag;
if (!drag || drag.pid !== event.pointerId) return false;
const point = this.owner._svgPoint(event as unknown as MouseEvent);
let next: Stair = { ...drag.original };
if (drag.mode === 'move') {
const center = [
drag.original.x * NORM_W + point[0] - drag.start[0],
drag.original.y * NORM_W + point[1] - drag.start[1],
];
next = this.withMagnet(next, center);
} else if (drag.mode === 'rotate') {
next.angle = normalizeStairAngle(
Math.atan2(point[1] - next.y * NORM_W, point[0] - next.x * NORM_W)
* 180 / Math.PI + 90,
);
if (event.shiftKey) next.angle = Math.round(next.angle / 45) * 45;
} else if (next.kind === 'spiral') {
next.radius = Math.max(STAIR_MIN_N,
Math.hypot(point[0] - next.x * NORM_W, point[1] - next.y * NORM_W) / NORM_W);
} else {
const resized = resizeFurnitureTransform({
x: (next.x - next.length / 2) * NORM_W,
y: (next.y - next.width / 2) * NORM_W,
w: next.length * NORM_W,
h: next.width * NORM_W,
angle: next.angle,
}, drag.resizeX, drag.resizeY, point[0], point[1], false, STAIR_MIN_N * NORM_W);
next = {
...next,
x: (resized.x + resized.w / 2) / NORM_W,
y: (resized.y + resized.h / 2) / NORM_W,
length: resized.w / NORM_W,
width: resized.h / NORM_W,
};
}
drag.moved ||= Math.hypot(point[0] - drag.start[0], point[1] - drag.start[1]) > 0.5;
this.write(this.stairs.map((item) => item.id === next.id ? next : item));
return true;
}
private endDrag(event: PointerEvent, cancelled: boolean): boolean {
const drag = this.drag;
if (!drag || drag.pid !== event.pointerId) return false;
if (cancelled) {
this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item));
} else if (drag.moved) {
this.owner._recordGeometry(this.owner._t('history.stair_move'), drag.before);
this.owner._saveConfigDebounced();
}
this.drag = null;
return true;
}
public pointerUp(event: PointerEvent): boolean {
return this.endDrag(event, false);
}
public pointerCancel(event: PointerEvent): boolean {
return this.endDrag(event, true);
}
public undoActiveDrag(): boolean {
if (!this.drag) return false;
const drag = this.drag;
this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item));
this.drag = null;
return true;
}
public escape(): boolean {
if (this.undoActiveDrag()) return true;
if (!this.selectedId) return false;
this.selectedId = null;
this.owner.requestUpdate();
return true;
}
public beforeModeChange(mode: StairEditorHostPort['_mode']): void {
if (mode !== this.owner._mode) this.undoActiveDrag();
}
public afterModeChange(): void {
if (this.owner._mode !== 'plan') this.selectedId = null;
}
public clearGesture(): void {
this.drag = null;
this.selectedId = null;
}
public toolbarGroup(): EditorToolbarGroup {
return {
id: 'stairs',
label: this.owner._t('markup.stairs'),
icon: 'mdi:stairs',
activeItemId: this.owner._tool === 'stairs' ? this.preset : undefined,
items: [{
id: 'straight',
label: this.owner._t('stairs.straight'),
icon: 'mdi:stairs',
role: 'tool',
invoke: () => this.activatePlacement('straight'),
}, {
id: 'spiral',
label: this.owner._t('stairs.spiral'),
icon: 'mdi:rotate-orbit',
role: 'tool',
invoke: () => this.activatePlacement('spiral'),
}],
};
}
public renderLayer(): TemplateResult {
const spaceIds = new Set(this.owner._model.map((item) => item.id));
const items = this.stairs.map((stair) => {
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
const outline = geometry.outline.map((point) => point.join(',')).join(' ');
const selected = this.owner._mode === 'plan'
&& (this.owner._tool === 'select' || this.owner._tool === 'stairs')
&& this.selectedId === stair.id;
const targetState = stairTargetState(
stair, this.owner._space, spaceIds, this.owner._hasFixedFloor,
);
const inputEnabled = this.owner._mode === 'plan'
&& (this.owner._tool === 'select' || this.owner._tool === 'stairs');
const select = (event: Event): void => {
if (this.owner._mode === 'plan') {
event.stopPropagation();
this.selectedId = stair.id;
this.owner.requestUpdate();
}
};
const resizeHandles = stair.kind === 'spiral'
? [{ point: [stair.x * NORM_W + stair.radius * NORM_W, stair.y * NORM_W], sx: 1, sy: 1 }]
: [
{ point: geometry.outline[0], sx: -1, sy: -1 },
{ point: geometry.outline[1], sx: 1, sy: -1 },
{ point: geometry.outline[2], sx: 1, sy: 1 },
{ point: geometry.outline[3], sx: -1, sy: 1 },
{ point: geometry.outline[0].map((value, axis) =>
(value + geometry.outline[1][axis]) / 2), sx: 0, sy: -1 },
{ point: geometry.outline[1].map((value, axis) =>
(value + geometry.outline[2][axis]) / 2), sx: 1, sy: 0 },
{ point: geometry.outline[2].map((value, axis) =>
(value + geometry.outline[3][axis]) / 2), sx: 0, sy: 1 },
{ point: geometry.outline[3].map((value, axis) =>
(value + geometry.outline[0][axis]) / 2), sx: -1, sy: 0 },
];
const bearing = stair.angle * Math.PI / 180;
const outer = (stair.kind === 'spiral' ? stair.radius : stair.width / 2) * NORM_W;
const rotatePoint = [
stair.x * NORM_W + Math.sin(bearing) * (outer + this.owner._gridPitch * 2),
stair.y * NORM_W - Math.cos(bearing) * (outer + this.owner._gridPitch * 2),
];
return svg`<g class="hp-stair ${selected ? 'selected' : ''} ${inputEnabled ? 'input-enabled' : ''}"
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
data-target-state=${targetState}
role="img"
aria-label=${this.owner._t('markup.stairs')}
@dblclick=${(event: MouseEvent) => {
event.stopPropagation();
if (this.owner._mode === 'plan') this.openDialog(stair);
}}>
<polygon class="hp-stair-outline" points=${outline}></polygon>
<polygon class="hp-stair-hit" points=${outline}
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'move')}
@click=${select}></polygon>
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
${selected ? svg`
${resizeHandles.map((handle) => svg`<circle class="hp-stair-handle hp-stair-resize"
cx=${handle.point[0]} cy=${handle.point[1]} r=${this.owner._gridPitch * 0.65}
@pointerdown=${(event: PointerEvent) => this.pointerDown(
event, stair, 'resize', handle.sx as -1 | 0 | 1, handle.sy as -1 | 0 | 1,
)}></circle>`)}
<line class="hp-stair-rotate-leader" x1=${stair.x * NORM_W} y1=${stair.y * NORM_W}
x2=${rotatePoint[0]} y2=${rotatePoint[1]}></line>
<circle class="hp-stair-handle hp-stair-rotate"
cx=${rotatePoint[0]} cy=${rotatePoint[1]} r=${this.owner._gridPitch * 0.65}
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'rotate')}></circle>
` : nothing}
</g>`;
});
return svg`<g class="hp-stairs-layer">${items}</g>` as unknown as TemplateResult;
}
public renderDialog(): TemplateResult | typeof nothing {
const dialog = this.dialog;
const current = this.stairs.find((item) => item.id === dialog?.id);
if (!dialog || !current) return nothing;
const targetState = stairTargetState(
{ ...current, target_space_id: dialog.targetSpaceId || null } as Stair,
this.owner._space, new Set(this.owner._model.map((item) => item.id)),
this.owner._hasFixedFloor,
);
const directionOptions: ReadonlyArray<readonly [string, I18nKey]> = dialog.kind === 'straight'
? [['forward', 'stairs.forward'], ['backward', 'stairs.backward']] as const
: [['clockwise', 'stairs.clockwise'], ['counterclockwise', 'stairs.counterclockwise']] as const;
const field = (key: 'length' | 'width' | 'radius', label: I18nKey) => html`
<label>${this.owner._t(label)}</label>
<div class="row"><input class="namein tempin" type="number" min="0.01" step="any"
.value=${dialog[key]}
@input=${(event: Event) => this.updateDialog({
[key]: (event.target as HTMLInputElement).value,
})}><span class="opl">${this.owner._t(
this.owner._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm',
)}</span></div>`;
return html`<hp-dialog .hass=${this.owner.hass} data-kind="stairs" wide
.title=${this.owner._t('stairs.properties')} icon="mdi:stairs"
@hp-close=${() => this.closeDialog()}>
<div class="body">
<label>${this.owner._t('stairs.type')}</label>
<select class="areasel"
@change=${(event: Event) => {
const kind = (event.target as HTMLSelectElement).value as Stair['kind'];
const converted = convertStairKind(current, kind);
const sizes = stairPhysicalSizeCm(converted, this.owner._cellCm);
this.updateDialog({
kind,
length: cmToField(kind === 'straight' ? sizes[0] : 100, this.owner._imperial),
width: cmToField(kind === 'straight' ? sizes[1] : 100, this.owner._imperial),
radius: cmToField(kind === 'spiral' ? sizes[0] : 90, this.owner._imperial),
direction: converted.direction,
});
}}>
<option value="straight" ?selected=${dialog.kind === 'straight'}>${this.owner._t('stairs.straight')}</option>
<option value="spiral" ?selected=${dialog.kind === 'spiral'}>${this.owner._t('stairs.spiral')}</option>
</select>
${dialog.kind === 'straight'
? html`${field('length', 'stairs.length')}${field('width', 'stairs.width')}`
: field('radius', 'stairs.radius')}
<label>${this.owner._t('stairs.rotation')}</label>
<input class="namein tempin" type="number" step="any" .value=${dialog.angle}
@input=${(event: Event) => this.updateDialog({
angle: (event.target as HTMLInputElement).value,
})}>
<label>${this.owner._t('stairs.direction')}</label>
<div class="segmented">${directionOptions.map(([value, key]) => html`
<button class="btn ${dialog.direction === value ? 'on' : ''}"
@click=${() => this.updateDialog({
direction: value as Stair['direction'],
})}>${this.owner._t(key)}</button>`)}</div>
<label>${this.owner._t('stairs.target')}</label>
<select class="areasel"
@change=${(event: Event) => this.updateDialog({
targetSpaceId: (event.target as HTMLSelectElement).value,
})}>
<option value="" ?selected=${dialog.targetSpaceId === ''}>${this.owner._t('stairs.no_target')}</option>
${this.owner._model.filter((item) => item.id !== this.owner._space).map((item) => html`
<option value=${item.id} ?selected=${dialog.targetSpaceId === item.id}>${item.title}</option>`)}
</select>
${targetState !== 'active' ? html`<p class="hint warn">${this.owner._t(
targetState === 'fixed' ? 'stairs.fixed_floor' : 'stairs.target_warning',
)}</p>` : nothing}
</div>
<div class="row dialog-action-footer" slot="footer">
<button class="btn danger" data-hp="dialog-confirm" @click=${() => this.delete(dialog.id)}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.owner._t('btn.delete')}</button>
<span class="spacer"></span>
<button class="btn ghost" data-hp="dialog-cancel"
@click=${() => this.closeDialog()}>${this.owner._t('btn.cancel')}</button>
<button class="btn on" data-hp="dialog-confirm" @click=${() => this.saveDialog()}>
<ha-icon icon="mdi:check"></ha-icon>${this.owner._t('btn.save')}</button>
</div>
</hp-dialog>`;
}
private closeDialog(): void {
this.dialog = null;
this.owner.requestUpdate();
}
}
+97
View File
@@ -0,0 +1,97 @@
import { nothing, svg, type TemplateResult } from 'lit';
import {
cachedStairRenderGeometry, stairList, type Stair,
} from './stairs';
import { stairTargetState } from './stairs-editor-model';
import type { SpaceModel } from './types';
export interface StairViewHostPort {
_mode: 'view' | 'plan' | 'devices' | 'decor';
_curSpaceCfg: unknown;
_model: SpaceModel[];
_space: string;
_hasFixedFloor: boolean;
_suppressClick: boolean;
_cellCm: number;
_tabClick(spaceId: string): void;
_t(key: 'markup.stairs'): string;
}
type ViewPress = { pid: number; startedAt: number };
/** Eager, read-only stair surface. All editing stays in the lazy editor graph. */
export class StairViewRuntime {
private press: ViewPress | null = null;
private suppressClick = false;
public constructor(private readonly owner: StairViewHostPort) {}
private get stairs(): Stair[] {
return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs);
}
public pointerDown(event: PointerEvent): void {
this.suppressClick = false;
if (event.pointerType === 'touch') {
this.press = { pid: event.pointerId, startedAt: performance.now() };
}
}
public pointerUp(event: PointerEvent): void {
const press = this.press;
if (!press || press.pid !== event.pointerId) return;
this.press = null;
if (performance.now() - press.startedAt >= 600) this.suppressClick = true;
}
public pointerCancel(event: PointerEvent): void {
if (this.press?.pid !== event.pointerId) return;
this.press = null;
this.suppressClick = true;
}
public clearGesture(): void {
this.press = null;
this.suppressClick = false;
}
public renderLayer(): TemplateResult {
const spaceIds = new Set(this.owner._model.map((item) => item.id));
const interactive = this.owner._mode === 'view';
const items = this.stairs.map((stair) => {
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
const outline = geometry.outline.map((point) => point.join(',')).join(' ');
const targetState = stairTargetState(
stair, this.owner._space, spaceIds, this.owner._hasFixedFloor,
);
const active = interactive && targetState === 'active';
const navigate = (event: Event): void => {
event.stopPropagation();
if (event.type === 'keydown') this.suppressClick = false;
if (!active || this.owner._suppressClick || this.suppressClick
|| !stair.target_space_id) return;
this.owner._tabClick(stair.target_space_id);
};
return svg`<g class="hp-stair ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
data-target-state=${targetState}
role=${active ? 'link' : 'img'} tabindex=${active ? '0' : nothing}
aria-label=${this.owner._t('markup.stairs')}
@click=${navigate}
@keydown=${(event: KeyboardEvent) => {
if (active && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
navigate(event);
}
}}>
<polygon class="hp-stair-outline" points=${outline}></polygon>
<polygon class="hp-stair-hit" points=${outline}
@pointerdown=${(event: PointerEvent) => this.pointerDown(event)}></polygon>
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
</g>`;
});
return svg`<g class="hp-stairs-layer">${items}</g>` as unknown as TemplateResult;
}
}
+249
View File
@@ -0,0 +1,249 @@
import { difference, type Geom } from 'polyclip-ts';
import { geometryArea } from './physical-geometry';
import { CANVAS_LIMIT, GRID_N, NORM_W } from './canvas-constants';
export const STAIR_TREAD_CM = 30;
export const MAX_STAIRS_PER_SPACE = 250;
export type StraightStairDirection = 'forward' | 'backward';
export type SpiralStairDirection = 'clockwise' | 'counterclockwise';
interface StairCommon {
id: string;
x: number;
y: number;
angle: number;
target_space_id?: string | null;
}
export interface StraightStair extends StairCommon {
kind: 'straight';
direction: StraightStairDirection;
length: number;
width: number;
}
export interface SpiralStair extends StairCommon {
kind: 'spiral';
direction: SpiralStairDirection;
radius: number;
}
export type Stair = StraightStair | SpiralStair;
export interface StairLine {
a: [number, number];
b: [number, number];
}
export interface StairRenderGeometry {
outline: number[][];
treads: StairLine[];
arrowPath: string;
center: [number, number];
}
type CachedRenderGeometry = {
fingerprint: string;
geometry: StairRenderGeometry;
};
/**
* Live entity updates repaint the card without changing persisted stairs.
* Keep the comparatively dense spiral/tread geometry behind an object-scoped
* cache so those unrelated updates do not rebuild it on every render.
*
* The fingerprint also makes the helper safe for the few write paths which
* temporarily mutate a config record in place before the authoritative model
* is rebuilt. Weak keys keep deleted spaces/stairs collectible.
*/
const RENDER_GEOMETRY_CACHE = new WeakMap<object, CachedRenderGeometry>();
function renderFingerprint(stair: Stair, cellCm: number, scale: number): string {
return stair.kind === 'straight'
? `${cellCm}|${scale}|${stair.kind}|${stair.x}|${stair.y}|${stair.angle}|${stair.direction}|${stair.length}|${stair.width}`
: `${cellCm}|${scale}|${stair.kind}|${stair.x}|${stair.y}|${stair.angle}|${stair.direction}|${stair.radius}`;
}
const finite = (value: unknown): number | null => {
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
const cmToNorm = (cm: number, cellCm: number): number => (
Number(cm) / ((Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N)
);
export function isStair(value: unknown): value is Stair {
if (!value || typeof value !== 'object') return false;
const item = value as Record<string, unknown>;
if (typeof item.id !== 'string' || !item.id || (item.kind !== 'straight' && item.kind !== 'spiral')) return false;
if (![item.x, item.y, item.angle].every((entry) => finite(entry) !== null)) return false;
if (Math.abs(Number(item.x)) > CANVAS_LIMIT || Math.abs(Number(item.y)) > CANVAS_LIMIT
|| Math.abs(Number(item.angle)) > 360) return false;
if (item.target_space_id != null && typeof item.target_space_id !== 'string') return false;
const validSize = (entry: unknown): boolean => {
const size = finite(entry) ?? 0;
return size > 0 && size <= CANVAS_LIMIT;
};
if (item.kind === 'straight') {
return item.direction === 'forward' || item.direction === 'backward'
? validSize(item.length) && validSize(item.width)
: false;
}
return item.direction === 'clockwise' || item.direction === 'counterclockwise'
? validSize(item.radius)
: false;
}
export function stairList(value: unknown): Stair[] {
return Array.isArray(value) ? value.filter(isStair).slice(0, MAX_STAIRS_PER_SPACE) : [];
}
const rotate = (x: number, y: number, angle: number): [number, number] => {
const radians = angle * Math.PI / 180;
const cosine = Math.cos(radians), sine = Math.sin(radians);
return [x * cosine - y * sine, x * sine + y * cosine];
};
const worldPoint = (stair: Stair, localX: number, localY: number, scale: number): [number, number] => {
const [dx, dy] = rotate(localX * scale, localY * scale, stair.angle);
return [stair.x * scale + dx, stair.y * scale + dy];
};
export function stairOutline(stair: Stair, scale = NORM_W, circleSegments = 64): number[][] {
if (stair.kind === 'straight') {
const halfLength = stair.length * scale / 2;
const halfWidth = stair.width * scale / 2;
return [
[-halfLength, -halfWidth], [halfLength, -halfWidth],
[halfLength, halfWidth], [-halfLength, halfWidth],
].map(([x, y]) => {
const [dx, dy] = rotate(x, y, stair.angle);
return [stair.x * scale + dx, stair.y * scale + dy];
});
}
const count = Math.max(24, Math.round(circleSegments));
return Array.from({ length: count }, (_, index) => {
const radians = index * Math.PI * 2 / count;
return [
(stair.x + Math.cos(radians) * stair.radius) * scale,
(stair.y + Math.sin(radians) * stair.radius) * scale,
];
});
}
const arrowHead = (
tip: [number, number], bearing: number, size: number,
): string => {
const left = rotate(-size, -size * 0.62, bearing);
const right = rotate(-size, size * 0.62, bearing);
return `M ${tip[0]} ${tip[1]} L ${tip[0] + left[0]} ${tip[1] + left[1]} M ${tip[0]} ${tip[1]} L ${tip[0] + right[0]} ${tip[1] + right[1]}`;
};
export function stairRenderGeometry(
stair: Stair, cellCm: number, scale = NORM_W,
): StairRenderGeometry {
const center: [number, number] = [stair.x * scale, stair.y * scale];
const outline = stairOutline(stair, scale);
const treadN = cmToNorm(STAIR_TREAD_CM, cellCm);
if (stair.kind === 'straight') {
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;
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);
const tip = worldPoint(stair, toX, 0, scale);
const bearing = stair.angle + (forward ? 0 : 180);
const size = Math.min(stair.width, stair.length) * scale * 0.16;
return {
outline, treads, center,
arrowPath: `M ${from[0]} ${from[1]} L ${tip[0]} ${tip[1]} ${arrowHead(tip, bearing, size)}`,
};
}
const travelRadius = stair.radius * 2 / 3;
const circumference = Math.PI * 2 * travelRadius;
const count = Math.max(1, Math.floor(circumference / treadN));
const sign = stair.direction === 'clockwise' ? 1 : -1;
const treads: StairLine[] = [];
for (let index = 0; index < count; index++) {
const localAngle = index * (treadN / travelRadius) * sign;
const degrees = stair.angle + localAngle * 180 / Math.PI;
const inner = rotate(stair.radius * scale * 0.18, 0, degrees);
const outer = rotate(stair.radius * scale, 0, degrees);
treads.push({
a: [center[0] + inner[0], center[1] + inner[1]],
b: [center[0] + outer[0], center[1] + outer[1]],
});
}
const radius = stair.radius * scale * 0.62;
const start = stair.angle * Math.PI / 180;
const sweep = sign * Math.PI * 1.5;
const end = start + sweep;
const from: [number, number] = [center[0] + Math.cos(start) * radius, center[1] + Math.sin(start) * radius];
const tip: [number, number] = [center[0] + Math.cos(end) * radius, center[1] + Math.sin(end) * radius];
const sweepFlag = sign > 0 ? 1 : 0;
const tangent = end * 180 / Math.PI + (sign > 0 ? 90 : -90);
return {
outline, treads, center,
arrowPath: `M ${from[0]} ${from[1]} A ${radius} ${radius} 0 1 ${sweepFlag} ${tip[0]} ${tip[1]} ${arrowHead(tip, tangent, stair.radius * scale * 0.16)}`,
};
}
export function cachedStairRenderGeometry(
stair: Stair, cellCm: number, scale = NORM_W,
): StairRenderGeometry {
const fingerprint = renderFingerprint(stair, cellCm, scale);
const cached = RENDER_GEOMETRY_CACHE.get(stair);
if (cached?.fingerprint === fingerprint) return cached.geometry;
const geometry = stairRenderGeometry(stair, cellCm, scale);
RENDER_GEOMETRY_CACHE.set(stair, { fingerprint, geometry });
return geometry;
}
export function stairFootprintGeometry(stair: Stair, scale = NORM_W): Geom {
const ring = stairOutline(stair, scale);
return (ring.length ? [[[...ring, ring[0]]]] : []) as unknown as Geom;
}
export function floorAreaMinusStairs(
floor: readonly number[][], stairs: readonly Stair[] | null | undefined, scale = NORM_W,
): number {
if (floor.length < 3) return 0;
const geometry = [[[...floor.map((point) => [point[0], point[1]]), [floor[0][0], floor[0][1]]]]] as Geom;
return geometryAreaMinusStairs(geometry, stairs, scale);
}
export function geometryAreaMinusStairs(
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
): number {
return Math.max(0, geometryArea(geometryMinusStairs(source, stairs, scale)));
}
export function geometryMinusStairs(
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
): Geom {
const footprints = stairList(stairs).map((stair) => stairFootprintGeometry(stair, scale));
if (!footprints.length) return source;
try {
// A single sweep avoids repeatedly rebuilding the same subject for dense floors.
return difference(source, ...footprints);
} catch {
// Keep a valid room usable if one future record reaches this layer malformed.
let geometry = source;
for (const footprint of footprints) {
try { geometry = difference(geometry, footprint); } catch { /* skip only the bad record */ }
}
return geometry;
}
}
+45
View File
@@ -1563,5 +1563,50 @@ export const planStyles = css`
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
.stage.markup.tool-stairs { cursor: crosshair; }
.hp-stair-outline {
fill: color-mix(in srgb, var(--hp-accent) 12%, transparent);
stroke: var(--primary-text-color, #202124);
stroke-width: calc(2px / var(--hp-plan-screen-scale, 1));
vector-effect: non-scaling-stroke;
}
.hp-stair-tread,
.hp-stair-arrow {
fill: none;
stroke: var(--primary-text-color, #202124);
stroke-width: calc(1.5px / var(--hp-plan-screen-scale, 1));
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.hp-stair-arrow { stroke-width: calc(2.5px / var(--hp-plan-screen-scale, 1)); }
.hp-stair-hit { fill: transparent; stroke: transparent; stroke-width: 14px; pointer-events: none; }
.hp-stair.input-enabled .hp-stair-hit { pointer-events: all; }
.hp-stair.navigable { cursor: pointer; }
:host([data-pointer-hover]) .hp-stair.navigable:hover .hp-stair-outline {
stroke: var(--hp-accent);
stroke-width: calc(2.5px / var(--hp-plan-screen-scale, 1));
}
.hp-stair.selected .hp-stair-outline {
stroke: var(--hp-accent);
stroke-width: calc(3px / var(--hp-plan-screen-scale, 1));
}
.hp-stair-handle {
fill: var(--hp-accent);
stroke: var(--hp-bg, #fff);
stroke-width: 2px;
vector-effect: non-scaling-stroke;
pointer-events: all;
touch-action: none;
}
.hp-stair-resize { cursor: nwse-resize; }
.hp-stair-rotate { cursor: grab; }
.hp-stair-rotate-leader {
stroke: var(--hp-accent);
stroke-width: 1.5px;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
`;
+4
View File
@@ -9,6 +9,7 @@ import { innerContourForRoom, multiWallNodesForGeometry, wallBodiesGeometry } fr
import type { Marker, ServerConfig, SpaceModel, SummaryPanelSource } from './types';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type { SummaryHass } from './summary-panel-host';
import { geometryMinusStairs } from './stairs';
/** Count the unique real HA devices represented anywhere on the plan. */
export function representedHaDeviceIds(input: {
@@ -118,6 +119,9 @@ export function* cleanFloorAreaSteps(
spaceFloor = unionGeometry(spaceFloor, clean);
yield;
}
// Difference distributes over union. Subtracting the shared stair set once
// avoids rooms x stairs polygon clips on dense plans (#663).
spaceFloor = geometryMinusStairs(spaceFloor || [], space.stairs);
const cmPerUnit = prepared.cellCm / GRID_PITCH;
total += geometryArea(spaceFloor) * cmPerUnit * cmPerUnit / 1e4;
}
+2
View File
@@ -84,6 +84,8 @@ export interface SpaceModel {
wall_segments: WallSegmentEntry[];
partitions: PartitionCfg[];
wall_columns: WallColumnCfg[];
/** Independent continuous plan objects linking this floor to another one. */
stairs: import('./stairs').Stair[];
}
export interface PdfRef {
+23
View File
@@ -31,6 +31,29 @@ test('typical small plan: the frame is exactly what is drawn (unchanged behaviou
assert.deepEqual(contentBounds(withVb), { x: 390, y: 390, w: 220, h: 220 });
});
test('#663 stairs contribute their exact rotated and circular footprints to fit-all', () => {
const rotated = model({ id: 's', stairs: [{
id: 'straight', kind: 'straight', x: 0.5, y: 0.5, angle: 45,
direction: 'forward', length: 0.2, width: 0.1,
}] });
const [box] = contentItems(rotated);
const half = 150 / Math.sqrt(2);
assert.ok(Math.abs(box.minX - (500 - half)) < 1e-9);
assert.ok(Math.abs(box.maxX - (500 + half)) < 1e-9);
assert.ok(Math.abs(box.minY - (500 - half)) < 1e-9);
assert.ok(Math.abs(box.maxY - (500 + half)) < 1e-9);
const round = model({ id: 's', stairs: [{
id: 'spiral', kind: 'spiral', x: 0.25, y: 0.4, angle: 17,
direction: 'counterclockwise', radius: 0.08,
}] });
const circle = contentItems(round)[0];
assert.ok(Math.abs(circle.minX - 170) < 1e-9);
assert.ok(Math.abs(circle.minY - 320) < 1e-9);
assert.ok(Math.abs(circle.maxX - 330) < 1e-9);
assert.ok(Math.abs(circle.maxY - 480) < 1e-9);
});
test('per-edge frame padding can remove only the top inset', () => {
const m = model({ id: 's', rooms: [{
id: 'r', poly: [[0.2, 0.3], [0.8, 0.3], [0.8, 0.7], [0.2, 0.7]],
+15
View File
@@ -163,6 +163,21 @@ test('#383 furniture flip flags survive frontend canonicalization unchanged', ()
assert.equal(furniture.flip_v, false);
});
test('#663 stair transforms keep continuous wall-face magnet positions', () => {
const stair = {
id: 'stair', kind: 'straight', direction: 'forward', target_space_id: 'upper',
x: 0.5000001, y: 0.3999999, length: 0.2000001, width: 0.0999999,
angle: 17.1234567896,
};
const output = canonicalizeConfigGeometry({ spaces: [{ id: 'ground', stairs: [stair] }] })
.spaces[0].stairs[0];
assert.deepEqual(output, {
...stair,
x: 0.5000001, y: 0.3999999, length: 0.2000001, width: 0.0999999,
angle: 17.12345679,
});
});
test('one position changes only x/y and preserves future metadata (#224)', () => {
const input = {
s: 'floor', x: 0.1000000006, y: -0.0000000004,
+1 -1
View File
@@ -445,7 +445,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 64);
assert.equal(GOLDEN_MATRIX_VERSION, 65);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
+3
View File
@@ -492,6 +492,7 @@ test('i18n: German values equal to English are explicitly reviewed', () => {
'vac.diag_position',
'version_mismatch.backend',
'radar.source_optional',
'stairs.radius', // #663: Radius is the same engineering term in EN and DE.
]);
const equalKeys = Object.keys(en).filter((key) => en[key] === de[key]);
assert.deepEqual(new Set(equalKeys), allowed);
@@ -544,6 +545,8 @@ test('i18n: French values equal to English are explicitly reviewed (#371)', () =
'wallthick.unit_cm',
'radar.heading',
'radar.radians',
'stairs.type', // #663: Type and Rotation are valid French/English homographs.
'stairs.rotation',
]);
const equalKeys = Object.keys(en).filter((key) => en[key] === fr[key]);
assert.deepEqual(new Set(equalKeys), allowed);
+2
View File
@@ -12,6 +12,8 @@ test('large-house fixture meets the HP-PERF-01 reference counts', () => {
assert.equal(count('partitions'), LARGE_HOUSE_COUNTS.partitions);
assert.equal(count('wall_columns'), LARGE_HOUSE_COUNTS.columns);
assert.equal(count('decor'), LARGE_HOUSE_COUNTS.decor);
assert.equal(count('stairs'), LARGE_HOUSE_COUNTS.stairs);
assert.equal(fixture.config.spaces[0].stairs.length, 250, 'one floor exercises the backend cap');
assert.equal(Object.keys(fixture.devices).length, LARGE_HOUSE_COUNTS.devices);
assert.equal(Object.keys(fixture.entities).length, LARGE_HOUSE_COUNTS.devices);
assert.equal(fixture.config.markers.length > 0, true);
+29
View File
@@ -170,6 +170,35 @@ test('PDF scene includes architecture and respects names/dimensions switches', (
'door leaf/arc is represented by vector lines');
});
test('#663 PDF renders both stair symbols and uses stair-adjusted clean area', () => {
const withStairs = structuredClone(rawSpace);
withStairs.stairs = [
{
id: 'straight', kind: 'straight', x: 0.4, y: 0.4, angle: 0,
direction: 'forward', length: 0.2, width: 0.1, target_space_id: 'upper',
},
{
id: 'spiral', kind: 'spiral', x: 0.68, y: 0.5, angle: 30,
direction: 'counterclockwise', radius: 0.06, target_space_id: null,
},
];
const output = buildRawPage(withStairs, {
dimensions: true, roomNames: false, decor: false, backdrop: false,
});
const stairOutlines = output.commands.filter((command) => command.kind === 'path'
&& (command.rings[0]?.length === 4 || command.rings[0]?.length === 64));
assert.equal(stairOutlines.length, 2, 'stairs are architectural output even when decor is hidden');
assert.equal(output.commands.filter((command) => command.kind === 'vector').length, 2,
'each stair keeps its direction arrow');
const area = output.commands.find((command) => command.kind === 'text'
&& /m²/.test(command.text));
assert.ok(area, 'room area is printed');
// Base room: 700×600 render units. The two non-overlapping footprints are
// 200×100 and a 60-unit-radius circle; cmPerUnit is 1.2.
const expectedM2 = (420_000 - 20_000 - Math.PI * 60 ** 2) * 1.2 ** 2 / 10_000;
assert.match(area.text, new RegExp(`^${expectedM2.toFixed(1).replace('.', '[.,]')}\\s*m²$`));
});
test('shared wall architecture is emitted once and devices never enter the PDF scene', () => {
const output = sharedPage({ dimensions: false, roomNames: false, decor: false, backdrop: false });
const architecture = output.commands.filter((command) => command.kind === 'path');
+158
View File
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import {
cachedStairRenderGeometry,
floorAreaMinusStairs,
geometryAreaMinusStairs,
isStair,
stairRenderGeometry,
} from '../test-build/stairs.js';
import {
convertStairKind,
defaultStair,
snapStairToStairs,
stairPhysicalSizeCm,
stairTargetState,
} from '../test-build/stairs-editor-model.js';
const straight = (extra = {}) => ({
id: 'straight', kind: 'straight', x: 0.5, y: 0.5, angle: 0,
direction: 'forward', length: 0.24, width: 0.1,
target_space_id: 'upper', ...extra,
});
const spiral = (extra = {}) => ({
id: 'spiral', kind: 'spiral', x: 0.5, y: 0.5, angle: 0,
direction: 'clockwise', radius: 0.1,
target_space_id: 'upper', ...extra,
});
test('#663 validates the discriminated stair model and preserves future fields', () => {
assert.equal(isStair({ ...straight(), future: { keep: true } }), true);
assert.equal(isStair({ ...straight(), direction: 'clockwise' }), false);
assert.equal(isStair({ ...straight(), width: 0 }), false);
assert.equal(isStair({ ...spiral(), direction: 'backward' }), false);
assert.equal(isStair({ ...spiral(), radius: Number.NaN }), false);
assert.equal(isStair({ ...spiral(), radius: 5001 }), false);
assert.equal(isStair({ ...straight(), angle: 361 }), false);
});
test('#663 default physical sizes and type conversion are predictable', () => {
const first = defaultStair('straight', 500, 400, 5, 'a');
assert.equal(first.kind, 'straight');
assert.deepEqual(stairPhysicalSizeCm(first, 5).map(Math.round), [240, 100]);
first.target_space_id = 'upper';
const round = convertStairKind(first, 'spiral');
assert.equal(round.kind, 'spiral');
assert.equal(Math.round(stairPhysicalSizeCm(round, 5)[0]), 120);
const restored = convertStairKind(round, 'straight');
assert.equal(restored.kind, 'straight');
assert.deepEqual(stairPhysicalSizeCm(restored, 5).map(Math.round), [240, 240]);
assert.equal(restored.target_space_id, 'upper');
});
test('#663 straight stair treads keep exact 30 cm intervals and top remainder', () => {
const geometry = stairRenderGeometry(straight(), 5);
assert.equal(geometry.treads.length, 9);
for (let index = 1; index < geometry.treads.length; index++) {
assert.equal(geometry.treads[index].a[0] - geometry.treads[index - 1].a[0], 25);
}
assert.equal(geometry.treads[0].a[0], 405, 'first line is 30 cm after the lower edge');
assert.equal(geometry.treads.at(-1).a[0], 605, 'the short remainder stays before the top edge');
assert.deepEqual(
geometry.treads.map((line) => line.b[1] - line.a[1]),
Array(9).fill(100),
);
const zoomed = stairRenderGeometry(straight(), 5, 500);
assert.equal(zoomed.treads.length, geometry.treads.length,
'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');
});
test('#663 spiral stair uses one turn with 30 cm travel-line spacing', () => {
const geometry = stairRenderGeometry(spiral({ angle: 30 }), 5);
assert.equal(geometry.treads.length, 16);
const center = geometry.center;
const angles = geometry.treads.map((line) => Math.atan2(
line.b[1] - center[1], line.b[0] - center[0],
));
const unwrapped = angles.reduce((result, angle) => {
let value = angle;
while (result.length && value <= result.at(-1)) value += Math.PI * 2;
result.push(value);
return result;
}, []);
const expected = 25 / (100 * 2 / 3);
for (let index = 1; index < unwrapped.length; index++) {
assert.ok(Math.abs((unwrapped[index] - unwrapped[index - 1]) - expected) < 1e-10);
}
const reverse = stairRenderGeometry(spiral({ direction: 'counterclockwise' }), 5);
assert.ok(reverse.treads[1].b[1] < reverse.treads[0].b[1]);
});
test('#663 cached render geometry survives live repaints and invalidates on transform', () => {
const item = straight();
const first = cachedStairRenderGeometry(item, 5);
const second = cachedStairRenderGeometry(item, 5);
assert.equal(second, first, 'unchanged stair reuses the dense render geometry');
item.angle = 45;
const changed = cachedStairRenderGeometry(item, 5);
assert.notEqual(changed, first, 'in-place edits cannot leave a stale cache entry');
});
test('#663 stair magnet covers rectangle/rectangle, rectangle/circle and circle/circle footprints', () => {
const rect = straight({ id: 'rect', x: 0.5, y: 0.5, length: 0.2, width: 0.1 });
const circle = spiral({ id: 'circle', x: 0.5, y: 0.5, radius: 0.08 });
const movingRect = straight({ id: 'moving-rect', length: 0.2, width: 0.1 });
const movingCircle = spiral({ id: 'moving-circle', radius: 0.08 });
assert.deepEqual(snapStairToStairs(movingRect, [704, 500], [rect], 10), [700, 500]);
assert.deepEqual(snapStairToStairs(movingCircle, [686, 500], [rect], 10), [680, 500]);
assert.deepEqual(snapStairToStairs(movingRect, [686, 500], [circle], 10), [680, 500]);
assert.deepEqual(snapStairToStairs(movingCircle, [664, 500], [circle], 10), [660, 500]);
assert.deepEqual(snapStairToStairs(movingCircle, [700, 500], [circle], 10), [700, 500]);
assert.deepEqual(circle, spiral({ id: 'circle', x: 0.5, y: 0.5, radius: 0.08 }));
});
test('#663 area removes only stair overlap and never becomes negative', () => {
const floor = [[0, 0], [1000, 0], [1000, 1000], [0, 1000]];
assert.equal(floorAreaMinusStairs(floor, [straight()]), 976_000);
assert.equal(floorAreaMinusStairs(floor, [straight({ x: 0.95, length: 0.2, width: 0.2 })]), 970_000);
const circular = floorAreaMinusStairs(floor, [spiral()]);
assert.ok(Math.abs(circular - (1_000_000 - Math.PI * 10_000)) < 60,
'64-segment circle remains physically accurate');
const source = [[[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]];
assert.equal(geometryAreaMinusStairs(source, [straight({ length: 2, width: 2 })]), 0);
});
test('#663 target states distinguish active, missing, self, deleted and fixed', () => {
const spaces = new Set(['ground', 'upper']);
assert.equal(stairTargetState(straight(), 'ground', spaces, false), 'active');
assert.equal(stairTargetState(straight({ target_space_id: null }), 'ground', spaces, false), 'missing');
assert.equal(stairTargetState(straight({ target_space_id: 'ground' }), 'ground', spaces, false), 'self');
assert.equal(stairTargetState(straight({ target_space_id: 'gone' }), 'ground', spaces, false), 'deleted');
assert.equal(stairTargetState(straight(), 'ground', spaces, true), 'fixed');
});
test('#663 Optimize preserves continuous authored stair transforms exactly', () => {
const authored = straight({
x: 0.123456789, y: -0.287654321, angle: 17.123456789,
length: 0.234567891, width: 0.087654319,
});
const config = {
model_version: 10,
spaces: [{
id: 'ground', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [], wall_segments: [], stairs: [authored],
}, {
id: 'upper', title: 'Upper', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [], wall_segments: [],
}],
markers: [], settings: {},
};
const result = optimizePlans(config, {});
assert.deepEqual(result.config.spaces[0].stairs[0], authored);
});
@@ -85,6 +85,27 @@ def test_scalar_contract_is_symmetric_and_keeps_off_grid_geometry() -> None:
assert canonicalize_number(float("inf")) == float("inf")
def test_stair_transform_keeps_continuous_wall_face_magnet_position() -> None:
stair = {
"id": "stair",
"kind": "straight",
"direction": "forward",
"target_space_id": "upper",
"x": 0.5000001,
"y": 0.3999999,
"length": 0.2000001,
"width": 0.0999999,
"angle": 17.1234567896,
}
output = canonicalize_config_geometry(
{"spaces": [{"id": "ground", "stairs": [stair]}]}
)["spaces"][0]["stairs"][0]
assert output == {
**stair,
"angle": 17.12345679,
}
def test_all_4801_lattice_nodes_and_nine_decimal_forms_share_exact_bits() -> None:
assert LATTICE_GRID_N == 240
assert LATTICE_NOISE_STEPS == 1e-4
+2 -1
View File
@@ -27,7 +27,7 @@ async def test_diagnostics_redact_marker_bindings_and_all_settings(hass) -> None
"spaces": [{
"id": "floor", "aspect": 1.5, "plan_url": None,
"rooms": [{"area": "private-area"}],
"partitions": [], "wall_columns": [],
"partitions": [], "wall_columns": [], "stairs": [{"id": "private-stair"}],
}],
"markers": [{
"id": "marker", "binding": "device:private-device",
@@ -55,3 +55,4 @@ async def test_diagnostics_redact_marker_bindings_and_all_settings(hass) -> None
assert result["rev"] == 7
assert result["layout_entries"] == 1
assert result["spaces"][0]["rooms"] == 1
assert result["spaces"][0]["stairs"] == 1
+36
View File
@@ -931,6 +931,11 @@ def _plan_only_source() -> tuple[dict[str, Any], dict[str, Any]]:
"walls": [],
"partitions": [{"id": "partition", "a": [0, 0.5], "b": [1, 0.5], "cm": 12}],
"wall_columns": [{"id": "column", "shape": "circle", "center": [0.2, 0.2], "cm": 30}],
"stairs": [{
"id": "stairs", "kind": "straight", "x": 0.35, "y": 0.45,
"angle": 12.5, "direction": "forward", "length": 0.2, "width": 0.1,
"target_space_id": "upper", "future_stair": "drop",
}],
"decor": [
{
"id": "modern", "kind": "text", "x": 0.2, "y": 0.2,
@@ -1025,6 +1030,11 @@ def test_plan_only_export_projects_geometry_and_round_trips_room_labels(tmp_path
assert by_id["furniture"]["symbol"] == "sofa"
assert by_id["furniture"]["flip_h"] is True
assert by_id["furniture"]["flip_v"] is False
assert exported["stairs"] == [{
"id": "stairs", "kind": "straight", "x": 0.35, "y": 0.45,
"angle": 12.5, "direction": "forward", "target_space_id": "upper",
"length": 0.2, "width": 0.1,
}]
parsed = parse_document(json.dumps(document).encode())
preview = create_preview(
@@ -1054,6 +1064,7 @@ def test_plan_only_export_projects_geometry_and_round_trips_room_labels(tmp_path
assert merged["markers"] == []
imported_room = merged["spaces"][0]["rooms"][0]
assert imported_room.get("area") is None
assert merged["spaces"][0]["stairs"][0]["target_space_id"] is None
assert merged_layout == {
"rl_" + imported_room["id"]: {
"x": 0.45, "y": 0.55, "s": details["space_id"], "k": 1.4,
@@ -1649,6 +1660,31 @@ def test_issue_611_ambiguous_target_route_is_preserved_and_reported() -> None:
}
def test_issue_663_full_import_repairs_stair_floor_target_by_exact_space_map() -> None:
current = {
"spaces": [{
"id": "space-ground", "title": "Ground", "view_box": [0, 0, 1, 1],
"rooms": [], "stairs": [{
"id": "stair-new", "kind": "straight", "x": 0.5, "y": 0.5,
"angle": 0, "direction": "forward", "length": 0.2, "width": 0.1,
"target_space_id": "upper",
}],
}, {
"id": "space-upper", "title": "Upper", "view_box": [0, 0, 1, 1],
"rooms": [],
}],
"markers": [], "settings": {},
}
report = import_export_api._empty_reference_report()
repaired, _layout, count = import_export_api._repair_target_space_refs(
current, {}, {"space": {"ground": "space-ground", "upper": "space-upper"}},
{}, report, set(),
)
assert repaired["spaces"][0]["stairs"][0]["target_space_id"] == "space-upper"
assert count == 1
assert report["remapped"]["target"] == {"stair.target_space_id": 1}
@pytest.mark.parametrize(
"vacuum",
[
@@ -515,3 +515,4 @@ async def test_system_health_reports_complete_panel_matrix(
result = await system_health.system_health_info(hass)
assert {key: result[key] for key in expected} == expected
assert result["stairs"] == 0
+7
View File
@@ -110,6 +110,11 @@ def test_issue_244_space_delete_dependency_and_tombstone_candidate() -> None:
"future_namespace": {"sentinel": "kept"},
},
}
config["spaces"][1]["stairs"] = [{
"id": "stairs", "kind": "spiral", "x": 0.5, "y": 0.5,
"angle": 0, "direction": "clockwise", "radius": 0.1,
"target_space_id": "f1",
}]
layout = {"all": {"s": "f1"}, "position": {"s": "f1"}, "removed": {"s": "f1"}}
assert _space_marker_dependencies(config, layout, "f1") == ["all", "position"]
@@ -119,6 +124,7 @@ def test_issue_244_space_delete_dependency_and_tombstone_candidate() -> None:
)
assert dependencies == []
assert [item["id"] for item in candidate["spaces"]] == ["f2"]
assert candidate["spaces"][0]["stairs"][0]["target_space_id"] is None
assert candidate["markers"][0] == {
"id": "removed", "binding": "entity:light.old", "removed": True, "name": "Kept",
}
@@ -126,6 +132,7 @@ def test_issue_244_space_delete_dependency_and_tombstone_candidate() -> None:
assert removed_layout == 3
assert candidate["settings"] == config["settings"]
assert config["markers"][0]["space"] == "f1"
assert config["spaces"][1]["stairs"][0]["target_space_id"] == "f1"
def test_issue_244_last_occupied_space_candidate_detaches_all_affected_markers() -> None:
+11
View File
@@ -272,6 +272,11 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(
{"id": "line-a", "kind": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1},
{"id": "text-a", "kind": "text", "text": "private"},
],
"stairs": [{
"id": "private-stair", "kind": "straight", "x": 0.25, "y": 0.5,
"angle": 15, "direction": "forward", "length": 0.2, "width": 0.1,
"target_space_id": "private-target", "unknown": "drop",
}],
"open_spans": [{"a": [0, 0], "b": [0, 1]}, "bad"],
}],
"markers": [
@@ -326,6 +331,12 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(
assert space["rooms"][0]["settings"]["temp_source_kind"] == "entity"
assert space["rooms"][0]["settings"]["hum_source_kind"] == "unknown"
assert len(space["walls"]) == 2
assert space["stairs"] == [{
"id": "stair-rich-1", "kind": "straight", "x": 0.25, "y": 0.5,
"angle": 15, "direction": "forward", "length": 0.2, "width": 0.1,
"target_space_id": "space-rich-2",
}]
assert package["summary"]["stairs"] == 1
assert space["walls"][1] == {"cm": 12, "key": "wall-rich-2"}
assert "room_drafts" not in space
assert space["partitions"][0]["cm"] == 8
+56
View File
@@ -26,6 +26,62 @@ _HOUSEPLAN_ROOT = os.path.join(_PACKAGE_ROOT, "houseplan")
v = load_pure("hp_validation", Path(_HOUSEPLAN_ROOT) / "validation.py")
def _stair_space(stairs):
return {
"id": "ground", "title": "Ground", "view_box": [0, 0, 1, 1],
"rooms": [], "wall_segments": [], "stairs": stairs,
}
def test_issue_663_stair_schema_is_discriminated_bounded_and_forward_compatible():
straight = {
"id": "straight", "kind": "straight", "x": 0.123456789,
"y": -0.25, "angle": 17.5, "direction": "forward",
"length": 0.24, "width": 0.1, "target_space_id": "upper",
"future": {"keep": True},
}
spiral = {
"id": "spiral", "kind": "spiral", "x": 0.6, "y": 0.4,
"angle": -30, "direction": "counterclockwise", "radius": 0.08,
"target_space_id": None,
}
validated = v.CONFIG_SCHEMA({
"spaces": [_stair_space([straight, spiral]), {
"id": "upper", "title": "Upper", "view_box": [0, 0, 1, 1],
"rooms": [], "wall_segments": [],
}],
})
assert validated["spaces"][0]["stairs"][0] == straight
assert validated["spaces"][0]["stairs"][1] == spiral
broken = [
{**straight, "width": 0},
{**straight, "direction": "clockwise"},
{**straight, "radius": 0.1},
{key: value for key, value in straight.items() if key != "length"},
{**spiral, "direction": "forward"},
{**spiral, "length": 0.2},
{**spiral, "radius": float("nan")},
]
for stair in broken:
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [_stair_space([stair])]})
duplicate = {**straight, "id": "same"}
with pytest.raises(vol.Invalid, match="geometry object ids"):
v.CONFIG_SCHEMA({"spaces": [{
**_stair_space([duplicate]),
"decor": [{
"id": "same", "kind": "line", "x1": 0, "y1": 0,
"x2": 0.1, "y2": 0.1,
}],
}]})
too_many = [{**straight, "id": f"stair-{index}"} for index in range(v.MAX_STAIRS + 1)]
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [_stair_space(too_many)]})
def test_backend_model_version_matches_frontend_constant():
root = os.path.dirname(os.path.dirname(__file__))
source = open(os.path.join(root, "src", "plan-optimizer.ts"), encoding="utf-8").read()
+2 -2
View File
@@ -23,8 +23,8 @@
"src/devices.ts", "src/device-inbox.ts",
"src/virtual-light-state.ts", "src/config-store.ts", "src/config-reload-authority.ts", "src/config-write-conflict.ts", "src/summary-panel.ts", "src/summary-panel-metrics.ts", "src/summary-panel-i18n.ts", "src/summary-panel-identity.ts", "src/summary-panel-picker.ts", "src/summary-panel-runtime-loaded.ts", "src/header-menu.ts", "src/iso-materials.ts", "src/iso-first-frame.ts", "src/iso-tiles.ts", "src/iso-sun.ts",
"src/backdrop-probe.ts",
"src/types.ts", "src/editors/dialog-baseline.ts", "src/editors/color-tile-ink.ts", "src/editors/general-form-state.ts", "src/editors/space-form-state.ts", "src/editors/marker-form-state.ts", "src/editors/room-form-state.ts",
"src/space-geometry.ts", "src/junction-limits.ts", "src/room-gear-drag.ts",
"src/types.ts", "src/canvas-constants.ts", "src/editors/dialog-baseline.ts", "src/editors/color-tile-ink.ts", "src/editors/general-form-state.ts", "src/editors/space-form-state.ts", "src/editors/marker-form-state.ts", "src/editors/room-form-state.ts",
"src/space-geometry.ts", "src/stairs.ts", "src/stairs-editor-model.ts", "src/junction-limits.ts", "src/room-gear-drag.ts",
"src/space-order.ts", "src/card-editor-validation.ts",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
"src/visual-continuity.ts", "src/version-recovery.ts", "src/version-recovery-card.ts", "src/mode-transition.ts", "src/viewport-transition.ts", "src/boot-soft-layout.ts", "src/room-fit.ts", "src/editor-runtime-loader.ts", "src/editor-secondary.ts", "src/pointer-modality.ts", "src/touch-gesture-click-guard.ts",