diff --git a/README.md b/README.md index 787b2bea..1dfbd895 100755 --- a/README.md +++ b/README.md @@ -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) diff --git a/custom_components/houseplan/coordinate_canonicalization.py b/custom_components/houseplan/coordinate_canonicalization.py index 53a5b30b..d44299f6 100644 --- a/custom_components/houseplan/coordinate_canonicalization.py +++ b/custom_components/houseplan/coordinate_canonicalization.py @@ -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")): diff --git a/custom_components/houseplan/diagnostics.py b/custom_components/houseplan/diagnostics.py index f608900e..19647c4e 100644 --- a/custom_components/houseplan/diagnostics.py +++ b/custom_components/houseplan/diagnostics.py @@ -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", []) ], diff --git a/custom_components/houseplan/import_export.py b/custom_components/houseplan/import_export.py index afc200f7..2d2f50bd 100644 --- a/custom_components/houseplan/import_export.py +++ b/custom_components/houseplan/import_export.py @@ -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 [] diff --git a/custom_components/houseplan/support_package.py b/custom_components/houseplan/support_package.py index 9138c9da..d8e97e24 100644 --- a/custom_components/houseplan/support_package.py +++ b/custom_components/houseplan/support_package.py @@ -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())), diff --git a/custom_components/houseplan/system_health.py b/custom_components/houseplan/system_health.py index e07901f3..0f2ee756 100644 --- a/custom_components/houseplan/system_health.py +++ b/custom_components/houseplan/system_health.py @@ -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", {})), } diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py index cf53c763..cf207533 100644 --- a/custom_components/houseplan/validation.py +++ b/custom_components/houseplan/validation.py @@ -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( { diff --git a/custom_components/houseplan/wall_segment_model.py b/custom_components/houseplan/wall_segment_model.py index 69689d9d..0452e506 100644 --- a/custom_components/houseplan/wall_segment_model.py +++ b/custom_components/houseplan/wall_segment_model.py @@ -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"] diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index ef011301..31908928 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -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 diff --git a/demo/benchmark_large_house.mjs b/demo/benchmark_large_house.mjs index 146a6c68..23a8cfe4 100644 --- a/demo/benchmark_large_house.mjs +++ b/demo/benchmark_large_house.mjs @@ -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; diff --git a/demo/fixtures/large-house.mjs b/demo/fixtures/large-house.mjs index ee5d4208..27020599 100644 --- a/demo/fixtures/large-house.mjs +++ b/demo/fixtures/large-house.mjs @@ -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); diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs index b000a764..5af8d3cb 100644 --- a/demo/golden/harness.mjs +++ b/demo/golden/harness.mjs @@ -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 || ''}`); + 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; } diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index ceefe3a2..7e9e1836 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -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. diff --git a/demo/smoke_stairs.mjs b/demo/smoke_stairs.mjs new file mode 100644 index 00000000..8173ed62 --- /dev/null +++ b/demo/smoke_stairs.mjs @@ -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); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4f7efa3a..ad4eedb2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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:|entity:|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 diff --git a/docs/CANVAS.md b/docs/CANVAS.md index 97e3fdc5..8e85a1d7 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -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). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bceaa6ed..ffc3edf3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index f14eda56..eff6ab2a 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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 - Основная панель стала компактнее и перестала дёргаться: крестик **×** diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index afcebdca..a02188c0 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -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`. diff --git a/docs/ISOMETRIC.md b/docs/ISOMETRIC.md index 33bd0e9f..a80206e6 100644 --- a/docs/ISOMETRIC.md +++ b/docs/ISOMETRIC.md @@ -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; diff --git a/docs/README.ru.md b/docs/README.ru.md index 76051a81..fcc653f1 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -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: задачи, приоритеты, решения и статусы выполнения | Старые тематические документы в этом каталоге остаются инженерными спецификациями и историей решений. При расхождении пользовательского описания с интерфейсом текущей версии приоритет имеет новое руководство, а при расхождении с фактическим поведением — код текущей версии. diff --git a/docs/STAIRS.md b/docs/STAIRS.md new file mode 100644 index 00000000..a89c66da --- /dev/null +++ b/docs/STAIRS.md @@ -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. diff --git a/docs/STYLING-HOOKS.md b/docs/STYLING-HOOKS.md index ba423bc2..eae84874 100644 --- a/docs/STYLING-HOOKS.md +++ b/docs/STYLING-HOOKS.md @@ -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` | diff --git a/docs/TOUCH-SUPPORT.md b/docs/TOUCH-SUPPORT.md index bbd44887..02ef347f 100644 --- a/docs/TOUCH-SUPPORT.md +++ b/docs/TOUCH-SUPPORT.md @@ -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 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 5f1e574b..672624db 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 032cf88c..8f9fac6e 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -779,6 +779,7 @@ Undo оптимизации. | Стены | Непрерывную цепочку стен; при замыкании предлагает комнаты, при выходе сохраняет независимые стены | Площадь появляется только у подтверждённой комнаты | Положительная толщина блокирует свет; нулевая следует настройке пространства | Частичное перекрытие комнат запрещено; отдельных инструментов «Перегородка» и «Граница» нет | | Колонна | Квадратную или круглую опору | Не меняет площадь комнаты | Блокирует свет внутри своей формы | Имеет одну форму/размер/поворот, но не является стеной или комнатой | | Проём | Дверь, окно, открытый проём или ворота на стене комнаты либо законченном независимом отрезке | Не меняет площадь | Дверь/ворота пропускают свет между полами, открытый проём — всегда; только наружное окно комнаты может давать солнечный луч | Должен целиком помещаться на одном подходящем отрезке стены | +| Лестница | Прямой марш или один оборот винтовой лестницы с автоматическим шагом ступеней 30 см и стрелкой подъёма | Вычитает из чистой площади только пересечение своего габарита | Не влияет на Glow, солнце и стены | Существует только на этом этаже; необязательная корректная цель превращает её в ссылку в Просмотре | Остальные операции меняют уже созданную геометрию: @@ -790,6 +791,17 @@ Undo оптимизации. | Толщина | Задаёт толщину 0–100 см выбранному участку или всем стенам комнаты | | Удалить комнату | Открывает выбор: удалить комнату и оставить её физические стены либо удалить комнату вместе с ними; общие стены остаются всегда | +Выберите **Лестница → Прямая** или **Лестница → Винтовая**, затем кликните по +плану. Выбранная лестница перемещается и меняет размер плавно, как мебель; +`Shift` привязывает поворот к 45°. Магнит ставит её к видимой физической грани +стены или вплотную к другой лестнице, а **Оптимизировать планы** сохраняет этот +continuous-transform без привязки к сетке. Двойной клик открывает размеры, +направление подъёма, угол и целевой этаж. В Просмотре чистый клик переходит на +корректную цель и восстанавливает сохранённый вид этажа; хвост жеста, битая +ссылка и карточка с закреплённым этажом ничего не делают. На целевом этаже +объект автоматически не создаётся. В 2.5D символ остаётся плоским на полу. +Подробный контракт — в [документе о лестницах](STAIRS.md). + При варианте **«Удалить комнату, оставить стены»** эксклюзивные участки положительной толщины становятся обычными независимыми перегородками с той же толщиной, а их двери/окна перепривязываются к ним. Нулевые участки сохраняются diff --git a/docs/UX-MODES.md b/docs/UX-MODES.md index dba9cd9c..6bd88bac 100644 --- a/docs/UX-MODES.md +++ b/docs/UX-MODES.md @@ -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 diff --git a/docs/data-hp-contract.json b/docs/data-hp-contract.json index 3f97c739..9fcb8834 100644 --- a/docs/data-hp-contract.json +++ b/docs/data-hp-contract.json @@ -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" ] diff --git a/docs/images/screenshots.json b/docs/images/screenshots.json index efb52e35..3ecd11c3 100644 --- a/docs/images/screenshots.json +++ b/docs/images/screenshots.json @@ -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" } }, diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index dd214e48..4e2cbd63 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -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; /** diff --git a/scripts/config-schema.json b/scripts/config-schema.json index 60068f9c..e70267cc 100644 --- a/scripts/config-schema.json +++ b/scripts/config-schema.json @@ -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": "", + "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": "", + "required": false + }, + "config.spaces[].stairs[].radius": { + "max": 5000.0, + "min": 1e-07, + "opaque": "", + "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": "", + "required": false + }, + "config.spaces[].stairs[].x": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].stairs[].y": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, "config.spaces[].title": { "required": true, "type": "str" diff --git a/scripts/monolith-baseline.json b/scripts/monolith-baseline.json index 6f50c91d..96049460 100644 --- a/scripts/monolith-baseline.json +++ b/scripts/monolith-baseline.json @@ -4,5 +4,5 @@ "hostRefs": 4880, "portPrivates": 94, "harnessPrivates": 100, - "bundleBytes": 2540346 + "bundleBytes": 2567812 } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 4f6c10ce..c872e4e6 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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' + ' ${decorImages}\n' + + ' ${stairShapes}\n' + ' ${glowPools}\n ${wallUnion', replace: ' ${passageGlowTunnels}\n' + ' ${!space.bg && !disp.showNames ? svg`${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}`;\n' + ' })}` : nothing}\n' + ' ${decorImages}\n' + + ' ${stairShapes}\n' + ' ${glowPools}\n' + ' ${wallUnion', }], diff --git a/src/canvas-constants.ts b/src/canvas-constants.ts new file mode 100644 index 00000000..2d00c2e1 --- /dev/null +++ b/src/canvas-constants.ts @@ -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; diff --git a/src/card-runtime.ts b/src/card-runtime.ts index 49569502..5402f8e4 100644 --- a/src/card-runtime.ts +++ b/src/card-runtime.ts @@ -27,13 +27,13 @@ export const lruWrite = (cache: Map, 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([ '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. */ diff --git a/src/clean-floor.ts b/src/clean-floor.ts new file mode 100644 index 00000000..26304477 --- /dev/null +++ b/src/clean-floor.ts @@ -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; + 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; +} diff --git a/src/coordinate-canonicalization.ts b/src/coordinate-canonicalization.ts index abc4d955..5c34b086 100644 --- a/src/coordinate-canonicalization.ts +++ b/src/coordinate-canonicalization.ts @@ -357,6 +357,13 @@ export function canonicalizeConfigGeometryInPlace(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); diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 469490fa..efb24df8 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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(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(); + private _cleanFloorCache = new Map(); private _innerContourCache = new Map(); 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`(); @@ -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); diff --git a/src/i18n/de.json b/src/i18n/de.json index 600c5174..9a58156a 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -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.", diff --git a/src/i18n/en.json b/src/i18n/en.json index 9cd515e7..cb3b3a89 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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.", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 3f73b31a..cde3ff15 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -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.", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 435f8453..acfc7c07 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -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": "Копия создаст небезопасную геометрию стен. Исправьте исходное пространство и повторите попытку.", diff --git a/src/pdf/pdf-scene.ts b/src/pdf/pdf-scene.ts index 388c3a67..aaae6eb4 100644 --- a/src/pdf/pdf-scene.ts +++ b/src/pdf/pdf-scene.ts @@ -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; [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); diff --git a/src/space-copy-runtime.ts b/src/space-copy-runtime.ts index 324d5b35..c8dcec4c 100644 --- a/src/space-copy-runtime.ts +++ b/src/space-copy-runtime.ts @@ -31,6 +31,7 @@ const ERROR_KEYS: Record = { 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', diff --git a/src/space-copy.ts b/src/space-copy.ts index 65371f97..d889c885 100644 --- a/src/space-copy.ts +++ b/src/space-copy.ts @@ -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); diff --git a/src/space-geometry.ts b/src/space-geometry.ts index ed8dc978..4f61ad4b 100644 --- a/src/space-geometry.ts +++ b/src/space-geometry.ts @@ -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 diff --git a/src/space-render.ts b/src/space-render.ts index a0cdedf3..30ff2979 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -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)} `; }); + const stairShapes = space.stairs.map((stair) => { + const geometry = cachedStairRenderGeometry(stair, cellCm); + return svg``; + }); return html`
${decorImages} + ${stairShapes} ${glowPools} ${wallUnion ? svg` diff --git a/src/stairs-editor-model.ts b/src/stairs-editor-model.ts new file mode 100644 index 00000000..a56eabd1 --- /dev/null +++ b/src/stairs-editor-model.ts @@ -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, 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'; +} diff --git a/src/stairs-editor.ts b/src/stairs-editor.ts new file mode 100644 index 00000000..5eae2ca2 --- /dev/null +++ b/src/stairs-editor.ts @@ -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; + _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; + _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): 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` { + event.stopPropagation(); + if (this.owner._mode === 'plan') this.openDialog(stair); + }}> + + this.pointerDown(event, stair, 'move')} + @click=${select}> + ${geometry.treads.map((line) => svg``)} + + ${selected ? svg` + ${resizeHandles.map((handle) => svg` this.pointerDown( + event, stair, 'resize', handle.sx as -1 | 0 | 1, handle.sy as -1 | 0 | 1, + )}>`)} + + this.pointerDown(event, stair, 'rotate')}> + ` : nothing} + `; + }); + return svg`${items}` 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 = 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` + +
this.updateDialog({ + [key]: (event.target as HTMLInputElement).value, + })}>${this.owner._t( + this.owner._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm', + )}
`; + return html` this.closeDialog()}> +
+ + + ${dialog.kind === 'straight' + ? html`${field('length', 'stairs.length')}${field('width', 'stairs.width')}` + : field('radius', 'stairs.radius')} + + this.updateDialog({ + angle: (event.target as HTMLInputElement).value, + })}> + +
${directionOptions.map(([value, key]) => html` + `)}
+ + + ${targetState !== 'active' ? html`

${this.owner._t( + targetState === 'fixed' ? 'stairs.fixed_floor' : 'stairs.target_warning', + )}

` : nothing} +
+ +
`; + } + + private closeDialog(): void { + this.dialog = null; + this.owner.requestUpdate(); + } +} diff --git a/src/stairs-view.ts b/src/stairs-view.ts new file mode 100644 index 00000000..b45e671d --- /dev/null +++ b/src/stairs-view.ts @@ -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` { + if (active && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + navigate(event); + } + }}> + + this.pointerDown(event)}> + ${geometry.treads.map((line) => svg``)} + + `; + }); + return svg`${items}` as unknown as TemplateResult; + } +} diff --git a/src/stairs.ts b/src/stairs.ts new file mode 100644 index 00000000..1543f340 --- /dev/null +++ b/src/stairs.ts @@ -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(); + +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; + 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; + } +} diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts index 014b9855..046e120b 100644 --- a/src/styles/plan.styles.ts +++ b/src/styles/plan.styles.ts @@ -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; } `; diff --git a/src/summary-panel-metrics.ts b/src/summary-panel-metrics.ts index 3e998183..8c987951 100644 --- a/src/summary-panel-metrics.ts +++ b/src/summary-panel-metrics.ts @@ -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; } diff --git a/src/types.ts b/src/types.ts index 2c266e61..ac491430 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/test/canvas.test.mjs b/test/canvas.test.mjs index 287965a4..10356eee 100644 --- a/test/canvas.test.mjs +++ b/test/canvas.test.mjs @@ -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]], diff --git a/test/coordinate-canonicalization.test.mjs b/test/coordinate-canonicalization.test.mjs index 86221626..d29770cf 100644 --- a/test/coordinate-canonicalization.test.mjs +++ b/test/coordinate-canonicalization.test.mjs @@ -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, diff --git a/test/golden-matrix.test.mjs b/test/golden-matrix.test.mjs index 6a655485..b51fa15b 100644 --- a/test/golden-matrix.test.mjs +++ b/test/golden-matrix.test.mjs @@ -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'); diff --git a/test/i18n.test.mjs b/test/i18n.test.mjs index a4946f7b..ce99060b 100644 --- a/test/i18n.test.mjs +++ b/test/i18n.test.mjs @@ -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); diff --git a/test/large-house-fixture.test.mjs b/test/large-house-fixture.test.mjs index 1c3e40fa..389ab16b 100644 --- a/test/large-house-fixture.test.mjs +++ b/test/large-house-fixture.test.mjs @@ -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); diff --git a/test/pdf-scene.test.mjs b/test/pdf-scene.test.mjs index 28575ace..6228dba1 100644 --- a/test/pdf-scene.test.mjs +++ b/test/pdf-scene.test.mjs @@ -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'); diff --git a/test/stairs.test.mjs b/test/stairs.test.mjs new file mode 100644 index 00000000..a962f576 --- /dev/null +++ b/test/stairs.test.mjs @@ -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); +}); diff --git a/tests_backend/test_coordinate_canonicalization_pure.py b/tests_backend/test_coordinate_canonicalization_pure.py index 6ed17636..51bdd2cd 100644 --- a/tests_backend/test_coordinate_canonicalization_pure.py +++ b/tests_backend/test_coordinate_canonicalization_pure.py @@ -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 diff --git a/tests_backend/test_ha_diagnostics.py b/tests_backend/test_ha_diagnostics.py index c119a1b0..fe1b0cb7 100644 --- a/tests_backend/test_ha_diagnostics.py +++ b/tests_backend/test_ha_diagnostics.py @@ -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 diff --git a/tests_backend/test_ha_import_export.py b/tests_backend/test_ha_import_export.py index 506698ff..288a7e73 100644 --- a/tests_backend/test_ha_import_export.py +++ b/tests_backend/test_ha_import_export.py @@ -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", [ diff --git a/tests_backend/test_ha_panel_registration.py b/tests_backend/test_ha_panel_registration.py index be9a61d8..ef524790 100644 --- a/tests_backend/test_ha_panel_registration.py +++ b/tests_backend/test_ha_panel_registration.py @@ -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 diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index 5ceba033..cc6ebf1b 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -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: diff --git a/tests_backend/test_support_package.py b/tests_backend/test_support_package.py index fcc43345..74f50cb0 100644 --- a/tests_backend/test_support_package.py +++ b/tests_backend/test_support_package.py @@ -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 diff --git a/tests_backend/test_validation.py b/tests_backend/test_validation.py index 058a7547..29aad979 100644 --- a/tests_backend/test_validation.py +++ b/tests_backend/test_validation.py @@ -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() diff --git a/tsconfig.test.json b/tsconfig.test.json index 1401fc21..2b3a16c8 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -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",