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:

  1. The apex starts above the base; the ring [BL, BR, apex] is counter-clockwise. ref.index for the apex is 2.
  2. Somewhere mid-drag the apex crosses the base. The triangle is now a perfectly valid triangle wound the other way (clockwise).
  3. _update normalises the preview and, seeing a clockwise exterior, reverses the ring. The apex is now at index 0; a base corner is at index 2.
  4. The next pointermove writes positions[2] = to. It moves a base corner to the cursor.
  5. 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:

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

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:

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.

Edit this page on GitHub — this site is generated from docs/adr/0011-transient-previews-preserve-ring-order.md, which is the source of truth.