ADR 0010 — A tool declares what it is dragging; middleware must not fight it

Status: accepted · Amends: ADR 0003 · Amended by: —

Context

ADR 0003 claims that snapping reaches every tool purely through interaction middleware that rewrites ctx.lngLat, and that this is why the measure plugin snaps to parcel corners without containing a line of snapping code or ever importing @blaeu/plugin-snap.

That claim is true for tools that place new geometry. It was false — and destructively so — for tools that drag existing geometry, which is most of plugin-edit.

The failure is worth stating precisely, because nothing about it looks like a bug:

  1. The user grabs the south-east corner of a parcel and drags it 8 m.
  2. On the first pointermove, the snap engine scans for candidates near the pointer. It finds the parcel's own corner — still sitting where the drag began, a few pixels away — and the vertex handle drawn on top of it, which is a real feature in a real store collection.
  3. It snaps the pointer back onto them.
  4. The tool computes the vertex's new position from the (snapped) pointer, concludes it has not moved, and writes it back where it was.
  5. Repeat, forever.

Every drag shorter than the snap tolerance became a silent no-op. A scale gesture grabbed its own corner, so the ratio of pointer distances stayed 1 and the parcel refused to resize. Nothing threw. Nothing logged. The store was never corrupted — it was simply never changed. The parcel just would not edit, and the only way to make it edit was to uninstall the optional plugin.

Measured, with the same gesture, in preset-cadastre/src/drag-with-snap.test.ts:

edit alone   ->  0.001 m from the drop point
edit + snap  -> 10.977 m from the drop point   (i.e. exactly back where it started)

cadastrePreset() ships both plugins. The flagship preset shipped broken vertex editing.

plugin-draw did not suffer this, because it works around it: its session duck-types ctx.tryPlugin('snap') and calls SnapApi.setInProgress() / SnapApi.exclude() so the rubber band does not snap to itself. That is a second, out-of-band channel between two plugins — and plugin-edit simply never grew one.

Decision

Two facts move into the kernel, and neither plugin learns about the other.

// A tool states what it has hold of, for the duration of one gesture.
interface ToolManager {
  setDragging(ids: readonly FeatureId[]): void
  readonly dragging: readonly FeatureId[]
}

// Middleware reads it off the interaction context.
interface InteractionContext {
  readonly dragging: readonly FeatureId[]
}

// A feature states that it is a picture of the data, not data.
interface FeatureMeta {
  readonly snappable?: boolean // default true
}

Alternatives rejected

Give plugin-edit a duck-typed tryPlugin('snap') channel, the way plugin-draw has one. The smallest change available: the pattern already exists in the tree, it already works, and it needs nothing from the kernel. Rejected because it is a plugin-to-plugin phone call in everything but the import graph — the edit plugin would have to know that snapping exists, know the shape of SnapApi.exclude(), and re-learn it for the next middleware that reasons about nearby features. Dragging is not a snapping concern; it is a fact about the gesture that any middleware may need, so it belongs on a kernel type anyone may read.

List the handles by id in the exclusion set, rather than marking them on the feature. Rejected because handles are rebuilt on every frame of a drag — an id list would go stale mid-gesture, and the failure would look exactly like the bug this ADR fixes.

Filter by collection name inside the snap plugin (skip anything in edit-handles). Rejected because a snap plugin holding a hardcoded list of the collection names the edit plugin happens to use is precisely the coupling this library exists to avoid, and it silently excludes a third-party tool that names its scaffolding anything else.

Consequences

Edit this page on GitHub — this site is generated from docs/adr/0010-tools-declare-what-they-drag.md, which is the source of truth.