mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
@@ -0,0 +1,147 @@
|
||||
# ADR #282 — Wall geometry representation
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/282
|
||||
- Status: accepted as direction; stage 0 accepted for implementation
|
||||
- Date: 2026-08-24
|
||||
- Related: #34 (frontend decomposition), #264 (resize controller slice),
|
||||
`docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`
|
||||
|
||||
## Context
|
||||
|
||||
Nine of the last ten specifications are the same class of defect: #271, #272,
|
||||
#273, #275, #276, #277, #278, #279, #280, preceded by #249, #250, #253, #258 and
|
||||
#261. Over thirty days the wall geometry took 23 fix commits and
|
||||
`src/wall-thickness.ts` alone took 46. The ten geometry modules contain 595
|
||||
mentions of a tolerance or an epsilon; `src/resize.ts` has 154 in 1017 lines,
|
||||
one every seventh line. The specs rate their own risk at 9 and 10 out of 10.
|
||||
|
||||
Two titles show where this has arrived. #279 is "a nearly orthogonal T
|
||||
junction" — a defect caused by a deviation no one can see. #278 is "a local
|
||||
extra-body union failure must not blank the whole masonry" — a specification
|
||||
written not to stop polygon unions from failing but to reduce the damage when
|
||||
they do.
|
||||
|
||||
Three properties of the representation produce that stream. They are properties
|
||||
of the data model, not of any algorithm, and no amount of care in the algorithms
|
||||
removes them.
|
||||
|
||||
**A coordinate is a float in 0..1 with a step of 1/240.** A lattice node is
|
||||
`k/240`, which has no exact binary representation. A round trip through storage,
|
||||
a multiplication by `NORM_W` for rendering and a midpoint computation each
|
||||
introduce a difference in the last bits. #258 and #279 are that. So is the
|
||||
ever-recurring non-idempotence of Optimize (#248): it looks for noise it cannot
|
||||
remove, because the exact value it would write does not exist.
|
||||
|
||||
**A wall's identity is derived rather than stored.** `wallKey` is a quantised
|
||||
midpoint plus an angle bucket, joined into a string. For a wall whose length is
|
||||
an odd number of steps the midpoint lands exactly on a rounding tie — measured
|
||||
in #258. The worse consequence is structural: because identity is derived from
|
||||
geometry, every editing operation must re-key its records
|
||||
(`rekeyWallsAfterMove`). One path that forgets loses a thickness record (#253);
|
||||
an import that forgets leaves a dangling reference (#244, #252).
|
||||
|
||||
**A wall exists in two representations at once.** A room polygon edge, and a
|
||||
separate thickness record addressed by key; an independent partition is a third
|
||||
form of the same thing. "Is this edge part of a contour" is therefore computed
|
||||
(`edgeKinds`, `sharedSegsOf`, `atomicPolyForRoom`) rather than stored. The
|
||||
junction is drawn by boolean operations over offset polygons — the most
|
||||
float-sensitive construction available. #197, #249, #261, #270, #272, #275 and
|
||||
#278 all live there.
|
||||
|
||||
The owner states the target model in one sentence: a segment with endpoints on
|
||||
grid nodes and a thickness; either part of a contour or not; openings lie on it.
|
||||
That is exactly the model which removes the first two properties by
|
||||
construction — a deviation from vertical becomes inexpressible rather than
|
||||
caught by a tolerance.
|
||||
|
||||
It hides one thing that matters. Thickness belongs to the edge *between two
|
||||
rooms*, and at a node where three walls of different thickness meet the shape of
|
||||
the junction does not follow from any one segment: the node has to be an object
|
||||
in its own right. Separately, a diagonal between two lattice nodes is legal
|
||||
while its offset contour has irrational vertices — integers remove identity and
|
||||
topology problems, not the arithmetic of mitres.
|
||||
|
||||
## Decision
|
||||
|
||||
Change the representation in four independently shippable stages. Each stage
|
||||
removes a named class of defect and is worthless as a partial migration, so each
|
||||
must land whole. Only stage 0 is accepted for implementation now; stages 1 to 4
|
||||
are accepted as direction, in the same sense as #34 — a target, not a mandate to
|
||||
create the files up front.
|
||||
|
||||
### Stage 0 — a representation invariant
|
||||
|
||||
Measure what is actually stored: for every coordinate in the model, how far it
|
||||
is from the nearest lattice node, in steps. Report three populations separately,
|
||||
because they are different problems:
|
||||
|
||||
- **exactly on a node** — nothing to do;
|
||||
- **near a node but not exact** (deviation far below a step) — this is the noise
|
||||
class, the one that produces #258 and #279, and the one Optimize claims to
|
||||
remove and cannot;
|
||||
- **far from a node** — geometry the current model legitimately allows, since
|
||||
nothing forbids an off-grid vertex today.
|
||||
|
||||
Stage 0 fixes nothing. It converts a silent property into a measured one and
|
||||
answers a question nobody can answer today: how much of a real plan is in the
|
||||
noise class, and where it comes from. It also gives Optimize a convergence
|
||||
criterion that can be checked instead of asserted.
|
||||
|
||||
Deliberately not a red gate. The shipped fixtures contain authored coordinates
|
||||
such as `0.06` and `0.2875`, which are 14.4 and 69 steps from a node — legal
|
||||
today. A check that fails on them would be switched off in a week.
|
||||
|
||||
### Stage 1 — stored identity
|
||||
|
||||
Give every wall segment a stable `id`. Rooms reference segment ids; thickness
|
||||
lives on the segment. Re-keying stops being a thing that exists, which removes
|
||||
#253, #258 and half of #248 as possible defects. The schema gains a field, old
|
||||
keys stay readable, and nothing in the renderer changes. This is the largest
|
||||
reduction in risk per unit of work in the whole plan, and it does not depend on
|
||||
any other stage.
|
||||
|
||||
### Stage 2 — an integer lattice in storage
|
||||
|
||||
Endpoints become node indices rather than floats. Equality is exact, midpoints
|
||||
are exact when kept in doubled indices, and orthogonality is a comparison rather
|
||||
than a tolerance. A single migration with a report replaces Optimize's endless
|
||||
attempt at the same thing. The honest cost: a `STORAGE_VERSION` change, a
|
||||
migration, and matching edits to backend validation and to import/export.
|
||||
|
||||
### Stage 3 — one planar graph
|
||||
|
||||
Nodes, edges carrying thickness and kind, rooms as cycles of edges. A shared
|
||||
wall becomes one object instead of two coincident polygon edges, and "part of a
|
||||
contour" becomes a stored fact. This is the owner's sentence expressed as a
|
||||
model.
|
||||
|
||||
### Stage 4 — junctions in closed form
|
||||
|
||||
At a node of degree n with known thicknesses, sort the rays by angle and compute
|
||||
the junction outline directly. No polygon booleans, therefore no slivers, no
|
||||
"retained versus discarded wedge" ambiguity and no failure mode where one union
|
||||
blanks the masonry.
|
||||
|
||||
## Consequences
|
||||
|
||||
Data already off the lattice does not fix itself. What to do with a vertex
|
||||
3·10⁻⁷ from a node is a product decision for the owner, not a technical one, and
|
||||
stage 0 exists to size it before that decision is taken.
|
||||
|
||||
Diagonals keep irrational offsets. Stage 4 must be tolerant there by design
|
||||
rather than by accident, and that tolerance is legitimate — unlike the tolerances
|
||||
that currently stand in for exact identity.
|
||||
|
||||
None of this cancels an open P1. The owner's installation has to work now, so
|
||||
the visible defects still get fixed on the current representation, and the
|
||||
stages proceed alongside.
|
||||
|
||||
The process consequence is immediate and independent of the code. Nine parallel
|
||||
specifications currently edit one subsystem, each rating its own risk at 9 or 10
|
||||
out of 10, and they list each other as related (#277 → #276, #278; #278 → #276,
|
||||
#277). That is one task cut into nine branches which will conflict on merge.
|
||||
#279 has already been accepted by self-review under an owner decision, which is
|
||||
the process yielding to the throughput of the defect stream. The recommendation
|
||||
recorded here: admit no new specification in this subsystem except one that
|
||||
fixes something visible on a live installation, and spend the freed capacity on
|
||||
stages 0 and 1.
|
||||
Reference in New Issue
Block a user