Two consequences of the preview dropping from ~1535 ms to ~142 ms.
The manifest is now scoped to the object being edited. It was 844 KB on a
41-road workspace — 239 handles, 89% of the bytes being `affects` id lists at 46
ids per junction handle — shipped on every preview while the map rendered six.
A segment selection returns 12.2 KB, a 69x reduction, and the cost no longer
multiplies with each handle kind we are about to add. Omitting the selection
keeps the full manifest for the compiler and existing callers, and the tests
assert that scoping is a filter of the full manifest rather than a second
derivation.
The ghost is re-aimed from "estimated geometry" to "what you asked for". Its
guide line existed to mark the origin through a long wait that no longer happens,
so it is gone; what remains is what the preview cannot say — the numeric delta
and whether the drag has hit its clamp. The translucent outline
research/joint-solver.md asked for is deliberately not built: drawing it
accurately means recomputing the road surface in the browser, which the design
forbids, and drawing it crudely would be wrong exactly at transitions, junction
boundaries and clamps. A preview that lies is worse than none.
`degraded` finally has a consumer. EditSession has tracked it since the session
work but nothing read it; a slow solve now dims the ghost in place with a pending
label instead of clearing it and letting the geometry flicker, as design.md
requires.
Selection also fixed a latent hazard: the manifest effect reloads on every
selection change, and it used to call session.load() each time, which would have
discarded unsaved edits the moment the user clicked another road. It now adopts a
document only when the compiled document actually changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/api/import` set `session.area` but never `session.context.compileFresh`, which
is only wired when the server starts with an area on the command line. Every
session imported through the browser therefore answered "请先导入 OSM 文件" to
`/api/compile`, even though the import had just succeeded.
This predates direct editing — it broke "保存并重新生成" for UI-imported
workspaces — but it surfaced now, because saving a direct edit recompiles and so
reported a failed regeneration after a save that had in fact succeeded.
Covered by a new HTTP-level test: import the fixture, then recompile twice. The
bug lived in the wiring between two handlers rather than in either one, so
nothing below the HTTP boundary could catch it. Verified by reverting the fix and
watching the test fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Saving wrote the v2 document but nothing else, and `/api/state` serves the
outputs of the last compile — so a refresh showed the pre-edit geometry and a
successful save was indistinguishable from a failed one. Reported as "saved,
refreshed, edit gone"; the document on disk was in fact correct, resolving as
`exact` with the constraint reaching the profile.
Save now recompiles and clears the preview overlay, since the baseline carries
the edit afterwards and would otherwise draw it twice. The recompile is reported
separately from the save: if it fails the message says the edit was saved and
names the compile error, rather than claiming the save failed.
The existing identity test could not have caught this. An ignored `editsFile`
and an empty document produce the same output, so it proved nothing about a
non-empty one. The API test now compiles with and without a saved constraint and
asserts the outputs differ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 5 of the map editor. EditSession becomes the single owner of the constraint
set, replacing the pair of refs step 4 kept alongside it: it now holds the saved
operations too, and `fragment()` assembles the constraints plus every operation
they reference — the shape both preview and save send. That removes the class of
bug that produced the earlier 400, because callers can no longer ship a
constraint whose provenance points at nothing.
Save goes through `expectedDocumentVersion`. A 409 adopts the server's version so
the next attempt is checked against reality, and says which version won instead
of failing silently. Undo of a saved gesture appends an inverse operation rather
than rewriting persisted history; undo of an unsaved one just moves the cursor.
Discard drops unsaved commands, cancels anything in flight, and returns the map
to the baseline.
One gesture mints one operation id, released on pointerup. Reusing an id across
gestures put two operations with the same id in the document, which
validateEditDocument() rejects.
The API test now covers the payload the client actually sends — `constraints` +
`operations` merged into the active document, rather than a whole document — and
asserts that a save survives a reload as `exact`. Sending constraints without
their operations is asserted to be rejected rather than written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things, kept in one commit because they touch overlapping hunks of the
same two files and this environment has no interactive hunk staging. Splitting
them by file would have drawn boundaries that misrepresent what changed.
1. Step 4 of the map editor. A native OpenLayers PointerInteraction turns a
drag into a clamped constraint value, the ghost source shows it immediately,
and the solver's answer replaces a parallel set of preview layers while the
baseline layers are hidden rather than overwritten. Preview requests debounce
at 80 ms, pointerup flushes without waiting, and a newer request aborts the
one in flight; EditSession decides which answers count. Handle positions come
from the clamped value, so a handle stops at its limit instead of following
the cursor. Three of the four drag capabilities are live: edge offset,
sidewalk width, lane divider.
2. Road edge handles were drawn on the wrong side. offsetLine() offsets
counter-clockwise from the direction of travel and sidewalks use
`heading + (side === 'left' ? -90 : 90)`, so left is `tangent - 90`;
makeRoadHandles() placed the left handle at `tangent + 90`, over the right
kerb. Dragging the visually-left handle moved the right edge. Fixed on both
sides of the wire, with regression tests that name the sides geographically
rather than by axis sign.
3. Roads the junctions geometrically fill are now read-only. The 0.45 cap per
reserve made the existing `unavailable` branch unreachable, so a 14.5 m stub
between two junctions was offered a 1.5 m editable band with no room for the
transitions a road-interval constraint needs. Greying only affects the
manifest: constraints already saved against such a road keep being solved, so
the geometry output is unchanged and the fixture baselines do not move.
Range handles are built and unit-tested but hidden behind
`intervalEditingSupported`: compileGeometry() reads neither profile.interval nor
profile.transitions, so every edit applies to the whole road and the control
would have had no effect. Recorded in research/interval-not-applied.md, which
also blocks one PRD acceptance criterion.
The ol-ext probe stays in the tree as a manual harness; ol-ext is still not a
dependency.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds resolveDirectEditConstraints between compileRoadModel and
compileGeometry, per the parent design's data flow. The stage is an
identity transform until the individual constraint kinds land: it
returns empty road profiles, junction plans and handle manifest, so
compileGeometry's geometry code is untouched and output stays
byte-identical.
Constraint planning is separated from solving up front. Only `exact`
and `recheck` constraints may reach the solver; `disabled`,
`pending`, `conflicted` and `stale` are reported as unapplied with a
reason. SOLVED_KINDS starts empty and grows one entry per kind, so a
partially delivered solver reports what it skipped instead of
publishing half-solved geometry.
diagnostic() moves to src/compile/diagnostics.js so the solver can
share the compiler's diagnostic shape without depending on fs, which
would break the pure-function boundary that lets preview, the full
compile and the CLI export share one implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>