Files
road-compiler/.trellis/spec/backend/direct-edit-api.md
que01 e08af11b7d docs: record the direct-edit client and side conventions
Three of this task's defects were cross-layer wiring rather than logic, so they
belong in the spec where the next session will read them, not only in the task's
research notes.

Frontend: a new client spec covering layer ownership (baseline is never written,
preview hides rather than overwrites), why a handle carries only its id, why a
handle's position must derive from the clamped value, the ban on computing inside
a setState updater, and why per-pointermove React state is the wrong owner of
per-frame feedback.

Backend: the left/right convention now states the three places that must agree
and why a sign convention can be wrong on both sides of the wire at once; a table
of the four profile fields the geometry stage actually reads, so nobody builds UI
for interval-scoped editing again before the compiler honours it; and the
save-is-not-applying contract, including that compileFresh must be installed by
every path that sets session.area.

Also records that an identity test cannot catch an ignored editsFile, and that a
regression test is only trustworthy once it has been seen to fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 10:27:04 +08:00

6.6 KiB

Direct Edit Solver And API

1. Scope / Trigger

This contract covers the pure direct-edit resolver and the workbench endpoints that preview, persist, checkpoint, and rebase native-road-edits/v2 documents. It is required because the feature crosses compiler, storage, and HTTP layers.

2. Signatures

resolveDirectEditConstraints(model, editDocument, context)
// -> { roadProfiles, junctionPlans, constraintStates, handles, diagnostics }

GET  /api/edit-state
POST /api/edit-preview
POST /api/edits
POST /api/revisions
POST /api/revisions/:id/rebase

3. Contracts

  • The resolver is deterministic and must not import fs, path, http, or write files. It accepts a baseline model, an optional validated v2 document, and { revisionId, compilerGeometryVersion, previewSeq } context.
  • GET /api/edit-state returns the active document, documentVersion, active revision, constraint states, diagnostics, and road-edit-handles/v1 manifest.
  • POST /api/edit-preview accepts document (or replacement constraints and operations) and optional integer previewSeq. It returns the same manifest/state data plus compiled layers, echoes previewSeq, and never changes active files. Work over the 300ms budget sets degraded: true.
  • POST /api/edits requires integer expectedDocumentVersion and either a complete document or replacement arrays. Saves through a staging file and rename, increments documentVersion, and returns the active revision.
  • A version mismatch returns HTTP 409 with { ok: false, error, current } and leaves the active document byte-identical.
  • POST /api/revisions requires a non-empty label; it creates an immutable checkpoint and returns its manifest plus current edit state.
  • POST /api/revisions/:id/rebase is read-only and returns status counts plus per-constraint details. pending, conflicted, and stale constraints do not enter geometry solving.

4. Validation & Error Matrix

Condition Required behavior
Missing or invalid v2 document fields Reject with a field-specific 400 error
Missing expectedDocumentVersion Reject with 400; do not write
Expected version differs from active version Return 409 and current version; do not write
Empty checkpoint label Reject with 400; no revision is created
Missing active workspace for preview Reject with 400; no files are created
Solver replay anchor cannot resolve Mark stale or conflicted with diagnostic; never silently apply
Compiler geometry version differs Mark all replayable constraints recheck without changing values
Invariant violation (lane width, crossing boundaries, self-intersection, connector bounds) Return a blocking diagnostic; do not clamp or silently repair

5. Good / Base / Bad Cases

  • Good: preview the same document later saved with the same active baseline; compiled layers and solver diagnostics agree, while tracked file bytes and mtimes remain unchanged.
  • Base: an empty v2 document yields byte-for-byte baseline geometry and an empty constraint-state list.
  • Bad: two tabs save version N; the second save with N receives 409 and cannot overwrite the first tab's document.

6. Tests Required

  • Unit: resolver purity, empty-document identity, five replay statuses, handle manifest fields, and blocking invariant diagnostics.
  • Integration: preview file bytes/mtimes unchanged, previewSeq echoed, preview layers matching formal compilation, and degraded represented as a boolean.
  • Persistence: atomic save, version-409 no-write path, checkpoint parent and digest integrity, and rebase counts/details.

7. Wrong vs Correct

Wrong

fs.writeFileSync(activeEdits, JSON.stringify(draft));

This permits half-written documents and allows a stale browser tab to overwrite newer edits.

Correct

if (expectedDocumentVersion !== current.documentVersion) throw conflict409();
saveEditDocument(activeEdits, { ...draft, documentVersion: current.documentVersion });

The service checks the version before the atomic version-bumping write, while the pure resolver remains reusable by preview and formal compilation.

8. Left and right

left is heading - 90. Three places agree and must keep agreeing:

  • offsetLine() (native-road.js) offsets a positive value counter-clockwise from the direction of travel, i.e. toward geographic left.
  • centerlineShift = (edgeOffsets.left - edgeOffsets.right) / 2, so widening the left edge moves the centerline left.
  • Sidewalks use heading + (side === 'left' ? -90 : 90).

The handle manifest reports axisAzimuth = tangent + 90 for both sides, which is therefore right. Handle placement must offset the left handle by tangent - 90.

Regression: makeRoadHandles() once placed the left handle at tangent + 90, drawing it over the right kerb, so dragging the visually-left handle moved the right edge. Assert sides by geography (a north-heading road's left handle is west of its centerline), never by axis sign — a sign convention can be wrong on both sides of the wire at once and still look self-consistent.

9. What the geometry stage actually reads

compileGeometry() honours only four profile fields:

Field Read at
edgeOffsets.left/right native-road.js centerline shift
widthMeters road width
sidewalkWidths.left/right sidewalkRing()
laneDividerOffsets lane separator placement

profile.interval and profile.transitions are written by the solver and read by nothing: every direct edit currently applies to the whole road. Do not add UI for interval-scoped editing until that changes — see research/interval-not-applied.md in the map-editor task.

10. Saving is not applying

POST /api/edits writes the v2 document only. /api/state serves the outputs of the last compile, so a save without a recompile leaves a refresh showing pre-edit geometry — indistinguishable from a failed save.

compileInput() is the only caller that passes editsFile, and session.context.compileFresh must be installed by every path that sets session.area, including /api/import. It was originally wired only when the server started with an area on the command line, so every UI-imported session answered 请先导入 OSM 文件 to /api/compile.

An identity test cannot catch a editsFile regression: an ignored document and an empty one produce identical output. Assert that a non-empty document changes the compiled output. Wiring gaps between two handlers need an HTTP-level test, and a regression test is only trustworthy once you have watched it fail without the fix.