mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
docs(adr): decide the wall geometry representation
Nine of the last ten specs are one class of defect, 23 fix commits in thirty days, 595 tolerance mentions across ten geometry modules, one every seventh line in resize.ts. The specs rate their own risk at 9 and 10 of 10, and two titles show where that arrived: a nearly orthogonal T junction, and a union failure that must merely damage less. The ADR names three properties of the data model that produce the stream — a float coordinate on a 1/240 step, an identity derived instead of stored, and a wall living in two representations at once — and records four stages against them. Stage 0 is accepted for implementation; the rest are direction, in the same sense as #34. Issue: #282 User-Visible: no
This commit is contained in:
@@ -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