ADR 0011 — A transient edit preview preserves ring order; only durable writes rewind
Status: accepted · Amends: — · Amended by: —
Context
Two rules the store enforces on every write collided, and the collision silently rewrote land.
Rule one — rings are wound RFC 7946 on the way in. normaliseRing (in
packages/core/src/utils/geometry.ts) reverses a ring whose signed area has the wrong sign,
so an exterior ring is always counter-clockwise and a hole always clockwise. This is
load-bearing, not cosmetic: a wrongly-wound hole becomes a second exterior ring and the
parcel's area is then the sum of the rings rather than the difference — a number that goes
on a deed. FeatureStore._update applies it to every edit, exactly as _add applies it to
every ingest.
Rule two — an interactive edit addresses corners by position. The vertex tool captures a
set of VertexRefs at pointer-down — { feature, part, ring, index } — and replays them on
every pointermove (plugin-edit/src/tools/vertex.ts). Each frame dispatches a transient
MoveVerticesCommand, whose rewrite does positions[ref.index] = to against the geometry
currently in the store.
Now drag a triangle's apex straight down, across its base and out the far side:
- The apex starts above the base; the ring
[BL, BR, apex]is counter-clockwise.ref.indexfor the apex is2. - Somewhere mid-drag the apex crosses the base. The triangle is now a perfectly valid triangle wound the other way (clockwise).
_updatenormalises the preview and, seeing a clockwise exterior, reverses the ring. The apex is now at index0; a base corner is at index2.- The next
pointermovewritespositions[2] = to. It moves a base corner to the cursor. - For the rest of the gesture the drag rewrites the wrong corners. No error, no log. On release, a parcel with two corners in the wrong place is committed.
Measured in plugin-edit/src/edit.test.ts ("vertex drag across the polygon"), against the
pre-fix code: an untouched base corner ends up 33 m from where it started.
The preview/commit split (ADR 0009) did not help. It changed
which writes are validated and recorded in history; it did not change the fact that a
transient preview still goes through _update and is still normalised. The reversal happens
on the preview.
Decision
A transient preview write preserves the ring's coordinate order. Ingest and the durable commit still rewind.
_update grows an option:
_update(
features: readonly BlaeuFeature[],
options?: { readonly rewindRings?: boolean }, // default true
): readonly BlaeuFeature[]
which threads to normaliseGeometry(…, rewind) and on to normaliseRing, where rewind === false skips the open.reverse() step and nothing else — the ring is still quantised to the
CRS grid, still de-duplicated, still closed, and still rejected if it collapses to fewer than
three corners or to zero area.
GeometryEditCommand.execute passes { rewindRings: !this.transient }. So:
- every preview frame (
transient) leaves the ring order exactly as the edit produced it, and the positional refs captured at pointer-down keep addressing the same corners for the whole gesture — even across the frame where the winding flips; - the durable commit (
CommitEditCommand, never transient) and every ingest (_add,materialise) rewind as before.
rewindRings defaults to true, so every other caller — the built-in UpdateFeaturesCommand,
undo/redo, a programmatic edit — is unchanged.
Alternatives rejected
Recompute the refs from the pre-edit geometry after each frame. Keeps the store's winding rule untouched, which is the attractive part. Rejected because it does not survive insert-then-drag: grabbing a midpoint inserts a vertex and immediately drags it in one gesture, so the pre-edit ring has one fewer vertex than the one being addressed and every recomputed index is off by one past the insertion point. A fix that needs a special case for the second most common edit gesture is not a fix.
Match corners by coordinate in the tool, re-resolving each ref against the ring on every
frame. Rejected because it fights coincident vertices — two parcels sharing a corner, which is
the normal state of a cadastre — and because it duplicates, less well, what normaliseRing
already knows about the ring it just rewrote. The honest statement is narrower: a preview is
not an ingest, and only an ingest owes RFC 7946 winding immediately. That belongs at the write,
not in every tool that holds a positional ref.
Stop rewinding altogether, and let the store keep whatever winding an edit produced. Rejected outright: a wrongly-wound hole becomes a second exterior ring and the parcel's area becomes the sum of the rings rather than the difference — a number that goes on a deed. Everything the store keeps is still wound; only a scratch frame under the user's finger is not.
Consequences
- The reported critical is fixed. Dragging a vertex clear across the polygon now tracks the grabbed corner the whole way and leaves the others exactly where they were; the test asserts all three, plus that the committed parcel is still wound RFC 7946.
- No stored parcel is ever wound the wrong way. The winding invariant is unchanged for everything that survives a gesture: the durable commit rewinds, so a parcel the user inverts mid-drag lands committed as a correctly-wound ring. The only geometry that is ever held un-rewound is a per-frame preview, which is overwritten on the next frame and replaced by the rewound commit on release. A reversed ring is the same polygon — same corners, same area, same rendering — so nothing downstream that reads a preview (the renderer, a hit test, the topology index, which all key on coordinates, not order) sees anything different.
- It also fixes insert-then-drag. Grabbing a midpoint inserts a vertex and immediately drags it, one gesture. Because nothing reorders the ring until the post-gesture commit, the refs the insert handed back stay valid through the drag — a case a "recompute from the pre-edit geometry" fix would have had to special-case, because the pre-edit ring has one fewer vertex.
- The fix lives at the write, not in the tool, for the reasons given above — which means it applies to every tool that addresses a ring positionally, including ones nobody has written yet, rather than to the two that happened to report the bug.
Follow-ups — a positional ref must not outlive the commit's rewind
Splitting the preview order (un-rewound) from the committed order (rewound) means the ring
does still get reordered once, at the commit. Anything holding a positional vertex
reference across that boundary is then stale. A commit-time rewind was always possible;
what changed is that the gesture now completes cleanly first (rather than corrupting the
geometry as it went), so a stale ref lands in a valid, actionable state instead of amid
garbage. Two such refs existed, and both are re-anchored to coordinates rather than indices:
- The Delete key (
plugin-edit/src/tools/vertex.ts) cached the working vertex as aVertexRefand re-picked its handle by ring index. After a winding-flipping drag committed, that index named a different corner, so Delete removed one the user never touched. It now tracks the vertex's coordinate and re-picks the handle sitting on it — order-proof. - A re-entrant gesture during an async commit.
commit()is asynchronous (a validation middleware may await a server), so its rewind can land after the user has grabbed a corner again — reversing the ring under the new gesture's live positional refs. The controller now converges the ring to committed winding synchronously on release (EditController.#rewindToCommittedWinding), before the async commit and before any next gesture, so the commit's own rewind is a no-op and can reorder nothing. A no-op for the common drag that never flipped a winding.
Both are the same lesson the main decision teaches, one layer out: a ring index is only stable within a gesture that does not rewind; the moment a rewind can intervene, address the corner by where it is, not by where it sits in the array.
Amendment (2026-08-18) — cardinality is part of the same contract
The original decision made rewind: false skip the winding step and nothing else. That was
half the fix, because re-winding is not the only thing that renumbers a ring. normaliseRing
also calls dedupeConsecutive, and it called it unconditionally, ahead of the guarded
reverse:
const quantised = ring.map((p) => quantisePosition(p, crs))
const open = dedupeConsecutive(quantised) // ← ran on previews too
So a preview preserved ring order and not ring cardinality. Drag a corner onto the one next to it and the pair collapses mid-gesture: the ring shortens, every index past the collapse point shifts down by one, and every later frame of that drag moves the wrong corner — the identical failure this ADR was written to prevent, reached by the other door.
This is not a pointer accident, it is what the shipped preset makes easy. preset-cadastre runs
snap at 12 px with topological: true, and setDragging excludes only the feature being
dragged, so a neighbour's coincident corner is a live snap target sitting right next to your
own. Aiming 0.3 m short of it collapsed a four-corner parcel to three, and one undo was the only
signal.
rewind: false now means "preserve order and cardinality": neither dedupeConsecutive nor
the reverse runs. normaliseLine takes the same flag, because a LineString is edited by the same
positional-index tool and has the same exposure.
The durable commit still dedupes, so nothing with a duplicated corner is ever stored. A drag that ends on a collapsed pair therefore resolves once, at the commit, with the gesture over — instead of silently mid-drag. If the collapse takes the ring below three distinct corners, the commit refuses it and the edit reverts, which is the honest outcome and strictly better than quietly storing a shape the surveyor did not draw.
Pinned by packages/core/src/hostile-input.test.ts ("a transient preview keeps its vertex
count"), confirmed to fail against the unconditional dedupeConsecutive.
docs/adr/0011-transient-previews-preserve-ring-order.md, which is the source of truth.