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>
150 lines
6.6 KiB
Markdown
150 lines
6.6 KiB
Markdown
# 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
|
|
|
|
```js
|
|
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
|
|
|
|
```js
|
|
fs.writeFileSync(activeEdits, JSON.stringify(draft));
|
|
```
|
|
|
|
This permits half-written documents and allows a stale browser tab to overwrite
|
|
newer edits.
|
|
|
|
### Correct
|
|
|
|
```js
|
|
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.
|