mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Волна 3 эпика #674. ARCHITECTURE.md 2330 → 653 строки: система координат прототипа 1489×1053, разделы Hidden Isometric Stage 2/4 (текущее — ISOMETRIC.md, история — ADR 122/570), хроники «Additions v1.28–v1.41» и «Audit follow-ups» сняты; подсистемы — короткое описание со ссылкой на канонический документ. Устаревшее исправлено по коду: дерево Layout (store.py, frontend_registration, весь список модулей), хранилища (config, layout, virtual_lights, trails), ленивые локали de и fr, таблица WS API (31 + 3 команды, #256-проекция, space/delete, files/cleanup без keep, контент через /api/houseplan/content), DevItem без несуществующих полей, отказ help/feedback по support_api. Всё ещё верное и не записанное в другом месте перенесено в канонические документы: DEVICE-PRESENTATION (заметки реализации, Action authority, черновик диалога), RADAR (карта реализации), VACUUM (владение кодом), CANVAS (icon_size, --hp-cell-visual-scale, барьер записи координат), FILTERING (#44, каталог), DECOR-EDITOR §7 (инварианты бэкенда ассетов), WALL-THICKNESS (§1 идентичность и нулевые стены, §2 кэши, §4 hover/туннели/острова, §6 удаление комнаты, §9 failed-core, §11 завершение цепочки и комната по грани), ISOMETRIC (isoPlaneMatrix, iso-overlays, створки, служебные атрибуты), LIGHT (Glow над заливкой #55, формула и screen-смешение), CONFIG-COMPATIBILITY (манифест схемы #33, Masonry-слоты #561, квадратный холст v1.48, устаревшие URL контента, layout/set #356), SCOPE (таблица владельцев при сборке файлов), WARM-REMOUNT §5 (холодная загрузка и визуальная непрерывность), UX-MODES (порядок стартового пространства), PDF-EXPORT (граница реализации), FURNITURE (adopt, BOOT_MAX_MS). Раздел TESTING «Backend quality gates (#42)» и строка DEVELOPMENT о bundle-freshness — из того же разбора, в предыдущем коммите. Issue: #680 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
690 lines
44 KiB
Markdown
690 lines
44 KiB
Markdown
# Wall thickness — the spec
|
||
|
||
Visual reference:
|
||
[docs/assets/wall-thickness-reference.png](assets/wall-thickness-reference.png)
|
||
(look at wall bodies only).
|
||
|
||
Owner intent: thick walls form one continuous hatched body (seamless L and T),
|
||
grow both ways from the room centreline, fills/light stay inside the inner
|
||
contour, displayed area is the clean floor, and sun wedges start at the two
|
||
corners of the globally selected inner or outer opening face.
|
||
|
||
Code: `src/wall-thickness.ts`, render in `src/houseplan-card.ts` /
|
||
`src/space-render.ts`. Sun: `src/sun.ts`. Tests: `test/wall-thickness.test.mjs`,
|
||
`demo/smoke_wall_thickness.mjs`.
|
||
|
||
## 1. Model
|
||
|
||
### Stable stored identity and zero walls (model v10, #282/#306/#478)
|
||
|
||
The decision record behind the stored representation — what the code calls
|
||
«ADR 282» — is `docs/adr/282-wall-geometry-representation.md`; the migration
|
||
matrix is in `CONFIG-COMPATIBILITY.md` (model v8–v10).
|
||
|
||
From model v8 every atomic room-wall interval has a stable record in
|
||
`space.wall_segments[]`. Its `id`, endpoints and `cm` are authoritative;
|
||
`rooms[].wall_ids[]` references the ordered atoms that form each contour.
|
||
`space.walls[]` remains a generated compatibility projection for older readers
|
||
and must not be used as the identity of a wall. A door, window, gate or passage
|
||
on a room wall stores a tagged `{kind:'wall', id, t}` host. Independent
|
||
partitions keep their existing stable IDs. Each accepted segment of an active
|
||
Walls chain is already an ordinary partition; only the chain ordering is
|
||
session-local.
|
||
|
||
Existing legacy plans are not rewritten on read. The model is materialised
|
||
atomically before the first structural edit, by **Optimize plans**, or when a
|
||
legacy plan is imported into a current target. Ambiguous or conflicting geometry blocks
|
||
the operation and leaves the stored plan unchanged. Ordinary settings and
|
||
presentation writes do not trigger the migration. Model v9 made `cm:0` a
|
||
public, canonical wall value; model v10 removes persisted room drafts and keeps
|
||
that value for contour atoms and independent walls.
|
||
There is no separate virtual-boundary entity or editor tool.
|
||
|
||
Every structural writer passes through the same wall-model barrier. A split
|
||
keeps the parent ID on the child containing the old midpoint (then the old first
|
||
endpoint on a tie), while the other child receives a new UUID. Merge, Resize,
|
||
room deletion, opening edits, Undo/Redo/recovery, Optimize and import/export
|
||
apply the same lineage and validation rules before one atomic persistence write.
|
||
Initial legacy IDs are deterministic, so frontend, backend and repeated
|
||
migrations converge; only genuinely new segments get UUIDs.
|
||
`src/wall-segment-model.ts` and `custom_components/houseplan/wall_segment_model.py`
|
||
share `test/fixtures/282-wall-identity-parity.json`. Writer-bypass mutants in
|
||
`scripts/mutation-registry.mjs` guard every structural writer entrance. A `cm:0`
|
||
atom or partition keeps its structural axis and stable identity but contributes
|
||
no masonry body, floor subtraction, paper, opening tunnel or opening host; one
|
||
resolver, `src/zero-walls.ts`, applies `space.zero_wall_style` to flat, static,
|
||
2.5D and light.
|
||
|
||
Per space: `walls: [{ key, cm, a?, b? }]`. `key` remains the quantised midpoint
|
||
and direction (modulo 180°) compatibility lookup; new or rewritten entries also
|
||
carry exact endpoints `a` / `b` in normalised plan coordinates. Config always
|
||
stores centimetres. Old `{key, cm}` data remains readable and is upgraded when
|
||
the affected boundary is edited. One physical
|
||
stretch has one thickness (atomic collinear spans when neighbours overlap only
|
||
partially). For valid plan geometry, **the key is computed from lattice-stable
|
||
endpoints**, never from a storage-rounded approximation of the same node.
|
||
`wallKey` first replaces only a coordinate already within
|
||
`max(pitch · 10⁻⁶, 10⁻⁹)` of its nearest node; arbitrary off-grid geometry is
|
||
not silently snapped. It then quantises the midpoint with `Math.round`. A wall
|
||
whose length is an odd number of grid steps has its midpoint exactly on a
|
||
rounding tie, and the two representations of one vertex — the exact node
|
||
`83/240` and a stored `0.345833333` — otherwise fall on opposite sides of it.
|
||
That is how #258 lost two records whose keys had drifted by one step.
|
||
|
||
Lookup order is exact key, strict same-span `a/b` (both endpoints, either
|
||
direction, within the same storage-noise epsilon), then the legacy
|
||
midpoint/direction fallback. The exact-span step repairs an already affected
|
||
plan immediately without writing it; it never treats a containing parent as
|
||
the same stretch. Parent-to-atomic inheritance remains the separate
|
||
`exactCoveringWall()` / `cmsForPoly()` contract. Explicit Optimize rewrites the
|
||
entry to the stable key and is idempotent after the nine-decimal storage
|
||
round-trip. `scripts/model-invariants.mjs` (`checkWallKeys`, #259) independently
|
||
grades a different stored key as an observation, not a violation: valid exact
|
||
endpoints now prove that the record is resolvable even when its compatibility
|
||
key is old or unparsable. Exact endpoints make a thickness boundary independent of whichever
|
||
room topology later happens to split the same straight line. Normalisation
|
||
merges consecutive pieces only inside a maximal run of equal thickness
|
||
with the same physical ownership: one outer room or the same sorted pair of
|
||
shared rooms. A different thickness, outer/shared transition or change of
|
||
shared-room pair remains a real break. Adjacent zero atoms follow the same
|
||
role-aware compaction; the canonical current model never writes compatibility
|
||
`open_spans` or `open_to`.
|
||
`normalizeWallIntervals()` is the single implementation, shared by explicit
|
||
Optimize and the room-deletion transaction; ambiguous multi-owner geometry is a
|
||
hard breakpoint and fails closed per atom (#299).
|
||
When a maximal wall run crosses a collinear vertex belonging to another room,
|
||
its exact endpoints cover that room's shorter child side too; lookup does not
|
||
depend on the compacted run's midpoint remaining inside every room.
|
||
|
||
Degrade unmatched keys silently on write. Resize / undo / scale transform exact
|
||
endpoints and re-key all touched spans in the same transaction. A moved room
|
||
edge may cover only part of a longer exact wall entry: Resize partitions that
|
||
entry at every collinear overlap boundary, transforms only the covered atoms
|
||
from the immutable pre-drag snapshot and leaves every uncovered remainder on
|
||
its old carrier. Equivalent transforms from two owners collapse to one; a
|
||
conflicting pair fails closed by retaining the source atom. Results deduplicate
|
||
only when canonical exact endpoints **and** centimetres match — the quantised
|
||
compatibility key alone may never erase a record. Generic affine transforms
|
||
retain the historical key-only midpoint fallback and never invent a legacy
|
||
length. Production fixed-topology Resize is stricter: only one whole-edge key
|
||
with one destination can move; an affected partial or ambiguous key rejects the
|
||
candidate before preview/commit. `walls` stays in the resize snapshot. If lossless
|
||
partitioning takes a valid 500-record input above the backend limit, the
|
||
frontend keeps every result so persistence rejects the transaction atomically;
|
||
it does not truncate masonry to make the write fit.
|
||
|
||
The production fixed-topology Resize path does not use the generic affine
|
||
projection for side walls. A rigidly translated moving edge carries every
|
||
breakpoint by the same vector; a side edge that only changes length moves its
|
||
paired topology endpoint and leaves interior thickness boundaries fixed. A
|
||
continuous carrier-coverage and lattice proof runs before preview/commit. It
|
||
compares exact historical debt by record identity and endpoint identity, so an
|
||
old off-grid endpoint is not silently migrated even when its record's other end
|
||
moves, while a new or changed violation rejects the whole candidate. This is
|
||
distinct from the retained generic scale/rotation helper
|
||
used only by isolated historical pure tests.
|
||
|
||
## 2. Growth (centreline ±½)
|
||
|
||
Every thick wall grows **half outward and half inward** from the polygon edge
|
||
(outer and shared alike). Silhouette is wider than the polygon by `cm/2` on
|
||
outer walls. Paper and the content frame grow under that outer half.
|
||
|
||
The exterior silhouette is generated from the boolean union of room
|
||
centrelines and its surviving `outer` atomic intervals. A shared Split edge
|
||
therefore disappears before exterior mitres are built. When Split ends at an
|
||
existing corner, its divider is clipped to the interior side of this envelope:
|
||
the real exterior mitre/bevel and unequal arm depths stay unchanged, while any
|
||
divider thickness remains inside the facade. The same computed geometry is
|
||
used for the full/static/hidden-isometric renderers and light occlusion. The
|
||
paper and masonry paths come from one cached structural pass in flat renderers;
|
||
live HA state ticks do not repeat the boolean topology. Saved room and wall data
|
||
is not migrated or rewritten.
|
||
|
||
Every endpoint of an exterior atomic interval is materialised on a collinear
|
||
boolean-union edge before offsetting. Unequal neighbouring half-depths form a
|
||
hard butt step at that exact endpoint on both the inner and outer faces; the
|
||
larger depth is never tapered or extended over the smaller/zero interval.
|
||
Geometry tolerances are render-space distances and are converted to a local
|
||
dimensionless edge fraction before breakpoint comparison or de-duplication.
|
||
This preserves the same `0 ↔ h` and `h1 ↔ h2` transition at normalized and
|
||
production (`coordScale = 1000`) scales.
|
||
|
||
Per-room rings remain the interior-join and nested-room representation; when an
|
||
acute child ring cannot be subtracted, its atomic interval quads provide the
|
||
safe physical fallback. The full card keeps the structural result in
|
||
`_wallUnionCache`; static cards use a weak server-snapshot cache guarded by a
|
||
structural geometry fingerprint, and cursor or HA state updates never repeat the
|
||
O(N²) `physicalBodySet()` node search.
|
||
|
||
## 3. Body render
|
||
|
||
Production body is the **ring** `outset(poly, half) − inset(poly, half)` per
|
||
room, **union**ed across rooms so shared and T junctions show one continuous
|
||
body with no internal seams (as in the reference plan). The body is painted in
|
||
two layers: a solid fill from global `settings.fill_colors.wall_fill` (default
|
||
opaque white, with its own opacity) **under** the diagonal hatch whose stroke
|
||
matches the wall outline. Normally neither replaces the other. When the body is
|
||
thinner than 3 CSS px on screen, the shared full/static render policy suppresses
|
||
only the hatch so it does not collapse into noise; the solid fill remains. Mitre
|
||
joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
|
||
|
||
At a physical node with **three or more distinct incident rays**, the stricter
|
||
multi-wall rule applies (#249). Shared room ownership and reversed interval
|
||
direction do not create extra rays. One structural node map records the largest
|
||
incident half-depth `H` and the finite `(half-depth, endpoint distance)` supports
|
||
of every co-directional ray. A longer thin support never extends a shorter thick
|
||
support, and no repair may continue either one beyond its saved endpoint (#271).
|
||
Every excessive join is cut back with a straight local bevel and may not extend
|
||
beyond `R = 1.25 × H`. Inside the room union, a bounded mask replaces the legacy
|
||
ring with the complete finite ray strips, retains their overlap through `R`, and
|
||
removes only the remaining excessive pairwise wedge.
|
||
The same bounded rule applies to the exterior half-wall and paper envelope:
|
||
local ray strips are clipped to that physical envelope rather than the room
|
||
centre, so a valid T-junction cannot become a white wedge. This keeps the node
|
||
centre and every arm area-connected without allowing overlap beyond `R` or an
|
||
interior child mitre to change a concave facade. Ordinary two-ray corners retain
|
||
the exact historical `MITRE_LIMIT = 4` contract. This is computed geometry only:
|
||
saved room outlines and wall entries are not rewritten (#261).
|
||
|
||
Every excessive pairwise cut also has a finite-width corridor through its
|
||
offset-line tip into the already empty angular sector (#272). Ending the cut at
|
||
the exact intersection is not sufficient: it creates a polygon hole that SVG
|
||
renders as an enclosed white triangle even though one mathematical point
|
||
touches the exterior. The corridor is bounded by the excess beyond `R`, is
|
||
scale-relative, and is applied to room masonry, final masonry and paper. It
|
||
therefore opens the legal discarded bevel to the exterior without filling it,
|
||
moving the `R` endpoints or shaving an incident wall centreline.
|
||
|
||
Exterior connectivity alone is not sufficient (#275). At a degree-3+ node,
|
||
every finite ray that has a perpendicular partner owns its complete physical
|
||
strip through the local repair window. The effective bevel cut excludes the
|
||
union of those protected strips, then the reconstruction unions them back as a
|
||
boolean fail-safe. A pair remains physically near-orthogonal when its angular
|
||
deviation from 90° is at most `0.25°` (#279); this small drafting tolerance is
|
||
independent of `cell_cm` and does not rewrite the saved axes. Rays are
|
||
classified pair by pair: a diagonal ray in a mixed
|
||
orthogonal/diagonal node remains subject to the bounded #249 bevel unless it
|
||
has its own perpendicular partner. The non-orthogonal #249 fixture therefore
|
||
keeps its approved empty wedge. Protected strips are unioned once for the
|
||
complete node map, because neighbouring repair masks can overlap; a later node
|
||
must not erase a strip restored by an earlier one. The result is still clipped
|
||
to the canonical physical paper envelope and explicit opening slots are cut
|
||
afterwards, so no wall is extended and no real doorway is filled.
|
||
|
||
A short incident ray may end inside the bounded replacement mask, with a
|
||
different shared wall attached at that real far endpoint (#288). The node map
|
||
records that attached strip separately from the incident ray, including its
|
||
own direction, finite length and half-depth. Local reconstruction restores only
|
||
the part of this finite shared strip inside the mask. It never projects the
|
||
short ray to the global radius and never treats an unrelated outer continuation
|
||
as node-owned material. This closes the real `349 / 120 / 5`-step gaps while
|
||
preserving the finite-ray and opening contracts from #271.
|
||
|
||
`NEAR_AXIS_MAX_DEGREES` in `src/near-axis.ts` is the single `0.25°` product
|
||
constant (#290). The masonry pair classifier derives its sine tolerance from
|
||
that source; Walls authoring and explicit Optimize use the corresponding
|
||
minor/major slope. Rendering may tolerate a legacy saved slope, but new Walls
|
||
segments are exact-axis and Optimize changes legacy geometry only after its
|
||
lossy preview is confirmed.
|
||
|
||
Clean-floor consumers subtract the cached, repaired canonical room masonry from
|
||
their source room and take its outer component. The result is clipped to the
|
||
source room on fallback. Openings and independent partitions are deliberately
|
||
excluded from this shared `roomGeom`, so a door does not change the room fill
|
||
and a detached body cannot punch it. Full and Static render paths reuse the same
|
||
structural cache instead of rebuilding wall booleans once per room.
|
||
|
||
**Junction nodes (#302, owner decision #5).** A degree-3+ node closes with a
|
||
FULL mitre, like an ordinary wall intersection on a drawing — the #249 chamfer
|
||
is retired. For every pair of angularly adjacent rays `junctionNodeGeometry`
|
||
builds one additive fan: the mitre is accepted when it sits IN the sector
|
||
(forward along the rays for an ordinary pair, backward for a reflex outer
|
||
corner), within the classic `MITRE_LIMIT` and never past a ray's thick
|
||
support (#271); a reflex pair without a valid mitre closes with the plain
|
||
chord, an ordinary one with a local bevel bounded by the support, the limit
|
||
and twice the pair's depth. The node also gets the exact support quads of its
|
||
rays. All pieces are clipped by the plain-corner facade bound
|
||
(`junctionNodeBound`), so a node cannot grow new facade at a concave vertex.
|
||
`bevelMultiWallBody` survives only as a TARGETED lateral trim for nodes with a
|
||
degenerately short thick support (#271); every other node is purely additive.
|
||
The objective invariant «body ⊇ support strips ∪ fans, inside the facade
|
||
bound» is machine-checked by `junctionContractHoles` in tests and the
|
||
`smoke_junction_holes` wiring probe. Degenerate zero-area rings left by
|
||
coincident chords are dropped.
|
||
|
||
**Visual mitre limit (#309, owner decision 2026-08-25).** A mitre apex may
|
||
protrude at most `VISUAL_MITRE_LIMIT = 1.5` maximal half-depths from the node
|
||
(a square corner of equal depths peaks at ~1.41·h, so right and obtuse
|
||
corners are byte-identical); a longer apex is closed with a flat chamfer
|
||
perpendicular to the apex direction at the limit (`chamferApex`). The rule
|
||
applies to the junction fans; `MITRE_LIMIT = 4` survives only as the sanity
|
||
bound for candidate construction. At a node of three or more canonical rays the
|
||
pair patches are not built at all: a pair patch lives in the sector OPPOSITE
|
||
its pair and painted a step over the thinner strips owning that sector (the
|
||
15/15/30/30 cross of the owner report) — such nodes are closed with the same
|
||
sector fans via a local multi-wall node map inside `linearWallJoinPatches`.
|
||
The #249 machinery (`MULTI_WALL_JOIN_LIMIT = 1.25`, `multiWallBevelCutsAt`,
|
||
room-contour mitres) is untouched.
|
||
|
||
**Pair apex and butt-end trim (#310, owner decision 2026-08-25).** A node of
|
||
exactly two rays keeps the FULL mitre at any length: two walls meet in a
|
||
drawing point, the #309 chamfer does not apply there. What does get removed is
|
||
the butt-end tooth: the deeper wall's rectangular end may poke sideways past
|
||
the outer face of its thinner partner right at the node —
|
||
`pairButtEndTrimWedges` returns, per wall, the addressed wedge (outside the
|
||
partner's apex-side outer face, within 2·halfDepth of the node along the
|
||
axis) which consumers subtract from the owning body before opening cuts. This
|
||
is the SECOND addressed subtraction of the junction pipeline, next to the
|
||
lateral trim of #271; both are strictly local to their node. Two-ray nodes are
|
||
invisible to `junctionContractHoles` (the node map requires 3+ canonical
|
||
rays), so their no-holes contract is a grid probe in the unit suite: masonry
|
||
inside the node neighbourhood equals (strips ∪ patch) − wedges.
|
||
|
||
**Hatch density is physical (#230).** The pattern step is a distance on the
|
||
plan, not a count of coordinate units: `wallHatchStepUnits(cellCm)` returns
|
||
`8 × (5 / cell_cm)`, which is 9.6 cm at every grid scale and exactly the
|
||
historical 8 units at the reference `cell_cm: 5`. The stroke width follows the
|
||
same factor, so the stripe-to-gap ratio is scale-invariant too. The step is
|
||
NOT compensated for zoom — a wall that re-hatches itself as you zoom is the
|
||
defect this rule replaced — and both renderers, interactive and static, read it
|
||
from the same function. Two independent guards fall back to the solid fill: the
|
||
body thinner than `WALL_HATCH_MIN_PX = 3` on screen, and the step itself
|
||
thinner than `HATCH_MIN_STEP_PX = 2` (`wallHatchNeedsSolid`). The step is
|
||
clamped to `[0.5, 80]` units so a pathological `cell_cm` cannot degenerate the
|
||
pattern.
|
||
|
||
A variable-offset join where exactly one adjacent edge has zero depth is a
|
||
local flat cap, not a mitre. Both `inset` and `outset` retain the physical
|
||
edge's offset point followed by the untouched zero-edge vertex (or the reverse
|
||
order when entering the physical edge). This keeps a zero-depth Split free of
|
||
masonry even when it meets a thick wall at a slightly non-collinear angle;
|
||
the cap cannot stretch into a taper along the divider. Joins between two
|
||
positive depths keep the bounded mitre/bevel contract above.
|
||
|
||
Independent partition segments keep flat raw quads for editor identity,
|
||
but exact endpoint↔endpoint and endpoint↔line nodes add computed join patches
|
||
before the presentation union. Each incident ray keeps its own half-depth;
|
||
ordinary corners use the same bounded `MITRE_LIMIT = 4` rule and excessive
|
||
spikes become bevels. A degree-one endpoint receives no patch and therefore
|
||
keeps its flat cap. This topology is render-only: a T does not split or rewrite
|
||
the saved target segment. The live open-outline/rubber-band preview calls the
|
||
same primitive with saved per-segment thicknesses plus the current field value.
|
||
|
||
Openings cut the body full-depth and jambs cap the whole cut. By default the
|
||
complete visible door/window/gate group is always centred on the wall axis,
|
||
including window glass. `flip_v` changes only the direction of door/window
|
||
geometry or the gate's 10° turn; it never translates the symbol toward a wall
|
||
face.
|
||
Association uses wall direction ≈ opening angle (mod 180°), then nearest span —
|
||
never a perpendicular neighbour at a T. One atomic `OpeningWallIndex` is
|
||
authoritative for physical depth/direction, wall cut and coloured tunnel, while
|
||
the shared symbol-placement helper turns that result into the user-visible
|
||
translation. Candidates must be effectively collinear with
|
||
the opening axis (not merely parallel within one grid cell); ties use complete
|
||
opening coverage, signed distance to the inner face, smaller room area and
|
||
stable room id. The index and batch tunnel geometry are cached by space,
|
||
configuration epoch and complete geometry, so HA state ticks change only the
|
||
resolved fill colours. Overlapping openings reserve each physical interval
|
||
once and cannot stack room-fill alpha.
|
||
"Effectively collinear" is deliberately strict: the perpendicular distance to
|
||
the candidate axis may not exceed `max(4% × grid pitch, 1e-9)`. A detached
|
||
parallel room beyond that tolerance is not an opening side; when only one real
|
||
owner remains, its fill continues through the full wall depth. Legacy openings
|
||
outside the same angle/distance contract no longer cut or offset a nearby wall
|
||
and must be re-snapped in the Plan editor.
|
||
At a zero-thickness endpoint, positive arms owned by different room contours
|
||
receive the same bounded mitre patch as arms from one contour; the junction has
|
||
one clean outer corner rather than two butt caps forming a step. The zero wall
|
||
itself remains centreline-based. View paints it before the wall body, which
|
||
masks the part inside each adjoining thick jamb; editors paint it after the
|
||
body so its full stored extent stays visible.
|
||
|
||
Computed zero-wall junction patches cross the polygon-boolean boundary only
|
||
after scale-relative sub-epsilon coordinate stabilisation. They are optional
|
||
local additions: each patch union is transactional, so an invalid, zero-area or
|
||
numerically rejected patch keeps the last valid body and does not prevent later
|
||
patches. This fallback never rounds persisted rooms, walls or open spans to the
|
||
grid and never turns a failure of the mandatory exterior/body/opening passes
|
||
into a successful result. One noisy junction therefore cannot remove otherwise
|
||
valid masonry, paper, floor faces or light barriers for the whole space (#197).
|
||
The same failure isolation covers degree-3+ repair: every node is rebuilt and
|
||
committed independently inside its bounded mask. A malformed local candidate
|
||
therefore keeps that node's previous body without reverting successful repairs
|
||
at unrelated nodes. The exterior paper uses the same `R`-bounded overlap as the
|
||
masonry; it never restarts the cut at the offset origins (#261).
|
||
Pairwise cuts cannot end in point contact: a small local connector crosses the
|
||
offset-line tip so every removed bevel sector is exterior-connected and never
|
||
an enclosed polygon hole (#272). Orthogonal finite strips are additionally
|
||
protected across the complete node map, so overlapping local masks cannot turn
|
||
that exterior-connected sector into an open notch inside a real wall (#275).
|
||
|
||
Runtime normalisation remains lossless for every positive exact thickness
|
||
interval, regardless of its length. The explicit **Optimize plans** maintenance
|
||
action has one deliberately lossy exception (#198): an isolated interval
|
||
strictly shorter than half a grid step inherits two equal collinear neighbours
|
||
when neither endpoint is a room vertex or resolved opening endpoint. End
|
||
fragments, unequal neighbours, chains of short changes and exact half-step
|
||
intervals remain untouched. Candidates come from one pre-change effective
|
||
profile, so cleanup cannot cascade; ordinary rendering and editor Save never
|
||
invoke it.
|
||
|
||
## 4. Floor, fills, light, area
|
||
|
||
- **Inner contour** = `inset(poly, half[])`.
|
||
- Room fills and glow are clipped to the inner contour (not painted into the
|
||
wall hatch).
|
||
- The free tunnel cut by a door, window or gate continues the effective room
|
||
fill through the wall body instead of exposing the neutral paper below the
|
||
plan. On an outer wall the one adjacent room owns the complete depth. On a
|
||
shared wall each room owns its half, with a hard colour change exactly on the
|
||
wall centreline. This is a base-fill layer only: Glow and sun remain above it
|
||
with their existing aperture, clipping, colour and opacity.
|
||
- A zero-thickness wall or orphan opening has no coloured tunnel. Mixed-thickness legacy
|
||
spans are clipped to their actual atomic wall bodies.
|
||
- Glow leaving through a door uses the clear rectangular opening tunnel: its
|
||
sector is the intersection of the doorway spans at the near and far inner
|
||
faces. The two jamb returns therefore clip off-axis light. Zero-thickness
|
||
walls cannot host openings.
|
||
- Displayed **m²** = area of the inner contour (clean floor). Wall-length
|
||
rulers and opening anchors stay on the centreline.
|
||
- With no thickness, inner = poly (parity with pre-thickness behaviour).
|
||
- View room hover is a late plain-SVG wash plus wide/narrow accent strokes over
|
||
the clean-floor geometry; the room information window shows the room's average
|
||
LQI and the formatted clean-floor area. The hover deliberately uses no CSS/SVG
|
||
filters: promoting a filtered sibling makes Chromium briefly recompose and
|
||
brighten the isolated screen-blended Glow layer.
|
||
- Opening-tunnel faces are one simple union contour per connected physical span:
|
||
thickness steps are vertices on the outer envelope, never touching translucent
|
||
rectangles, and both halves use the same nonzero winding across their tiny
|
||
centre overlap, so fractional rasterisation can neither cancel the fill into a
|
||
seam nor stack its opacity. `render/opening-tunnels.ts` only projects these
|
||
immutable inputs.
|
||
- A fully nested room is legal (`polyContainsPoly`): the parent's floor, data
|
||
fill and Glow base are evenodd paths with the island rings as holes
|
||
(`islandsOf`).
|
||
|
||
## 5. Sun
|
||
|
||
Wedges do not draw through wall bodies. The global `settings.sun_ray_origin`
|
||
selects their source face. `inner` (default and legacy fallback) translates the
|
||
full span from the centreline by `+d/2` along the receiving room's inward
|
||
normal, so both sides begin at the room-side corners. `outer` translates it by
|
||
`-d/2` and includes only the rectangular physical window tunnel before the
|
||
receiving room's inner contour; adjacent wall and exterior space stay clipped.
|
||
The nominal length and gradient begin at the selected span. `d = 0` makes both
|
||
modes identical. `hide_openings` hides the symbol only.
|
||
|
||
## 6. Tool / hooks / i18n
|
||
|
||
Plan-editor tool «Thickness»; hover whole wall; cm/in from HA; exact `0..100`
|
||
is valid for room walls and partitions, while empty/invalid input is
|
||
rejected; apply-to-room. Hooks: `data-hp="wall"`. i18n en/ru. Changing a
|
||
positive wall to `0` is rejected atomically while any opening uses it. Hit
|
||
widths and junction ambiguity are measured in CSS pixels through the live
|
||
viewBox, so a zero wall stays editable although it paints as a one-pixel line.
|
||
|
||
**Draw with thickness.** The Plan toolbar's **Walls** button carries its session
|
||
thickness field immediately on the right (default **15 cm**, or inches when HA
|
||
is imperial). Every accepted segment is immediately persisted as an ordinary
|
||
partition with the value used when it was placed. If a segment creates rooms,
|
||
consumed boundary atoms receive
|
||
their source value and unconsumed atoms become independent walls with the same
|
||
value; existing shared masonry remains authoritative. Empty / 0 leaves the new
|
||
wall at zero thickness. Live preview follows the rubber-band while drawing.
|
||
Split does
|
||
not use this field. The Thickness tool remains for later edits.
|
||
|
||
**Deleting a room.** The accessible confirmation offers two explicit physical
|
||
consequences. **Keep walls** converts only that room's exclusive positive solid
|
||
wall intervals into exact independent partitions, preserving their centimetre
|
||
thickness and rehosting openings to those partitions. Shared intervals remain;
|
||
exclusive zero-thickness edges become zero-thickness partitions. **Delete
|
||
walls** removes the
|
||
room without that conversion and cascades only openings owned by its exclusive
|
||
walls. Shared masonry, existing partitions, partition-hosted openings and their
|
||
physical thickness remain intact in both cases. The room, wall profile,
|
||
partitions and openings are one Undo/Redo and persistence transaction. The
|
||
remaining wall profile is normalised against its post-delete ownership, so an
|
||
equal thickness cannot be compacted across an outer/shared boundary or across
|
||
two different shared-room pairs.
|
||
`src/room-deletion.ts` plans the whole operation before any mutation;
|
||
confirmation is an accessible `hp-dialog`, never native `confirm()`. The same
|
||
command rewrites room references in every marker: a direct `room_id` is deleted
|
||
(remapped to the survivor for Merge) and exact values in cross-space
|
||
`vacuum.segment_map` are deleted/remapped. History snapshots only these fields,
|
||
so Undo/Redo and write rollback stay atomic with geometry while unrelated marker
|
||
and vacuum fields remain live (#477).
|
||
|
||
## 7. Out of scope
|
||
|
||
Decor-line thickness, per-side finish, auto-from-backdrop, plan-wide default.
|
||
|
||
## 8. Testing
|
||
|
||
Unit: ring closed at corners; half-out; inner area; atomic partial shared;
|
||
zero-wall T mitre; angle-aware opening; 45° wall; T-junction; detached parallel
|
||
room; nested-room tie; partially out-of-span legacy opening; overlapping
|
||
opening de-duplication; shared symbol/cut/tunnel rejection; thick-door tunnel
|
||
clipping and room-side colour ownership; whole and
|
||
atomic rekey after edge/scale, including a long exact record only partly
|
||
covered by the moved edge, key collisions and the 500-record boundary; corner Split exterior equality across
|
||
0/1/15/100 cm, unequal arms, both windings and convex/concave endpoints;
|
||
production-scale `0 ↔ 10`, `10 ↔ 20` and `1 ↔ 100` collinear transitions at
|
||
their exact endpoint; full 8-room/25-wall/3-cut virtual-junction resilience,
|
||
ULP-equivalent patch vertices, per-patch failure isolation and record-order
|
||
invariance (#197); explicit Optimize-only collapse of a sub-half-step isolated
|
||
thickness island with strict threshold and ambiguity guards (#198), including
|
||
the proven `equal → micro → equal` case beside exactly one room T-node while
|
||
opening endpoints and spans between two room topology nodes stay protected
|
||
(#273); exact `cell_cm: 5` and `cell_cm: 1` orthogonal T fixtures derived from
|
||
the two #275 owner backups, including overlapping neighbouring node masks,
|
||
mixed depth and the unchanged non-orthogonal #249 wedge;
|
||
the real `349 / 120 / 5` short-ray handoff to a 20 cm shared wall at
|
||
`cell_cm: 1/5/30`, reversed endpoints and permuted input (#288);
|
||
exact parent-run thickness inherited by atomic children across a zero-depth
|
||
neighbour, without partial-span leakage (#201/#306).
|
||
Role-aware compaction tests keep `shared(A,B)`, `outer(A)` and `shared(A,C)` as
|
||
separate records even at equal thickness, while equal neighbouring atoms within
|
||
one role still compact; the real first-floor fixture proves explicit Optimize
|
||
is invariant-free and idempotent (#299). The edit-walk real-plan seeds 1 and 3
|
||
exercise the same Optimize and Keep-walls entry points.
|
||
Browser: seamless frame; fill not in hatch; m² drops with thickness; partial
|
||
shared walls and mixed-thickness shared walls keep a visible disabled Resize
|
||
handle and cannot split or re-key their atomic records
|
||
(`demo/smoke_wall_thickness.mjs`, `demo/smoke_zero_walls.mjs`,
|
||
`demo/smoke_resize_wall_thickness.mjs`); an eligible uniformly thick exact
|
||
wall moves through the fixed-topology safe pipeline and Undo restores its
|
||
source (`demo/smoke_room_resize.mjs`);
|
||
the zero-wall preview paints above the real body; sun starts at the selected
|
||
inner/outer opening corners; nav mode restores after `can_write`; a 1 cm body uses
|
||
solid-only in both full and static cards while a 20 cm body keeps its hatch;
|
||
door/window/gate tunnels repeat outer/shared room fills without an axis seam
|
||
(`demo/smoke_opening_tunnel_fill.mjs`); corner Split keeps the same facade in
|
||
Plan/View/kiosk/static/isometric surfaces and the light barrier
|
||
(`demo/smoke_split_corner_wall.mjs`); a Split followed by all-room thickness
|
||
keeps the neighbouring zero facade clear across Plan, View, static, hidden Iso
|
||
and light masonry (`demo/smoke_wall_thickness_transition.mjs`); the complete
|
||
#197 fixture keeps the same non-empty canonical path across Plan, View, kiosk,
|
||
static, hidden Iso, paper/clean-floor and light/sun consumers while theme and HA
|
||
state ticks reuse the structural geometry
|
||
(`demo/smoke_junction_patch_resilience.mjs`); Optimize Preview/Cancel/Apply/
|
||
server Undo for guarded micro-interval cleanup
|
||
(`demo/smoke_optimize_micro_interval.mjs`); dense protected-strip sampling
|
||
through Plan, View, kiosk, Static, hidden Iso, paper/clean-floor and light
|
||
consumers (`demo/smoke_multiwall_strip_containment.mjs`); exact coincident
|
||
partition reconciliation, opening rehost, reload, one-shot Undo and resulting
|
||
Boundary/Thickness targets (`demo/smoke_optimize_coincident_partition.mjs`). The local
|
||
`scripts/wall-strip-containment.mjs` gate accepts external backups without
|
||
copying their contents into Git and checks raw, Optimize preview, applied
|
||
canonical storage and reload states.
|
||
|
||
`OptimizeDependencies` (`src/plan-optimizer.ts`) is a narrow seam: the
|
||
large-house benchmark substitutes a no-op and the unit contract counts
|
||
per-space calls; a source-ownership assertion fails if a render/pointer module
|
||
imports the reconciliation helper.
|
||
|
||
### Junction tooling (#302)
|
||
|
||
Purpose-built checks for node material: `junctionContractHoles` (the objective
|
||
«body ⊇ strips ∪ fans inside the facade bound» invariant, self-checked against
|
||
a deliberately holed fixture), the `smoke_junction_holes` wiring probe that
|
||
verifies the same contract against the rendered `d` path, sixteen close-up
|
||
golden scenes (`junction-*`) plus the owner's repro scene, and the
|
||
`junction-*` mutants in `scripts/mutation-gate.mjs`.
|
||
|
||
## 9. Independent partitions and columns
|
||
|
||
Their thickness is stored directly in centimetres: 0–100 cm for partition
|
||
segments, 1–150 cm for a column's outer side/diameter. Invalid input
|
||
blocks the commit and reports the valid range; no editor path silently clamps
|
||
it. These bodies are unioned with room-wall bodies only after door/window/gate cuts,
|
||
so an opening cannot punch a coincident independent wall. They are subtracted
|
||
from the cached clean floor, and the same body set is used by Glow and sun-ray
|
||
occlusion even when borders are hidden. A source inside/on a physical body is
|
||
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).
|
||
|
||
The canonical result is component-aware (#278). A valid room body remains the
|
||
primary component; every optional independent body and the exterior-shell merge
|
||
is added transactionally. If two individually valid operands cannot be unioned,
|
||
the latter is retained as a separate SVG/occlusion component and the result is
|
||
`degraded-extra`, so one local boolean failure cannot erase the whole floor.
|
||
Plan, View, Static, hidden Iso, paper and light use the same components, while
|
||
`roomGeom` excludes independent bodies so room area remains unchanged. This is
|
||
read-only recovery: strict Optimize and every physical-geometry edit reject a
|
||
degraded candidate and never silently delete or rewrite the offending object.
|
||
A core room-body failure is `failed-core`: it sits outside the optional-union
|
||
fallback and activates fail-dark rendering.
|
||
|
||
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).
|
||
|
||
Explicit Optimize has one stricter reconciliation pass (#276/#281/#296). It
|
||
atomizes an independent wall at consecutive solid room-wall and hosted-opening
|
||
boundaries. Every positive section is proved independently: one outer owner or
|
||
exactly two shared owners with one effective thickness, and no column,
|
||
second partition or conflicting opening. Proven sections are absorbed even
|
||
when several consecutive room intervals cover the source; unproven sections
|
||
are recombined into deterministic residual partitions. Hosted openings are
|
||
materialised at the same centre/angle on a proven room wall or rebound to the
|
||
single residual that contains them. The canonical thickness is
|
||
`max(roomCm, partitionCm)`, exactly the union envelope of centred coincident
|
||
bodies. The pass is immutable, idempotent and followed by
|
||
the common whole-plan geometry preflight; rendering, Resize and ordinary Save
|
||
never perform it implicitly.
|
||
|
||
`physicalBodySet()` separates raw 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
|
||
magnet semantics. Flat full/static render, hidden isometric, clean floor, Glow,
|
||
sun and source placement consume the joined set through the canonical masonry
|
||
pass, so an old butt face cannot become a visible seam or a false light barrier.
|
||
Exact near-misses remain separate, an interior↔interior X crossing keeps normal
|
||
boolean-union semantics, and malformed legacy segments fall back opaque without
|
||
writing configuration.
|
||
|
||
## 10. Architectural connection overlay (Walls tool)
|
||
|
||
When **Walls** is active in the Plan editor, a derived
|
||
pointer-transparent SVG layer exposes the centre axes of completed room walls
|
||
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 presentation
|
||
axes; 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.
|
||
Inside a 12 CSS px hit zone, an endpoint wins over every line and grows to
|
||
10 cm. Otherwise the nearest solid line receives one 10 cm dynamic node: the
|
||
raw pointer is projected onto that line, then quantized by the grid step along
|
||
the line from its stable start. This keeps diagonal connections wall-bound even
|
||
when neither resulting coordinate is a global grid multiple. The same resolver
|
||
runs again on click, so hover is only a preview and never authoritative.
|
||
|
||
Endpoint and line candidates override the normal grid and Shift/45° result.
|
||
Outside the hit zone, the ordinary snap contract (`CANVAS.md` §9.3–9.4) is unchanged. A line connection adds only the
|
||
new segment endpoint; it does not split or rewrite the existing wall. The
|
||
current anchor is excluded to prevent zero-length segments. The static geometry
|
||
is cached by structural editor state; pointer movement changes at most the
|
||
single active candidate and never writes config, layout or storage.
|
||
|
||
A separate diagnostic projection is present throughout the Plan editor (#296),
|
||
including tools other than **Walls**. For every independent wall
|
||
segment with a positive exact collinear overlap against another wall, it keeps
|
||
that source segment's complete axis and original endpoints visible. It is
|
||
painted after every wall body and zero-thickness axis and before openings, selection
|
||
chrome and transient previews. The layer is `pointer-events:none`,
|
||
`aria-hidden`, absent from View and cached by structural revision; it neither
|
||
deduplicates source identities nor participates in the architectural snap
|
||
resolver above. The 1 CSS px non-scaling axis and physical 5 cm nodes therefore
|
||
diagnose an otherwise invisible Resize blocker without changing any hit target.
|
||
|
||
## 11. Planar wall faces
|
||
|
||
Every completed Walls segment is persisted immediately as an ordinary
|
||
`partition`; only its ordered chain membership remains in memory. On the click
|
||
path only, an immutable planar graph is built from structural room edges and
|
||
partitions both before and after the latest segment. Unlike the
|
||
presentation/snap snapshot, this
|
||
face graph ignores door/window/gate/passage cuts; zero-thickness wall axes remain
|
||
structural graph edges even though they have no masonry body.
|
||
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
|
||
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 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.
|
||
`splitRoomPath()` is a strict partition: the two parts' areas must sum to the
|
||
original within a relative 1e-6 epsilon, otherwise the cut is rejected.
|
||
|
||
The terminal active path remains session-local while the resulting room dialogs
|
||
are open. Create/Keep-as-walls answers are buffered; Cancel/Esc discards all
|
||
answers and leaves the already persisted partitions unchanged. The final answer
|
||
revalidates the whole batch and applies accepted rooms while consuming only the
|
||
coincident partitions used by those rooms in one history/config transaction.
|
||
Graph construction never runs on pointermove, Home Assistant state updates or
|
||
ordinary rendering.
|
||
|
||
**Finishing a chain (#294, #477).** Changing Plan tool, editor or floor, `Esc`,
|
||
the tray's Reset, route/hash departure and a room-face batch whose every face
|
||
was rejected all finish an open chain through one bounded lossless finalizer
|
||
(`finalizeWallChainSpace`, `src/writer-fixed-point.ts`). It merges only the
|
||
seed-connected compatible collinear run, rehosts its openings and reconciles
|
||
only surviving positive seed partitions proven coincident with room masonry; the
|
||
cloned candidate crosses the current-model identity barrier, one local
|
||
physical/junction proof and storage canonicalization before atomic adoption.
|
||
Rejection keeps the visible chain and the original config. It adds no history
|
||
command and never sweeps unrelated legacy debt. Pan, pinch, pointer cancellation
|
||
and suppressed clicks never finish a chain or append a segment; a finished chain
|
||
is not resumed after reload or remount.
|
||
|
||
Active-chain Undo preserves the complete record of every surviving partition,
|
||
including its stable id; only a genuinely new edge receives a new identity. Each
|
||
segment history snapshot carries session-only chain seed ids: while the chain is
|
||
active the snapshot is literal and Undo removes one point; after finish,
|
||
Undo/Redo passes that seed scope through the same lossless finalizer, so hidden
|
||
collinear seams cannot return as durable Optimize debt (#477).
|
||
|
||
**Room from an existing face.** With no active chain, a Walls click queries the
|
||
smallest exact unoccupied bounded face at the raw point; boundary/snap hits and
|
||
desktop `Shift+click` still draw. Without an exact face, `src/wall-face-repair.ts`
|
||
may plan one endpoint→endpoint or endpoint→solid-line move of at most 2 physical
|
||
cm. Room vertices and endpoints of partitions that host an opening never move,
|
||
multiple valid repairs fail closed, and the immutable proposal is revalidated
|
||
against current geometry before use. Move and room are one history/config
|
||
transaction; cancelling or rejecting the room applies nothing.
|