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
+26 -13
View File
@@ -333,8 +333,8 @@ contribute. Three explicitly typed exceptions are stored per space:
independent wall segments and `wall_columns` for square/circular columns.
They do not create a room or HA area and never split a room implicitly. Their
physical bodies are unioned with room walls for rendering and light occlusion,
and subtracted from clean room floor area. Openings (doors, windows and gates)
still belong only to derived room walls and never cut an independent object.
and subtracted from clean room floor area. A finished partition may explicitly
host a door, window, gate or passage; unfinished drafts and columns may not.
Independent linear objects have two deliberate projections. Raw flat-capped
quads preserve source identity for hit/selection/drag/properties/delete/history
@@ -363,8 +363,11 @@ vertex cannot contribute a child-room mitre to the facade. Per-room rings remain
an interior join/nested-room representation, and atomic quads provide a safe
physical interval when an acute child ring cannot be subtracted. Paper and
masonry paths are emitted by that same geometry pass. Computed independent
junction patches enter as extras only after room opening cuts, so an opening
cannot cut a coincident partition and room exterior authority remains intact.
junction patches enter through the same physical union. A partition-hosted
opening is subtracted from its explicit raw body before that union. It also
cuts a derived room wall only when the wall is exactly collinear and covers
the complete hosted interval; crossing or nearby bodies remain opaque and room
exterior authority remains intact.
Before the exterior offset is built, each saved atomic endpoint splits its
containing collinear union edge. Offset changes are explicit butt steps at that
endpoint, including nonzero-to-zero transitions. The topology tolerance starts
@@ -489,16 +492,17 @@ navigation, focus restoration and outside-dismiss consumption), but no current
tools are grouped without a separate product decision. The change is UI-only:
plan/config models and geometry commands are unchanged.
## Doors, windows & gates (v1.23.0+)
## Doors, windows, gates & passages (v1.23.0+)
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle, a length and a
wall, while markers are free points whose positions live in the layout store. Model:
`{id, type: door|window|gate, x, y, angle, length, contact?, lock?, invert?, flip_h?, flip_v?}`
(normalized coords; `length` normalized by plan width). Placement snaps onto the nearest
**derived** wall via `snapToWall` (logic.ts) — the angle is normalized to [-90, 90) because two
rooms share a wall with opposite edge directions, and without that a drag across segment
boundaries flips the hinge. The opening then keeps **absolute coordinates**, so editing, merging
or deleting rooms never breaks it.
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle,
a length and one wall, while markers are free points whose positions live in
the layout store. Model:
`{id, type: door|window|gate|passage, x, y, angle, length, host?, contact?, lock?, invert?, flip_h?, flip_v?}`.
Room-wall openings omit `host` and retain the absolute-coordinate association.
An independent-wall opening stores
`host:{kind:'partition',id,t}`; the stable id and normalized position `t` are
authoritative, while `x/y/angle` are an atomically refreshed compatibility
projection. No explicit host ever falls back to a nearest wall.
Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall; windows
@@ -536,6 +540,15 @@ room both halves; shared openings use a local-coordinate hard stop at `y=0`.
Virtual spans, unfinished drafts and zero-thickness walls are ignored; legacy
spans are clipped per atomic body.
The same resolved host drives placement, symbol face, full-depth partition cut,
static/hidden-isometric rendering, Glow and edit operations. Rigid host drag
keeps `t` and updates every materialized projection in one history command.
Deleting a host with openings requires an explicit cascade dialog; an invalid
host fails dark and is visible only as a rebind diagnostic in Plan. Structural
room-face topology deliberately keeps every valid wall axis continuous through
all opening types (#185); only `open_spans` and absent walls are connectivity
gaps.
## Integration WS API
| Command | Parameters | Response |
+6 -5
View File
@@ -549,9 +549,9 @@ When **Walls** is active in the Plan editor, a derived
pointer-transparent SVG layer exposes the centre axes of completed room walls,
saved inactive outlines and independent partitions. It is painted after their
physical wall bodies, but before interactive editor chrome. Columns, decor,
devices, the active wall chain and its live preview are not candidates. Door,
window, gate and intentionally open-span intervals are cut from room axes; a
cut boundary does not become a new endpoint.
devices, the active wall chain and its live preview are not candidates.
Opening intervals remain part of the structural axes (#185); only intentionally
open spans are cut, and a cut boundary does not become a new endpoint.
The layer and hit resolver share one immutable geometry snapshot. Original
segment endpoints are deduplicated and drawn at a physical radius of 5 cm.
@@ -573,7 +573,7 @@ single active candidate and never writes config, layout or storage.
Every completed Walls segment is first persisted in the active `room_drafts`
chain. On the click path only, an immutable planar graph is built from solid
room edges after opening cuts, independent partitions, inactive drafts and the
room edges without presentation opening cuts, independent partitions, inactive drafts and the
active chain both before and after the latest segment. Endpoint, T, X and
collinear-overlap junctions atomize that computed graph without rewriting any
saved wall. A deterministic half-edge walk extracts bounded faces; canonical
@@ -582,7 +582,8 @@ identity ignores winding, cyclic start and derived collinear subdivision.
Only faces added by the latest segment and containing one of its atoms are
offered. They are ordered by area and then canonical key. Existing exact or
partially overlapping rooms are excluded, nested rooms remain legal, and any
physical gap — including an opening cut — remains a gap. A clean divider across
physical gap created by an `open_span` or absent wall remains a gap. A door,
window, gate or passage is a property of a wall and preserves connectivity. A clean divider across
one room reuses the Split contract: the larger side keeps the room identity,
metadata and device binding, and only the smaller side is offered.
+8
View File
@@ -19,6 +19,14 @@
Create/Keep/Cancel decisions. Ctrl/Cmd+click no longer adds an extra point
while the chain is still too short to close
([#173](https://github.com/Matysh/houseplan-card/issues/173)).
- Doors, windows, gates and open passages can now be placed in finished
independent Walls segments. They cut the full wall depth, move with their
host, keep the same Home Assistant state/actions as room-wall openings and
are removed only through an explicit cascade confirmation. Openings no
longer break the structural wall axis, so drawing a closed contour still
offers the room even when one of its walls already contains an opening
([#132](https://github.com/Matysh/houseplan-card/issues/132),
[#185](https://github.com/Matysh/houseplan-card/issues/185)).
- Collinear exterior walls now change thickness exactly at their saved
breakpoint. After splitting a room, a 10 cm wall therefore keeps its full
depth up to the divider without leaking onto an adjacent zero-thickness
+8
View File
@@ -27,6 +27,14 @@
Create/«Оставить стенами»/Cancel применяются атомарно. Ctrl/Cmd+клик не
добавляет лишнюю точку, пока для замыкания ещё недостаточно рёбер
([#173](https://github.com/Matysh/houseplan-card/issues/173)).
- Дверь, окно, ворота и открытый проём теперь можно поставить в законченный
независимый отрезок «Стен». Проём прорезает его на всю глубину, движется
вместе со своей стеной, сохраняет обычные состояния и действия Home
Assistant, а при удалении стены удаляется только после отдельного
подтверждения. Проём больше не разрывает структурную ось стены: замкнутый
контур предлагает создать комнату, даже если в одной из стен уже есть проём
([#132](https://github.com/Matysh/houseplan-card/issues/132),
[#185](https://github.com/Matysh/houseplan-card/issues/185)).
- Коллинеарные внешние стены теперь меняют толщину точно в сохранённой точке
перехода. После разделения комнаты стена 10 см сохраняет полную толщину до
перегородки и не продолжается по соседнему фасаду с нулевой толщиной; План,
+17
View File
@@ -69,6 +69,23 @@ fallback may show or rewrite it as a door. Before a permanent rollback, convert
saved passages deliberately in a current version; automatic conversion is not
performed because it would invent a leaf and binding semantics.
## Independent-wall opening host (#132)
`space.openings[].host` is an optional discriminated object
`{kind:'partition', id:string, t:number}`. Its absence preserves the historical
room-wall association. When present, the referenced partition in the same
space and normalized `t` are authoritative; the legacy `x/y/angle` siblings
remain a materialized compatibility projection for older readers. Full export,
plan-only export, merge and optimization preserve the host object.
Current writes validate the reference, fit and non-overlap. They also reject a
stale writer that keeps an existing opening but silently drops its host; this
prevents a downgrade from converting it into a nearby room-wall opening. Old
frontends may display only the materialized projection, so opening or editing a
hosted opening with an old bundle is unsupported. A missing/invalid host is not
re-associated automatically: current renderers fail dark and Plan offers an
explicit rebind.
## Four-phase background default and transfer (#146)
The schema remains `settings.bg_mode: static | daynight` globally and per
+7
View File
@@ -52,6 +52,13 @@ it never inherits transparency merely by not being a window.
So the light's masonry is cut by passages only and differs on purpose from the
drawn one.
The rule is identical for a passage hosted by a finished independent wall.
Only its explicit partition body (plus an exactly collinear covering room wall)
is cut. Windows remain opaque to indoor Glow, one-sided door/gate/passage cuts
remain opaque, and an invalid host fails dark. A window hosted by an independent
wall never becomes a sunlight source; sunlight still belongs to exterior room
windows.
Source placement follows the same geometry, fail-dark. If the source centre is
inside an opaque wall body, a window tunnel, or an exterior door/gate opening,
the source produces no Glow at all. It does not light the indoor half of the
+2 -1
View File
@@ -179,7 +179,8 @@ Boolean, global + per-space (null = inherit), default OFF.
For every opening of type «window» sitting on an EXTERIOR wall — a
wall stretch with no other room on its outer side, decided by probing
the existing room geometry just off both sides of the window; windows
on interior walls do not participate, and open (virtual) boundaries
on interior walls do not participate, windows explicitly hosted by independent
partitions do not participate, and open (virtual) boundaries
never qualify because both sides are rooms — the card draws a wedge
when BOTH hold:
+23
View File
@@ -52,6 +52,29 @@
- [ ] Пять passage-мутантов из `scripts/mutation-gate.mjs` пойманы своими
guards до передачи в review. [auto: mutation-gate]
## Independent-wall openings and structural axes (#132, #185)
- [ ] Door/window/gate/passage placement on a finished independent wall stores
`host.kind/id/t`; a coincident room wall chooses that explicit host, while
crossing or duplicate-host ties are rejected. [auto: opening-placement,
partition-openings]
- [ ] Every hosted type cuts only its host full-depth in Plan/View/Static/Iso;
exact composite room masonry is also cut, nearby bodies remain intact,
and malformed hosts fail dark. [auto: physical-geometry,
smoke_partition_openings]
- [ ] Rigid host drag preserves `t` and updates projections atomically; delete
lists hosted openings, Cancel changes nothing, Confirm cascades in one
Undo/Redo command. [auto: partition-openings, smoke_partition_openings]
- [ ] Contact/lock actions keep existing security rules; passage stays inert;
windows and exterior passages stay opaque to Glow, and partition windows
produce no sun wedge. [auto: runtime contracts, smoke_glow]
- [ ] Door/window/gate/passage presentation gaps do not split the structural
axis used by the Walls face graph; real `open_spans` still do. [auto:
plan-snap-overlay, smoke_room_autoclose, smoke_partition_openings]
- [ ] Backend rejects missing host references, out-of-range `t`, non-fitting or
overlapping hosted openings and stale host stripping; exports round-trip
the host. [auto: test_validation, test_ha_import_export]
## Device value badge (#90)
+23 -9
View File
@@ -56,7 +56,7 @@ House Plan — локальная интеграция и две Lovelace-кар
| Пространство | Этаж, двор, гараж, отдельное строение | Подложку, масштаб сетки, комнаты, стены, проёмы, декор и настройки отображения |
| Комната | Только замкнутый контур | Название, необязательную HA-зону, источники температуры/влажности и локальную заливку |
| Стена | Сторона контура комнаты | Необязательную толщину и виртуальные участки общей границы |
| Проём | Дверь, окно, открытый проём или ворота на стене | Размер и, где применимо, ориентацию, контактный датчик и замок |
| Проём | Дверь, окно, открытый проём или ворота на стене комнаты либо законченном независимом отрезке | Размер и, где применимо, ориентацию, контактный датчик и замок |
| Маркер | Значок устройства на плане | Привязку к HA, комнату, действие, внешний вид, свет, описание и файлы |
| Декор | Элемент подложки | Линию, фигуру, текст, мебель или положение изображения плана |
@@ -287,7 +287,8 @@ desktop: для точного рисования, Resize, модификато
независимыми стенами; отдельной кнопки завершения нет.
5. Когда новый сегмент замкнёт область собственными стенами, существующими
стенами либо вычисляемой конечной, T- или X-точкой, откроется диалог комнаты.
Разрыв проёма стеной не считается. Если возникло несколько областей, диалоги
Проём не разрывает структурную ось стены и не мешает замыканию; виртуальный
участок остаётся настоящим разрывом. Если возникло несколько областей, диалоги
идут от меньшей площади к большей.
6. Для каждой области задайте название и свободную HA-зону и нажмите
«Сохранить», либо выберите **Оставить стенами**. Cancel/Esc отменяет всю
@@ -299,7 +300,7 @@ desktop: для точного рисования, Resize, модификато
нарисованных стен видны тонкие осевые линии и точки их концов. Увеличенная
точка показывает точное место следующего соединения: конец стены имеет
приоритет, а увеличенная точка посреди линии создаёт T-соединение, не разрезая
существующую стену. В проёмах соединительной линии нет. Для точного наведения и
существующую стену. Осевая линия остаётся непрерывной и в месте проёма. Для точного наведения и
предпросмотра рекомендуется редактор на компьютере; tap на сенсорном экране
тоже выполняет привязку, но отдельный hover до касания не показывается.
@@ -327,7 +328,7 @@ T-соединение входит в проходящую стену без в
| Стены | Непрерывную цепочку стен; при замыкании предлагает комнаты, при выходе сохраняет независимые стены | Площадь появляется только у подтверждённой комнаты | Физические сегменты блокируют свет; проёмы пропускают его | Частичное перекрытие комнат запрещено; отдельного инструмента «Перегородка» нет |
| Колонна | Квадратную или круглую опору | Не меняет площадь комнаты | Блокирует свет внутри своей формы | Имеет одну форму/размер/поворот, но не является стеной или комнатой |
| Граница | Виртуальный участок общей стены | Не меняет геометрию и площадь | Свет проходит, физическая стенка не рисуется | Работает только на общей границе соседних комнат |
| Проём | Дверь, окно, открытый проём или ворота на стене | Не меняет площадь | Дверь/ворота пропускают свет по состоянию, открытый проём — всегда между комнатами; окно может давать солнечный луч | Должен целиком помещаться на подходящем отрезке стены |
| Проём | Дверь, окно, открытый проём или ворота на стене комнаты либо законченном независимом отрезке | Не меняет площадь | Дверь/ворота пропускают свет между полами, открытый проём — всегда; только наружное окно комнаты может давать солнечный луч | Должен целиком помещаться на одном подходящем отрезке стены |
Остальные операции меняют уже созданную геометрию:
@@ -376,7 +377,8 @@ Resize показывает живые длины и чистую площадь
или круглая форма и внешний размер 1–150 см; у квадрата доступен поворот, у
круга размер означает диаметр. Они вычитаются из чистой площади, перекрывают
Glow и солнечные лучи, но при Resize комнаты остаются на месте. За пределами
комнат эти объекты не создают пол или фон.
комнат эти объекты не создают пол или фон. Проём в независимой стене движется
вместе с выбранным отрезком и остаётся в том же относительном месте.
### Толщина стены
@@ -418,7 +420,8 @@ Glow и солнечные лучи, но при Resize комнаты оста
## 9. Двери, окна, открытые проёмы, ворота и замки
Проём всегда привязан к стене. Дверь и открытый проём по умолчанию создаются
Проём всегда привязан к одному отрезку стены комнаты или к одному законченному
независимому отрезку «Стен». Дверь и открытый проём по умолчанию создаются
шириной 90 см, окно — 120 см, ворота — 300 см. Допустимый размер в интерфейсе:
20–600 см с шагом 5 см.
@@ -432,8 +435,18 @@ Glow и солнечные лучи, но при Resize комнаты оста
Наводить указатель перед кликом необязательно: клик по телу толстой стены сам
находит её ось и открывает тот же диалог. Виртуальная стена не принимает проёмы.
Если несколько стен сходятся в одной точке, House Plan выбирает одну физическую
секцию детерминированно; при необходимости поставьте проём чуть дальше от стыка.
Если независимая стена точно совпадает со стеной комнаты, House Plan использует
её как явную привязку и прорезает единое составное тело. Не совпадающая
геометрически неоднозначность у стыка не угадывается: поставьте проём чуть
дальше от узла.
Проём можно перетаскивать только вдоль того же независимого отрезка. При его
удалении House Plan показывает отдельное подтверждение со всеми привязанными
проёмами; отмена ничего не меняет, подтверждение удаляет стену и перечисленные
проёмы одним шагом Undo. Если привязанный отрезок пропал из старого или
повреждённого конфига, в Редакторе плана проём помечается как потерявший стену
и предлагает перепривязку; в Просмотре он не рисуется и не создаёт ложный
проход для света.
Ворота по данным и поведению равны двери: могут иметь датчик открытия и замок, вырезают стену и пропускают Glow. Отличается только символ: две половинные створки без большой дуги, приоткрытые наружу на 10°. С датчиком угол меняется от 0° до 10°; без датчика показаны статически открытыми на 10°. Выбор распашных/откатных и других типов пока не предусмотрен.
@@ -493,7 +506,8 @@ Glow и солнечные лучи, но при Resize комнаты оста
продолжает текущую заливку/Glow-base через тоннель. Отображение уже существующих
дверей, окон и ворот в Static в рамках этой функции намеренно не менялось.
Солнце проходит только через наружные окна. Внутренние окна солнечных лучей не создают.
Солнце проходит только через наружные окна комнаты. Внутренние окна и окна в
независимых стенах солнечных лучей не создают.
<!-- docs-section: devices -->
+7
View File
@@ -98,6 +98,10 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
partitions. Closing one or more planar faces opens the room queue; its
decisions are buffered and applied as one Undo/Redo transaction. Re-selecting
Walls, Reset, pan, pinch and pointer cancellation are not finish actions.
- Opening places every existing opening type on a room wall or a finished
independent Walls segment. A hosted opening moves with that segment; deleting
the segment requires an explicit cascade confirmation. Its physical gap does
not break the structural wall axis used to recognize closed rooms (#185).
- Boundary is contextual: two points on one solid shared wall make the selected
stretch virtual; one click on an existing dashed stretch restores it whole.
Outer walls and room boundaries hidden under independent masonry are not
@@ -175,6 +179,9 @@ layer you cannot see is a layer you cannot edit.
places a square column whose side is the current Thickness value. Neither an
independent wall nor a column creates a room or HA area by itself. A closed
independent-wall ring subtracts only its wall body from room floor.
- 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.
- **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
+8
View File
@@ -200,6 +200,14 @@ fully occluded instead of leaking around its own masonry. The same fail-dark
placement rule applies to window tunnels and exterior door/gate openings;
interior passages remain valid source positions (#92).
An opening with explicit `host:{kind:'partition',id,t}` is resolved from that
partition alone and subtracted full-depth from its raw body before the joined
presentation union. A precisely collinear room wall covering the same interval
is cut as a composite; a crossing/nearby body is not. Host move keeps `t`,
delete requires cascade confirmation, and malformed/orphan hosts remain opaque.
Opening cuts change physical masonry, not the structural wall axes used for
room-face detection (#185).
`physicalBodySet()` separates raw draft/partition/column bodies from computed
junction patches and their joined geometry. Raw bodies remain authoritative for
hit testing, selection, drag, properties, deletion, history and furniture