Files
houseplan-card/docs/WALL-THICKNESS.md
T

538 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Wall thickness — the spec
Status: **implemented / evolving** (post beta.4 redesign). 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)
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.
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`.
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.
## 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).
## 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.
**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.
## 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.
### 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.
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.