mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
feat: добавить лестницы между этажами (#663)
Прямые и винтовые лестницы получили отдельную модель, инструменты редактора, безопасную межэтажную навигацию, вычитание из чистой площади и плоское отображение в 2.5D. Issue: #663 User-Visible: yes
This commit is contained in:
@@ -186,6 +186,7 @@ concurrent clients, but avoid editing the same object in two browsers at once.
|
||||
- [Full user guide](docs/USER-GUIDE.md)
|
||||
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
|
||||
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
|
||||
- [Stairs and floor links](docs/STAIRS.md)
|
||||
- [Background editor](docs/DECOR-EDITOR.md)
|
||||
- [Robot vacuums](docs/VACUUM.md)
|
||||
- [Touch support](docs/TOUCH-SUPPORT.md)
|
||||
|
||||
@@ -158,6 +158,12 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
_lattice_fields(decor, ("x", "y"))
|
||||
_scalar_fields(decor, ("scale", "angle"))
|
||||
|
||||
for stair in _records(space.get("stairs")):
|
||||
# #663: wall/stair magnet placement is continuous like furniture.
|
||||
# Scalar cleanup removes JSON tails without moving an authored
|
||||
# physical-face contact onto the plan grid.
|
||||
_scalar_fields(stair, ("x", "y", "length", "width", "radius", "angle"))
|
||||
|
||||
# Legacy-only read boundary: v9 drafts are canonicalized before the
|
||||
# wall-model migration turns their edges into current partitions.
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
|
||||
@@ -33,6 +33,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"partitions": len(s.get("partitions", [])),
|
||||
"wall_columns": len(s.get("wall_columns", [])),
|
||||
"stairs": len(s.get("stairs", [])),
|
||||
}
|
||||
for s in config.get("spaces", [])
|
||||
],
|
||||
|
||||
@@ -73,7 +73,7 @@ _LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
_PLAN_ONLY_DASH = "—"
|
||||
_IMPORT_ID_NAMESPACES = {
|
||||
"space", "room", "marker", "partition", "wall", "opening", "decor", "column",
|
||||
"space", "room", "marker", "partition", "wall", "opening", "decor", "column", "stair",
|
||||
}
|
||||
_MAX_IMPORT_LINEAGE_DEPTH = 16
|
||||
_REPORT_EXAMPLE_LIMIT = 24
|
||||
@@ -294,6 +294,14 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
projected["decor"] = [
|
||||
_project_plan_only_decor(shape) for shape in space.get("decor") or []
|
||||
]
|
||||
if "stairs" in space:
|
||||
projected["stairs"] = [
|
||||
_pick_fields(stair, (
|
||||
"id", "kind", "x", "y", "angle", "direction",
|
||||
"target_space_id", "length", "width", "radius",
|
||||
))
|
||||
for stair in space.get("stairs") or []
|
||||
]
|
||||
return projected
|
||||
|
||||
|
||||
@@ -742,6 +750,7 @@ def _counts(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, int]:
|
||||
"markers": len(config.get("markers") or []),
|
||||
"openings": sum(len(sp.get("openings") or []) for sp in spaces),
|
||||
"decor": sum(len(sp.get("decor") or []) for sp in spaces),
|
||||
"stairs": sum(len(sp.get("stairs") or []) for sp in spaces),
|
||||
"layout": len(layout),
|
||||
}
|
||||
|
||||
@@ -1132,6 +1141,14 @@ def _repair_target_space_refs(
|
||||
|
||||
for space in spaces:
|
||||
space_id = str(space.get("id", "?"))
|
||||
for stair in space.get("stairs") or []:
|
||||
target = stair.get("target_space_id") if isinstance(stair, dict) else None
|
||||
mapped = replace(
|
||||
f"{space_id}:{stair.get('id', '?')}", "stair.target_space_id",
|
||||
target, resolve_space,
|
||||
)
|
||||
if mapped is not None:
|
||||
stair["target_space_id"] = mapped
|
||||
for room in space.get("rooms") or []:
|
||||
room_id = str(room.get("id", "?"))
|
||||
values = room.get("open_to")
|
||||
@@ -1322,7 +1339,7 @@ def build_space_merge(
|
||||
(sp,), sp.get("rooms") or [],
|
||||
sp.get("partitions") or [], sp.get("wall_segments") or [],
|
||||
sp.get("wall_columns") or [],
|
||||
sp.get("openings") or [], sp.get("decor") or [],
|
||||
sp.get("openings") or [], sp.get("decor") or [], sp.get("stairs") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
@@ -1338,7 +1355,7 @@ def build_space_merge(
|
||||
(incoming_space,), incoming_space.get("rooms") or [],
|
||||
incoming_space.get("partitions") or [], incoming_space.get("wall_segments") or [],
|
||||
incoming_space.get("wall_columns") or [],
|
||||
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
|
||||
incoming_space.get("openings") or [], incoming_space.get("decor") or [], incoming_space.get("stairs") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
@@ -1362,6 +1379,7 @@ def build_space_merge(
|
||||
("rooms", "room"), ("partitions", "partition"),
|
||||
("wall_segments", "wall"),
|
||||
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
|
||||
("stairs", "stair"),
|
||||
):
|
||||
for item in space.get(collection) or []:
|
||||
if isinstance(item, dict) and item.get("id") is not None:
|
||||
@@ -1405,6 +1423,16 @@ def build_space_merge(
|
||||
reference_report, "incoming", "opening.host",
|
||||
str(opening.get("id", "?")), old_host_id,
|
||||
)
|
||||
# A one-space export cannot carry a trustworthy external floor target.
|
||||
# Self-links are invalid too because the imported source gets a new id.
|
||||
for stair in space.get("stairs") or []:
|
||||
target = stair.get("target_space_id") if isinstance(stair, dict) else None
|
||||
if target is not None:
|
||||
stair["target_space_id"] = None
|
||||
_report_remap(
|
||||
reference_report, "incoming", "stair.target_space_id",
|
||||
str(stair.get("id", "?")), str(target),
|
||||
)
|
||||
space["id"] = new_space_id
|
||||
space["title"] = _unique_title(
|
||||
str(space.get("title") or old_space_id), current_config.get("spaces") or []
|
||||
|
||||
@@ -374,6 +374,17 @@ def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[
|
||||
out["decor"] = [
|
||||
_project_decor(ids, item) for item in space.get("decor") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["stairs"] = []
|
||||
for item in space.get("stairs") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
projected = {"id": ids.get("stair", item.get("id"))}
|
||||
projected.update(_copy_keys(item, (
|
||||
"kind", "x", "y", "angle", "direction", "length", "width", "radius",
|
||||
)))
|
||||
if item.get("target_space_id"):
|
||||
projected["target_space_id"] = ids.get("space", item.get("target_space_id"))
|
||||
out["stairs"].append(projected)
|
||||
out["open_spans"] = [
|
||||
{"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
||||
for item in space.get("open_spans") or [] if isinstance(item, dict)
|
||||
@@ -436,6 +447,7 @@ def _summary(config: object, layout: object) -> dict[str, Any]:
|
||||
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
|
||||
"openings": dict(sorted(kinds.items())),
|
||||
"decor": dict(sorted(decor_kinds.items())),
|
||||
"stairs": sum(len(space.get("stairs") or []) for space in spaces if isinstance(space, dict)),
|
||||
"markers": {
|
||||
"total": len(markers),
|
||||
"lifecycle": dict(sorted(lifecycles.items())),
|
||||
|
||||
@@ -31,6 +31,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
|
||||
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
|
||||
"stairs": sum(len(s.get("stairs", [])) for s in config.get("spaces", [])),
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
|
||||
@@ -1198,6 +1198,7 @@ MAX_ROOMS = 400
|
||||
MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_STAIRS = 250
|
||||
# v8 atomises room boundaries. The 2 MiB wire cap is the practical bound; this
|
||||
# structural cap mirrors MAX_ROOMS * MAX_POLY_POINTS without depending on the
|
||||
# later constant declaration.
|
||||
@@ -1532,6 +1533,57 @@ DECOR_SCHEMA = vol.Any(
|
||||
)
|
||||
|
||||
|
||||
def _strict_stair(value: dict) -> dict:
|
||||
"""A discriminated stair record carries dimensions for exactly one kind."""
|
||||
if value["kind"] == "straight":
|
||||
if "radius" in value:
|
||||
raise vol.Invalid("radius is allowed only for spiral stairs")
|
||||
if value["direction"] not in ("forward", "backward"):
|
||||
raise vol.Invalid("straight stair direction must be forward or backward")
|
||||
else:
|
||||
if "length" in value or "width" in value:
|
||||
raise vol.Invalid("length and width are allowed only for straight stairs")
|
||||
if value["direction"] not in ("clockwise", "counterclockwise"):
|
||||
raise vol.Invalid("spiral stair direction must be clockwise or counterclockwise")
|
||||
return value
|
||||
|
||||
|
||||
def _stair_dimensions(value: dict) -> dict:
|
||||
"""Require the dimension fields owned by the selected stair kind."""
|
||||
complete = (
|
||||
value["kind"] == "straight" and "length" in value and "width" in value
|
||||
or value["kind"] == "spiral" and "radius" in value
|
||||
)
|
||||
if not complete:
|
||||
raise vol.Invalid("stair kind requires its dimensions")
|
||||
return value
|
||||
|
||||
|
||||
STAIR_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("kind"): vol.In(["straight", "spiral"]),
|
||||
vol.Required("x"): _NORM,
|
||||
vol.Required("y"): _NORM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Required("direction"): vol.In([
|
||||
"forward", "backward", "clockwise", "counterclockwise",
|
||||
]),
|
||||
vol.Optional("target_space_id"): vol.Any(
|
||||
None, vol.All(str, vol.Match(SPACE_ID_RE.pattern))
|
||||
),
|
||||
vol.Optional("length"): _FURN_SIZE,
|
||||
vol.Optional("width"): _FURN_SIZE,
|
||||
vol.Optional("radius"): _FURN_SIZE,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_stair_dimensions,
|
||||
_strict_stair,
|
||||
)
|
||||
|
||||
|
||||
def _wall_endpoints_pair(entry: dict) -> dict:
|
||||
"""Exact wall endpoints are useful only as a complete a/b pair."""
|
||||
if ("a" in entry) != ("b" in entry):
|
||||
@@ -1673,7 +1725,7 @@ OPENING_HOST_SCHEMA = vol.Any(PARTITION_OPENING_HOST_SCHEMA, WALL_OPENING_HOST_S
|
||||
def _space_geometry_invariants(value: dict) -> dict:
|
||||
"""All stored geometry shares ids; draft segments also have a space cap."""
|
||||
seen: set[str] = set()
|
||||
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns", "wall_segments"):
|
||||
for key in ("rooms", "openings", "decor", "stairs", "room_drafts", "partitions", "wall_columns", "wall_segments"):
|
||||
for item in value.get(key, []):
|
||||
item_id = item.get("id")
|
||||
if not item_id:
|
||||
@@ -1768,6 +1820,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("stairs"): vol.All([STAIR_SCHEMA], vol.Length(max=MAX_STAIRS)),
|
||||
vol.Optional("openings"): vol.All([
|
||||
vol.Schema(
|
||||
{
|
||||
|
||||
@@ -439,7 +439,7 @@ def _thickness(space: dict[str, Any], atom: dict[str, Any], previous: dict[str,
|
||||
|
||||
def _non_catalog_ids(space: dict[str, Any]) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for name in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
|
||||
for name in ("rooms", "openings", "decor", "stairs", "room_drafts", "partitions", "wall_columns"):
|
||||
for item in space.get(name) or []:
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]:
|
||||
result.add(item["id"])
|
||||
@@ -661,7 +661,7 @@ def _migrate_room_drafts_to_partitions(space: dict[str, Any]) -> tuple[int, int]
|
||||
return 0, 0
|
||||
used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "partitions",
|
||||
for name in ("rooms", "openings", "decor", "stairs", "partitions",
|
||||
"wall_columns", "wall_segments")
|
||||
for item in space.get(name) or []
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
|
||||
|
||||
@@ -1848,6 +1848,10 @@ def _space_delete_candidate(
|
||||
item for item in candidate_config.get("spaces") or []
|
||||
if item.get("id") != space_id
|
||||
]
|
||||
for remaining_space in candidate_config["spaces"]:
|
||||
for stair in remaining_space.get("stairs") or []:
|
||||
if isinstance(stair, dict) and stair.get("target_space_id") == space_id:
|
||||
stair["target_space_id"] = None
|
||||
for marker in candidate_config.get("markers") or []:
|
||||
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
|
||||
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -727,6 +727,18 @@ export function prepareGoldenFixture(scenario) {
|
||||
}
|
||||
space.decor = structuredClone(scenario.decorOverride);
|
||||
}
|
||||
if (scenario.stairsOverride) {
|
||||
const space = requireSpace();
|
||||
const known = new Set();
|
||||
for (const stair of scenario.stairsOverride) {
|
||||
if (!stair?.id || known.has(stair.id))
|
||||
throw new Error(`golden stairsOverride has missing/duplicate id: ${stair?.id || '<empty>'}`);
|
||||
if (!['straight', 'spiral'].includes(stair.kind))
|
||||
throw new Error(`golden stairsOverride has unknown kind: ${stair.kind}`);
|
||||
known.add(stair.id);
|
||||
}
|
||||
space.stairs = structuredClone(scenario.stairsOverride);
|
||||
}
|
||||
if (scenario.openingGeometry) {
|
||||
const space = requireSpace();
|
||||
const opening = (space.openings || []).find(
|
||||
@@ -1538,6 +1550,17 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
throw new Error(`golden furniture transform frame is incomplete: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.stairSelection) {
|
||||
const target = card.renderRoot.querySelector(
|
||||
`[data-hp="stair"][data-id="${CSS.escape(scenario.stairSelection)}"] .hp-stair-hit`,
|
||||
);
|
||||
target?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (!target || !card.renderRoot.querySelector(
|
||||
`[data-hp="stair"][data-id="${CSS.escape(scenario.stairSelection)}"].selected`,
|
||||
)) throw new Error(`golden stair selection missing: ${scenario.id}`);
|
||||
}
|
||||
if (scenario.editorTray) {
|
||||
let expectedKind = '';
|
||||
if (scenario.editorTray === 'plan-selection') {
|
||||
@@ -2347,6 +2370,18 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (scenario.hoverStair) {
|
||||
const point = await page.evaluate((id) => {
|
||||
const stair = window.__goldenCard?.renderRoot?.querySelector(
|
||||
`[data-hp="stair"][data-id="${CSS.escape(id)}"] .hp-stair-hit`,
|
||||
);
|
||||
if (!stair) return null;
|
||||
const rect = stair.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}, scenario.hoverStair);
|
||||
if (!point) throw new Error(`golden hover stair missing: ${scenario.hoverStair}`);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+30
-1
@@ -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.
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
// #663: straight and spiral stairs are plan-editor objects, clean View links
|
||||
// between spaces, and flat floor content in 2.5D. Exercise only public DOM
|
||||
// hooks and the harness facade for writes; card internals are read-only oracles.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1100, height: 850 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const hp = window.__hpTest;
|
||||
const root = () => card.renderRoot;
|
||||
const settled = () => hp.settled();
|
||||
const settleCamera = async () => {
|
||||
for (let guard = 0; card._cameraTransition?.active && guard < 90; guard++)
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await settled();
|
||||
};
|
||||
const spaceCfg = (id) => card._serverCfg.spaces.find((space) => space.id === id);
|
||||
const stairs = (id = 'f1') => spaceCfg(id)?.stairs || [];
|
||||
const stairNode = (id) => root().querySelector(`[data-hp="stair"][data-id="${id}"]`);
|
||||
const planSvg = () => root().querySelector('.plan-svg');
|
||||
const stage = () => root().querySelector('.stage');
|
||||
const screen = ([x, y]) => {
|
||||
const point = new DOMPoint(x, y).matrixTransform(planSvg().getScreenCTM());
|
||||
return { clientX: point.x, clientY: point.y };
|
||||
};
|
||||
const clickPlan = async (point) => {
|
||||
stage().dispatchEvent(new MouseEvent('click', {
|
||||
...screen(point), bubbles: true, composed: true, cancelable: true, button: 0,
|
||||
}));
|
||||
await settled();
|
||||
};
|
||||
const pointer = (target, type, point, id, extra = {}) => target.dispatchEvent(new PointerEvent(type, {
|
||||
...screen(point), bubbles: true, composed: true, cancelable: true,
|
||||
pointerId: id, pointerType: extra.pointerType || 'mouse',
|
||||
button: type === 'pointerdown' ? 0 : -1, isPrimary: extra.isPrimary ?? true,
|
||||
shiftKey: !!extra.shiftKey,
|
||||
}));
|
||||
const chooseStair = async (kind) => {
|
||||
await hp.setTool('stairs');
|
||||
const item = root().querySelector(`[data-hp="tray"] [data-group-item="${kind}"]`);
|
||||
item?.click();
|
||||
await settled();
|
||||
return !!item;
|
||||
};
|
||||
const setDialogSelect = (select, value) => {
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||
};
|
||||
const openStairDialog = async (id) => {
|
||||
stairNode(id)?.dispatchEvent(new MouseEvent('dblclick', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await settled();
|
||||
return root().querySelector('[data-hp="dialog"][data-kind="stairs"]');
|
||||
};
|
||||
const saveDialog = async (dialog) => {
|
||||
dialog?.querySelector('.dialog-action-footer .btn.on')?.click();
|
||||
await settled();
|
||||
};
|
||||
const activeSpace = () => root().querySelector('[data-hp="space-tab"][aria-current="page"]')
|
||||
?.getAttribute('data-id');
|
||||
const closeTo = (a, b, tolerance = 1e-5) => Math.abs(a - b) <= tolerance;
|
||||
const result = {};
|
||||
|
||||
await hp.setServerConfig((config) => {
|
||||
for (const space of config.spaces) delete space.stairs;
|
||||
const first = config.spaces.find((space) => space.id === 'f1');
|
||||
first.partitions = [...(first.partitions || []), {
|
||||
id: 'stair-smoke-wall', a: [0.15, 0.60], b: [0.85, 0.60], cm: 20,
|
||||
}];
|
||||
return config;
|
||||
});
|
||||
await hp.switchSpace('f1');
|
||||
await hp.setMode('plan');
|
||||
|
||||
result.straightToolExists = await chooseStair('straight');
|
||||
await clickPlan([400, 650]);
|
||||
const straight = stairs().find((stair) => stair.kind === 'straight');
|
||||
result.straightCreatedOnlyOnCurrentFloor = !!straight && stairs('garden').length === 0;
|
||||
result.wallMagnetUsesPhysicalFace = !!straight
|
||||
&& closeTo(Math.abs(straight.y * 1000 - 600), straight.width * 500 + (20 / 5) * (1000 / 240) / 2, 2)
|
||||
&& closeTo(((straight.angle % 180) + 180) % 180, 0);
|
||||
|
||||
result.spiralToolExists = await chooseStair('spiral');
|
||||
await clickPlan([580, 650]);
|
||||
const spiral = stairs().find((stair) => stair.kind === 'spiral');
|
||||
result.spiralCreated = !!spiral && stairs().length === 2;
|
||||
result.stairMagnetTouchesOtherFootprint = !!straight && !!spiral
|
||||
&& closeTo(Math.abs(spiral.x - straight.x) * 1000,
|
||||
(straight.length / 2 + spiral.radius) * 1000, 0.5);
|
||||
result.renderedTypesAndDirections = !!root().querySelector(
|
||||
'[data-hp="stair"][data-kind="straight"] .hp-stair-arrow',
|
||||
) && !!root().querySelector('[data-hp="stair"][data-kind="spiral"] .hp-stair-arrow');
|
||||
|
||||
// Select and move the straight stair through its actual hit target.
|
||||
const start = { x: straight.x, y: straight.y };
|
||||
const straightHit = stairNode(straight.id)?.querySelector('.hp-stair-hit');
|
||||
straightHit?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await settled();
|
||||
pointer(straightHit, 'pointerdown', [start.x * 1000, start.y * 1000], 6631);
|
||||
pointer(stage(), 'pointermove', [start.x * 1000 - 180, start.y * 1000 - 170], 6631);
|
||||
pointer(stage(), 'pointerup', [start.x * 1000 - 180, start.y * 1000 - 170], 6631);
|
||||
await settled();
|
||||
const moved = stairs().find((stair) => stair.id === straight.id);
|
||||
result.moveUsesContinuousTransform = !!moved
|
||||
&& (!closeTo(moved.x, start.x) || !closeTo(moved.y, start.y));
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, cancelable: true,
|
||||
}));
|
||||
await settled();
|
||||
const undone = stairs().find((stair) => stair.id === straight.id);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyZ', ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true,
|
||||
}));
|
||||
await settled();
|
||||
const redone = stairs().find((stair) => stair.id === straight.id);
|
||||
result.moveUndoRedo = !!undone && !!redone
|
||||
&& closeTo(undone.x, start.x) && closeTo(undone.y, start.y)
|
||||
&& closeTo(redone.x, moved.x) && closeTo(redone.y, moved.y);
|
||||
|
||||
// Resize and Shift-rotate through the visible handles.
|
||||
stairNode(straight.id)?.querySelector('.hp-stair-hit')?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
await settled();
|
||||
const beforeResize = stairs().find((stair) => stair.id === straight.id);
|
||||
const resize = stairNode(straight.id)?.querySelector('.hp-stair-resize');
|
||||
const resizePoint = [Number(resize?.getAttribute('cx')), Number(resize?.getAttribute('cy'))];
|
||||
pointer(resize, 'pointerdown', resizePoint, 6632);
|
||||
pointer(stage(), 'pointermove', [resizePoint[0] - 35, resizePoint[1] - 25], 6632);
|
||||
pointer(stage(), 'pointerup', [resizePoint[0] - 35, resizePoint[1] - 25], 6632);
|
||||
await settled();
|
||||
const resized = stairs().find((stair) => stair.id === straight.id);
|
||||
result.resizeIsContinuous = !!beforeResize && !!resized
|
||||
&& (!closeTo(beforeResize.length, resized.length) || !closeTo(beforeResize.width, resized.width));
|
||||
const rotate = stairNode(straight.id)?.querySelector('.hp-stair-rotate');
|
||||
const rotatePoint = [Number(rotate?.getAttribute('cx')), Number(rotate?.getAttribute('cy'))];
|
||||
pointer(rotate, 'pointerdown', rotatePoint, 6633);
|
||||
pointer(stage(), 'pointermove', [rotatePoint[0] + 47, rotatePoint[1] + 19], 6633, { shiftKey: true });
|
||||
pointer(stage(), 'pointerup', [rotatePoint[0] + 47, rotatePoint[1] + 19], 6633, { shiftKey: true });
|
||||
await settled();
|
||||
const rotated = stairs().find((stair) => stair.id === straight.id);
|
||||
result.shiftRotationSnaps45 = !!rotated && closeTo(rotated.angle / 45, Math.round(rotated.angle / 45));
|
||||
|
||||
// Properties switch kind without changing identity, then switch it back and link Garden.
|
||||
let dialog = await openStairDialog(straight.id);
|
||||
const selects = dialog ? [...dialog.querySelectorAll('select')] : [];
|
||||
if (selects[0]) setDialogSelect(selects[0], 'spiral');
|
||||
await saveDialog(dialog);
|
||||
const converted = stairs().find((stair) => stair.id === straight.id);
|
||||
dialog = await openStairDialog(straight.id);
|
||||
const restoredSelects = dialog ? [...dialog.querySelectorAll('select')] : [];
|
||||
if (restoredSelects[0]) setDialogSelect(restoredSelects[0], 'straight');
|
||||
if (restoredSelects[1]) setDialogSelect(restoredSelects[1], 'garden');
|
||||
await saveDialog(dialog);
|
||||
const linked = stairs().find((stair) => stair.id === straight.id);
|
||||
result.kindSwitchKeepsIdentityAndLink = converted?.kind === 'spiral'
|
||||
&& linked?.kind === 'straight' && linked.target_space_id === 'garden';
|
||||
|
||||
await hp.setMode('view');
|
||||
const linkedNode = stairNode(straight.id);
|
||||
result.validLinkIsAccessible = linkedNode?.getAttribute('role') === 'link'
|
||||
&& linkedNode?.getAttribute('data-target-state') === 'active';
|
||||
|
||||
// A pan ending on the stair owns its compatibility click and cannot navigate.
|
||||
const linkedCenter = [linked.x * 1000, linked.y * 1000];
|
||||
pointer(linkedNode, 'pointerdown', linkedCenter, 6634, { pointerType: 'touch' });
|
||||
pointer(stage(), 'pointermove', [linkedCenter[0] + 60, linkedCenter[1] + 20], 6634, { pointerType: 'touch' });
|
||||
pointer(stage(), 'pointerup', [linkedCenter[0] + 60, linkedCenter[1] + 20], 6634, { pointerType: 'touch' });
|
||||
linkedNode?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
|
||||
result.panClickSuppressed = activeSpace() === 'f1';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
pointer(linkedNode, 'pointerdown', linkedCenter, 6635, { pointerType: 'touch' });
|
||||
pointer(stage(), 'pointermove', [linkedCenter[0] + 35, linkedCenter[1] + 45], 6635, { pointerType: 'touch' });
|
||||
pointer(stage(), 'pointercancel', [linkedCenter[0] + 35, linkedCenter[1] + 45], 6635, { pointerType: 'touch' });
|
||||
linkedNode?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
|
||||
result.pointerCancelClickSuppressed = activeSpace() === 'f1';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Cancellation without any movement must also disarm the compatibility
|
||||
// click; it cannot rely on the pan path having set _suppressClick.
|
||||
pointer(stairNode(straight.id)?.querySelector('.hp-stair-hit'),
|
||||
'pointerdown', linkedCenter, 6642, { pointerType: 'touch' });
|
||||
pointer(stage(), 'pointercancel', linkedCenter, 6642, { pointerType: 'touch' });
|
||||
stairNode(straight.id)?.querySelector('.hp-stair-hit')?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }),
|
||||
);
|
||||
result.cancelledTapClickSuppressed = activeSpace() === 'f1';
|
||||
|
||||
// A motionless long press is still a gesture, not a floor-link activation.
|
||||
pointer(stairNode(straight.id)?.querySelector('.hp-stair-hit'),
|
||||
'pointerdown', linkedCenter, 6636, { pointerType: 'touch' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
pointer(stage(), 'pointerup', linkedCenter, 6636, { pointerType: 'touch' });
|
||||
stairNode(straight.id)?.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
result.longPressClickSuppressed = activeSpace() === 'f1';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The shared multi-touch barrier owns delayed compatibility clicks even
|
||||
// when the first finger started on this interactive child.
|
||||
pointer(stairNode(straight.id)?.querySelector('.hp-stair-hit'),
|
||||
'pointerdown', linkedCenter, 6637, { pointerType: 'touch' });
|
||||
pointer(stage(), 'pointerdown', [linkedCenter[0] + 80, linkedCenter[1] + 30], 6638, {
|
||||
pointerType: 'touch', isPrimary: false,
|
||||
});
|
||||
pointer(stage(), 'pointermove', [linkedCenter[0] + 110, linkedCenter[1] + 45], 6638, {
|
||||
pointerType: 'touch', isPrimary: false,
|
||||
});
|
||||
pointer(stage(), 'pointerup', [linkedCenter[0] + 110, linkedCenter[1] + 45], 6638, {
|
||||
pointerType: 'touch', isPrimary: false,
|
||||
});
|
||||
pointer(stage(), 'pointerup', linkedCenter, 6637, { pointerType: 'touch' });
|
||||
stairNode(straight.id)?.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
result.pinchClickSuppressed = activeSpace() === 'f1';
|
||||
// A new pointer sequence is the shared guard's explicit re-arm signal.
|
||||
pointer(stage(), 'pointerdown', [40, 40], 6640);
|
||||
pointer(stage(), 'pointerup', [40, 40], 6640);
|
||||
|
||||
// Store a user view on Garden through public controls, return, then navigate by stair.
|
||||
await hp.switchSpace('garden');
|
||||
root().querySelector('[data-hp="zoom-in"]')?.click();
|
||||
await settleCamera();
|
||||
const remembered = planSvg().getAttribute('viewBox');
|
||||
await hp.switchSpace('f1');
|
||||
const cleanNode = stairNode(straight.id);
|
||||
pointer(cleanNode?.querySelector('.hp-stair-hit'), 'pointerdown', linkedCenter, 6639);
|
||||
pointer(stage(), 'pointerup', linkedCenter, 6639);
|
||||
cleanNode?.querySelector('.hp-stair-hit')?.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await settleCamera();
|
||||
result.cleanClickNavigatesAndRestoresView = activeSpace() === 'garden'
|
||||
&& planSvg().getAttribute('viewBox') === remembered;
|
||||
result.targetFloorGetsNoAutomaticStair = !root().querySelector('[data-hp="stair"]')
|
||||
&& stairs('garden').length === 0;
|
||||
|
||||
// The circular variant uses the same real hit surface and navigation path.
|
||||
await hp.setServerConfig((config) => {
|
||||
config.spaces.find((space) => space.id === 'f1').stairs
|
||||
.find((stair) => stair.id === spiral.id).target_space_id = 'garden';
|
||||
return config;
|
||||
});
|
||||
await hp.switchSpace('f1');
|
||||
const spiralNode = stairNode(spiral.id);
|
||||
const spiralCenter = [spiral.x * 1000, spiral.y * 1000];
|
||||
pointer(spiralNode?.querySelector('.hp-stair-hit'), 'pointerdown', spiralCenter, 6641);
|
||||
pointer(stage(), 'pointerup', spiralCenter, 6641);
|
||||
spiralNode?.querySelector('.hp-stair-hit')?.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await settleCamera();
|
||||
result.spiralCleanClickNavigates = activeSpace() === 'garden';
|
||||
|
||||
// Missing/self/deleted targets remain visible, repairable and inert.
|
||||
await hp.setServerConfig((config) => {
|
||||
const source = config.spaces.find((space) => space.id === 'f1');
|
||||
source.stairs.find((stair) => stair.id === linked.id).target_space_id = null;
|
||||
return config;
|
||||
});
|
||||
await hp.switchSpace('f1');
|
||||
let broken = stairNode(straight.id);
|
||||
broken?.querySelector('.hp-stair-hit')?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
result.missingTargetIsVisibleAndInert = activeSpace() === 'f1'
|
||||
&& broken?.getAttribute('data-target-state') === 'missing'
|
||||
&& broken?.getAttribute('role') === 'img';
|
||||
await hp.setServerConfig((config) => {
|
||||
config.spaces.find((space) => space.id === 'f1').stairs
|
||||
.find((stair) => stair.id === linked.id).target_space_id = 'f1';
|
||||
return config;
|
||||
});
|
||||
broken = stairNode(straight.id);
|
||||
broken?.querySelector('.hp-stair-hit')?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
result.selfTargetIsVisibleAndInert = activeSpace() === 'f1'
|
||||
&& broken?.getAttribute('data-target-state') === 'self'
|
||||
&& broken?.getAttribute('role') === 'img';
|
||||
await hp.setServerConfig((config) => {
|
||||
config.spaces.find((space) => space.id === 'f1').stairs
|
||||
.find((stair) => stair.id === linked.id).target_space_id = '__deleted__';
|
||||
return config;
|
||||
});
|
||||
broken = stairNode(straight.id);
|
||||
broken?.querySelector('.hp-stair-hit')?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
await settled();
|
||||
result.deletedTargetIsVisibleAndInert = activeSpace() === 'f1'
|
||||
&& broken?.getAttribute('data-target-state') === 'deleted'
|
||||
&& broken?.getAttribute('role') === 'img';
|
||||
await hp.setMode('plan');
|
||||
dialog = await openStairDialog(straight.id);
|
||||
result.brokenTargetHasRepairWarning = !!dialog?.querySelector('.hint.warn')
|
||||
&& [...dialog.querySelectorAll('option')].every((option) => option.value !== '__deleted__');
|
||||
await hp.close(dialog, { via: 'cancel' });
|
||||
result.dialogCancelCloses = !root().querySelector('[data-hp="dialog"][data-kind="stairs"]');
|
||||
|
||||
// A fixed-floor card keeps the same object visible but never presents a link.
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan', icon_size: 3.4, floor: 'f1' });
|
||||
await settled();
|
||||
await hp.setMode('view');
|
||||
const fixed = stairNode(straight.id);
|
||||
fixed?.querySelector('.hp-stair-hit')?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
await settled();
|
||||
result.fixedFloorIsVisibleAndInert = activeSpace() === 'f1'
|
||||
&& fixed?.getAttribute('data-target-state') === 'fixed'
|
||||
&& fixed?.getAttribute('role') === 'img';
|
||||
|
||||
// Restore a valid link and prove that the same flat symbol/hit target is
|
||||
// projected with the floor in 2.5D and still performs its View action.
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan', icon_size: 3.4 });
|
||||
await hp.setServerConfig((config) => {
|
||||
const source = config.spaces.find((space) => space.id === 'f1');
|
||||
source.stairs.find((stair) => stair.id === linked.id).target_space_id = 'garden';
|
||||
return config;
|
||||
});
|
||||
await hp.switchSpace('f1');
|
||||
await hp.setVolumetricView(true);
|
||||
const isoStair = stairNode(straight.id);
|
||||
result.flatIsoSymbolSharesFloorProjection = stage().classList.contains('projection-iso')
|
||||
&& !!isoStair?.closest('.iso-floor-scene')
|
||||
&& !!isoStair?.querySelector('.hp-stair-hit');
|
||||
isoStair?.querySelector('.hp-stair-hit')?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
await settled();
|
||||
result.isoHitTargetNavigates = activeSpace() === 'garden';
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
+29
-2
@@ -26,6 +26,11 @@ houseplan-card/
|
||||
│ ├─ houseplan-panel.ts # HA sidebar/app-bar host for the same full card
|
||||
│ ├─ editor-runtime-loader.ts # lazy loader: dedupe, retry and build handshake
|
||||
│ ├─ houseplan-editor-runtime.ts # Plan/Devices/Background composition root
|
||||
│ ├─ stairs-view.ts # eager read-only stair symbols, links and gesture guards
|
||||
│ ├─ stairs-editor.ts # lazy Plan stair tools, transforms and properties
|
||||
│ ├─ stairs-editor-model.ts # pure editor transforms, snapping and target-state helpers
|
||||
│ ├─ stairs.ts # stair model, render geometry and area math
|
||||
│ ├─ clean-floor.ts # shared room-floor subtraction including stair footprints
|
||||
│ ├─ decor-image-editor.ts # lazy Background/Furniture image palette, upload and properties controller
|
||||
│ ├─ houseplan-onboarding-runtime.ts # first-space/import dialogs, independent of editor
|
||||
│ ├─ iso-scene-render.ts # lazy 2.5D scene/runtime boundary (settings.volumetric_view, #649)
|
||||
@@ -656,7 +661,7 @@ not be added to an individual sink.
|
||||
"rooms":[{"id","name","area","poly|x/y/w/h","wall_ids":[…],"settings"}],
|
||||
"wall_segments":[{"id","a","b","cm","owners":[…]}],
|
||||
"partitions":[…], "wall_columns":[…],
|
||||
"openings":[…], "decor":[…], "settings":{…} }],
|
||||
"openings":[…], "decor":[…], "stairs":[…], "settings":{…} }],
|
||||
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden","removed",
|
||||
"name","icon","display","controls","is_light","glow_color","tap_action",
|
||||
"room_id","pdfs",… }],
|
||||
@@ -940,7 +945,7 @@ designer handoff are attached to issue #570.
|
||||
## Markup editor (v1.4.0+)
|
||||
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/column/merge/split/resize/opening/
|
||||
wallthick/delroom), `_path` (the current outline,
|
||||
stairs/wallthick/delroom), `_path` (the current outline,
|
||||
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
|
||||
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
|
||||
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
|
||||
@@ -1125,6 +1130,28 @@ While drawing, the length of the current segment follows the cursor (`_fmtLen`
|
||||
per-space `cell_cm` — canonical centimetres represented by one grid cell; new
|
||||
spaces use 1 cm or 2.54 cm/1 inch, while missing legacy values fall back to 5 cm.
|
||||
|
||||
### Stairs (#663)
|
||||
|
||||
Stairs are a separate Plan entity, not decor. The lazy `StairEditorRuntime`
|
||||
owns the straight/spiral tool group, selection, continuous
|
||||
move/resize/rotation, wall/stair magnet and properties. The eager
|
||||
`StairViewRuntime` owns only read-only symbols, guarded navigation and gesture
|
||||
suppression, so opening a plan does not load the editor graph. The root card
|
||||
owns lifecycle composition, shared Plan history/persistence and stage pointer
|
||||
terminals. `stairs.ts` is the pure boundary for the discriminated model, exact
|
||||
30 cm tread geometry, cached render projection and footprint containment;
|
||||
`stairs-editor-model.ts` contains the pure transform, target-state and
|
||||
footprint-to-footprint snapping helpers used by the editor.
|
||||
|
||||
`spaces[].stairs[]` is optional and capped at 250 records. A record exists on
|
||||
one space only; `target_space_id` is a one-way navigation reference and never
|
||||
creates target geometry. Straight footprints are rotated rectangles; spiral
|
||||
footprints are circles. `clean-floor.ts` subtracts their geometric overlap
|
||||
from the same clean-area result used by room cards and summary metrics, without
|
||||
cutting the painted floor or changing walls/light/vacuum. PDF and full/static
|
||||
renderers consume the same stair geometry. The 2.5D scene keeps the SVG object
|
||||
on its floor plane with no height or shadow. See `docs/STAIRS.md`.
|
||||
|
||||
## Editor chrome and contextual controls
|
||||
|
||||
Every editor uses one stable primary `.editbar`. Its `.editbar-tools` contains
|
||||
|
||||
+21
-9
@@ -16,7 +16,7 @@ coordinate system.
|
||||
|
||||
## Principle
|
||||
|
||||
1. **Coordinates keep their meaning.** Rooms, openings, decor and
|
||||
1. **Coordinates keep their meaning.** Rooms, openings, decor, stairs and
|
||||
device positions are still stored normalised. `1.0` is still the
|
||||
same distance it always was; `cell_cm` still ties a grid cell to
|
||||
real centimetres. **No data migration.** An existing plan opens as
|
||||
@@ -56,8 +56,10 @@ Negative zero becomes positive zero.
|
||||
|
||||
The lattice allow-list covers room outlines/extents, exact wall endpoints,
|
||||
opening `x/y`, decor origins/sizes/endpoints, drafts, partitions, columns, open
|
||||
spans and layout `x/y`. Angles, opening length/host `t`, decor scale and
|
||||
backdrop transforms retain the nine-decimal scalar contract. The traversal
|
||||
spans and layout `x/y`. Angles, opening length/host `t`, decor scale, backdrop
|
||||
transforms and every stair transform field retain the nine-decimal scalar
|
||||
contract. Stairs are deliberately excluded even when a value is near a grid
|
||||
node: their continuous wall-face magnet position is authored data. The traversal
|
||||
deliberately excludes `cell_cm`, `plan_aspect`, `view_box`,
|
||||
physical centimetre fields, colours/opacities/live values, presentation scales
|
||||
and vacuum affine calibration. Unknown/future numeric fields round-trip
|
||||
@@ -113,6 +115,7 @@ the exact target atomically without exposing a default-fit frame.
|
||||
| `_GEOM` (room x/y, poly points, opening x/y, `view_box` origin) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
|
||||
| `_EXTENT` (room w/h, `view_box` w/h) | `0.001 .. 4` | `0.001 .. 5000` | size — strictly positive |
|
||||
| `_NORM` (decor x/y/w/h) | `-1 .. 2` | `-5000 .. 5000` | coordinate |
|
||||
| stair `x/y`, `length/width/radius` | — | `-5000 .. 5000`; sizes positive | continuous transform |
|
||||
| opening `length` | `0.001 .. 1` | `0.001 .. 5000` | size — strictly positive |
|
||||
|
||||
`+/-5000` is **garbage insurance, not a frame**. At the historical compatibility
|
||||
@@ -132,6 +135,7 @@ tested. Input is a list of **items**, one per drawn/placed object:
|
||||
* the backdrop image rectangle, when the space has one;
|
||||
* every opening (door/window/gate) end-to-end segment;
|
||||
* every decor shape;
|
||||
* every stair, using its complete rotated rectangle or circular footprint;
|
||||
* every device the layout actually places in this space **and that the card
|
||||
actually draws** — a HIDDEN device (docs/FILTERING.md) is not content: the
|
||||
frame is presentation, and an object nobody can see must not decide what the
|
||||
@@ -276,7 +280,7 @@ produce a zero-sized SVG `viewBox`.
|
||||
in the decision — `_clampView` alone says how far you may walk.
|
||||
* **Who owns the pointer.** A drag pans only when it starts on empty
|
||||
scene: the room-resize handles, device badges, openings, room labels
|
||||
and the decor shapes take the pointer first (`_stagePointerDown`
|
||||
stairs and the decor shapes take the pointer first (`_stagePointerDown`
|
||||
bails out on them), and a drawing tool that consumes the press —
|
||||
decor line/rect/ellipse/text — bails out too. Two fingers are always
|
||||
a pinch, never a pan. On a **kiosk** screen at swipe zoom (`≤ 1`,
|
||||
@@ -526,10 +530,18 @@ exception to positional quantisation: it is continuous in both modes, with
|
||||
Shift selecting independent axes, while Shift on its rotation handle snaps to
|
||||
45°. Furniture placement and movement remain grid-bound.
|
||||
|
||||
Stairs use the continuous branch of this contract for their complete
|
||||
transform. Move and resize stay off-grid when authored there; Shift snaps only
|
||||
rotation to 45°. Wall magnet resolves the visible physical face (including
|
||||
half-thickness) and stair magnet resolves outer footprint-to-footprint contact.
|
||||
Neither save/load nor Optimize may replace that contact with a nearby lattice
|
||||
node.
|
||||
|
||||
### 9.5 «Оптимизировать планы» — explicit whole-plan maintenance
|
||||
|
||||
Existing and imported plans may still hold coordinates between the
|
||||
nodes. New editor operations cannot create more. General settings contain
|
||||
Existing and imported plans may still hold grid-bound coordinates between the
|
||||
nodes. Ordinary grid-bound editor operations do not create more; explicitly
|
||||
continuous objects are exempt. General settings contain
|
||||
a **Plan maintenance** group whose action previews and then repairs old
|
||||
data through all current passes: model upgrades, mandatory grid
|
||||
alignment, exact open-span canonicalisation and wall-interval compaction.
|
||||
@@ -585,9 +597,9 @@ either store changes, commits both revisions, and retains one snapshot.
|
||||
has changed since the optimization. A crash between store writes is
|
||||
completed from the intent on the next integration setup.
|
||||
|
||||
The grid pass deliberately excludes the complete transform of `furniture` and
|
||||
uploaded `image` decor. Their position, size and rotation are continuously
|
||||
authored values (#383), so changing even one of those fields would make
|
||||
The grid pass deliberately excludes the complete transform of `furniture`,
|
||||
uploaded `image` decor and `spaces[].stairs[]`. Their position, size and
|
||||
rotation are continuously authored values (#383, #663), so changing even one of those fields would make
|
||||
Optimize create debt from a normal editor operation. Other decor kinds and
|
||||
storage-level numeric canonicalization keep their existing grid contract
|
||||
(#477).
|
||||
|
||||
+10
-1
@@ -2,6 +2,16 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- The Plan editor can now place straight and one-turn spiral stair objects with
|
||||
physical sizes, automatic 30 cm treads and an ascent arrow. Stairs move,
|
||||
resize, rotate and snap continuously to wall faces or each other; their
|
||||
footprint is deducted from clean room area without cutting the visible
|
||||
floor. An optional one-way target turns a stair into safe floor navigation
|
||||
in View, while gestures, broken targets and fixed-floor cards remain inert.
|
||||
The target floor is never changed automatically, and the first 2.5D version
|
||||
stays flat on the floor plane
|
||||
([#663](https://github.com/Matysh/houseplan-card/issues/663)).
|
||||
|
||||
## v1.78.0-beta.5 — 2026-09-26
|
||||
|
||||
- In the 2.5D plan the metrics row under a room name (temperature, humidity,
|
||||
@@ -22,7 +32,6 @@
|
||||
footer button as the only clickable control. The regression came with the
|
||||
overlapping-marker fix in v1.76.0-beta.2
|
||||
([#664](https://github.com/Matysh/houseplan-card/issues/664)).
|
||||
|
||||
## v1.78.0-beta.4 — 2026-09-26
|
||||
|
||||
- The main toolbar is steadier and more compact: its editor close **×** now
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
- Основная панель стала компактнее и перестала дёргаться: крестик **×**
|
||||
|
||||
@@ -112,6 +112,28 @@ An older frontend ignores the field and shows Flat. An older backend preserves
|
||||
it through the unknown-settings policy. Full backup/import carries `settings`
|
||||
whole, so the value survives.
|
||||
|
||||
## Stairs (#663)
|
||||
|
||||
`spaces[].stairs[]` is an optional bounded (250 records per space)
|
||||
discriminated collection. `kind: "straight"` stores positive `length` and
|
||||
`width` plus `direction: "forward" | "backward"`; `kind: "spiral"` stores a
|
||||
positive `radius` plus `direction: "clockwise" | "counterclockwise"`. Both
|
||||
variants carry stable `id`, continuous normalized centre `x/y`, scalar
|
||||
`angle`, and an optional nullable `target_space_id`.
|
||||
|
||||
Stair transforms follow furniture's continuous contract. Config writes apply
|
||||
only the nine-decimal scalar cleanup to position, size and angle; they do not
|
||||
use near-lattice snapping, and Optimize does not move them. Full backup,
|
||||
support data and diagnostics retain the bounded records. Full import remaps a
|
||||
known target space id and clears a target that was not imported; single-space
|
||||
transfer cannot invent a link to an external floor. Deleting a target space
|
||||
clears incoming links without deleting the source stair.
|
||||
|
||||
The collection is additive and requires no model/store version migration. A
|
||||
current frontend renders and edits it; a legacy frontend ignores the objects.
|
||||
The current backend preserves valid records through unrelated writes, while a
|
||||
backend predating the field must not be used to edit a newer configuration.
|
||||
|
||||
## Sun-ray window face (#577)
|
||||
|
||||
`settings.sun_ray_origin` is an optional global enum: `inner` or `outer`.
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -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: задачи, приоритеты, решения и статусы выполнения |
|
||||
|
||||
Старые тематические документы в этом каталоге остаются инженерными спецификациями и историей решений. При расхождении пользовательского описания с интерфейсом текущей версии приоритет имеет новое руководство, а при расхождении с фактическим поведением — код текущей версии.
|
||||
|
||||
@@ -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.
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
Deleting a room also clears that exact room from direct device assignments and
|
||||
|
||||
@@ -779,6 +779,7 @@ Undo оптимизации.
|
||||
| Стены | Непрерывную цепочку стен; при замыкании предлагает комнаты, при выходе сохраняет независимые стены | Площадь появляется только у подтверждённой комнаты | Положительная толщина блокирует свет; нулевая следует настройке пространства | Частичное перекрытие комнат запрещено; отдельных инструментов «Перегородка» и «Граница» нет |
|
||||
| Колонна | Квадратную или круглую опору | Не меняет площадь комнаты | Блокирует свет внутри своей формы | Имеет одну форму/размер/поворот, но не является стеной или комнатой |
|
||||
| Проём | Дверь, окно, открытый проём или ворота на стене комнаты либо законченном независимом отрезке | Не меняет площадь | Дверь/ворота пропускают свет между полами, открытый проём — всегда; только наружное окно комнаты может давать солнечный луч | Должен целиком помещаться на одном подходящем отрезке стены |
|
||||
| Лестница | Прямой марш или один оборот винтовой лестницы с автоматическим шагом ступеней 30 см и стрелкой подъёма | Вычитает из чистой площади только пересечение своего габарита | Не влияет на Glow, солнце и стены | Существует только на этом этаже; необязательная корректная цель превращает её в ссылку в Просмотре |
|
||||
|
||||
Остальные операции меняют уже созданную геометрию:
|
||||
|
||||
@@ -790,6 +791,17 @@ Undo оптимизации.
|
||||
| Толщина | Задаёт толщину 0–100 см выбранному участку или всем стенам комнаты |
|
||||
| Удалить комнату | Открывает выбор: удалить комнату и оставить её физические стены либо удалить комнату вместе с ними; общие стены остаются всегда |
|
||||
|
||||
Выберите **Лестница → Прямая** или **Лестница → Винтовая**, затем кликните по
|
||||
плану. Выбранная лестница перемещается и меняет размер плавно, как мебель;
|
||||
`Shift` привязывает поворот к 45°. Магнит ставит её к видимой физической грани
|
||||
стены или вплотную к другой лестнице, а **Оптимизировать планы** сохраняет этот
|
||||
continuous-transform без привязки к сетке. Двойной клик открывает размеры,
|
||||
направление подъёма, угол и целевой этаж. В Просмотре чистый клик переходит на
|
||||
корректную цель и восстанавливает сохранённый вид этажа; хвост жеста, битая
|
||||
ссылка и карточка с закреплённым этажом ничего не делают. На целевом этаже
|
||||
объект автоматически не создаётся. В 2.5D символ остаётся плоским на полу.
|
||||
Подробный контракт — в [документе о лестницах](STAIRS.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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1379,6 +1379,76 @@
|
||||
"required": false,
|
||||
"type": "float"
|
||||
},
|
||||
"config.spaces[].stairs": {
|
||||
"required": false
|
||||
},
|
||||
"config.spaces[].stairs[]": {
|
||||
"allowExtra": true
|
||||
},
|
||||
"config.spaces[].stairs[].angle": {
|
||||
"max": 360.0,
|
||||
"min": -360.0,
|
||||
"opaque": "<function _finite>",
|
||||
"required": true
|
||||
},
|
||||
"config.spaces[].stairs[].direction": {
|
||||
"enum": [
|
||||
"backward",
|
||||
"clockwise",
|
||||
"counterclockwise",
|
||||
"forward"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
"config.spaces[].stairs[].id": {
|
||||
"maxLength": 64,
|
||||
"minLength": 1,
|
||||
"required": true,
|
||||
"type": "str"
|
||||
},
|
||||
"config.spaces[].stairs[].kind": {
|
||||
"enum": [
|
||||
"spiral",
|
||||
"straight"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
"config.spaces[].stairs[].length": {
|
||||
"max": 5000.0,
|
||||
"min": 1e-07,
|
||||
"opaque": "<function _finite>",
|
||||
"required": false
|
||||
},
|
||||
"config.spaces[].stairs[].radius": {
|
||||
"max": 5000.0,
|
||||
"min": 1e-07,
|
||||
"opaque": "<function _finite>",
|
||||
"required": false
|
||||
},
|
||||
"config.spaces[].stairs[].target_space_id": {
|
||||
"nullable": true,
|
||||
"opaque": "Match('^[a-z0-9_-]{1,64}$', msg=None)",
|
||||
"required": false,
|
||||
"type": "str"
|
||||
},
|
||||
"config.spaces[].stairs[].width": {
|
||||
"max": 5000.0,
|
||||
"min": 1e-07,
|
||||
"opaque": "<function _finite>",
|
||||
"required": false
|
||||
},
|
||||
"config.spaces[].stairs[].x": {
|
||||
"max": 5000.0,
|
||||
"min": -5000.0,
|
||||
"opaque": "<function _finite>",
|
||||
"required": true
|
||||
},
|
||||
"config.spaces[].stairs[].y": {
|
||||
"max": 5000.0,
|
||||
"min": -5000.0,
|
||||
"opaque": "<function _finite>",
|
||||
"required": true
|
||||
},
|
||||
"config.spaces[].title": {
|
||||
"required": true,
|
||||
"type": "str"
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"hostRefs": 4880,
|
||||
"portPrivates": 94,
|
||||
"harnessPrivates": 100,
|
||||
"bundleBytes": 2540346
|
||||
"bundleBytes": 2567812
|
||||
}
|
||||
|
||||
@@ -72,6 +72,91 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
|
||||
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
||||
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
||||
const MUTANT_DEFINITIONS = [
|
||||
{
|
||||
id: 'stairs-continuous-transform-snaps-to-lattice',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/coordinate-canonicalization.test.mjs',
|
||||
because: '#663 AC2/AC9/AC11: a wall-face magnet is continuous and save/load or Optimize '
|
||||
+ 'must not pull the stair back to a grid node.',
|
||||
patches: [{
|
||||
file: 'src/coordinate-canonicalization.ts',
|
||||
find: " scalarFields(stair, ['x', 'y', 'length', 'width', 'radius', 'angle']);\n",
|
||||
replace: " latticeFields(stair, ['x', 'y', 'length', 'width', 'radius', 'angle']); // mutant\n",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'stairs-area-does-not-subtract-footprints',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/stairs.test.mjs',
|
||||
because: '#663 AC4: stair footprints reduce the clean room area once while leaving the '
|
||||
+ 'visible floor polygon intact.',
|
||||
patches: [{
|
||||
file: 'src/stairs.ts',
|
||||
find: ' return difference(source, ...footprints);\n',
|
||||
replace: ' return source; // mutant: footprints no longer affect clean area\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'stairs-view-pan-opens-target-floor',
|
||||
guard: 'node demo/smoke_stairs.mjs',
|
||||
because: '#663 AC6: a pan or pinch beginning on a stair must not be promoted to the '
|
||||
+ 'compatibility click that navigates to another floor.',
|
||||
patches: [{
|
||||
file: 'src/stairs-view.ts',
|
||||
find: ' if (!active || this.owner._suppressClick || this.suppressClick\n',
|
||||
replace: ' if (!active || this.suppressClick\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'stairs-fixed-floor-still-navigates',
|
||||
guard: 'node demo/smoke_stairs.mjs',
|
||||
because: '#663 AC8: fixed-floor cards show stairs but never promise or perform navigation.',
|
||||
patches: [{
|
||||
file: 'src/stairs-editor-model.ts',
|
||||
find: " if (fixedFloor) return 'fixed';\n",
|
||||
replace: ' if (false && fixedFloor) return \'fixed\'; // mutant: fixed-floor guard removed\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'stairs-link-auto-creates-target-object',
|
||||
guard: 'node demo/smoke_stairs.mjs',
|
||||
because: '#663 AC5: assigning a target is a one-way link and must not invent a mirrored '
|
||||
+ 'stair on the destination floor.',
|
||||
patches: [{
|
||||
file: 'src/stairs-editor.ts',
|
||||
find: ' this.write(this.stairs.map((item) => item.id === next.id ? next : item));\n'
|
||||
+ ' this.owner._recordGeometry(this.owner._t(\'history.stair_edit\'), before);\n',
|
||||
replace: ' this.write(this.stairs.map((item) => item.id === next.id ? next : item));\n'
|
||||
+ ' const mirror = this.owner._model.find((item) => item.id === dialog.targetSpaceId);\n'
|
||||
+ ' if (mirror) mirror.stairs = [...(mirror.stairs || []), { ...next, id: `${next.id}-mirror` }];\n'
|
||||
+ ' this.owner._recordGeometry(this.owner._t(\'history.stair_edit\'), before);\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'stairs-tread-count-depends-on-render-scale',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/stairs.test.mjs',
|
||||
because: '#663 AC3: 30 cm is a physical interval; viewport scale may change pixels but '
|
||||
+ 'must not change the number or placement of treads.',
|
||||
patches: [{
|
||||
file: 'src/stairs.ts',
|
||||
find: ' const treadN = cmToNorm(STAIR_TREAD_CM, cellCm);\n',
|
||||
replace: ' const treadN = cmToNorm(STAIR_TREAD_CM, cellCm) * NORM_W / scale; // mutant\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'stairs-backend-allows-251-items',
|
||||
guard: 'node scripts/backend-test-guard.mjs '
|
||||
+ 'issue_663_stair_schema_is_discriminated_bounded_and_forward_compatible '
|
||||
+ 'tests_backend/test_validation.py',
|
||||
because: '#663 AC13: persisted stair collections are bounded at 250 before render and '
|
||||
+ 'support/diagnostic paths can receive them.',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/validation.py',
|
||||
find: ' vol.Optional("stairs"): vol.All([STAIR_SCHEMA], vol.Length(max=MAX_STAIRS)),\n',
|
||||
replace: ' vol.Optional("stairs"): vol.All([STAIR_SCHEMA], vol.Length(max=MAX_STAIRS + 1)), # mutant\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'acl-readonly-group-becomes-writer',
|
||||
guard: 'node scripts/backend-test-guard.mjs '
|
||||
@@ -6648,6 +6733,7 @@ const MUTANT_DEFINITIONS = [
|
||||
file: 'src/space-render.ts',
|
||||
find: ' ${passageGlowTunnels}\n'
|
||||
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
|
||||
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
|
||||
+ ' ${glowPools}\n ${wallUnion',
|
||||
replace: ' ${passageGlowTunnels}\n'
|
||||
+ ' ${!space.bg && !disp.showNames ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
|
||||
@@ -6656,6 +6742,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ ' data-area=${room.area || nothing} x=${center[0]} y=${center[1]}>${room.name}</text>`;\n'
|
||||
+ ' })}</g>` : nothing}\n'
|
||||
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
|
||||
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
|
||||
+ ' ${glowPools}\n'
|
||||
+ ' ${wallUnion',
|
||||
}],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Dependency-free coordinate constants shared by low-level geometry modules.
|
||||
* Keep this leaf free of model/render imports: modules such as stairs are
|
||||
* consumed by space-geometry itself and must not form an ESM initialisation
|
||||
* cycle through that higher-level facade.
|
||||
*/
|
||||
export const NORM_W = 1000;
|
||||
export const CANVAS_LIMIT = 5000;
|
||||
export const GRID_N = 240;
|
||||
export const GRID_PITCH = NORM_W / GRID_N;
|
||||
export const GRID_STEP_N = 1 / GRID_N;
|
||||
+2
-2
@@ -27,13 +27,13 @@ export const lruWrite = <K, V>(cache: Map<K, V>, key: K, value: V, limit: number
|
||||
};
|
||||
|
||||
export type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize'
|
||||
| 'opening' | 'wallthick' | 'delroom';
|
||||
| 'opening' | 'stairs' | 'wallthick' | 'delroom';
|
||||
export type DecorTool = 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text'
|
||||
| 'furniture' | 'image' | 'erase';
|
||||
|
||||
const MARKUP_TOOLS = new Set<MarkupTool>([
|
||||
'select', 'draw', 'column', 'merge', 'split', 'resize',
|
||||
'opening', 'wallthick', 'delroom',
|
||||
'opening', 'stairs', 'wallthick', 'delroom',
|
||||
]);
|
||||
|
||||
/** Warm viewport may contain a tool token written by an older bundle. */
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { floorMinusBodies, geometryArea, polyclipPathD } from './physical-geometry';
|
||||
import { geometryAreaMinusStairs } from './stairs';
|
||||
import type { RoomCfg, SpaceModel } from './types';
|
||||
import { lruRead, lruWrite } from './card-runtime';
|
||||
import type { Geom } from 'polyclip-ts';
|
||||
|
||||
export type CleanFloorResult = {
|
||||
floor: number[][];
|
||||
geom: Geom | null;
|
||||
path: string;
|
||||
area: number;
|
||||
};
|
||||
|
||||
export function cleanFloorForRoom(input: {
|
||||
room: RoomCfg;
|
||||
floor: number[][];
|
||||
space?: SpaceModel;
|
||||
configEpoch: number;
|
||||
resizePreview: boolean;
|
||||
cache: Map<string, CleanFloorResult>;
|
||||
physicalBodies(space: SpaceModel): number[][][];
|
||||
}): CleanFloorResult {
|
||||
const { room, floor, space } = input;
|
||||
if (!space) return {
|
||||
floor, geom: null, path: '', area: geometryArea([[[...floor, floor[0]]]]),
|
||||
};
|
||||
const roomKey = room.id || `#${space.rooms.indexOf(room)}`;
|
||||
const key = `${space.id}|${input.configEpoch}|${roomKey}`;
|
||||
if (!input.resizePreview) {
|
||||
const cached = lruRead(input.cache, key);
|
||||
if (cached.hit) return cached.value;
|
||||
}
|
||||
const xs = floor.map((point) => point[0]);
|
||||
const ys = floor.map((point) => point[1]);
|
||||
const box = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
|
||||
const candidates = input.physicalBodies(space).filter((body) => {
|
||||
const bx = body.map((point) => point[0]);
|
||||
const by = body.map((point) => point[1]);
|
||||
return Math.max(...bx) >= box[0] && Math.min(...bx) <= box[2]
|
||||
&& Math.max(...by) >= box[1] && Math.min(...by) <= box[3];
|
||||
});
|
||||
const geom = candidates.length ? floorMinusBodies(floor, candidates) : null;
|
||||
const result = {
|
||||
floor,
|
||||
geom,
|
||||
path: geom ? polyclipPathD(geom) : '',
|
||||
area: geometryAreaMinusStairs(geom || [[[...floor, floor[0]]]], space.stairs),
|
||||
};
|
||||
if (!input.resizePreview) lruWrite(input.cache, key, result, 600);
|
||||
return result;
|
||||
}
|
||||
@@ -357,6 +357,13 @@ export function canonicalizeConfigGeometryInPlace<T>(config: T): T {
|
||||
scalarFields(decor, ['scale', 'angle']);
|
||||
}
|
||||
}
|
||||
for (const stair of records(space.stairs)) {
|
||||
// #663 stairs inherit furniture's continuous transform contract. A wall
|
||||
// face may be an arbitrary half-thickness away from the grid, so only
|
||||
// remove insignificant JSON noise; never pull the magnet result onto a
|
||||
// lattice node.
|
||||
scalarFields(stair, ['x', 'y', 'length', 'width', 'radius', 'angle']);
|
||||
}
|
||||
|
||||
// Legacy-only read boundary; current v10 documents reject this field.
|
||||
for (const draft of records(space.room_drafts)) latticePoints(draft.points);
|
||||
|
||||
+37
-37
@@ -116,7 +116,7 @@ import {
|
||||
} from './config-reload-authority';
|
||||
import type { OptimisticAttempt } from './serialized-write-queue';
|
||||
import {
|
||||
COLUMN_MAX_CM, directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings,
|
||||
COLUMN_MAX_CM, directionalOccluders, floorMinusBodies, geometryOuterRings,
|
||||
polyclipPathD, physicalBodyParts, type PartitionOpeningCut,
|
||||
} from './physical-geometry';
|
||||
import { partitionOpeningFace, resolvePartitionOpeningCompat } from './partition-openings';
|
||||
@@ -265,6 +265,8 @@ import {
|
||||
type HaBindingStatus, type HaRegistrySnapshot,
|
||||
} from './ha-binding-status';
|
||||
import type { DecorShape, DecorStyle } from './editors/decor/types';
|
||||
import { StairViewRuntime, type StairViewHostPort } from './stairs-view';
|
||||
import { cleanFloorForRoom, type CleanFloorResult } from './clean-floor';
|
||||
import {
|
||||
DECOR_ASSETS_API_VERSION, decorAssetIds, projectDecorImage,
|
||||
resolveDecorAssets, type DecorAsset,
|
||||
@@ -430,6 +432,7 @@ interface SpaceGeometryState {
|
||||
partitions?: PartitionCfg[];
|
||||
wall_columns?: WallColumnCfg[];
|
||||
decor?: DecorShape[];
|
||||
stairs?: import('./stairs').Stair[];
|
||||
plan_transform: {
|
||||
plan_x?: number; plan_y?: number; plan_scale?: number;
|
||||
plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number;
|
||||
@@ -1675,6 +1678,7 @@ export class HouseplanCard extends LitElement {
|
||||
return this._mode === 'plan';
|
||||
}
|
||||
private _tool: MarkupTool = 'draw';
|
||||
private _stairsView = new StairViewRuntime(this as unknown as StairViewHostPort);
|
||||
/** UX-04: one named, 50-step command history for every plan-geometry tool. */
|
||||
private _geometryHistory = new CommandStack<SpaceGeometryState>(50);
|
||||
/** #74: independent session-local history for manual device placements. */
|
||||
@@ -1783,9 +1787,7 @@ export class HouseplanCard extends LitElement {
|
||||
/** Light cuts are type/floor-specific and differ from drawn masonry, but HA
|
||||
* state ticks must not rebuild independent-wall topology. */
|
||||
private _lightPhysicalBodiesCache: { key: string; all: number[][][] } | null = null;
|
||||
private _cleanFloorCache = new Map<string, {
|
||||
floor: number[][]; geom: any; path: string; area: number;
|
||||
}>();
|
||||
private _cleanFloorCache = new Map<string, CleanFloorResult>();
|
||||
private _innerContourCache = new Map<string, number[][] | null>();
|
||||
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
|
||||
private readonly _glowRuntimeHost: GlowRuntimeHost = {
|
||||
@@ -2891,6 +2893,10 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
if (!this._markup) return;
|
||||
if ((undo || redo) && inField) return; // keep native text-field history
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace')
|
||||
&& !inField && !inEditorSecondary && this._editorRuntime?.stairs.deleteSelected()) {
|
||||
e.preventDefault(); return;
|
||||
}
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && this._physicalSel
|
||||
&& !inField && !inEditorSecondary) {
|
||||
e.preventDefault();
|
||||
@@ -2904,6 +2910,7 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
if (undo) {
|
||||
e.preventDefault();
|
||||
if (this._editorRuntime?.stairs.undoActiveDrag()) return;
|
||||
if (this._resize?.dragging) {
|
||||
this._rszCancelDrag();
|
||||
return;
|
||||
@@ -2932,6 +2939,7 @@ export class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Escape') return;
|
||||
if (this._editorRuntime?.stairs.escape()) { e.preventDefault(); return; }
|
||||
if (this._physicalDrag || this._physicalRotate) {
|
||||
e.preventDefault();
|
||||
this._cancelPhysicalGesture();
|
||||
@@ -6732,6 +6740,7 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _stagePointerMove(ev: PointerEvent): void {
|
||||
if (this._editorRuntime?.stairs.pointerMove(ev)) return;
|
||||
if (this._physicalRotate?.pid === ev.pointerId) {
|
||||
this._physicalRotateMove(ev);
|
||||
return;
|
||||
@@ -6902,6 +6911,8 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
this._stairsView.pointerUp(ev);
|
||||
if (this._editorRuntime?.stairs.pointerUp(ev)) return;
|
||||
if (this._physicalDrag?.pid === ev.pointerId) {
|
||||
this._physicalUp(ev);
|
||||
return;
|
||||
@@ -7431,7 +7442,8 @@ export class HouseplanCard extends LitElement {
|
||||
void this._requestMode(mode, animate);
|
||||
return;
|
||||
}
|
||||
return this._editorRuntime._setMode(mode, animate);
|
||||
this._editorRuntime.stairs.beforeModeChange(mode); this._editorRuntime._setMode(mode, animate);
|
||||
this._editorRuntime.stairs.afterModeChange(); this._stairsView.clearGesture();
|
||||
}
|
||||
|
||||
/** Prime the Draw thickness field to 15 cm once per Plan session. */
|
||||
@@ -7696,8 +7708,10 @@ export class HouseplanCard extends LitElement {
|
||||
this._decorMove = null;
|
||||
this._dtDrag = null;
|
||||
this._bdDrag = null;
|
||||
this._stairsView.clearGesture();
|
||||
return;
|
||||
}
|
||||
this._editorRuntime.stairs.clearGesture();
|
||||
return this._editorRuntimeOrThrow()._clearGeometryGesture();
|
||||
}
|
||||
|
||||
@@ -7707,6 +7721,8 @@ export class HouseplanCard extends LitElement {
|
||||
this._flushHa();
|
||||
this._editorRuntime?._cancelPointerMove('markup-hover');
|
||||
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; this._doubleFit.clear();
|
||||
this._stairsView.pointerCancel(ev);
|
||||
if (this._editorRuntime?.stairs.pointerCancel(ev)) return;
|
||||
if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
|
||||
this._pointers.delete(ev.pointerId);
|
||||
if (this._pointers.size < 2) this._pinchStart = null;
|
||||
@@ -7714,6 +7730,9 @@ export class HouseplanCard extends LitElement {
|
||||
this._panStart = null;
|
||||
this._panLock = null;
|
||||
this._swipeStart = null;
|
||||
// Keep the compatibility click from this cancelled pointer blocked, but
|
||||
// do not poison the next deliberate gesture indefinitely.
|
||||
if (this._suppressClick) setTimeout(() => (this._suppressClick = false), 0);
|
||||
}
|
||||
if (this._pointers.size === 0) this._finishViewportGesture();
|
||||
}
|
||||
@@ -8580,7 +8599,7 @@ export class HouseplanCard extends LitElement {
|
||||
role: 'tool', invoke: () => this._activateOpeningPlacement('gate'),
|
||||
},
|
||||
],
|
||||
}];
|
||||
}, this._editorRuntimeOrThrow().stairs.toolbarGroup()];
|
||||
}
|
||||
|
||||
/** Stable target identity + current config epoch + operation revision. */
|
||||
@@ -8591,6 +8610,8 @@ export class HouseplanCard extends LitElement {
|
||||
if (this._mode === 'plan') {
|
||||
const sel = this._physicalSel;
|
||||
if (sel) return `${base}:selection:${sel.kind}:${sel.id}`;
|
||||
const stairKey = this._editorRuntime?.stairs.selectionKey();
|
||||
if (stairKey) return `${base}:${stairKey}`;
|
||||
return `${base}:tool:${this._tool}:${this._path.length}`;
|
||||
}
|
||||
if (this._mode === 'decor') {
|
||||
@@ -8618,7 +8639,7 @@ export class HouseplanCard extends LitElement {
|
||||
|| this._infoCard || this._rulesDialog || this._settingsDialog || this._supportDialog
|
||||
|| this._alignDialog || this._importDialog || this._kioskDialog || this._summary?.blocksOtherDialogs()
|
||||
|| this._backupExportDialog || this._backupImportDialog
|
||||
|| this._wallDialog);
|
||||
|| this._wallDialog || this._editorRuntime?.stairs.dialogOpen);
|
||||
}
|
||||
|
||||
private _renderEditorSecondary(): TemplateResult | typeof nothing {
|
||||
@@ -9474,37 +9495,13 @@ export class HouseplanCard extends LitElement {
|
||||
* rooms which touch only a small subset of independent bodies. */
|
||||
private _cleanFloor(
|
||||
room: RoomCfg, floor: number[][], space: SpaceModel | undefined = this._spaceModel(),
|
||||
): { floor: number[][]; geom: any; path: string; area: number } {
|
||||
if (!space) {
|
||||
return {
|
||||
floor, geom: null, path: '',
|
||||
area: geometryArea([[[...floor, floor[0]]]]),
|
||||
};
|
||||
}
|
||||
const roomKey = room.id || `#${space.rooms.indexOf(room)}`;
|
||||
const key = `${space.id}|${this._cfgEpoch}|${roomKey}`;
|
||||
if (!this._resize?.preview) {
|
||||
const cached = lruRead(this._cleanFloorCache, key);
|
||||
if (cached.hit) return cached.value;
|
||||
}
|
||||
const xs = floor.map((p) => p[0]), ys = floor.map((p) => p[1]);
|
||||
const box = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
|
||||
const candidates = this._physicalBodiesR(space).filter((body) => {
|
||||
const bx = body.map((p) => p[0]), by = body.map((p) => p[1]);
|
||||
return Math.max(...bx) >= box[0] && Math.min(...bx) <= box[2]
|
||||
&& Math.max(...by) >= box[1] && Math.min(...by) <= box[3];
|
||||
): CleanFloorResult {
|
||||
return cleanFloorForRoom({
|
||||
room, floor, space, configEpoch: this._cfgEpoch,
|
||||
resizePreview: !!this._resize?.preview,
|
||||
cache: this._cleanFloorCache,
|
||||
physicalBodies: (model) => this._physicalBodiesR(model),
|
||||
});
|
||||
const geom = candidates.length ? floorMinusBodies(floor, candidates) : null;
|
||||
const result = {
|
||||
floor,
|
||||
geom,
|
||||
path: geom ? polyclipPathD(geom) : '',
|
||||
area: geom ? geometryArea(geom) : geometryArea([[[...floor, floor[0]]]]),
|
||||
};
|
||||
if (!this._resize?.preview) {
|
||||
lruWrite(this._cleanFloorCache, key, result, 600);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Effective auto icon for a binding selected but not saved yet. */
|
||||
@@ -11069,6 +11066,8 @@ export class HouseplanCard extends LitElement {
|
||||
hide_decor visual-only: the decor editor must always paint
|
||||
stored shapes so they remain editable. */}
|
||||
${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer(undefined, view)}
|
||||
${this._mode === 'plan' && this._editorRuntime
|
||||
? this._editorRuntime.stairs.renderLayer() : this._stairsView.renderLayer()}
|
||||
${glowLayerVisible ? this._renderGlowLayer(space, disp, view) : nothing}
|
||||
${this._renderSunRays(space)}
|
||||
${this._editing ? svg`<g class="hp-editor-only-layer"
|
||||
@@ -11206,6 +11205,7 @@ export class HouseplanCard extends LitElement {
|
||||
${this._decorShapeDialog ? this._editorRuntime ? this._renderDecorShapeDialog() : nothing : nothing}
|
||||
${this._backdropDialog ? this._editorRuntime ? this._renderBackdropDialog() : nothing : nothing}
|
||||
${this._decorEraseConfirm ? this._editorRuntime ? this._renderDecorEraseConfirm() : nothing : nothing}
|
||||
${this._editorRuntime?.stairs.dialogOpen ? this._editorRuntime.stairs.renderDialog() : nothing}
|
||||
${this._spaceDialog
|
||||
? (this._onboardingRuntime || this._editorRuntime) ? this._renderSpaceDialog() : nothing
|
||||
: nothing}
|
||||
|
||||
@@ -222,7 +222,7 @@ import {
|
||||
type SupportDialogState,
|
||||
type SupportPreview,
|
||||
} from './support-feedback';
|
||||
import { renderBackdropGuard, renderPlanBackdropGuard, stagePlanFile, uploadPlanFile } from './backdrop-pick';
|
||||
import { renderBackdropGuard, renderPlanBackdropGuard, stagePlanFile, uploadPlanFile } from './backdrop-pick'; import { StairEditorRuntime, type StairEditorHostPort } from './stairs-editor';
|
||||
import { CommandStack } from './command-stack';
|
||||
import type { DeviceLayout, DevicePositionState } from './device-position-history';
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
@@ -333,12 +333,10 @@ const NORM_W = 1000; // side of the render space — the canvas is square (v1.48
|
||||
/** #313: one Thickness-tool hit — a room interval or independent masonry. */
|
||||
type WallThickSource = { kind: 'room' }
|
||||
| { kind: 'partition'; id: string };
|
||||
type WallThickHit = {
|
||||
a: number[]; b: number[]; roomId: string; segs: number[][];
|
||||
open: boolean; cm: number; source: WallThickSource;
|
||||
};
|
||||
type WallThickHit = { a: number[]; b: number[]; roomId: string; segs: number[][];
|
||||
open: boolean; cm: number; source: WallThickSource };
|
||||
|
||||
type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'wallthick' | 'delroom';
|
||||
type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'stairs' | 'wallthick' | 'delroom';
|
||||
type WallFaceCandidate = WallGraphFace & {
|
||||
split?: { roomId: string; mainPoly: number[][]; newPoly: number[][] };
|
||||
consumeAllActive?: boolean;
|
||||
@@ -375,7 +373,7 @@ interface SpaceGeometryState {
|
||||
open_spans?: OpenSpanEntry[];
|
||||
partitions?: PartitionCfg[];
|
||||
wall_columns?: WallColumnCfg[];
|
||||
decor?: DecorShape[];
|
||||
decor?: DecorShape[]; stairs?: import('./stairs').Stair[];
|
||||
plan_transform: {
|
||||
plan_x?: number; plan_y?: number; plan_scale?: number;
|
||||
plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number;
|
||||
@@ -803,6 +801,7 @@ export { EDITOR_LANGUAGE_RUNTIME }; // #627: see src/i18n/editor-language.ts
|
||||
|
||||
export class HouseplanEditorRuntime {
|
||||
public readonly languageRuntime = EDITOR_LANGUAGE_RUNTIME;
|
||||
public readonly stairs = new StairEditorRuntime(this.host as unknown as StairEditorHostPort);
|
||||
private _junctionBaselineCache = new WeakMap<object, {
|
||||
spaceId: string; fingerprint: string; violations: JunctionLimitViolation[];
|
||||
}>();
|
||||
@@ -1224,6 +1223,7 @@ public _activateMarkupTool(tool: MarkupTool): void {
|
||||
else this.host._resize.reset();
|
||||
}
|
||||
this.host._tool = tool;
|
||||
if (tool !== 'select' && tool !== 'stairs') this.stairs.clearSelection();
|
||||
if (tool === 'resize') this.host._resize.selectRoom(null);
|
||||
if (tool === 'wallthick') this.host._wallDialog = null;
|
||||
}
|
||||
@@ -1567,7 +1567,7 @@ public _geometrySnapshotFromConfig(config: any, spaceId: string): SpaceGeometryS
|
||||
...(Array.isArray((sp as any).wall_columns)
|
||||
? { wall_columns: copy((sp as any).wall_columns) }
|
||||
: {}),
|
||||
...(Array.isArray(sp.decor) ? { decor: copy(sp.decor) } : {}),
|
||||
...(Array.isArray(sp.decor) ? { decor: copy(sp.decor) } : {}), ...(Array.isArray(sp.stairs) ? { stairs: copy(sp.stairs) } : {}),
|
||||
plan_transform,
|
||||
};
|
||||
}
|
||||
@@ -1609,7 +1609,7 @@ public _restoreGeometryStateInConfig(
|
||||
}
|
||||
}
|
||||
const assign = (key: 'openings' | 'walls' | 'wall_segments' | 'open_spans'
|
||||
| 'partitions' | 'wall_columns' | 'decor', value: unknown): void => {
|
||||
| 'partitions' | 'wall_columns' | 'decor' | 'stairs', value: unknown): void => {
|
||||
if (value !== undefined) (sp as any)[key] = copy(value);
|
||||
else if (!(preserveIdentityHints && key === 'wall_segments')) delete (sp as any)[key];
|
||||
};
|
||||
@@ -1620,7 +1620,7 @@ public _restoreGeometryStateInConfig(
|
||||
assign('open_spans', state.open_spans);
|
||||
assign('partitions', state.partitions);
|
||||
assign('wall_columns', state.wall_columns);
|
||||
assign('decor', state.decor);
|
||||
assign('decor', state.decor); assign('stairs', state.stairs);
|
||||
if (preserveIdentityHints) {
|
||||
for (const opening of sp.openings || []) {
|
||||
const old: any = oldOpenings.get(opening.id);
|
||||
@@ -2008,7 +2008,7 @@ public _stagePointerCancel(ev: PointerEvent): void {
|
||||
if (this.host._pointers.size < 2) this.host._pinchStart = null;
|
||||
if (this.host._pointers.size === 0) {
|
||||
this.host._panStart = null;
|
||||
this.host._panLock = null;
|
||||
this.host._panLock = null; if (this['host']._suppressClick) setTimeout(() => (this['host']._suppressClick = false), 0);
|
||||
}
|
||||
if (this.host._viewportGestureDirty && this.host._pointers.size === 0) {
|
||||
this.host._viewportGestureDirty = false;
|
||||
@@ -2231,7 +2231,7 @@ public _markupClick(ev: MouseEvent): void {
|
||||
if (path.some((n) => n?.classList?.contains?.('physical-hit'))) return;
|
||||
const raw = this._svgPoint(ev);
|
||||
if (this.host._tool === 'select') {
|
||||
this.host._physicalSel = null;
|
||||
this.host._physicalSel = null; this.stairs.clearSelection();
|
||||
return;
|
||||
}
|
||||
if (this.host._tool === 'resize') {
|
||||
@@ -2266,6 +2266,7 @@ public _markupClick(ev: MouseEvent): void {
|
||||
this._columnClick(raw);
|
||||
return;
|
||||
}
|
||||
if (this['host']._tool === 'stairs') { this.stairs.placeAt(raw); return; }
|
||||
// Walls: every completed segment is immediately an ordinary independent wall.
|
||||
this.host._wallRepairDiagnostic = null;
|
||||
const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -30,6 +30,28 @@
|
||||
"title.markup_column": "Колонна: кликните точку сетки для квадратной колонны",
|
||||
"markup.partition": "Перегородка",
|
||||
"markup.column": "Колонна",
|
||||
"markup.stairs": "Лестница",
|
||||
"stairs.straight": "Прямая",
|
||||
"stairs.spiral": "Винтовая",
|
||||
"stairs.properties": "Свойства лестницы",
|
||||
"stairs.type": "Тип",
|
||||
"stairs.length": "Длина",
|
||||
"stairs.width": "Ширина",
|
||||
"stairs.radius": "Радиус",
|
||||
"stairs.rotation": "Поворот",
|
||||
"stairs.direction": "Направление подъёма",
|
||||
"stairs.forward": "Вперёд",
|
||||
"stairs.backward": "Назад",
|
||||
"stairs.clockwise": "По часовой стрелке",
|
||||
"stairs.counterclockwise": "Против часовой стрелки",
|
||||
"stairs.target": "Ведёт на этаж",
|
||||
"stairs.no_target": "Не выбран",
|
||||
"stairs.target_warning": "Выберите другой существующий этаж, чтобы включить переход.",
|
||||
"stairs.fixed_floor": "В карточке с фиксированным этажом переход отключён.",
|
||||
"history.stair_add": "Добавить лестницу",
|
||||
"history.stair_edit": "Изменить лестницу",
|
||||
"history.stair_move": "Переместить лестницу",
|
||||
"history.stair_delete": "Удалить лестницу",
|
||||
"markup.hint_column": "кликните точку сетки, чтобы поставить квадратную колонну",
|
||||
"history.wall_segment": "Добавление отрезка стены",
|
||||
"history.column_add": "Добавление колонны",
|
||||
@@ -281,6 +303,7 @@
|
||||
"space.copy_error_partitions_limit": "В этом пространстве слишком много стен для копирования.",
|
||||
"space.copy_error_openings_limit": "В этом пространстве слишком много проёмов для копирования.",
|
||||
"space.copy_error_decor_limit": "В этом пространстве слишком много элементов подложки для копирования.",
|
||||
"space.copy_error_stairs_limit": "В этом пространстве слишком много лестниц для копирования.",
|
||||
"space.copy_error_columns_limit": "В этом пространстве слишком много колонн для копирования.",
|
||||
"space.copy_error_opening_host": "Один из проёмов больше не привязан к стене. Исправьте или удалите его перед копированием.",
|
||||
"space.copy_error_geometry": "Копия создаст небезопасную геометрию стен. Исправьте исходное пространство и повторите попытку.",
|
||||
|
||||
+29
-6
@@ -5,7 +5,7 @@ import { canonicalFurnitureId } from '../furniture-id';
|
||||
import { formatArea } from '../area-format';
|
||||
import { roomPoly } from '../logic';
|
||||
import {
|
||||
floorMinusBodies, geometryAllRings, geometryArea, geometryOuterRings, physicalBodyParts,
|
||||
floorMinusBodies, geometryAllRings, geometryOuterRings, physicalBodyParts,
|
||||
pointInPhysicalGeometry,
|
||||
} from '../physical-geometry';
|
||||
import {
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
type GeometryOpeningProjection,
|
||||
} from '../plan-geometry-preflight';
|
||||
import { labelPos, GRID_PITCH, GRID_STEP_N, NORM_W } from '../space-geometry';
|
||||
import {
|
||||
cachedStairRenderGeometry, geometryAreaMinusStairs, stairOutline, type Stair,
|
||||
} from '../stairs';
|
||||
import type { ServerConfig, SpaceModel } from '../types';
|
||||
import {
|
||||
innerContourForRoom, openingInnerFaceOffset, wallBodiesGeometry,
|
||||
@@ -62,6 +65,7 @@ export interface PdfRawSpace {
|
||||
cell_cm?: unknown;
|
||||
walls?: WallEntry[];
|
||||
decor?: DecorShape[];
|
||||
stairs?: Stair[];
|
||||
settings?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -301,13 +305,10 @@ function preparePdfScene(input: PdfSceneInput): PreparedPdfScene {
|
||||
const localBodies = !Number.isFinite(cachedArea)
|
||||
? bodiesOverlappingRing(contour, built.extras) : [];
|
||||
const candidates = localBodies.length ? floorMinusBodies(contour, localBodies) : null;
|
||||
const cleanGeometry = candidates || [[[...contour, contour[0]]]];
|
||||
const areaUnits = Number.isFinite(cachedArea) && (cachedArea as number) >= 0
|
||||
? cachedArea as number
|
||||
: candidates ? geometryArea(candidates) : Math.abs(
|
||||
contour.reduce((sum, point, index) => {
|
||||
const next = contour[(index + 1) % contour.length];
|
||||
return sum + point[0] * next[1] - next[0] * point[1];
|
||||
}, 0) / 2);
|
||||
: geometryAreaMinusStairs(cleanGeometry, input.space.stairs);
|
||||
roomAreas.set(room, areaUnits);
|
||||
}
|
||||
const architectureRings = built.geometry.status === 'failed-core'
|
||||
@@ -315,6 +316,7 @@ function preparePdfScene(input: PdfSceneInput): PreparedPdfScene {
|
||||
: built.geometry.components.flatMap((component) => geometryAllRings(component.geom));
|
||||
const allBounds = [...architectureRings, ...built.zero.lines.map((line) => [[line[0], line[1]], [line[2], line[3]]])];
|
||||
if (input.options.decor) allBounds.push(...decorBounds(input.rawSpace, true));
|
||||
allBounds.push(...input.space.stairs.map((stair) => stairOutline(stair)));
|
||||
for (const raster of input.rasters || []) allBounds.push(rotatedRectRing(
|
||||
raster.x, raster.y, raster.drawWidth, raster.drawHeight, raster.angle,
|
||||
));
|
||||
@@ -460,6 +462,27 @@ function buildPdfCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
// Stairs are plan geometry, not optional decor. Keep their PDF symbol and
|
||||
// physical area semantics aligned with the two on-screen renderers.
|
||||
const stairMatrix = {
|
||||
a: pointPerUnit, b: 0, c: 0, d: pointPerUnit,
|
||||
e: left - bounds.minX * pointPerUnit,
|
||||
f: top - bounds.minY * pointPerUnit,
|
||||
};
|
||||
for (const stair of input.space.stairs) {
|
||||
const geometry = cachedStairRenderGeometry(stair, built.cellCm);
|
||||
commands.push({
|
||||
kind: 'path', rings: [geometry.outline.map(pt)], stroke: INK, width: 0.25 * MM,
|
||||
});
|
||||
for (const tread of geometry.treads) commands.push({
|
||||
kind: 'line', points: [pt(tread.a), pt(tread.b)], stroke: INK, width: 0.2 * MM,
|
||||
});
|
||||
commands.push({
|
||||
kind: 'vector', ops: transformSvgPath(geometry.arrowPath, stairMatrix),
|
||||
stroke: INK, width: 0.3 * MM,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.options.dimensions && built.geometry.roomGeom) {
|
||||
for (const [ringIndex, ring] of geometryOuterRings(built.geometry.roomGeom).entries()) {
|
||||
const epsilon = dimensionEpsilonUnits(cmPerUnit);
|
||||
|
||||
@@ -31,6 +31,7 @@ const ERROR_KEYS: Record<SpaceCopyErrorCode, I18nKey> = {
|
||||
partitions_limit: 'space.copy_error_partitions_limit',
|
||||
openings_limit: 'space.copy_error_openings_limit',
|
||||
decor_limit: 'space.copy_error_decor_limit',
|
||||
stairs_limit: 'space.copy_error_stairs_limit',
|
||||
columns_limit: 'space.copy_error_columns_limit',
|
||||
opening_host_missing: 'space.copy_error_opening_host',
|
||||
opening_host_unknown: 'space.copy_error_opening_host',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const SPACE_COPY_LIMITS = Object.freeze({
|
||||
openings: 500,
|
||||
decor: 1000,
|
||||
columns: 500,
|
||||
stairs: 250,
|
||||
});
|
||||
|
||||
export type SpaceCopyErrorCode =
|
||||
@@ -16,6 +17,7 @@ export type SpaceCopyErrorCode =
|
||||
| 'openings_limit'
|
||||
| 'decor_limit'
|
||||
| 'columns_limit'
|
||||
| 'stairs_limit'
|
||||
| 'opening_host_missing'
|
||||
| 'opening_host_unknown'
|
||||
| 'geometry_unsafe';
|
||||
@@ -113,11 +115,13 @@ export function createSpaceCopyCandidate(
|
||||
const openings = geometryList(source.openings);
|
||||
const decor = geometryList(source.decor);
|
||||
const columns = geometryList(source.wall_columns);
|
||||
const stairs = geometryList(source.stairs);
|
||||
const partitionCount = wallSegments.length + sourcePartitions.length;
|
||||
if (partitionCount > SPACE_COPY_LIMITS.partitions) throw new SpaceCopyError('partitions_limit');
|
||||
if (openings.length > SPACE_COPY_LIMITS.openings) throw new SpaceCopyError('openings_limit');
|
||||
if (decor.length > SPACE_COPY_LIMITS.decor) throw new SpaceCopyError('decor_limit');
|
||||
if (columns.length > SPACE_COPY_LIMITS.columns) throw new SpaceCopyError('columns_limit');
|
||||
if (stairs.length > SPACE_COPY_LIMITS.stairs) throw new SpaceCopyError('stairs_limit');
|
||||
|
||||
let serial = 0;
|
||||
const nextId = (prefix: string): string => `${prefix}${seed}${(serial++).toString(36)}`;
|
||||
@@ -164,6 +168,7 @@ export function createSpaceCopyCandidate(
|
||||
});
|
||||
const copiedDecor = decor.map((item) => ({ ...clone(item), id: nextId('cd') }));
|
||||
const copiedColumns = columns.map((item) => ({ ...clone(item), id: nextId('cc') }));
|
||||
const copiedStairs = stairs.map((item) => ({ ...clone(item), id: nextId('cs') }));
|
||||
|
||||
const newSpaceId = `s${seed}`;
|
||||
if (spaces.some((item) => item.id === newSpaceId)) throw new SpaceCopyError('source_invalid');
|
||||
@@ -181,6 +186,7 @@ export function createSpaceCopyCandidate(
|
||||
if (copiedOpenings.length) copiedSpace.openings = copiedOpenings;
|
||||
if (copiedDecor.length) copiedSpace.decor = copiedDecor;
|
||||
if (copiedColumns.length) copiedSpace.wall_columns = copiedColumns;
|
||||
if (copiedStairs.length) copiedSpace.stairs = copiedStairs;
|
||||
|
||||
const config = clone(input);
|
||||
config.spaces.splice(sourceIndex + 1, 0, copiedSpace);
|
||||
|
||||
@@ -8,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
|
||||
|
||||
@@ -60,6 +60,7 @@ import { geometryOpenings } from './plan-geometry-preflight';
|
||||
import { resolveDeviceAreaRelocations } from './device-area-relocation';
|
||||
import { projectDecorImage } from './decor-assets';
|
||||
import type { DecorShape } from './editors/decor/types';
|
||||
import { cachedStairRenderGeometry, stairOutline } from './stairs';
|
||||
import {
|
||||
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
|
||||
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
|
||||
@@ -381,6 +382,14 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
minX: Math.min(line[0], line[2]), minY: Math.min(line[1], line[3]),
|
||||
maxX: Math.max(line[0], line[2]), maxY: Math.max(line[1], line[3]),
|
||||
});
|
||||
for (const stair of space.stairs) {
|
||||
const outline = stairOutline(stair);
|
||||
const xs = outline.map((point) => point[0]), ys = outline.map((point) => point[1]);
|
||||
if (xs.length) placed.push({
|
||||
minX: Math.min(...xs), minY: Math.min(...ys),
|
||||
maxX: Math.max(...xs), maxY: Math.max(...ys),
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve architectural opening hosts before framing: tight house mode must
|
||||
// include their maximum painted envelope without consulting live state.
|
||||
@@ -893,6 +902,17 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
${renderOpeningVisibleGeometry(spec)}
|
||||
</g>`;
|
||||
});
|
||||
const stairShapes = space.stairs.map((stair) => {
|
||||
const geometry = cachedStairRenderGeometry(stair, cellCm);
|
||||
return svg`<g class="hp-stair" data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
|
||||
data-target-state="fixed"
|
||||
aria-hidden="true" pointer-events="none">
|
||||
<polygon class="hp-stair-outline" points=${geometry.outline.map((point) => point.join(',')).join(' ')}></polygon>
|
||||
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
|
||||
</g>`;
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}"
|
||||
@@ -941,6 +961,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
: nothing}
|
||||
${passageGlowTunnels}
|
||||
<g class="decorlayer" pointer-events="none">${decorImages}</g>
|
||||
<g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>
|
||||
${glowPools}
|
||||
${wallUnion
|
||||
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { GRID_N, NORM_W } from './canvas-constants';
|
||||
import type { Stair } from './stairs';
|
||||
|
||||
export const STAIR_MIN_N = 0.0005;
|
||||
|
||||
const finite = (value: unknown): number | null => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
};
|
||||
|
||||
const cmToNorm = (cm: number, cellCm: number): number => (
|
||||
Number(cm) / ((Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N)
|
||||
);
|
||||
|
||||
const normToCm = (value: number, cellCm: number): number => (
|
||||
Number(value) * (Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N
|
||||
);
|
||||
|
||||
export function normalizeStairAngle(value: unknown): number {
|
||||
const number = finite(value) ?? 0;
|
||||
const normalized = ((number % 360) + 360) % 360;
|
||||
return Number(normalized.toFixed(6));
|
||||
}
|
||||
|
||||
export function defaultStair(
|
||||
kind: Stair['kind'], x: number, y: number, cellCm: number, id: string,
|
||||
): Stair {
|
||||
if (kind === 'spiral') return {
|
||||
id, kind, x: x / NORM_W, y: y / NORM_W, angle: 0,
|
||||
direction: 'clockwise', radius: cmToNorm(90, cellCm), target_space_id: null,
|
||||
};
|
||||
return {
|
||||
id, kind, x: x / NORM_W, y: y / NORM_W, angle: 0,
|
||||
direction: 'forward', length: cmToNorm(240, cellCm), width: cmToNorm(100, cellCm),
|
||||
target_space_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function convertStairKind(stair: Stair, kind: Stair['kind']): Stair {
|
||||
if (stair.kind === kind) return { ...stair };
|
||||
if (stair.kind === 'straight') return {
|
||||
id: stair.id, kind: 'spiral', x: stair.x, y: stair.y, angle: stair.angle,
|
||||
direction: stair.direction === 'backward' ? 'counterclockwise' : 'clockwise',
|
||||
radius: Math.max(stair.length, stair.width) / 2,
|
||||
target_space_id: stair.target_space_id ?? null,
|
||||
};
|
||||
return {
|
||||
id: stair.id, kind: 'straight', x: stair.x, y: stair.y, angle: stair.angle,
|
||||
direction: stair.direction === 'counterclockwise' ? 'backward' : 'forward',
|
||||
length: stair.radius * 2, width: stair.radius * 2,
|
||||
target_space_id: stair.target_space_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const supportAlong = (stair: Stair, dx: number, dy: number, scale: number): number => {
|
||||
if (stair.kind === 'spiral') return stair.radius * scale;
|
||||
const bearing = stair.angle * Math.PI / 180;
|
||||
const ux = Math.cos(bearing), uy = Math.sin(bearing);
|
||||
const vx = -uy, vy = ux;
|
||||
return Math.abs(dx * ux + dy * uy) * stair.length * scale / 2
|
||||
+ Math.abs(dx * vx + dy * vy) * stair.width * scale / 2;
|
||||
};
|
||||
|
||||
/** Snap one independently movable footprint flush to the nearest other stair. */
|
||||
export function snapStairToStairs(
|
||||
stair: Stair,
|
||||
center: readonly number[],
|
||||
others: readonly Stair[],
|
||||
reach: number,
|
||||
scale = NORM_W,
|
||||
): [number, number] {
|
||||
let best: { x: number; y: number; gap: number } | null = null;
|
||||
const moved = { ...stair, x: center[0] / scale, y: center[1] / scale } as Stair;
|
||||
for (const other of others) {
|
||||
if (other.id === stair.id) continue;
|
||||
const ox = other.x * scale, oy = other.y * scale;
|
||||
const dx0 = center[0] - ox, dy0 = center[1] - oy;
|
||||
const distance = Math.hypot(dx0, dy0);
|
||||
if (!(distance > 1e-8)) continue;
|
||||
const dx = dx0 / distance, dy = dy0 / distance;
|
||||
const target = supportAlong(moved, dx, dy, scale)
|
||||
+ supportAlong(other, dx, dy, scale);
|
||||
const gap = Math.abs(distance - target);
|
||||
if (gap <= reach && (!best || gap < best.gap)) {
|
||||
best = { x: ox + dx * target, y: oy + dy * target, gap };
|
||||
}
|
||||
}
|
||||
return best ? [best.x, best.y] : [center[0], center[1]];
|
||||
}
|
||||
|
||||
export function stairPhysicalSizeCm(stair: Stair, cellCm: number): number[] {
|
||||
return stair.kind === 'straight'
|
||||
? [normToCm(stair.length, cellCm), normToCm(stair.width, cellCm)]
|
||||
: [normToCm(stair.radius, cellCm)];
|
||||
}
|
||||
|
||||
export function stairTargetState(
|
||||
stair: Stair, currentSpaceId: string, spaceIds: ReadonlySet<string>, fixedFloor: boolean,
|
||||
): 'active' | 'missing' | 'self' | 'deleted' | 'fixed' {
|
||||
if (fixedFloor) return 'fixed';
|
||||
if (!stair.target_space_id) return 'missing';
|
||||
if (stair.target_space_id === currentSpaceId) return 'self';
|
||||
return spaceIds.has(stair.target_space_id) ? 'active' : 'deleted';
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import { html, nothing, svg, type TemplateResult } from 'lit';
|
||||
import { resizeFurnitureTransform } from './furniture';
|
||||
import { FURN_WALL_CELLS, snapFurnitureToWall } from './furniture-placement';
|
||||
import {
|
||||
furnitureWallSurfacesFor, type FurnitureWallSurfaceSource,
|
||||
} from './furniture-wall-surface';
|
||||
import { strictNumber, type MarkupTool } from './card-runtime';
|
||||
import type { EditorToolbarGroup } from './editor-secondary';
|
||||
import type { I18nKey } from './i18n';
|
||||
import { clampCanvasN, NORM_W } from './space-geometry';
|
||||
import {
|
||||
convertStairKind, defaultStair, normalizeStairAngle, snapStairToStairs,
|
||||
stairPhysicalSizeCm, stairTargetState, STAIR_MIN_N,
|
||||
} from './stairs-editor-model';
|
||||
import {
|
||||
cachedStairRenderGeometry, MAX_STAIRS_PER_SPACE, stairList, type Stair,
|
||||
} from './stairs';
|
||||
import type { SpaceModel } from './types';
|
||||
import { cmToField, fieldToCm } from './wall-thickness';
|
||||
|
||||
type StairDialog = {
|
||||
id: string;
|
||||
kind: Stair['kind'];
|
||||
length: string;
|
||||
width: string;
|
||||
radius: string;
|
||||
angle: string;
|
||||
direction: Stair['direction'];
|
||||
targetSpaceId: string;
|
||||
};
|
||||
|
||||
type StairDrag = {
|
||||
pid: number;
|
||||
id: string;
|
||||
mode: 'move' | 'resize' | 'rotate';
|
||||
resizeX: -1 | 0 | 1;
|
||||
resizeY: -1 | 0 | 1;
|
||||
start: number[];
|
||||
original: Stair;
|
||||
before: unknown;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
/** Narrow internal seam owned by HouseplanCard. The cast at construction keeps
|
||||
* these implementation details private to the card while this module keeps the
|
||||
* stairs feature out of the already size-limited card core. */
|
||||
export interface StairEditorHostPort {
|
||||
hass: unknown;
|
||||
_mode: 'view' | 'plan' | 'devices' | 'decor';
|
||||
_tool: MarkupTool;
|
||||
_curSpaceCfg: unknown;
|
||||
_cfgEpoch: number;
|
||||
_cleanFloorCache: Map<unknown, unknown>;
|
||||
_modelCache: unknown;
|
||||
_gridPitch: number;
|
||||
_cellCm: number;
|
||||
_imperial: boolean;
|
||||
_model: SpaceModel[];
|
||||
_space: string;
|
||||
_hasFixedFloor: boolean;
|
||||
_suppressClick: boolean;
|
||||
requestUpdate(): void;
|
||||
_showToast(message: string): void;
|
||||
_t(key: I18nKey, vars?: Record<string, string | number>): string;
|
||||
_geometrySnapshot(): unknown;
|
||||
_recordGeometry(name: string, before: unknown): void;
|
||||
_saveConfigDebounced(): void;
|
||||
_cmToUnits(cm: number): number;
|
||||
_svgPoint(event: MouseEvent): number[];
|
||||
_tabClick(spaceId: string): void;
|
||||
_activateMarkupTool(tool: MarkupTool): void;
|
||||
}
|
||||
|
||||
export class StairEditorRuntime {
|
||||
private preset: Stair['kind'] = 'straight';
|
||||
private selectedId: string | null = null;
|
||||
private dialog: StairDialog | null = null;
|
||||
private drag: StairDrag | null = null;
|
||||
|
||||
public constructor(private readonly owner: StairEditorHostPort) {}
|
||||
|
||||
public get selected(): string | null { return this.selectedId; }
|
||||
public get dialogOpen(): boolean { return this.dialog !== null; }
|
||||
public get dragging(): boolean { return this.drag !== null; }
|
||||
|
||||
public clearSelection(): void {
|
||||
this.selectedId = null;
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
|
||||
public selectionKey(): string | null {
|
||||
return this.selectedId ? `selection:stair:${this.selectedId}` : null;
|
||||
}
|
||||
|
||||
private get stairs(): Stair[] {
|
||||
return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs);
|
||||
}
|
||||
|
||||
private write(stairs: Stair[]): void {
|
||||
const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null;
|
||||
if (!space) return;
|
||||
if (stairs.length) space.stairs = stairs;
|
||||
else delete space.stairs;
|
||||
this.owner._cfgEpoch++;
|
||||
this.owner._cleanFloorCache.clear();
|
||||
this.owner._modelCache = null;
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
|
||||
private withMagnet(stair: Stair, center: readonly number[]): Stair {
|
||||
let cx = center[0], cy = center[1], angle = stair.angle;
|
||||
const depth = (stair.kind === 'straight' ? stair.width : stair.radius * 2) * NORM_W;
|
||||
const wall = snapFurnitureToWall(
|
||||
cx, cy, depth,
|
||||
furnitureWallSurfacesFor(this.owner as unknown as FurnitureWallSurfaceSource),
|
||||
this.owner._gridPitch * FURN_WALL_CELLS, 0, [cx, cy],
|
||||
);
|
||||
if (wall) {
|
||||
cx = wall.cx;
|
||||
cy = wall.cy;
|
||||
if (stair.kind === 'straight') angle = wall.angle;
|
||||
}
|
||||
|
||||
const moved = { ...stair, x: cx / NORM_W, y: cy / NORM_W, angle } as Stair;
|
||||
[cx, cy] = snapStairToStairs(
|
||||
moved, [cx, cy], this.stairs, this.owner._gridPitch * FURN_WALL_CELLS,
|
||||
);
|
||||
return {
|
||||
...stair, x: clampCanvasN(cx / NORM_W), y: clampCanvasN(cy / NORM_W), angle,
|
||||
} as Stair;
|
||||
}
|
||||
|
||||
public activatePlacement(kind: Stair['kind']): void {
|
||||
this.preset = kind;
|
||||
this.selectedId = null;
|
||||
this.owner._activateMarkupTool('stairs');
|
||||
}
|
||||
|
||||
public placeAt(point: number[]): void {
|
||||
const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null;
|
||||
if (!space || this.stairs.length >= MAX_STAIRS_PER_SPACE) {
|
||||
this.owner._showToast(this.owner._t('toast.physical_limit'));
|
||||
return;
|
||||
}
|
||||
const before = this.owner._geometrySnapshot();
|
||||
const id = `stair-${crypto.randomUUID?.()
|
||||
|| `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`;
|
||||
const stair = this.withMagnet(
|
||||
defaultStair(this.preset, point[0], point[1], this.owner._cellCm, id), point,
|
||||
);
|
||||
this.write([...this.stairs, stair]);
|
||||
this.selectedId = id;
|
||||
this.owner._recordGeometry(this.owner._t('history.stair_add'), before);
|
||||
this.owner._saveConfigDebounced();
|
||||
}
|
||||
|
||||
private openDialog(stair: Stair): void {
|
||||
const sizes = stairPhysicalSizeCm(stair, this.owner._cellCm);
|
||||
this.dialog = {
|
||||
id: stair.id,
|
||||
kind: stair.kind,
|
||||
length: cmToField(stair.kind === 'straight' ? sizes[0] : 100, this.owner._imperial),
|
||||
width: cmToField(stair.kind === 'straight' ? sizes[1] : 100, this.owner._imperial),
|
||||
radius: cmToField(stair.kind === 'spiral' ? sizes[0] : 90, this.owner._imperial),
|
||||
angle: String(stair.angle),
|
||||
direction: stair.direction,
|
||||
targetSpaceId: stair.target_space_id || '',
|
||||
};
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
|
||||
private updateDialog(patch: Partial<StairDialog>): void {
|
||||
if (!this.dialog) return;
|
||||
this.dialog = { ...this.dialog, ...patch };
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
|
||||
private saveDialog(): void {
|
||||
const dialog = this.dialog;
|
||||
const current = this.stairs.find((item) => item.id === dialog?.id);
|
||||
if (!dialog || !current) return;
|
||||
const lengthCm = fieldToCm(dialog.length, this.owner._imperial);
|
||||
const widthCm = fieldToCm(dialog.width, this.owner._imperial);
|
||||
const radiusCm = fieldToCm(dialog.radius, this.owner._imperial);
|
||||
const angle = strictNumber(dialog.angle);
|
||||
if (angle == null || (dialog.kind === 'straight'
|
||||
? !(lengthCm && widthCm) : !radiusCm)) return;
|
||||
const before = this.owner._geometrySnapshot();
|
||||
let next = convertStairKind(current, dialog.kind);
|
||||
if (next.kind === 'straight') next = {
|
||||
...next,
|
||||
length: Math.max(STAIR_MIN_N, this.owner._cmToUnits(lengthCm!) / NORM_W),
|
||||
width: Math.max(STAIR_MIN_N, this.owner._cmToUnits(widthCm!) / NORM_W),
|
||||
direction: dialog.direction === 'backward' ? 'backward' : 'forward',
|
||||
};
|
||||
else next = {
|
||||
...next,
|
||||
radius: Math.max(STAIR_MIN_N, this.owner._cmToUnits(radiusCm!) / NORM_W),
|
||||
direction: dialog.direction === 'counterclockwise' ? 'counterclockwise' : 'clockwise',
|
||||
};
|
||||
next = {
|
||||
...next,
|
||||
angle: normalizeStairAngle(angle),
|
||||
target_space_id: dialog.targetSpaceId || null,
|
||||
} as Stair;
|
||||
this.write(this.stairs.map((item) => item.id === next.id ? next : item));
|
||||
this.owner._recordGeometry(this.owner._t('history.stair_edit'), before);
|
||||
this.owner._saveConfigDebounced();
|
||||
this.dialog = null;
|
||||
}
|
||||
|
||||
private delete(id: string): void {
|
||||
const before = this.owner._geometrySnapshot();
|
||||
this.write(this.stairs.filter((item) => item.id !== id));
|
||||
this.selectedId = null;
|
||||
this.dialog = null;
|
||||
this.owner._recordGeometry(this.owner._t('history.stair_delete'), before);
|
||||
this.owner._saveConfigDebounced();
|
||||
}
|
||||
|
||||
public deleteSelected(): boolean {
|
||||
if (!this.selectedId) return false;
|
||||
this.delete(this.selectedId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private pointerDown(
|
||||
event: PointerEvent,
|
||||
stair: Stair,
|
||||
mode: 'move' | 'resize' | 'rotate',
|
||||
resizeX: -1 | 0 | 1 = 1,
|
||||
resizeY: -1 | 0 | 1 = 1,
|
||||
): void {
|
||||
if (this.owner._mode !== 'plan'
|
||||
|| (this.owner._tool !== 'select' && this.owner._tool !== 'stairs')) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.selectedId = stair.id;
|
||||
this.drag = {
|
||||
pid: event.pointerId,
|
||||
id: stair.id,
|
||||
mode,
|
||||
resizeX,
|
||||
resizeY,
|
||||
start: this.owner._svgPoint(event as unknown as MouseEvent),
|
||||
original: structuredClone(stair),
|
||||
before: this.owner._geometrySnapshot(),
|
||||
moved: false,
|
||||
};
|
||||
try { (event.currentTarget as Element).setPointerCapture(event.pointerId); }
|
||||
catch { /* synthetic event */ }
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
|
||||
public pointerMove(event: PointerEvent): boolean {
|
||||
const drag = this.drag;
|
||||
if (!drag || drag.pid !== event.pointerId) return false;
|
||||
const point = this.owner._svgPoint(event as unknown as MouseEvent);
|
||||
let next: Stair = { ...drag.original };
|
||||
if (drag.mode === 'move') {
|
||||
const center = [
|
||||
drag.original.x * NORM_W + point[0] - drag.start[0],
|
||||
drag.original.y * NORM_W + point[1] - drag.start[1],
|
||||
];
|
||||
next = this.withMagnet(next, center);
|
||||
} else if (drag.mode === 'rotate') {
|
||||
next.angle = normalizeStairAngle(
|
||||
Math.atan2(point[1] - next.y * NORM_W, point[0] - next.x * NORM_W)
|
||||
* 180 / Math.PI + 90,
|
||||
);
|
||||
if (event.shiftKey) next.angle = Math.round(next.angle / 45) * 45;
|
||||
} else if (next.kind === 'spiral') {
|
||||
next.radius = Math.max(STAIR_MIN_N,
|
||||
Math.hypot(point[0] - next.x * NORM_W, point[1] - next.y * NORM_W) / NORM_W);
|
||||
} else {
|
||||
const resized = resizeFurnitureTransform({
|
||||
x: (next.x - next.length / 2) * NORM_W,
|
||||
y: (next.y - next.width / 2) * NORM_W,
|
||||
w: next.length * NORM_W,
|
||||
h: next.width * NORM_W,
|
||||
angle: next.angle,
|
||||
}, drag.resizeX, drag.resizeY, point[0], point[1], false, STAIR_MIN_N * NORM_W);
|
||||
next = {
|
||||
...next,
|
||||
x: (resized.x + resized.w / 2) / NORM_W,
|
||||
y: (resized.y + resized.h / 2) / NORM_W,
|
||||
length: resized.w / NORM_W,
|
||||
width: resized.h / NORM_W,
|
||||
};
|
||||
}
|
||||
drag.moved ||= Math.hypot(point[0] - drag.start[0], point[1] - drag.start[1]) > 0.5;
|
||||
this.write(this.stairs.map((item) => item.id === next.id ? next : item));
|
||||
return true;
|
||||
}
|
||||
|
||||
private endDrag(event: PointerEvent, cancelled: boolean): boolean {
|
||||
const drag = this.drag;
|
||||
if (!drag || drag.pid !== event.pointerId) return false;
|
||||
if (cancelled) {
|
||||
this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item));
|
||||
} else if (drag.moved) {
|
||||
this.owner._recordGeometry(this.owner._t('history.stair_move'), drag.before);
|
||||
this.owner._saveConfigDebounced();
|
||||
}
|
||||
this.drag = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public pointerUp(event: PointerEvent): boolean {
|
||||
return this.endDrag(event, false);
|
||||
}
|
||||
|
||||
public pointerCancel(event: PointerEvent): boolean {
|
||||
return this.endDrag(event, true);
|
||||
}
|
||||
|
||||
public undoActiveDrag(): boolean {
|
||||
if (!this.drag) return false;
|
||||
const drag = this.drag;
|
||||
this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item));
|
||||
this.drag = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public escape(): boolean {
|
||||
if (this.undoActiveDrag()) return true;
|
||||
if (!this.selectedId) return false;
|
||||
this.selectedId = null;
|
||||
this.owner.requestUpdate();
|
||||
return true;
|
||||
}
|
||||
|
||||
public beforeModeChange(mode: StairEditorHostPort['_mode']): void {
|
||||
if (mode !== this.owner._mode) this.undoActiveDrag();
|
||||
}
|
||||
|
||||
public afterModeChange(): void {
|
||||
if (this.owner._mode !== 'plan') this.selectedId = null;
|
||||
}
|
||||
|
||||
public clearGesture(): void {
|
||||
this.drag = null;
|
||||
this.selectedId = null;
|
||||
}
|
||||
|
||||
public toolbarGroup(): EditorToolbarGroup {
|
||||
return {
|
||||
id: 'stairs',
|
||||
label: this.owner._t('markup.stairs'),
|
||||
icon: 'mdi:stairs',
|
||||
activeItemId: this.owner._tool === 'stairs' ? this.preset : undefined,
|
||||
items: [{
|
||||
id: 'straight',
|
||||
label: this.owner._t('stairs.straight'),
|
||||
icon: 'mdi:stairs',
|
||||
role: 'tool',
|
||||
invoke: () => this.activatePlacement('straight'),
|
||||
}, {
|
||||
id: 'spiral',
|
||||
label: this.owner._t('stairs.spiral'),
|
||||
icon: 'mdi:rotate-orbit',
|
||||
role: 'tool',
|
||||
invoke: () => this.activatePlacement('spiral'),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
public renderLayer(): TemplateResult {
|
||||
const spaceIds = new Set(this.owner._model.map((item) => item.id));
|
||||
const items = this.stairs.map((stair) => {
|
||||
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
|
||||
const outline = geometry.outline.map((point) => point.join(',')).join(' ');
|
||||
const selected = this.owner._mode === 'plan'
|
||||
&& (this.owner._tool === 'select' || this.owner._tool === 'stairs')
|
||||
&& this.selectedId === stair.id;
|
||||
const targetState = stairTargetState(
|
||||
stair, this.owner._space, spaceIds, this.owner._hasFixedFloor,
|
||||
);
|
||||
const inputEnabled = this.owner._mode === 'plan'
|
||||
&& (this.owner._tool === 'select' || this.owner._tool === 'stairs');
|
||||
const select = (event: Event): void => {
|
||||
if (this.owner._mode === 'plan') {
|
||||
event.stopPropagation();
|
||||
this.selectedId = stair.id;
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
};
|
||||
const resizeHandles = stair.kind === 'spiral'
|
||||
? [{ point: [stair.x * NORM_W + stair.radius * NORM_W, stair.y * NORM_W], sx: 1, sy: 1 }]
|
||||
: [
|
||||
{ point: geometry.outline[0], sx: -1, sy: -1 },
|
||||
{ point: geometry.outline[1], sx: 1, sy: -1 },
|
||||
{ point: geometry.outline[2], sx: 1, sy: 1 },
|
||||
{ point: geometry.outline[3], sx: -1, sy: 1 },
|
||||
{ point: geometry.outline[0].map((value, axis) =>
|
||||
(value + geometry.outline[1][axis]) / 2), sx: 0, sy: -1 },
|
||||
{ point: geometry.outline[1].map((value, axis) =>
|
||||
(value + geometry.outline[2][axis]) / 2), sx: 1, sy: 0 },
|
||||
{ point: geometry.outline[2].map((value, axis) =>
|
||||
(value + geometry.outline[3][axis]) / 2), sx: 0, sy: 1 },
|
||||
{ point: geometry.outline[3].map((value, axis) =>
|
||||
(value + geometry.outline[0][axis]) / 2), sx: -1, sy: 0 },
|
||||
];
|
||||
const bearing = stair.angle * Math.PI / 180;
|
||||
const outer = (stair.kind === 'spiral' ? stair.radius : stair.width / 2) * NORM_W;
|
||||
const rotatePoint = [
|
||||
stair.x * NORM_W + Math.sin(bearing) * (outer + this.owner._gridPitch * 2),
|
||||
stair.y * NORM_W - Math.cos(bearing) * (outer + this.owner._gridPitch * 2),
|
||||
];
|
||||
return svg`<g class="hp-stair ${selected ? 'selected' : ''} ${inputEnabled ? 'input-enabled' : ''}"
|
||||
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
|
||||
data-target-state=${targetState}
|
||||
role="img"
|
||||
aria-label=${this.owner._t('markup.stairs')}
|
||||
@dblclick=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (this.owner._mode === 'plan') this.openDialog(stair);
|
||||
}}>
|
||||
<polygon class="hp-stair-outline" points=${outline}></polygon>
|
||||
<polygon class="hp-stair-hit" points=${outline}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'move')}
|
||||
@click=${select}></polygon>
|
||||
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
|
||||
${selected ? svg`
|
||||
${resizeHandles.map((handle) => svg`<circle class="hp-stair-handle hp-stair-resize"
|
||||
cx=${handle.point[0]} cy=${handle.point[1]} r=${this.owner._gridPitch * 0.65}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(
|
||||
event, stair, 'resize', handle.sx as -1 | 0 | 1, handle.sy as -1 | 0 | 1,
|
||||
)}></circle>`)}
|
||||
<line class="hp-stair-rotate-leader" x1=${stair.x * NORM_W} y1=${stair.y * NORM_W}
|
||||
x2=${rotatePoint[0]} y2=${rotatePoint[1]}></line>
|
||||
<circle class="hp-stair-handle hp-stair-rotate"
|
||||
cx=${rotatePoint[0]} cy=${rotatePoint[1]} r=${this.owner._gridPitch * 0.65}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'rotate')}></circle>
|
||||
` : nothing}
|
||||
</g>`;
|
||||
});
|
||||
return svg`<g class="hp-stairs-layer">${items}</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
public renderDialog(): TemplateResult | typeof nothing {
|
||||
const dialog = this.dialog;
|
||||
const current = this.stairs.find((item) => item.id === dialog?.id);
|
||||
if (!dialog || !current) return nothing;
|
||||
const targetState = stairTargetState(
|
||||
{ ...current, target_space_id: dialog.targetSpaceId || null } as Stair,
|
||||
this.owner._space, new Set(this.owner._model.map((item) => item.id)),
|
||||
this.owner._hasFixedFloor,
|
||||
);
|
||||
const directionOptions: ReadonlyArray<readonly [string, I18nKey]> = dialog.kind === 'straight'
|
||||
? [['forward', 'stairs.forward'], ['backward', 'stairs.backward']] as const
|
||||
: [['clockwise', 'stairs.clockwise'], ['counterclockwise', 'stairs.counterclockwise']] as const;
|
||||
const field = (key: 'length' | 'width' | 'radius', label: I18nKey) => html`
|
||||
<label>${this.owner._t(label)}</label>
|
||||
<div class="row"><input class="namein tempin" type="number" min="0.01" step="any"
|
||||
.value=${dialog[key]}
|
||||
@input=${(event: Event) => this.updateDialog({
|
||||
[key]: (event.target as HTMLInputElement).value,
|
||||
})}><span class="opl">${this.owner._t(
|
||||
this.owner._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm',
|
||||
)}</span></div>`;
|
||||
return html`<hp-dialog .hass=${this.owner.hass} data-kind="stairs" wide
|
||||
.title=${this.owner._t('stairs.properties')} icon="mdi:stairs"
|
||||
@hp-close=${() => this.closeDialog()}>
|
||||
<div class="body">
|
||||
<label>${this.owner._t('stairs.type')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(event: Event) => {
|
||||
const kind = (event.target as HTMLSelectElement).value as Stair['kind'];
|
||||
const converted = convertStairKind(current, kind);
|
||||
const sizes = stairPhysicalSizeCm(converted, this.owner._cellCm);
|
||||
this.updateDialog({
|
||||
kind,
|
||||
length: cmToField(kind === 'straight' ? sizes[0] : 100, this.owner._imperial),
|
||||
width: cmToField(kind === 'straight' ? sizes[1] : 100, this.owner._imperial),
|
||||
radius: cmToField(kind === 'spiral' ? sizes[0] : 90, this.owner._imperial),
|
||||
direction: converted.direction,
|
||||
});
|
||||
}}>
|
||||
<option value="straight" ?selected=${dialog.kind === 'straight'}>${this.owner._t('stairs.straight')}</option>
|
||||
<option value="spiral" ?selected=${dialog.kind === 'spiral'}>${this.owner._t('stairs.spiral')}</option>
|
||||
</select>
|
||||
${dialog.kind === 'straight'
|
||||
? html`${field('length', 'stairs.length')}${field('width', 'stairs.width')}`
|
||||
: field('radius', 'stairs.radius')}
|
||||
<label>${this.owner._t('stairs.rotation')}</label>
|
||||
<input class="namein tempin" type="number" step="any" .value=${dialog.angle}
|
||||
@input=${(event: Event) => this.updateDialog({
|
||||
angle: (event.target as HTMLInputElement).value,
|
||||
})}>
|
||||
<label>${this.owner._t('stairs.direction')}</label>
|
||||
<div class="segmented">${directionOptions.map(([value, key]) => html`
|
||||
<button class="btn ${dialog.direction === value ? 'on' : ''}"
|
||||
@click=${() => this.updateDialog({
|
||||
direction: value as Stair['direction'],
|
||||
})}>${this.owner._t(key)}</button>`)}</div>
|
||||
<label>${this.owner._t('stairs.target')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(event: Event) => this.updateDialog({
|
||||
targetSpaceId: (event.target as HTMLSelectElement).value,
|
||||
})}>
|
||||
<option value="" ?selected=${dialog.targetSpaceId === ''}>${this.owner._t('stairs.no_target')}</option>
|
||||
${this.owner._model.filter((item) => item.id !== this.owner._space).map((item) => html`
|
||||
<option value=${item.id} ?selected=${dialog.targetSpaceId === item.id}>${item.title}</option>`)}
|
||||
</select>
|
||||
${targetState !== 'active' ? html`<p class="hint warn">${this.owner._t(
|
||||
targetState === 'fixed' ? 'stairs.fixed_floor' : 'stairs.target_warning',
|
||||
)}</p>` : nothing}
|
||||
</div>
|
||||
<div class="row dialog-action-footer" slot="footer">
|
||||
<button class="btn danger" data-hp="dialog-confirm" @click=${() => this.delete(dialog.id)}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.owner._t('btn.delete')}</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" data-hp="dialog-cancel"
|
||||
@click=${() => this.closeDialog()}>${this.owner._t('btn.cancel')}</button>
|
||||
<button class="btn on" data-hp="dialog-confirm" @click=${() => this.saveDialog()}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this.owner._t('btn.save')}</button>
|
||||
</div>
|
||||
</hp-dialog>`;
|
||||
}
|
||||
|
||||
private closeDialog(): void {
|
||||
this.dialog = null;
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { nothing, svg, type TemplateResult } from 'lit';
|
||||
import {
|
||||
cachedStairRenderGeometry, stairList, type Stair,
|
||||
} from './stairs';
|
||||
import { stairTargetState } from './stairs-editor-model';
|
||||
import type { SpaceModel } from './types';
|
||||
|
||||
export interface StairViewHostPort {
|
||||
_mode: 'view' | 'plan' | 'devices' | 'decor';
|
||||
_curSpaceCfg: unknown;
|
||||
_model: SpaceModel[];
|
||||
_space: string;
|
||||
_hasFixedFloor: boolean;
|
||||
_suppressClick: boolean;
|
||||
_cellCm: number;
|
||||
_tabClick(spaceId: string): void;
|
||||
_t(key: 'markup.stairs'): string;
|
||||
}
|
||||
|
||||
type ViewPress = { pid: number; startedAt: number };
|
||||
|
||||
/** Eager, read-only stair surface. All editing stays in the lazy editor graph. */
|
||||
export class StairViewRuntime {
|
||||
private press: ViewPress | null = null;
|
||||
private suppressClick = false;
|
||||
|
||||
public constructor(private readonly owner: StairViewHostPort) {}
|
||||
|
||||
private get stairs(): Stair[] {
|
||||
return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs);
|
||||
}
|
||||
|
||||
public pointerDown(event: PointerEvent): void {
|
||||
this.suppressClick = false;
|
||||
if (event.pointerType === 'touch') {
|
||||
this.press = { pid: event.pointerId, startedAt: performance.now() };
|
||||
}
|
||||
}
|
||||
|
||||
public pointerUp(event: PointerEvent): void {
|
||||
const press = this.press;
|
||||
if (!press || press.pid !== event.pointerId) return;
|
||||
this.press = null;
|
||||
if (performance.now() - press.startedAt >= 600) this.suppressClick = true;
|
||||
}
|
||||
|
||||
public pointerCancel(event: PointerEvent): void {
|
||||
if (this.press?.pid !== event.pointerId) return;
|
||||
this.press = null;
|
||||
this.suppressClick = true;
|
||||
}
|
||||
|
||||
public clearGesture(): void {
|
||||
this.press = null;
|
||||
this.suppressClick = false;
|
||||
}
|
||||
|
||||
public renderLayer(): TemplateResult {
|
||||
const spaceIds = new Set(this.owner._model.map((item) => item.id));
|
||||
const interactive = this.owner._mode === 'view';
|
||||
const items = this.stairs.map((stair) => {
|
||||
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
|
||||
const outline = geometry.outline.map((point) => point.join(',')).join(' ');
|
||||
const targetState = stairTargetState(
|
||||
stair, this.owner._space, spaceIds, this.owner._hasFixedFloor,
|
||||
);
|
||||
const active = interactive && targetState === 'active';
|
||||
const navigate = (event: Event): void => {
|
||||
event.stopPropagation();
|
||||
if (event.type === 'keydown') this.suppressClick = false;
|
||||
if (!active || this.owner._suppressClick || this.suppressClick
|
||||
|| !stair.target_space_id) return;
|
||||
this.owner._tabClick(stair.target_space_id);
|
||||
};
|
||||
return svg`<g class="hp-stair ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
|
||||
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
|
||||
data-target-state=${targetState}
|
||||
role=${active ? 'link' : 'img'} tabindex=${active ? '0' : nothing}
|
||||
aria-label=${this.owner._t('markup.stairs')}
|
||||
@click=${navigate}
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (active && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
navigate(event);
|
||||
}
|
||||
}}>
|
||||
<polygon class="hp-stair-outline" points=${outline}></polygon>
|
||||
<polygon class="hp-stair-hit" points=${outline}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event)}></polygon>
|
||||
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
|
||||
</g>`;
|
||||
});
|
||||
return svg`<g class="hp-stairs-layer">${items}</g>` as unknown as TemplateResult;
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
import { difference, type Geom } from 'polyclip-ts';
|
||||
import { geometryArea } from './physical-geometry';
|
||||
import { CANVAS_LIMIT, GRID_N, NORM_W } from './canvas-constants';
|
||||
|
||||
export const STAIR_TREAD_CM = 30;
|
||||
export const MAX_STAIRS_PER_SPACE = 250;
|
||||
|
||||
export type StraightStairDirection = 'forward' | 'backward';
|
||||
export type SpiralStairDirection = 'clockwise' | 'counterclockwise';
|
||||
|
||||
interface StairCommon {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
angle: number;
|
||||
target_space_id?: string | null;
|
||||
}
|
||||
|
||||
export interface StraightStair extends StairCommon {
|
||||
kind: 'straight';
|
||||
direction: StraightStairDirection;
|
||||
length: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface SpiralStair extends StairCommon {
|
||||
kind: 'spiral';
|
||||
direction: SpiralStairDirection;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export type Stair = StraightStair | SpiralStair;
|
||||
|
||||
export interface StairLine {
|
||||
a: [number, number];
|
||||
b: [number, number];
|
||||
}
|
||||
|
||||
export interface StairRenderGeometry {
|
||||
outline: number[][];
|
||||
treads: StairLine[];
|
||||
arrowPath: string;
|
||||
center: [number, number];
|
||||
}
|
||||
|
||||
type CachedRenderGeometry = {
|
||||
fingerprint: string;
|
||||
geometry: StairRenderGeometry;
|
||||
};
|
||||
|
||||
/**
|
||||
* Live entity updates repaint the card without changing persisted stairs.
|
||||
* Keep the comparatively dense spiral/tread geometry behind an object-scoped
|
||||
* cache so those unrelated updates do not rebuild it on every render.
|
||||
*
|
||||
* The fingerprint also makes the helper safe for the few write paths which
|
||||
* temporarily mutate a config record in place before the authoritative model
|
||||
* is rebuilt. Weak keys keep deleted spaces/stairs collectible.
|
||||
*/
|
||||
const RENDER_GEOMETRY_CACHE = new WeakMap<object, CachedRenderGeometry>();
|
||||
|
||||
function renderFingerprint(stair: Stair, cellCm: number, scale: number): string {
|
||||
return stair.kind === 'straight'
|
||||
? `${cellCm}|${scale}|${stair.kind}|${stair.x}|${stair.y}|${stair.angle}|${stair.direction}|${stair.length}|${stair.width}`
|
||||
: `${cellCm}|${scale}|${stair.kind}|${stair.x}|${stair.y}|${stair.angle}|${stair.direction}|${stair.radius}`;
|
||||
}
|
||||
|
||||
const finite = (value: unknown): number | null => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
};
|
||||
|
||||
const cmToNorm = (cm: number, cellCm: number): number => (
|
||||
Number(cm) / ((Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N)
|
||||
);
|
||||
|
||||
export function isStair(value: unknown): value is Stair {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (typeof item.id !== 'string' || !item.id || (item.kind !== 'straight' && item.kind !== 'spiral')) return false;
|
||||
if (![item.x, item.y, item.angle].every((entry) => finite(entry) !== null)) return false;
|
||||
if (Math.abs(Number(item.x)) > CANVAS_LIMIT || Math.abs(Number(item.y)) > CANVAS_LIMIT
|
||||
|| Math.abs(Number(item.angle)) > 360) return false;
|
||||
if (item.target_space_id != null && typeof item.target_space_id !== 'string') return false;
|
||||
const validSize = (entry: unknown): boolean => {
|
||||
const size = finite(entry) ?? 0;
|
||||
return size > 0 && size <= CANVAS_LIMIT;
|
||||
};
|
||||
if (item.kind === 'straight') {
|
||||
return item.direction === 'forward' || item.direction === 'backward'
|
||||
? validSize(item.length) && validSize(item.width)
|
||||
: false;
|
||||
}
|
||||
return item.direction === 'clockwise' || item.direction === 'counterclockwise'
|
||||
? validSize(item.radius)
|
||||
: false;
|
||||
}
|
||||
|
||||
export function stairList(value: unknown): Stair[] {
|
||||
return Array.isArray(value) ? value.filter(isStair).slice(0, MAX_STAIRS_PER_SPACE) : [];
|
||||
}
|
||||
|
||||
const rotate = (x: number, y: number, angle: number): [number, number] => {
|
||||
const radians = angle * Math.PI / 180;
|
||||
const cosine = Math.cos(radians), sine = Math.sin(radians);
|
||||
return [x * cosine - y * sine, x * sine + y * cosine];
|
||||
};
|
||||
|
||||
const worldPoint = (stair: Stair, localX: number, localY: number, scale: number): [number, number] => {
|
||||
const [dx, dy] = rotate(localX * scale, localY * scale, stair.angle);
|
||||
return [stair.x * scale + dx, stair.y * scale + dy];
|
||||
};
|
||||
|
||||
export function stairOutline(stair: Stair, scale = NORM_W, circleSegments = 64): number[][] {
|
||||
if (stair.kind === 'straight') {
|
||||
const halfLength = stair.length * scale / 2;
|
||||
const halfWidth = stair.width * scale / 2;
|
||||
return [
|
||||
[-halfLength, -halfWidth], [halfLength, -halfWidth],
|
||||
[halfLength, halfWidth], [-halfLength, halfWidth],
|
||||
].map(([x, y]) => {
|
||||
const [dx, dy] = rotate(x, y, stair.angle);
|
||||
return [stair.x * scale + dx, stair.y * scale + dy];
|
||||
});
|
||||
}
|
||||
const count = Math.max(24, Math.round(circleSegments));
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const radians = index * Math.PI * 2 / count;
|
||||
return [
|
||||
(stair.x + Math.cos(radians) * stair.radius) * scale,
|
||||
(stair.y + Math.sin(radians) * stair.radius) * scale,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const arrowHead = (
|
||||
tip: [number, number], bearing: number, size: number,
|
||||
): string => {
|
||||
const left = rotate(-size, -size * 0.62, bearing);
|
||||
const right = rotate(-size, size * 0.62, bearing);
|
||||
return `M ${tip[0]} ${tip[1]} L ${tip[0] + left[0]} ${tip[1] + left[1]} M ${tip[0]} ${tip[1]} L ${tip[0] + right[0]} ${tip[1] + right[1]}`;
|
||||
};
|
||||
|
||||
export function stairRenderGeometry(
|
||||
stair: Stair, cellCm: number, scale = NORM_W,
|
||||
): StairRenderGeometry {
|
||||
const center: [number, number] = [stair.x * scale, stair.y * scale];
|
||||
const outline = stairOutline(stair, scale);
|
||||
const treadN = cmToNorm(STAIR_TREAD_CM, cellCm);
|
||||
if (stair.kind === 'straight') {
|
||||
const count = Math.max(0, Math.floor(stair.length / treadN));
|
||||
const treads: StairLine[] = [];
|
||||
for (let index = 1; index <= count; index++) {
|
||||
const x = -stair.length / 2 + index * treadN;
|
||||
if (x >= stair.length / 2 - 1e-10) break;
|
||||
treads.push({
|
||||
a: worldPoint(stair, x, -stair.width / 2, scale),
|
||||
b: worldPoint(stair, x, stair.width / 2, scale),
|
||||
});
|
||||
}
|
||||
const forward = stair.direction === 'forward';
|
||||
const fromX = (forward ? -0.3 : 0.3) * stair.length;
|
||||
const toX = (forward ? 0.3 : -0.3) * stair.length;
|
||||
const from = worldPoint(stair, fromX, 0, scale);
|
||||
const tip = worldPoint(stair, toX, 0, scale);
|
||||
const bearing = stair.angle + (forward ? 0 : 180);
|
||||
const size = Math.min(stair.width, stair.length) * scale * 0.16;
|
||||
return {
|
||||
outline, treads, center,
|
||||
arrowPath: `M ${from[0]} ${from[1]} L ${tip[0]} ${tip[1]} ${arrowHead(tip, bearing, size)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const travelRadius = stair.radius * 2 / 3;
|
||||
const circumference = Math.PI * 2 * travelRadius;
|
||||
const count = Math.max(1, Math.floor(circumference / treadN));
|
||||
const sign = stair.direction === 'clockwise' ? 1 : -1;
|
||||
const treads: StairLine[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const localAngle = index * (treadN / travelRadius) * sign;
|
||||
const degrees = stair.angle + localAngle * 180 / Math.PI;
|
||||
const inner = rotate(stair.radius * scale * 0.18, 0, degrees);
|
||||
const outer = rotate(stair.radius * scale, 0, degrees);
|
||||
treads.push({
|
||||
a: [center[0] + inner[0], center[1] + inner[1]],
|
||||
b: [center[0] + outer[0], center[1] + outer[1]],
|
||||
});
|
||||
}
|
||||
const radius = stair.radius * scale * 0.62;
|
||||
const start = stair.angle * Math.PI / 180;
|
||||
const sweep = sign * Math.PI * 1.5;
|
||||
const end = start + sweep;
|
||||
const from: [number, number] = [center[0] + Math.cos(start) * radius, center[1] + Math.sin(start) * radius];
|
||||
const tip: [number, number] = [center[0] + Math.cos(end) * radius, center[1] + Math.sin(end) * radius];
|
||||
const sweepFlag = sign > 0 ? 1 : 0;
|
||||
const tangent = end * 180 / Math.PI + (sign > 0 ? 90 : -90);
|
||||
return {
|
||||
outline, treads, center,
|
||||
arrowPath: `M ${from[0]} ${from[1]} A ${radius} ${radius} 0 1 ${sweepFlag} ${tip[0]} ${tip[1]} ${arrowHead(tip, tangent, stair.radius * scale * 0.16)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function cachedStairRenderGeometry(
|
||||
stair: Stair, cellCm: number, scale = NORM_W,
|
||||
): StairRenderGeometry {
|
||||
const fingerprint = renderFingerprint(stair, cellCm, scale);
|
||||
const cached = RENDER_GEOMETRY_CACHE.get(stair);
|
||||
if (cached?.fingerprint === fingerprint) return cached.geometry;
|
||||
const geometry = stairRenderGeometry(stair, cellCm, scale);
|
||||
RENDER_GEOMETRY_CACHE.set(stair, { fingerprint, geometry });
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function stairFootprintGeometry(stair: Stair, scale = NORM_W): Geom {
|
||||
const ring = stairOutline(stair, scale);
|
||||
return (ring.length ? [[[...ring, ring[0]]]] : []) as unknown as Geom;
|
||||
}
|
||||
|
||||
export function floorAreaMinusStairs(
|
||||
floor: readonly number[][], stairs: readonly Stair[] | null | undefined, scale = NORM_W,
|
||||
): number {
|
||||
if (floor.length < 3) return 0;
|
||||
const geometry = [[[...floor.map((point) => [point[0], point[1]]), [floor[0][0], floor[0][1]]]]] as Geom;
|
||||
return geometryAreaMinusStairs(geometry, stairs, scale);
|
||||
}
|
||||
|
||||
export function geometryAreaMinusStairs(
|
||||
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
|
||||
): number {
|
||||
return Math.max(0, geometryArea(geometryMinusStairs(source, stairs, scale)));
|
||||
}
|
||||
|
||||
export function geometryMinusStairs(
|
||||
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
|
||||
): Geom {
|
||||
const footprints = stairList(stairs).map((stair) => stairFootprintGeometry(stair, scale));
|
||||
if (!footprints.length) return source;
|
||||
try {
|
||||
// A single sweep avoids repeatedly rebuilding the same subject for dense floors.
|
||||
return difference(source, ...footprints);
|
||||
} catch {
|
||||
// Keep a valid room usable if one future record reaches this layer malformed.
|
||||
let geometry = source;
|
||||
for (const footprint of footprints) {
|
||||
try { geometry = difference(geometry, footprint); } catch { /* skip only the bad record */ }
|
||||
}
|
||||
return geometry;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import {
|
||||
cachedStairRenderGeometry,
|
||||
floorAreaMinusStairs,
|
||||
geometryAreaMinusStairs,
|
||||
isStair,
|
||||
stairRenderGeometry,
|
||||
} from '../test-build/stairs.js';
|
||||
import {
|
||||
convertStairKind,
|
||||
defaultStair,
|
||||
snapStairToStairs,
|
||||
stairPhysicalSizeCm,
|
||||
stairTargetState,
|
||||
} from '../test-build/stairs-editor-model.js';
|
||||
|
||||
const straight = (extra = {}) => ({
|
||||
id: 'straight', kind: 'straight', x: 0.5, y: 0.5, angle: 0,
|
||||
direction: 'forward', length: 0.24, width: 0.1,
|
||||
target_space_id: 'upper', ...extra,
|
||||
});
|
||||
|
||||
const spiral = (extra = {}) => ({
|
||||
id: 'spiral', kind: 'spiral', x: 0.5, y: 0.5, angle: 0,
|
||||
direction: 'clockwise', radius: 0.1,
|
||||
target_space_id: 'upper', ...extra,
|
||||
});
|
||||
|
||||
test('#663 validates the discriminated stair model and preserves future fields', () => {
|
||||
assert.equal(isStair({ ...straight(), future: { keep: true } }), true);
|
||||
assert.equal(isStair({ ...straight(), direction: 'clockwise' }), false);
|
||||
assert.equal(isStair({ ...straight(), width: 0 }), false);
|
||||
assert.equal(isStair({ ...spiral(), direction: 'backward' }), false);
|
||||
assert.equal(isStair({ ...spiral(), radius: Number.NaN }), false);
|
||||
assert.equal(isStair({ ...spiral(), radius: 5001 }), false);
|
||||
assert.equal(isStair({ ...straight(), angle: 361 }), false);
|
||||
});
|
||||
|
||||
test('#663 default physical sizes and type conversion are predictable', () => {
|
||||
const first = defaultStair('straight', 500, 400, 5, 'a');
|
||||
assert.equal(first.kind, 'straight');
|
||||
assert.deepEqual(stairPhysicalSizeCm(first, 5).map(Math.round), [240, 100]);
|
||||
first.target_space_id = 'upper';
|
||||
const round = convertStairKind(first, 'spiral');
|
||||
assert.equal(round.kind, 'spiral');
|
||||
assert.equal(Math.round(stairPhysicalSizeCm(round, 5)[0]), 120);
|
||||
const restored = convertStairKind(round, 'straight');
|
||||
assert.equal(restored.kind, 'straight');
|
||||
assert.deepEqual(stairPhysicalSizeCm(restored, 5).map(Math.round), [240, 240]);
|
||||
assert.equal(restored.target_space_id, 'upper');
|
||||
});
|
||||
|
||||
test('#663 straight stair treads keep exact 30 cm intervals and top remainder', () => {
|
||||
const geometry = stairRenderGeometry(straight(), 5);
|
||||
assert.equal(geometry.treads.length, 9);
|
||||
for (let index = 1; index < geometry.treads.length; index++) {
|
||||
assert.equal(geometry.treads[index].a[0] - geometry.treads[index - 1].a[0], 25);
|
||||
}
|
||||
assert.equal(geometry.treads[0].a[0], 405, 'first line is 30 cm after the lower edge');
|
||||
assert.equal(geometry.treads.at(-1).a[0], 605, 'the short remainder stays before the top edge');
|
||||
assert.deepEqual(
|
||||
geometry.treads.map((line) => line.b[1] - line.a[1]),
|
||||
Array(9).fill(100),
|
||||
);
|
||||
const zoomed = stairRenderGeometry(straight(), 5, 500);
|
||||
assert.equal(zoomed.treads.length, geometry.treads.length,
|
||||
'zoom changes only pixels, never the physical tread count');
|
||||
assert.equal(zoomed.treads[1].a[0] - zoomed.treads[0].a[0], 12.5,
|
||||
'the same 30 cm interval scales with the symbol, not with viewport zoom');
|
||||
});
|
||||
|
||||
test('#663 spiral stair uses one turn with 30 cm travel-line spacing', () => {
|
||||
const geometry = stairRenderGeometry(spiral({ angle: 30 }), 5);
|
||||
assert.equal(geometry.treads.length, 16);
|
||||
const center = geometry.center;
|
||||
const angles = geometry.treads.map((line) => Math.atan2(
|
||||
line.b[1] - center[1], line.b[0] - center[0],
|
||||
));
|
||||
const unwrapped = angles.reduce((result, angle) => {
|
||||
let value = angle;
|
||||
while (result.length && value <= result.at(-1)) value += Math.PI * 2;
|
||||
result.push(value);
|
||||
return result;
|
||||
}, []);
|
||||
const expected = 25 / (100 * 2 / 3);
|
||||
for (let index = 1; index < unwrapped.length; index++) {
|
||||
assert.ok(Math.abs((unwrapped[index] - unwrapped[index - 1]) - expected) < 1e-10);
|
||||
}
|
||||
const reverse = stairRenderGeometry(spiral({ direction: 'counterclockwise' }), 5);
|
||||
assert.ok(reverse.treads[1].b[1] < reverse.treads[0].b[1]);
|
||||
});
|
||||
|
||||
test('#663 cached render geometry survives live repaints and invalidates on transform', () => {
|
||||
const item = straight();
|
||||
const first = cachedStairRenderGeometry(item, 5);
|
||||
const second = cachedStairRenderGeometry(item, 5);
|
||||
assert.equal(second, first, 'unchanged stair reuses the dense render geometry');
|
||||
item.angle = 45;
|
||||
const changed = cachedStairRenderGeometry(item, 5);
|
||||
assert.notEqual(changed, first, 'in-place edits cannot leave a stale cache entry');
|
||||
});
|
||||
|
||||
test('#663 stair magnet covers rectangle/rectangle, rectangle/circle and circle/circle footprints', () => {
|
||||
const rect = straight({ id: 'rect', x: 0.5, y: 0.5, length: 0.2, width: 0.1 });
|
||||
const circle = spiral({ id: 'circle', x: 0.5, y: 0.5, radius: 0.08 });
|
||||
const movingRect = straight({ id: 'moving-rect', length: 0.2, width: 0.1 });
|
||||
const movingCircle = spiral({ id: 'moving-circle', radius: 0.08 });
|
||||
|
||||
assert.deepEqual(snapStairToStairs(movingRect, [704, 500], [rect], 10), [700, 500]);
|
||||
assert.deepEqual(snapStairToStairs(movingCircle, [686, 500], [rect], 10), [680, 500]);
|
||||
assert.deepEqual(snapStairToStairs(movingRect, [686, 500], [circle], 10), [680, 500]);
|
||||
assert.deepEqual(snapStairToStairs(movingCircle, [664, 500], [circle], 10), [660, 500]);
|
||||
assert.deepEqual(snapStairToStairs(movingCircle, [700, 500], [circle], 10), [700, 500]);
|
||||
assert.deepEqual(circle, spiral({ id: 'circle', x: 0.5, y: 0.5, radius: 0.08 }));
|
||||
});
|
||||
|
||||
test('#663 area removes only stair overlap and never becomes negative', () => {
|
||||
const floor = [[0, 0], [1000, 0], [1000, 1000], [0, 1000]];
|
||||
assert.equal(floorAreaMinusStairs(floor, [straight()]), 976_000);
|
||||
assert.equal(floorAreaMinusStairs(floor, [straight({ x: 0.95, length: 0.2, width: 0.2 })]), 970_000);
|
||||
const circular = floorAreaMinusStairs(floor, [spiral()]);
|
||||
assert.ok(Math.abs(circular - (1_000_000 - Math.PI * 10_000)) < 60,
|
||||
'64-segment circle remains physically accurate');
|
||||
const source = [[[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]];
|
||||
assert.equal(geometryAreaMinusStairs(source, [straight({ length: 2, width: 2 })]), 0);
|
||||
});
|
||||
|
||||
test('#663 target states distinguish active, missing, self, deleted and fixed', () => {
|
||||
const spaces = new Set(['ground', 'upper']);
|
||||
assert.equal(stairTargetState(straight(), 'ground', spaces, false), 'active');
|
||||
assert.equal(stairTargetState(straight({ target_space_id: null }), 'ground', spaces, false), 'missing');
|
||||
assert.equal(stairTargetState(straight({ target_space_id: 'ground' }), 'ground', spaces, false), 'self');
|
||||
assert.equal(stairTargetState(straight({ target_space_id: 'gone' }), 'ground', spaces, false), 'deleted');
|
||||
assert.equal(stairTargetState(straight(), 'ground', spaces, true), 'fixed');
|
||||
});
|
||||
|
||||
test('#663 Optimize preserves continuous authored stair transforms exactly', () => {
|
||||
const authored = straight({
|
||||
x: 0.123456789, y: -0.287654321, angle: 17.123456789,
|
||||
length: 0.234567891, width: 0.087654319,
|
||||
});
|
||||
const config = {
|
||||
model_version: 10,
|
||||
spaces: [{
|
||||
id: 'ground', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [], wall_segments: [], stairs: [authored],
|
||||
}, {
|
||||
id: 'upper', title: 'Upper', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [], wall_segments: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const result = optimizePlans(config, {});
|
||||
assert.deepEqual(result.config.spaces[0].stairs[0], authored);
|
||||
});
|
||||
@@ -85,6 +85,27 @@ def test_scalar_contract_is_symmetric_and_keeps_off_grid_geometry() -> None:
|
||||
assert canonicalize_number(float("inf")) == float("inf")
|
||||
|
||||
|
||||
def test_stair_transform_keeps_continuous_wall_face_magnet_position() -> None:
|
||||
stair = {
|
||||
"id": "stair",
|
||||
"kind": "straight",
|
||||
"direction": "forward",
|
||||
"target_space_id": "upper",
|
||||
"x": 0.5000001,
|
||||
"y": 0.3999999,
|
||||
"length": 0.2000001,
|
||||
"width": 0.0999999,
|
||||
"angle": 17.1234567896,
|
||||
}
|
||||
output = canonicalize_config_geometry(
|
||||
{"spaces": [{"id": "ground", "stairs": [stair]}]}
|
||||
)["spaces"][0]["stairs"][0]
|
||||
assert output == {
|
||||
**stair,
|
||||
"angle": 17.12345679,
|
||||
}
|
||||
|
||||
|
||||
def test_all_4801_lattice_nodes_and_nine_decimal_forms_share_exact_bits() -> None:
|
||||
assert LATTICE_GRID_N == 240
|
||||
assert LATTICE_NOISE_STEPS == 1e-4
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -931,6 +931,11 @@ def _plan_only_source() -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"walls": [],
|
||||
"partitions": [{"id": "partition", "a": [0, 0.5], "b": [1, 0.5], "cm": 12}],
|
||||
"wall_columns": [{"id": "column", "shape": "circle", "center": [0.2, 0.2], "cm": 30}],
|
||||
"stairs": [{
|
||||
"id": "stairs", "kind": "straight", "x": 0.35, "y": 0.45,
|
||||
"angle": 12.5, "direction": "forward", "length": 0.2, "width": 0.1,
|
||||
"target_space_id": "upper", "future_stair": "drop",
|
||||
}],
|
||||
"decor": [
|
||||
{
|
||||
"id": "modern", "kind": "text", "x": 0.2, "y": 0.2,
|
||||
@@ -1025,6 +1030,11 @@ def test_plan_only_export_projects_geometry_and_round_trips_room_labels(tmp_path
|
||||
assert by_id["furniture"]["symbol"] == "sofa"
|
||||
assert by_id["furniture"]["flip_h"] is True
|
||||
assert by_id["furniture"]["flip_v"] is False
|
||||
assert exported["stairs"] == [{
|
||||
"id": "stairs", "kind": "straight", "x": 0.35, "y": 0.45,
|
||||
"angle": 12.5, "direction": "forward", "target_space_id": "upper",
|
||||
"length": 0.2, "width": 0.1,
|
||||
}]
|
||||
|
||||
parsed = parse_document(json.dumps(document).encode())
|
||||
preview = create_preview(
|
||||
@@ -1054,6 +1064,7 @@ def test_plan_only_export_projects_geometry_and_round_trips_room_labels(tmp_path
|
||||
assert merged["markers"] == []
|
||||
imported_room = merged["spaces"][0]["rooms"][0]
|
||||
assert imported_room.get("area") is None
|
||||
assert merged["spaces"][0]["stairs"][0]["target_space_id"] is None
|
||||
assert merged_layout == {
|
||||
"rl_" + imported_room["id"]: {
|
||||
"x": 0.45, "y": 0.55, "s": details["space_id"], "k": 1.4,
|
||||
@@ -1649,6 +1660,31 @@ def test_issue_611_ambiguous_target_route_is_preserved_and_reported() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_issue_663_full_import_repairs_stair_floor_target_by_exact_space_map() -> None:
|
||||
current = {
|
||||
"spaces": [{
|
||||
"id": "space-ground", "title": "Ground", "view_box": [0, 0, 1, 1],
|
||||
"rooms": [], "stairs": [{
|
||||
"id": "stair-new", "kind": "straight", "x": 0.5, "y": 0.5,
|
||||
"angle": 0, "direction": "forward", "length": 0.2, "width": 0.1,
|
||||
"target_space_id": "upper",
|
||||
}],
|
||||
}, {
|
||||
"id": "space-upper", "title": "Upper", "view_box": [0, 0, 1, 1],
|
||||
"rooms": [],
|
||||
}],
|
||||
"markers": [], "settings": {},
|
||||
}
|
||||
report = import_export_api._empty_reference_report()
|
||||
repaired, _layout, count = import_export_api._repair_target_space_refs(
|
||||
current, {}, {"space": {"ground": "space-ground", "upper": "space-upper"}},
|
||||
{}, report, set(),
|
||||
)
|
||||
assert repaired["spaces"][0]["stairs"][0]["target_space_id"] == "space-upper"
|
||||
assert count == 1
|
||||
assert report["remapped"]["target"] == {"stair.target_space_id": 1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"vacuum",
|
||||
[
|
||||
|
||||
@@ -515,3 +515,4 @@ async def test_system_health_reports_complete_panel_matrix(
|
||||
result = await system_health.system_health_info(hass)
|
||||
|
||||
assert {key: result[key] for key in expected} == expected
|
||||
assert result["stairs"] == 0
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,62 @@ _HOUSEPLAN_ROOT = os.path.join(_PACKAGE_ROOT, "houseplan")
|
||||
v = load_pure("hp_validation", Path(_HOUSEPLAN_ROOT) / "validation.py")
|
||||
|
||||
|
||||
def _stair_space(stairs):
|
||||
return {
|
||||
"id": "ground", "title": "Ground", "view_box": [0, 0, 1, 1],
|
||||
"rooms": [], "wall_segments": [], "stairs": stairs,
|
||||
}
|
||||
|
||||
|
||||
def test_issue_663_stair_schema_is_discriminated_bounded_and_forward_compatible():
|
||||
straight = {
|
||||
"id": "straight", "kind": "straight", "x": 0.123456789,
|
||||
"y": -0.25, "angle": 17.5, "direction": "forward",
|
||||
"length": 0.24, "width": 0.1, "target_space_id": "upper",
|
||||
"future": {"keep": True},
|
||||
}
|
||||
spiral = {
|
||||
"id": "spiral", "kind": "spiral", "x": 0.6, "y": 0.4,
|
||||
"angle": -30, "direction": "counterclockwise", "radius": 0.08,
|
||||
"target_space_id": None,
|
||||
}
|
||||
validated = v.CONFIG_SCHEMA({
|
||||
"spaces": [_stair_space([straight, spiral]), {
|
||||
"id": "upper", "title": "Upper", "view_box": [0, 0, 1, 1],
|
||||
"rooms": [], "wall_segments": [],
|
||||
}],
|
||||
})
|
||||
assert validated["spaces"][0]["stairs"][0] == straight
|
||||
assert validated["spaces"][0]["stairs"][1] == spiral
|
||||
|
||||
broken = [
|
||||
{**straight, "width": 0},
|
||||
{**straight, "direction": "clockwise"},
|
||||
{**straight, "radius": 0.1},
|
||||
{key: value for key, value in straight.items() if key != "length"},
|
||||
{**spiral, "direction": "forward"},
|
||||
{**spiral, "length": 0.2},
|
||||
{**spiral, "radius": float("nan")},
|
||||
]
|
||||
for stair in broken:
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [_stair_space([stair])]})
|
||||
|
||||
duplicate = {**straight, "id": "same"}
|
||||
with pytest.raises(vol.Invalid, match="geometry object ids"):
|
||||
v.CONFIG_SCHEMA({"spaces": [{
|
||||
**_stair_space([duplicate]),
|
||||
"decor": [{
|
||||
"id": "same", "kind": "line", "x1": 0, "y1": 0,
|
||||
"x2": 0.1, "y2": 0.1,
|
||||
}],
|
||||
}]})
|
||||
|
||||
too_many = [{**straight, "id": f"stair-{index}"} for index in range(v.MAX_STAIRS + 1)]
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [_stair_space(too_many)]})
|
||||
|
||||
|
||||
def test_backend_model_version_matches_frontend_constant():
|
||||
root = os.path.dirname(os.path.dirname(__file__))
|
||||
source = open(os.path.join(root, "src", "plan-optimizer.ts"), encoding="utf-8").read()
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user