feat: support openings in independent walls

Issue: #132
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 01:11:55 +03:00
parent 083621342a
commit 9f77e3e932
40 changed files with 2114 additions and 603 deletions
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -48,9 +48,10 @@ from .validation import (
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
validate_opening_passages,
validate_opening_passages, validate_partition_opening_hosts,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
)
FORMAT = "houseplan-export"
@@ -264,7 +265,8 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
_pick_fields(
opening,
("id", "type", "x", "y", "angle", "length")
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v")),
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v"))
+ (("host",) if opening.get("host") else ()),
)
for opening in space.get("openings") or []
]
@@ -980,7 +982,8 @@ def build_space_merge(
validate_marker_light_entities(merged_config, current_config)
validate_marker_value_badges(merged_config, current_config)
validate_opening_passages(merged_config, current_config)
except (MarkerControlError, OpeningPassageError) as err:
validate_partition_opening_hosts(merged_config, current_config)
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
raise ImportFailure(err.code, str(err)) from err
try:
merged_layout = LAYOUT_SCHEMA(merged_layout)
+66
View File
@@ -46,6 +46,39 @@ class OpeningPassageError(ValueError):
)
class PartitionOpeningHostError(ValueError):
"""A write tried to strip explicit host identity from a surviving opening."""
code = "invalid_partition_opening_host"
def validate_partition_opening_hosts(
config: dict, previous: dict | None = None
) -> None:
"""Prevent an older writer from silently downgrading hosted openings.
Deleting the opening together with its partition remains valid. Only a
surviving record that previously had an explicit host must keep one.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
old_space = old_spaces.get(str(space.get("id", "")))
if not old_space:
continue
old_openings = {
str(opening.get("id")): opening
for opening in old_space.get("openings") or []
}
for opening in space.get("openings") or []:
old = old_openings.get(str(opening.get("id", "")))
if old and old.get("host") is not None and opening.get("host") is None:
raise PartitionOpeningHostError(
f"space={space.get('id', '')}; opening={opening.get('id', '')}; host removed"
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
@@ -767,6 +800,15 @@ WALL_COLUMN_SCHEMA = vol.All(
_strict_wall_column,
)
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("partition"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
@@ -784,6 +826,29 @@ def _space_geometry_invariants(value: dict) -> dict:
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in value.get("openings", []):
host = opening.get("host")
if host is None:
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
length = float(opening["length"])
along = float(host["t"]) * span
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("partition opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = hosted_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
raise vol.Invalid("partition openings must not overlap")
occupied.append((lo, hi))
return value
@@ -849,6 +914,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
+7 -4
View File
@@ -57,8 +57,9 @@ from .virtual_lights import (
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError, sanitize_filename,
validate_opening_passages,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, valid_space_id,
)
@@ -1256,7 +1257,8 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
validate_opening_passages(msg["config"], data.get("config"))
except (MarkerControlError, OpeningPassageError) as err:
validate_partition_opening_hosts(msg["config"], data.get("config"))
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
connection.send_error(msg["id"], err.code, str(err))
return
# An internal plan url must name a file that exists. The card can pick a
@@ -1367,7 +1369,8 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
validate_opening_passages(msg["config"], config_data.get("config"))
except (MarkerControlError, OpeningPassageError) as err:
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
connection.send_error(msg["id"], err.code, str(err))
return