16 KiB
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). 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; legacy entries
without endpoints keep the midpoint fallback. walls stays in the resize
snapshot.
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.
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; jambs cap the cut; window glass mid-tunnel;
door swing from the inner 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 the symbol offset, physical
wall cut and coloured tunnel. 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).
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; 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, topology and ambiguity guards (#198);
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; a partial
virtual stretch, its solid thick remainders and Undo move as one real resize;
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).
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).
An opening with explicit host:{kind:'partition',id,t} is resolved from that
partition alone and subtracted full-depth from its raw body before the joined
presentation union. A precisely collinear room wall covering the same interval
is cut as a composite; a crossing/nearby body is not. Host move keeps t,
delete requires cascade confirmation, and malformed/orphan hosts remain opaque.
Opening cuts change physical masonry, not the structural wall axes used for
room-face detection (#185).
physicalBodySet() separates raw draft/partition/column bodies from computed
junction patches and their joined geometry. Raw bodies remain authoritative for
hit testing, selection, drag, properties, deletion, history and furniture
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.