Files
houseplan-card/docs/WALL-THICKNESS.md
T
2026-08-24 10:43:33 +03:00

26 KiB
Raw Blame History

Wall thickness — the spec

Status: implemented / evolving (post beta.4 redesign). Visual reference: docs/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 room-side corners of an opening.

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

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. Open boundaries refuse thickness. 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 solid pieces into each maximal run of equal thickness; a different thickness or a virtual gap remains a real break. Likewise, touching/overlapping open_spans of the same room pair are stored as one span; pair ownership remains a hard boundary so Split can derive exact open_to links. 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. Legacy entries without endpoints keep the unambiguous whole-key/midpoint fallback and are never split by inventing a length. 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.

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, unioned 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.

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.

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 draft/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 an open_span endpoint, real arms owned by different room contours receive the same bounded mitre patch as arms from one contour; a virtual T therefore has one clean outer corner rather than two butt caps forming a step. The virtual segment 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 and live preview stay visible.

Computed virtual-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, virtual span, orphan opening or opening associated only with an unfinished room draft 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; a zero-depth wall keeps the original centreline sector.
  • 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. Their full source span is translated from the centreline by half the wall depth along the receiving room's inward normal, so both side rays begin at the two room-side corners of the window opening. Clip to the receiving room's inner contour. d = 0 keeps the previous centreline/full-span wedge. hide_openings hides the symbol only.

6. Tool / hooks / i18n

Plan-editor tool «Thickness»; hover whole wall; cm/in from HA; empty/0 clears; 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 persisted draft segment remembers the value used when it was placed. Finishing an open chain transfers those values to the resulting independent walls. 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 thin. Live thick 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, virtual gaps and zero-thickness edges are not materialised. 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.

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; virtual-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; exact parent-run thickness inherited by atomic children when closing a virtual neighbour, without partial-span leakage (#201). Browser: seamless frame; fill not in hatch; m² drops with thickness; partial shared walls, mixed-thickness shared walls and walls containing a partial virtual stretch keep a visible disabled Resize handle and cannot split or re-key their atomic records (demo/smoke_wall_thickness.mjs, demo/smoke_resize_virtual_thick.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 virtual rubber band paints above the real body; sun starts at the room-side 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.

9. Independent partitions, drafts and columns

Their thickness is stored directly in centimetres: 1–100 cm for draft and 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). It may remove a partition only when its axis is endpoint-to-endpoint identical to one uniform, solid outer interval owned by one room or shared interval owned by exactly two rooms, and no draft, column, second partition or conflicting opening makes the result ambiguous. Hosted openings are materialised at the same centre/angle as ordinary room-wall openings. The canonical thickness is max(roomCm, partitionCm), which is exactly the union envelope of the 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 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 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.