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:
- The user grabs the south-east corner of a parcel and drags it 8 m.
- 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. - It snaps the pointer back onto them.
- The tool computes the vertex's new position from the (snapped) pointer, concludes it has not moved, and writes it back where it was.
- 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
}
plugin-edit's vertex and transform tools calltools.setDragging([...])on pointer-down andsetDragging([])on pointer-up or Escape.plugin-edit's handles and its transform box are written withmeta.snappable === false.plugin-snap's engine unionsctx.dragginginto its exclusion set, and skips any feature withsnappable === false.
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
- The bug is fixed, and snapping still works. The drag now lands 0.001 m from the drop point, and a corner aimed near a neighbouring parcel's corner still snaps exactly onto it (< 2 mm) — which is the operation a surveyor actually cares about, and the one that must survive any fix. The neighbour is not moved. All three are asserted.
plugin-editstill does not importplugin-snap, andplugin-snapstill does not importplugin-edit.scripts/check-boundaries.mjsenforces it and passes. The two plugins meet on kernel types neither of them owns, which is the same shape as the rest of the library.- ADR 0003's claim is amended, not withdrawn. Snapping still reaches every tool through
middleware, and a tool still contains no snapping code. But "purely" was too strong: a tool
that drags geometry must tell the pipeline what is in play, or any middleware that
reasons about nearby features will fight the gesture. That is not a wart in the snapping
design — it is a fact about dragging, and every CAD system encodes it somewhere. The
question is only whether it is encoded as a plugin-to-plugin phone call (which is what
plugin-drawdoes, and what this ADR now supersedes forplugin-edit) or as a fact on a kernel type that anyone may read. It is the second. snappableis deliberately broader than snapping. Anything that treats features as content — measurement, selection, a nearest-feature query, an export — wants to skip UI scaffolding, and the alternative is every such plugin growing its own list of other plugins' collection names. The name is the first consumer, not the limit of the concept.plugin-drawstill uses itstryPlugin('snap')channel. It works, and itssetInProgress()carries information (the ring I am still closing) thatdraggingdoes not yet express. Folding it into this mechanism is a follow-up, not a prerequisite.
docs/adr/0010-tools-declare-what-they-drag.md, which is the source of truth.