42 Commits

Author SHA1 Message Date
d893e406d6 perf: stop recomputing control bounds in overlap tests
compileGeometry took 1575 ms on a 41-road area and grew superlinearly — 6x the
time for 2x the roads — while feature count grew linearly. Profiling put 84% of
it in three adjacent lines: `bounds` alone was 821 ms, `ringsOverlap` 258 ms, and
the intermediate arrays cost another 163 ms of GC.

The callers walk every lane in 0.25 m steps and test each step's rectangle
against every control feature, which is millions of calls on a small area. Each
one recomputed the control's bounding box from scratch through four
`Math.min(...ring.map(...))` spreads, allocating four arrays per call for a box
that never changes.

Control rings are the same array objects for the whole compile, so their bounds
are now computed once into a WeakMap, candidate bounds are taken in a single
allocation-free pass, and the cheap rejection moved up into `ringsOverlapControl`
so the exact test only runs for boxes that actually touch.

1575 ms -> 104 ms, and the per-road cost is flat instead of rising, so this now
extends to city-scale areas rather than degrading quadratically. Output is
unchanged, which the fixture baselines prove.

Found because a preview felt slow to drag. The measurement mattered more than the
reading did: the nested `model.roads.find()` calls that looked like the culprit
were not, and building the scoped-compile architecture first would have left this
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 11:41:39 +08:00
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
4ef7b478b2 chore(task): archive 08-26-direct-edit-map-editor 2026-08-28 10:25:42 +08:00
ef55556f19 chore(task): archive 08-27-junction-dominated-roads 2026-08-28 10:19:25 +08:00
62795a97b9 fix: install the recompile hook on UI import
`/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>
2026-08-28 09:37:27 +08:00
d94c546450 fix: recompile after saving direct edits
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>
2026-08-28 09:25:09 +08:00
38593f6f67 feat: save, undo and redo direct edits
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>
2026-08-27 18:11:52 +08:00
92297270f1 feat: drag road handles with ghost and server preview
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>
2026-08-27 17:38:34 +08:00
bc4b9a9717 feat: add direct edit handles behind directEdit flag
Steps 1-3 of the main map road interval editor.

EditSession keeps the command stack, undo/redo and previewSeq arbitration as
pure logic with no OpenLayers reference, so all of it is unit-tested in node.
Pointer displacement converts to meters through EPSG:4326 and spherical
distance: treating a 3857 delta as meters desyncs the geometry from the cursor
by 1/cos(latitude). Handle drags project onto the axis the manifest declares
and clamp to its range, so the client never writes a coordinate into a road
polygon.

All of it sits behind a directEdit flag that defaults to off. With the flag off
the workbench requests no manifest, creates no extra source and registers no
interaction, so behaviour matches main.

The ol-ext probe passed its three gates but is not adopted for road handles.
Transform translates by the raw pointer delta, so a handle detaches from its
clamped constraint value: a drag reading -24.1 m produced a draft of -5.4 m.
Production needs the handle position derived from the constraint instead, which
means owning the position update, so native OL PointerInteraction will carry
the drag. ol-ext stays out of package.json; the probe is kept as a manual
harness. Reserve handles are unreachable with the current solver, recorded in
research/ rather than worked around.

Also names the dead backend when an API response is empty, instead of
surfacing "Unexpected end of JSON input" from response.json().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 14:59:11 +08:00
e25c564bb9 chore: record journal 2026-08-27 13:30:19 +08:00
2d85211558 chore(task): archive 08-26-direct-edit-solver-api 2026-08-27 13:29:18 +08:00
a26d4be666 docs: document direct edit solver and API contracts 2026-08-27 13:25:50 +08:00
f3f763f94c feat: persist direct edits with revision rebase 2026-08-27 12:52:40 +08:00
41c314fc61 feat: add direct edit preview APIs 2026-08-27 12:48:29 +08:00
735ce69d26 feat: classify direct edit replay states 2026-08-27 12:43:35 +08:00
94e319c717 chore: record direct edit solving progress 2026-08-27 12:37:32 +08:00
0eb404d8f7 feat: solve direct edit constraints 2026-08-27 12:36:35 +08:00
67e67e66bf chore: record direct edit manifest progress 2026-08-27 12:19:54 +08:00
ab63d23896 feat: generate direct edit handle manifest 2026-08-27 12:18:11 +08:00
3297d0c97a feat: insert direct edit constraint solver stage
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>
2026-08-27 10:29:45 +08:00
c5e762e1a9 chore: record journal 2026-08-27 09:06:40 +08:00
98e8a770a3 chore(task): archive 08-26-direct-edit-documents 2026-08-27 09:01:21 +08:00
ba8eaba9d9 feat: compile revisions from area config snapshots 2026-08-26 18:08:55 +08:00
c0c8a16dd4 feat: add immutable road revision storage 2026-08-26 17:54:38 +08:00
35002371ae feat: add native road edits document validation 2026-08-26 17:46:02 +08:00
93f09e399e chore(road-editor): plan direct-edit task tree and add client test infra
Planning: parent design.md becomes the single authoritative contract
(constraint model with 6 kinds, handle manifest, coordinate/unit
layering, preview sequencing, storage layout and lazy migration, area
config snapshot, API contract). Work is split into four independently
verifiable child tasks with per-step gates and rollback points.

Test infra: pin vitest 4.1.11, add test:client:unit for client pure
logic, extend prettier globs to root *.ts so vitest.config.ts is checked.

Add .gitignore: the repo had none, so inputs/, outputs/, workbench-data/
and the client build output were untracked rather than ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:33:42 +08:00
7dc7ede2b8 chore(task): archive 08-26-workbench-selection-save-fix 2026-08-26 15:39:18 +08:00
02c3a4d3e4 fix: avoid map refresh on road selection 2026-08-26 15:39:17 +08:00
89e0a9ce37 chore(task): archive 00-bootstrap-guidelines 2026-08-26 15:29:12 +08:00
faaa33717f docs: add frontend development guidelines 2026-08-26 15:29:11 +08:00
f7601d554e chore(task): archive 08-26-workbench-layer-scene-polish 2026-08-26 15:26:42 +08:00
3c5f39ed73 fix: refine workbench map layers 2026-08-26 15:26:41 +08:00
2f2bd6cbf0 chore(task): archive 08-26-scene-preview-fix 2026-08-26 15:09:27 +08:00
4133a5244a fix: restore scene preview styles 2026-08-26 15:09:27 +08:00
89b402de7a chore(task): archive 08-26-react-workbench-migration 2026-08-26 15:02:14 +08:00
3ddb33e321 feat: migrate workbench to React 2026-08-26 15:01:07 +08:00
f15e69c868 chore: archive web OSM import task 2026-08-26 14:06:27 +08:00
181e575e1e feat: add web OSM import workflow 2026-08-26 14:05:26 +08:00
81e02c670d feat: export portable native road packages 2026-08-26 11:59:48 +08:00
3b4befb025 fix: preserve native road metadata order 2026-08-26 10:44:17 +08:00
54d77ef09d fix: preserve native road render order 2026-08-26 10:25:08 +08:00
82afafa294 feat: publish native road layer manifest 2026-08-26 09:53:21 +08:00
168 changed files with 27816 additions and 1585 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
node_modules/
# 本机独有的编译输入(体积大、按区域各异,不入库)
inputs/
# 编译与测试产物
outputs/
workbench/client/dist/
# 工作台运行期数据:导入的 OSM、overrides、信号与派生产物
workbench-data/
.DS_Store

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
workbench/client/dist/
.trellis/

5
.prettierrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -0,0 +1,149 @@
# 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.

View File

@@ -0,0 +1,6 @@
# Backend Development Guidelines
## Revision Storage
- [Road Revision Storage](./road-revision-storage.md) -- immutable workbench revision layout, validation, and recovery contract.
- [Direct Edit Solver And API](./direct-edit-api.md) -- pure constraint solving, preview/save endpoints, version protection, and rebase behavior.

View File

@@ -0,0 +1,93 @@
# Road Revision Storage
## 1. Scope / Trigger
`src/compile/road-revisions.js` owns the persistent v2 workbench layout. It is called after a successful import and when an existing workbench area is read, so legacy `import-*` directories migrate lazily without an administrative command.
## 2. Signatures
```js
ensureRevisionStore(workspace) // -> { paths, active, baseline }
setActiveAreaConfig(workspace, options) // -> { paths, areaConfig }
readAreaConfigSnapshot(file) // -> compiler options
createCheckpoint(workspace, label) // -> restored revision
readRevision(workspace, revisionId) // -> { manifest, osm, areaConfig, nativeRoadOverrides, directEdits, trafficSignals }
```
## 3. Contracts
- Legacy inputs remain at `source.osm`, `native-road-overrides.json`, and `native-traffic-signals.json`; migration must never rewrite them.
- OSM bytes are stored once under `osm/<sha256>.osm`.
- `active/native-road-edits.json` starts as a validated v2 document at version `0`; `active/state.json` points to the active revision.
- The active config is `{ schema: 'road-workbench-area-config/v1', options }` at `active/area-config.snapshot.json`. Updating it must also update `base.areaConfigSha256` through a versioned v2 document write.
- A revision is immutable at `revisions/rev-NNNN/`. Its `manifest.json` uses `road-workbench-revision/v1`, references the content-addressed OSM and frozen `area-config.snapshot.json`, lists frozen JSON documents, and records SHA-256 digests.
- `compileInput()` uses `areaConfigSnapshotFile` when present. Its external `options` are ignored in that mode; a missing or invalid snapshot is an error, never a fallback.
## 4. Validation And Error Matrix
| Condition | Error / behavior |
| --- | --- |
| Required legacy input missing | `Revision source is missing: <path>` |
| Snapshot file is missing | `Area config snapshot is missing: <path>` |
| Snapshot schema or options are invalid | `Invalid area config snapshot: <path>` |
| Content-addressed OSM differs from its filename digest | `Content-addressed OSM is corrupt` |
| Revision manifest schema or ID mismatch | `Invalid revision manifest` |
| Revision ID is not `rev-NNNN` | Reject with `Invalid revision id` |
| Manifest path leaves its OSM or revision directory | Reject the read; never resolve an external path |
| Referenced frozen OSM or JSON digest mismatch | Reject the read with a digest mismatch error |
| Empty checkpoint label | Reject with `label must be a non-empty string` |
## 5. Good / Base / Bad Cases
- Good: first open of a legacy directory adds `osm/`, `active/`, and `revisions/rev-0001/`, leaving old files byte-identical.
- Base: opening an already migrated directory is idempotent; an interrupted migration with a baseline but no active state repairs only the missing state file.
- Bad: mutating a frozen revision document, config snapshot, or referenced OSM must make `readRevision()` fail rather than returning altered input.
## 6. Tests Required
`test/road-revisions.js` must cover:
- legacy-byte preservation and baseline creation;
- equal OSM bytes yielding one content-addressed copy;
- named checkpoint creation, parent linkage, and complete restore;
- manifest digest equality with the actual frozen files, including area config;
- active config writes incrementing `documentVersion` and preserving old revision snapshots;
- compiler snapshot precedence and an explicit missing-snapshot error;
- invalid checkpoint labels.
`test/fixtures.js` compares normalized current compiler outputs with the checked-in `native-road-package/v1.1` fixture baselines. Regenerate them only through `npm run test:fixtures:update-baseline` after an intentional compiler output change.
## 7. Wrong Vs Correct
### Wrong
```js
fs.copyFileSync(sourceOsm, path.join(revisionDirectory, 'source.osm'));
```
This duplicates equal OSM files and provides no integrity check.
### Correct
```js
const { file, digest } = storeOsm(paths);
manifest.source = { osmFile: path.relative(paths.workspace, file), osmSha256: digest };
```
The content address is both the deduplication key and the integrity contract.
### Wrong
```js
compileInput({ ...input, options: externalOptions });
```
This allows later external config changes to alter a frozen revision.
### Correct
```js
compileInput({ ...input, areaConfigSnapshotFile: revisionSnapshot });
```
The compiler validates and consumes the frozen snapshot exclusively.

View File

@@ -0,0 +1,5 @@
# Component Guidelines
Use typed React function components. `App.tsx` owns session/editor composition, while reusable map and UI concerns live in named components. Prefer the shared `ui/button.tsx` primitive for commands. Do not create business UI with `querySelector` or `innerHTML`.
Forms use controlled inputs and submit handlers. Keep map canvas DOM owned by OpenLayers and surrounding controls owned by React.

View File

@@ -0,0 +1,164 @@
# Direct Edit Client
> Trigger: any change under `workbench/client/src/edit/`, `MapCanvas.tsx`, or the
> direct-edit wiring in `App.tsx`. Read this before editing handles, ghosts,
> previews, or the save path.
Related: [Direct Edit Solver And API](../backend/direct-edit-api.md) for the wire
contract this client consumes.
---
## 1. Layer ownership
Four layer groups, and only three of them are ever written by the client:
| Group | Written by | Rule |
| --- | --- | --- |
| baseline (`createLayers()`) | `updateLayers()` only | **Never** written by edit code |
| `editHandles` | `EditHandleLayer.render()` | Handle features carry only `handleId` |
| `editGhost` | `EditGhostLayer` | Client-side, non-authoritative |
| `editPreview` | `EditPreviewLayer.show()` | Server geometry; **hides** baseline, never overwrites it |
`EditPreviewLayer` builds its own layer set from the same `createLayers()` factory
so preview and baseline cannot drift in styling, and `show()` hides only the
baseline layers it actually supersedes. `clear()` restores visibility from the
layer switches, never from a hardcoded default.
**Forbidden**: writing compiler output into a baseline source. It is the invariant
the whole split exists to protect, and nothing in the type system enforces it.
---
## 2. A handle carries nothing but its id
Handle features hold `handleId` and nothing else. Kind, anchor, axis, range and
disabled reason are looked up in the manifest. That is why `EditHandleLayer` owns
both the source and the index — they cannot fall out of step.
**Forbidden**: copying manifest fields onto the feature. It creates a second
constraint model on the client, which is the failure the cross-layer guide warns
about.
---
## 3. Handle position comes from the value, never the cursor
`handlePositionFor(handle, value)` is the inverse of `projectHandleValue()`. A
drag projects onto the manifest's axis, clamps to the manifest's range, and the
handle is then placed from the clamped value.
This is not cosmetic. The ol-ext probe translated its proxy by the raw pointer
delta, and a drag reading `-24.146 m` left the handle 24 m out while the
constraint clamped at `-5.400 m` — the handle pointed at a road shape that cannot
exist. Any input adapter must own the position update for this reason.
---
## 4. Sides: left is `tangent - 90`
The manifest reports `axisAzimuth = tangent + 90` for **both** sides, and that
azimuth is the geometry compiler's *right*. See the backend spec for why. On the
client this means `outwardSign()` negates the left side, and a test that asserts a
side must name the direction geographically (`OUTWARD_LEFT` / `OUTWARD_RIGHT`)
rather than by axis sign.
**Mistake made**: the first implementation assumed left was the positive axis, so
dragging the visually-left handle moved the right kerb. Both the server placement
and the client sign were wrong together, so neither side's tests caught it.
---
## 5. A constraint must travel with its operation
`validateEditDocument()` rejects any constraint whose `provenance.operationId` is
not a recorded operation, and the check covers already-saved constraints too.
Build the pair with `draftConstraint()` + `operationFor()`, and send
`EditSession.fragment()`, which assembles both halves.
One gesture mints one operation id, released on pointerup. Reusing an id across
gestures puts two operations with the same id in the document, which is also
rejected.
**Mistake made**: sending `constraints` alone returned HTTP 400 on every drag.
---
## 6. Never compute inside a `setState` updater
React defers updater functions and, under StrictMode, calls them more than once.
By the time one runs, closure variables captured during the gesture may already be
cleared.
```ts
// WRONG — threw `toLonLat(null)` and white-screened the page
setReport((current) => ({ ...current, meters: projectHandleValue(handle, start!, now) }));
// RIGHT — compute in the event handler, pass plain values in
const meters = projectHandleValue(handle, start, now);
setReport((current) => ({ ...current, meters }));
```
The `start!` non-null assertion is what hid the runtime problem from the type
checker. The same rule covers reading live OpenLayers state inside an updater: it
samples a different moment than the event did.
---
## 7. Per-frame feedback does not go through React
The ghost writes its OpenLayers source directly. Driving a readout from state on
every `pointermove` produced ~1600 renders in a single probe session.
`requestAnimationFrame` coalescing is the minimum; writing straight to the source
is the rule for production.
---
## 8. `EditSession` owns the constraint set, and is mutated in place
`EditSession` holds the saved baseline, its operations, the command stack, and the
`previewSeq` watermark. `PreviewRequester` captures the session object once, so
adopting a server document uses `load()` rather than constructing a new session —
a swapped object would leave the requester arbitrating for a session nobody reads.
`previewSeq` arbitration lives in `acceptPreview()`, not in the request layer: the
request layer only sends and cancels. The discard rule is worth unit-testing and
must not depend on network timing.
Undo of a **saved** gesture appends an inverse operation; persisted history is
never rewritten. Undo of an unsaved one just moves the cursor.
---
## 9. The `directEdit` flag means nothing exists
With the flag off: no edit source is created, no layer is added, no interaction is
registered, and no manifest request is made. The network trace and the canvas must
match `main` exactly. Guard once at construction, not per call site.
`intervalEditingSupported` is a second, narrower switch: range handles stay hidden
while `compileGeometry()` ignores `profile.interval`. A control whose drag changes
nothing is worse than no control.
---
## 10. Saving is not the same as applying
`POST /api/edits` writes the document; `/api/state` serves the outputs of the last
compile. Saving without recompiling leaves a refresh showing the pre-edit geometry,
which is indistinguishable from a failed save. Report the save and the recompile
separately — if the recompile fails, the save still succeeded and the message must
say so.
---
## Checklist before committing edit-client changes
- [ ] No baseline source is written
- [ ] No manifest field is copied onto a feature
- [ ] Handle positions derive from clamped values
- [ ] Sides asserted geographically, not by axis sign
- [ ] Constraints sent with their operations
- [ ] No geometry or OL reads inside a `setState` updater
- [ ] No per-`pointermove` React state updates
- [ ] Flag off ⇒ no sources, no layers, no interactions, no requests

View File

@@ -0,0 +1,5 @@
# Directory Structure
The Vite workbench lives in `workbench/client/`. Application code is under `src/`: `components/` for UI, `map/` for OpenLayers adapters, `lib/` for API clients, `types/` for API contracts, and `ui/` for shared primitives. The Node HTTP server remains `workbench/server.js`.
Keep OpenLayers construction and source mutation outside page components; `src/components/MapCanvas.tsx` owns lifecycle and `src/map/layers.ts` owns the layer registry.

View File

@@ -0,0 +1,3 @@
# Hook Guidelines
Use effects for imperative OpenLayers lifecycle only. `MapCanvas.tsx` constructs one map on mount, updates sources when server state changes, and updates visibility separately. Store callback-sensitive values such as selected road and scene mode in refs so stable OpenLayers style callbacks see current values.

View File

@@ -0,0 +1,40 @@
# Frontend Development Guidelines
> Best practices for frontend development in this project.
---
## Overview
This directory contains guidelines for frontend development. Fill in each file with your project's specific conventions.
---
## Guidelines Index
| Guide | Description | Status |
|-------|-------------|--------|
| [Direct Edit Client](./direct-edit-client.md) | Handle/ghost/preview layer ownership, drag projection, session and save contracts | Filled |
| [Directory Structure](./directory-structure.md) | Module organization and file layout | To fill |
| [Component Guidelines](./component-guidelines.md) | Component patterns, props, composition | To fill |
| [Hook Guidelines](./hook-guidelines.md) | Custom hooks, data fetching patterns | To fill |
| [State Management](./state-management.md) | Local state, global state, server state | To fill |
| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | To fill |
| [Type Safety](./type-safety.md) | Type patterns, validation | To fill |
---
## How to Fill These Guidelines
For each guideline file:
1. Document your project's **actual conventions** (not ideals)
2. Include **code examples** from your codebase
3. List **forbidden patterns** and why
4. Add **common mistakes** your team has made
The goal is to help AI assistants and new team members understand how YOUR project works.
---
**Language**: All documentation should be written in **English**.

View File

@@ -0,0 +1,37 @@
# Quality Guidelines
> Code quality standards for frontend development.
---
## Overview
The workbench frontend uses Prettier as the source formatting authority. Code is formatted before review; formatting is not left to individual editor settings.
---
## Forbidden Patterns
- Do not commit dense, manually minified, or single-line JSX/TypeScript.
- Do not hand-format around Prettier output.
---
## Required Patterns
- Run `npm run format` after editing frontend files.
- Run `npm run format:check` in the validation gate.
- Keep React components, hooks, types, and API clients in separately formatted modules once a component becomes non-trivial.
---
## Testing Requirements
- Run `npm run format:check`, `npm run test:client`, and `npm run build` for frontend changes.
---
## Code Review Checklist
- Formatting check passes.
- UI behavior changes do not rebuild the OpenLayers map unless the map lifecycle explicitly requires it.

View File

@@ -0,0 +1,3 @@
# State Management
Use component `useState` for server state, selection, staged overrides, layer visibility and UI filters. `src/lib/api.ts` is the sole browser HTTP boundary. Keep staged overrides separate from persisted `WorkbenchState.overrides`; save them before compile.

View File

@@ -0,0 +1,3 @@
# Type Safety
Define browser contracts in `src/types/state.ts` from actual workbench API payloads. Use `unknown` for extensible GeoJSON properties and comparison payloads, then narrow locally. Run `npm run test:client` after TypeScript changes; avoid `any` and broad assertions.

View File

@@ -0,0 +1,223 @@
# Code Reuse Thinking Guide
> **Purpose**: Stop and think before creating new code - does it already exist?
---
## The Problem
**Duplicated code is the #1 source of inconsistency bugs.**
When you copy-paste or rewrite existing logic:
- Bug fixes don't propagate
- Behavior diverges over time
- Codebase becomes harder to understand
---
## Before Writing New Code
### Step 1: Search First
```bash
# Search for similar function names
grep -r "functionName" .
# Search for similar logic
grep -r "keyword" .
```
### Step 2: Ask These Questions
| Question | If Yes... |
|----------|-----------|
| Does a similar function exist? | Use or extend it |
| Is this pattern used elsewhere? | Follow the existing pattern |
| Could this be a shared utility? | Create it in the right place |
| Am I copying code from another file? | **STOP** - extract to shared |
---
## Common Duplication Patterns
### Pattern 1: Copy-Paste Functions
**Bad**: Copying a validation function to another file
**Good**: Extract to shared utilities, import where needed
### Pattern 2: Similar Components
**Bad**: Creating a new component that's 80% similar to existing
**Good**: Extend existing component with props/variants
### Pattern 3: Repeated Constants
**Bad**: Defining the same constant in multiple files
**Good**: Single source of truth, import everywhere
### Pattern 4: Repeated Payload Field Extraction
**Bad**: Multiple consumers cast the same JSON/event fields locally:
```typescript
const description = (ev as { description?: string }).description;
const context = (ev as { context?: ContextEntry[] }).context;
```
This is duplicated contract logic even when the code is only two lines. Each
consumer now has its own definition of what a valid payload means.
**Good**: Put the decoder, type guard, or projection next to the data owner:
```typescript
if (isThreadEvent(ev)) {
renderThreadEvent(ev);
}
```
**Rule**: If the same untyped payload field is read in 2+ places, create a
shared type guard / normalizer / projection before adding a third reader.
---
## When to Abstract
**Abstract when**:
- Same code appears 3+ times
- Logic is complex enough to have bugs
- Multiple people might need this
**Don't abstract when**:
- Only used once
- Trivial one-liner
- Abstraction would be more complex than duplication
---
## After Batch Modifications
When you've made similar changes to multiple files:
1. **Review**: Did you catch all instances?
2. **Search**: Run grep to find any missed
3. **Consider**: Should this be abstracted?
### Reducers Should Use Exhaustive Structure
When state is derived from action-like values (`action`, `kind`, `status`,
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
```typescript
// BAD - action-specific state transitions are hard to audit
if (action === "opened") { ... }
else if (action === "comment") { ... }
else if (action === "status") { ... }
// GOOD - one reducer owns the transition table
switch (event.action) {
case "opened":
...
return;
case "comment":
...
return;
}
```
This matters when the event log is the source of truth. A reducer is the
documented replay model; display code and commands should not duplicate pieces
of that replay model.
---
## Checklist Before Commit
- [ ] Searched for existing similar code
- [ ] No copy-pasted logic that should be shared
- [ ] No repeated untyped payload field extraction outside a shared decoder
- [ ] Constants defined in one place
- [ ] Similar patterns follow same structure
- [ ] Reducer/action transitions live in one reducer or command dispatcher
---
## Gotcha: Python if/elif/else Exhaustive Check
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
**Example** (`cli_adapter.py`):
```python
# BAD: "gemini" falls through to else, returns "claude"
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
else:
return "claude" # gemini silently gets "claude"!
# GOOD: explicit branch for every platform
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
elif self.platform == "gemini":
return "gemini"
else:
return "claude"
```
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
---
## Gotcha: Asymmetric Mechanisms Producing Same Output
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
**Prevention**:
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
- When migrating directory structures, search for ALL code paths that reference the old structure
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
---
## Template File Registration (Trellis-specific)
When adding new files to `src/templates/trellis/scripts/`:
**Single registration point**: `src/templates/trellis/index.ts`
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
2. Add to `getAllScripts()` Map
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
### Quick Checklist for New Scripts
```bash
# After adding a new .py file, verify it's in getAllScripts():
grep -l "newFileName" src/templates/trellis/index.ts # Should match
```
### Template Sync Convention
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
```bash
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
```
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.

View File

@@ -0,0 +1,327 @@
# Cross-Layer Thinking Guide
> **Purpose**: Think through data flow across layers before implementing.
---
## The Problem
**Most bugs happen at layer boundaries**, not within layers.
Common cross-layer bugs:
- API returns format A, frontend expects format B
- Database stores X, service transforms to Y, but loses data
- Multiple layers implement the same logic differently
---
## Before Implementing Cross-Layer Features
### Step 1: Map the Data Flow
Draw out how data moves:
```
Source → Transform → Store → Retrieve → Transform → Display
```
For each arrow, ask:
- What format is the data in?
- What could go wrong?
- Who is responsible for validation?
### Step 2: Identify Boundaries
| Boundary | Common Issues |
| --------------------- | --------------------------------- |
| API ↔ Service | Type mismatches, missing fields |
| Service ↔ Database | Format conversions, null handling |
| Backend ↔ Frontend | Serialization, date formats |
| Component ↔ Component | Props shape changes |
### Step 3: Define Contracts
For each boundary:
- What is the exact input format?
- What is the exact output format?
- What errors can occur?
---
## Common Cross-Layer Mistakes
### Mistake 1: Implicit Format Assumptions
**Bad**: Assuming date format without checking
**Good**: Explicit format conversion at boundaries
### Mistake 2: Scattered Validation
**Bad**: Validating the same thing in multiple layers
**Good**: Validate once at the entry point
### Mistake 3: Leaky Abstractions
**Bad**: Component knows about database schema
**Good**: Each layer only knows its neighbors
### Mistake 4: Every Consumer Parses The Same Payload
**Bad**: A command reads JSONL events and casts fields inline:
```typescript
const thread = (ev as { thread?: string }).thread;
const labels = (ev as { labels?: string[] }).labels;
```
This looks local, but it means every consumer owns a private version of the
event contract. The next field change will update one command and miss another.
**Good**: Decode once at the event boundary, then export typed projections:
```typescript
if (!isThreadEvent(ev)) return false;
return ev.thread === filter.thread;
```
**Rule**: For append-only logs, JSON streams, RPC payloads, or config files,
create one owner for:
- event / payload type definitions
- type guards and normalization from `unknown`
- metadata projections used by UI commands
- reducers that replay state from the source of truth
Rendering code may format fields, but it must not redefine the payload contract.
---
## Checklist for Cross-Layer Features
Before implementation:
- [ ] Mapped the complete data flow
- [ ] Identified all layer boundaries
- [ ] Defined format at each boundary
- [ ] Decided where validation happens
After implementation:
- [ ] Tested with edge cases (null, empty, invalid)
- [ ] Verified error handling at each boundary
- [ ] Checked data survives round-trip
- [ ] Checked that consumers import shared decoders / projections instead of
casting payload fields locally
- [ ] Checked that derived state points back to the source event identifier
(`seq`, `id`, `version`) instead of inventing a second cursor
---
## Cross-Platform Template Consistency
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
### Checklist: After Modifying Any Command Template
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
---
## Generated Runtime Template Upgrade Consistency
Some generated files are both documentation and runtime input. In Trellis,
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
SessionStart filters, and per-turn hooks. Template changes must be validated
against both fresh init and upgrade paths.
### Checklist: After Modifying A Runtime-Parsed Template
- [ ] Identify every runtime parser that reads the template, not just the file
writer that installs it
- [ ] Check whether relevant syntax lives outside obvious managed regions
such as tag blocks
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
the older `.trellis/.version`
- [ ] Add an upgrade regression using an older pristine template fixture, then
assert the installed file reaches the current packaged shape
- [ ] Update the backend spec that owns the runtime contract
---
## Versioned Documentation Boundary
Versioned documentation is a cross-layer boundary: source paths, `docs.json`
version routing, and the rendered version selector must all describe the same
release line.
### Checklist: Before Editing Versioned Docs
- [ ] Identify the target release line: stable, beta, or RC
- [ ] Verify the edited MDX path matches that line:
- stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}`
- beta: `docs-site/beta/**` and `docs-site/zh/beta/**`
- RC: `docs-site/rc/**` and `docs-site/zh/rc/**`
- [ ] Verify `docs.json` navigation points the version label to the same paths
- [ ] Grep the opposite tree for release-line-specific terms before committing
- [ ] Treat beta content appearing under root release paths as a source-path bug,
not a rendering bug
**Real-world example**: A beta-only task workflow change documented
`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex
mode banners under root `start/` and `advanced/` paths. The docs site then
served 0.6 beta behavior under the Release selector. The fix was to restore root
release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep
audit for beta markers against the root release tree.
**Real-world example**: Codex inline mode changed workflow platform markers from
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
markers outside those blocks. Result: upgraded projects got new hook scripts
but old workflow routing, so `get_context.py --mode phase --platform codex`
could return empty Phase 2.1 detail.
---
## Mode-Detection Probe Checklist
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
### Before implementing:
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
- [ ] Transient errors **abort or retry**, never silently switch modes
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
### After implementing:
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
---
## Cross-Platform Template Consistency
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
### Checklist: After Modifying Any Command Template
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
---
## Generated Runtime Template Upgrade Consistency
Some generated files are both documentation and runtime input. In Trellis,
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
SessionStart filters, and per-turn hooks. Template changes must be validated
against both fresh init and upgrade paths.
### Checklist: After Modifying A Runtime-Parsed Template
- [ ] Identify every runtime parser that reads the template, not just the file
writer that installs it
- [ ] Check whether relevant syntax lives outside obvious managed regions
such as tag blocks
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
the older `.trellis/.version`
- [ ] Add an upgrade regression using an older pristine template fixture, then
assert the installed file reaches the current packaged shape
- [ ] Update the backend spec that owns the runtime contract
**Real-world example**: Codex inline mode changed workflow platform markers from
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
markers outside those blocks. Result: upgraded projects got new hook scripts
but old workflow routing, so `get_context.py --mode phase --platform codex`
could return empty Phase 2.1 detail.
---
## Mode-Detection Probe Checklist
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
### Before implementing:
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
- [ ] Transient errors **abort or retry**, never silently switch modes
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
### After implementing:
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
---
## When to Create Flow Documentation
Create detailed flow docs when:
- Feature spans 3+ layers
- Multiple teams are involved
- Data format is complex
- Feature has caused bugs before
---
## Event Log / Projection Boundary
Append-only logs are cross-layer contracts. A single event travels through:
```
CLI input → event writer → events.jsonl → reader → filter → reducer → display
```
### Checklist: After Adding A New Event Kind Or Field
- [ ] Add the event kind to the central event taxonomy
- [ ] Add a typed event variant or type guard at the event layer
- [ ] Add normalization helpers for array/object fields that come from
user input or JSON
- [ ] Keep `seq` / `id` assignment in the event writer only
- [ ] Make filters and reducers consume the typed event guard, not local casts
- [ ] Make display code consume reducer output or typed events, not raw JSON
- [ ] Add at least one regression that proves history replay and live filtering
use the same filter model
**Real-world example**: Thread channels added `kind: "thread"`, `description`,
`context`, labels, and `lastSeq`. The first implementation replayed thread
state correctly, but several commands still re-parsed event payload fields with
local casts. The fix was to make the core event layer own `ThreadChannelEvent`
and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata
projection, and make `reduceThreads` the only thread replay reducer.

View File

@@ -0,0 +1,97 @@
# Thinking Guides
> **Purpose**: Expand your thinking to catch things you might not have considered.
---
## Why Thinking Guides?
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
- Didn't think about what happens at layer boundaries → cross-layer bugs
- Didn't think about code patterns repeating → duplicated code everywhere
- Didn't think about edge cases → runtime errors
- Didn't think about future maintainers → unreadable code
These guides help you **ask the right questions before coding**.
---
## Available Guides
| Guide | Purpose | When to Use |
|-------|---------|-------------|
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
---
## Quick Reference: Thinking Triggers
### When to Think About Cross-Layer Issues
- [ ] Feature touches 3+ layers (API, Service, Component, Database)
- [ ] Data format changes between layers
- [ ] Multiple consumers need the same data
- [ ] You're not sure where to put some logic
- [ ] You are adding an event kind, JSONL record, RPC payload, or config field
- [ ] UI / command code starts casting raw payload fields directly
→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md)
### When to Think About Code Reuse
- [ ] You're writing similar code to something that exists
- [ ] You see the same pattern repeated 3+ times
- [ ] You're adding a new field to multiple places
- [ ] **You're modifying any constant or config**
- [ ] **You're creating a new utility/helper function** ← Search first!
- [ ] Two files read the same untyped payload field with local casts
- [ ] Multiple branches update the same derived state from `kind` / `action`
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
### When Verifying AI Cross-Review Results
- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?)
- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source?
- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design?
- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test
**Common AI reviewer false-positive patterns**:
1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input
2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs
3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name)
**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews.
---
## Pre-Modification Rule (CRITICAL)
> **Before changing ANY value, ALWAYS search first!**
```bash
# Search for the value you're about to change
grep -r "value_to_change" .
```
This single habit prevents most "forgot to update X" bugs.
---
## How to Use This Directory
1. **Before coding**: Skim the relevant thinking guide
2. **During coding**: If something feels repetitive or complex, check the guides
3. **After bugs**: Add new insights to the relevant guide (learn from mistakes)
---
## Contributing
Found a new "didn't think of that" moment? Add it to the relevant guide.
---
**Core Principle**: 30 minutes of thinking saves 3 hours of debugging.

View File

@@ -0,0 +1,4 @@
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "逐条核对四态语义、会话互斥与「应用不等于保存」是否被正确实现"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "核对路口三个 kind 的锚点、approach 优先级与 reserve 独占边界"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "核对预览是否覆盖全部受影响派生对象,以及拟合失败时的诊断行为"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否重复实现 EditSession、命令栈或 ghost/preview 图层"}

View File

@@ -0,0 +1,21 @@
# 设计
技术合约不在本文重复定义。权威定义见父任务 `.trellis/tasks/08-26-direct-manipulation-road-editor/design.md` 的以下小节:
- 「约束模型」— `junction-approach-width``junction-cutback``junction-corner-radius` 三个 kind 与其锚点。
- 「编辑所有权」— junction reserve 独占、`junction-approach-width` 优先于道路 profile、单 `JunctionRef` 会话。
- 「Handle manifest」— 手柄字段与 `reserves`
- 「预览时序与延迟预算」— 防抖、`previewSeq` 仲裁、`degraded`
- 「上线与回滚形态」— `directEdit` 开关。
会话状态、四态语义与所有权边界的完整推导见父任务 `research/junction-tools.md`
## 本子任务的局部决定
- 工作区是独立路由,不是主地图上的浮层面板:路口编辑是高密度局部操作,叠在主地图上会与道路手柄争夺命中区。
- 复用 `direct-edit-map-editor``EditSession`、ghost/preview source 与命令栈,不新建第二套编辑基础设施。差异只在加载的上下文范围与可用 kind。
- 草稿属于活动工作副本的 session不是独立导出文件。因此「取消」只需丢弃 session不涉及文件回滚。
- 「应用到工作区」不写盘:合并后的约束是活动副本的未保存状态,交给主地图的普通 undo/redo 管理。这样保存路径只有一条。
- 会话互斥用显式守卫实现:存在未处理草稿时切换 `JunctionRef` 直接拒绝并提示,不做自动应用或自动丢弃——两者都会让用户丢失意图。
- 本期只实现 `JunctionRef.type === 'node'``cluster` 分支保留类型但入口置灰,避免把 cluster 降级成多个互相冲突的普通路口编辑。
- 单个角部圆角:本期只允许编辑一个 corner多角部批量编辑属后续任务。

View File

@@ -0,0 +1,6 @@
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "父任务权威合约:三个 junction kind、编辑所有权、handle manifest、预览时序、directEdit 开关"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "会话边界、四态语义、入口与上下文加载范围、所有权划分的完整推导"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "路口局部求解必须一起重算的派生对象清单与约束不变量"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "现有 compileJunctionPlans、cutbackMeters 与 junctionTemplates 的能力边界"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "必须复用 EditSession、ghost/preview source 与命令栈,禁止新建第二套编辑基础设施"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "会话状态与约束在客户端、API、求解器之间的流转一致性"}

View File

@@ -0,0 +1,41 @@
# 实施计划
对应父任务 `implement.md` 的第 7 步。每步一个提交,门禁不过就停。
前置:`direct-edit-solver-api`junction 三个 kind 的求解与预览端点)与 `direct-edit-map-editor``EditSession`、ghost/preview source、命令栈均已交付。
## 1. 工作区路由与上下文加载
- 目标:能进能回,且不丢全局方位。
- 范围:单 `JunctionRef` 路由(本期只走 `node``cluster` 入口置灰);从路口面或诊断进入;加载该路口的编辑上下文(路口面、进口、相邻道路短上下文、车道/connector、步行带、停止线、斑马线、诊断、当前约束返回主地图并恢复位置。
- 验证:`npm run test:client``npm run build`;手测——从路口面进入、从诊断进入、返回后主地图位置与选中态保持。
- 门禁:`cluster` 入口置灰且有原因提示,不落到 node 分支。
- 回滚点:纯新增路由,`directEdit` 开关关闭即隐藏入口。
## 2. 会话状态机与互斥守卫
- 目标:四态语义先于几何落地,避免后续用几何调试状态。
- 范围:`draft` / `applied` / `saved` / `cancelled` 的纯逻辑;未处理草稿时切换 `JunctionRef` 的拒绝守卫;取消复原。复用 `EditSession`,不新建第二套命令栈。
- 验证:`npm run test:client:unit` —— draft → 应用后成为主工作区未保存约束;取消后活动副本无残留;存在未处理草稿时切换被拒绝且草稿不丢;应用后的编辑可被主地图 undo/redo 撤销。
- 门禁:切换拒绝路径有测试;不存在自动应用或自动丢弃草稿的分支。
- 回滚点纯逻辑模块revert 后回到第 1 步的只读工作区。
## 3. 三项可拖能力与完整预览
- 目标进口宽度、cutback、单角部圆角真正可编辑预览覆盖全部受影响对象。
- 范围:三类手柄的渲染与拖拽;首次拖拽即 ghost防抖后服务端局部权威预览预览覆盖道路面、路口面、步行带、车道中心线、connector/movement、停止线、斑马线、标线拟合失败返回最后有效几何加结构化诊断。
- 验证:`npm run test:client:unit`(手柄事件到三个 kind 的约束值投影)、`npm run test`(局部求解与全量编译对同一约束集结果一致)、`npm run test:client``npm run build`;手测——三项各自可拖;预览包含上述全部对象;制造拟合失败时不把非法图形显示成已应用。
- 门禁:预览对象清单逐项核对通过;失败态显示最后有效几何而非空白或非法图形。
- 回滚点:按 kind 分批提交,可单独回退某一类手柄。
## 4. 闭环验证
- 目标:与主工作区和持久化链路接通。
- 范围:应用回主地图、保存、重新编译的完整往返;`expectedDocumentVersion` 复用主地图已实现的冲突处理。
- 验证:`npm run format:check``npm run test``npm run test:client``npm run test:client:unit``npm run build`;手测——应用后返回主地图显示同一几何;保存后重新编译几何不变且约束状态 `exact`
- 门禁:`prd.md` 全部验收标准勾选。
- 回滚点revert 后回到第 3 步的预览-only 状态。
## 步骤依赖
1 → 2 → 3 → 4 硬顺序。第 2 步先于第 3 步是刻意的:会话四态若在几何可拖之后才做,取消与互斥的缺陷会被误当成求解问题。

View File

@@ -0,0 +1,41 @@
# 最小 JunctionTools
父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。需求来源与权威合约在父任务的 `prd.md` / `design.md`
## 目标
用专用工作区承载路口编辑验证单一普通路口的进口宽度、cutback 与单个角部圆角,闭合"预览 → 应用到工作区 → 保存 → 取消"四态。这是路口能力的最小可信切片,不是完整路口编辑器。
## 顺序依赖
前置:`direct-edit-solver-api`junction 相关的三个 kind 求解与预览端点)与 `direct-edit-map-editor``EditSession`、ghost/preview 图层、命令栈)。
后继无。cluster 高级编辑、多路口联动为独立后续任务。
## 范围
-`JunctionRef``{type:'node'|'cluster', id}`,本期只走 `node`)的工作区路由与返回主地图的上下文保持。
- 加载该路口的编辑上下文:路口面、进口、相邻道路短上下文、车道/connector、步行带、停止线、斑马线、诊断与当前约束。
- 三项可拖能力进口宽度、cutback、单个角部圆角。
- session draft首次拖拽即 ghost防抖后服务端权威局部预览。
- 「应用到工作区」合并有效草稿为活动副本的未保存约束;「保存」持久化;「取消」丢弃草稿并复原。
- 会话边界:切换相邻路口前必须应用或取消。
## 不做
- cluster 的高级编辑与复合路口模板编排。
- 多个路口的联合会话或联动编辑。
- 控制设施的逐个手工布置。
- 主地图道路区间的编辑(属 `direct-edit-map-editor`)。
- OSM 图拓扑的修改。
## 验收标准
- [ ] 从路口面或诊断可进入工作区,并能带位置返回主地图。
- [ ] 三项能力各自可拖,首次拖拽即有 ghost防抖后显示服务端权威几何。
- [ ] 预览包含全部受影响派生对象道路面、路口面、步行带、车道中心线、connector/movement、停止线、斑马线、标线。
- [ ] 拟合失败时返回最后有效几何加结构化诊断,不把非法图形显示成已应用。
- [ ] 「应用到工作区」后返回主地图仍显示同一几何,且该编辑处于未保存状态、可被主地图 undo/redo 管理。
- [ ] 「取消」后回到进入工作区前的状态,活动副本无残留。
- [ ] 未处理草稿时切换相邻路口被拒绝,并提示先应用或取消。
- [ ] 「保存」后重新编译几何不变,约束状态 `exact`
- [ ] `npm run format:check``npm run test``npm run test:client``npm run test:client:unit``npm run build` 全绿。

View File

@@ -0,0 +1,26 @@
{
"id": "direct-edit-junction-tools",
"name": "direct-edit-junction-tools",
"title": "最小 JunctionTools",
"description": "单一普通路口的进口宽度、cutback、单角部圆角与预览/应用/取消",
"status": "planning",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-26-direct-manipulation-road-editor",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,5 @@
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查跨层数据流是否一致:约束 kind、manifest 字段、API 载荷、客户端类型不得各写一套"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否出现第二套米制换算、第二套 ID 校验或重复的 overrides 应用逻辑"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "逐条核对约束不变量最小车道宽、外缘不交叉、路口连续、connector 包含性"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "核对编辑所有权与会话边界未被越过:道路 handle 不得进入 junction reserve"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "核对 v1 overrides、feature 回链字段与既有 API 行为未被破坏"}

View File

@@ -0,0 +1,253 @@
# 直接操纵道路编辑设计
> 本文是约束模型、合约与边界的唯一权威定义。`research/` 下的文档是探索记录;如与本文冲突,以本文为准。
## 架构边界
OSM、area config 快照和现有 v1 overrides 仍是道路模型的基线。`native-road-edits/v2` 记录 OSM 语义锚点上的约束与操作,不保存最终 GeoJSON。
```text
OSM + area config snapshot + v1 overrides + v2 direct edits
-> compileRoadModel
-> applyRoadOverrides
-> resolveDirectEditConstraints
-> editable profiles + junction plans + handle manifest
-> compileGeometry -> GeoJSON / diagnostics / layers
```
预览与正式编译共用 `resolveDirectEditConstraints``compileGeometry`;浏览器不实现第二套道路几何算法。
## 约束模型
锚点类型(约束挂在哪里)与约束 kind约束什么是两个维度。此前 `research/data-model-options.md` 与本文混用二者,导致两套命名冲突,此处统一。
```ts
type Side = 'left' | 'right'
type SemanticAnchor =
| { type: 'road-station'; roadId: string; station: number; side?: Side }
| { type: 'road-interval'; roadId: string; startStation: number; endStation: number; side?: Side }
| { type: 'junction-approach'; nodeId: string; segmentId: string; side?: Side }
| { type: 'junction-corner'; nodeId: string; incomingRoadId: string; outgoingRoadId: string }
type RoadConstraintKind =
| 'road-edge-offset'
| 'road-sidewalk-width'
| 'road-lane-divider'
| 'junction-approach-width'
| 'junction-cutback'
| 'junction-corner-radius'
```
| kind | 锚点 | value | 单位与含义 | 所有者 |
| --- | --- | --- | --- | --- |
| `road-edge-offset` | `road-interval` | `{ offsetMeters, transition }` | 米,相对基线外缘的法向偏移 | 主地图 |
| `road-sidewalk-width` | `road-interval` | `{ widthMeters, transition }` | 米,`0` 表示关闭步行带 | 主地图 |
| `road-lane-divider` | `road-interval` | `{ boundaryIndex, offsetMeters, transition }` | 米,相对中心线的带符号横向偏移(左负右正) | 主地图 |
| `junction-approach-width` | `junction-approach` | `{ widthMeters }` | 米 | JunctionTools |
| `junction-cutback` | `junction-approach` | `{ cutbackMeters }` | 米 | JunctionTools |
| `junction-corner-radius` | `junction-corner` | `{ radiusMeters }` | 米 | JunctionTools |
这 6 个 kind 与 PRD 首期范围一一对应(外缘、步行带、车道分隔 + 进口、cutback、角部没有多余项也没有缺口。
`transition: 'smoothstep' | 'linear'`,默认 `smoothstep`,作用于 interval 两端回归基线的过渡段。
`boundaryIndex` 从左外缘起 1-based有效范围 `1..laneCount-1`。重放时若车道数变化导致越界,该约束转 `stale`,不做近似映射。
`station` 是相对道路中心线的归一化弧长 `0..1`,不是绝对经纬度或数组下标。保存时同时写 `anchorSnapshot`(当时坐标、切线方位、道路长度、相邻 OSM node ID作为重定位与冲突检测证据。
道路横断面拖拽默认写 `road-interval`:拖拽位置为区间中心,求解器按道路长度与两端 junction reserve 推导初始 `startStation` / `endStation`;用户用范围手柄只更新该区间,不存储鼠标轨迹。
## Handle manifest
服务端从同一语义模型生成手柄清单,客户端不自行推导手柄位置或可拖方向。
```ts
interface HandleManifest {
schema: 'road-edit-handles/v1'
revisionId: string
previewSeq: number
handles: EditHandle[]
reserves: JunctionReserve[]
}
interface EditHandle {
handleId: string
kind: RoadConstraintKind
anchor: SemanticAnchor
position: [number, number] // EPSG:4326
axisAzimuth: number // 允许拖拽的方向,度,相对真北
value: { current: number; min: number; max: number; unit: 'meter' }
constraintId?: string // 已有约束回链
affects: string[] // 受影响派生 feature 的 native_id
editable: boolean
disabledReason?: string
}
interface JunctionReserve {
nodeId: string
roadId: string
fromStation: number
toStation: number
}
```
手柄 feature 的 properties 只携带 `handleId`,语义通过 manifest 反查。`reserves` 是主地图禁入区:落在其中的道路手柄必须 `editable: false` 且给出 `disabledReason`,引导用户进入 `JunctionTools``affects` 用于拖拽时高亮将被同步改变的对象,满足 PRD 第 2 条。
## 坐标与单位分层
持久化层只有米和归一化 station没有像素、没有度。
| 层 | 坐标 / 单位 | 规则 |
| --- | --- | --- |
| 持久化约束 | 米 + station `0..1` | 禁止写入经纬度或像素 |
| 服务端求解 | 局部米制框架 | 复用 `src/geometry/lane-geometry.js``metersAt()``haversineMeters()``projectedDistanceAlong()``lateralOffsetFrom()`,不新增第二套换算 |
| 传输 | EPSG:4326 + 方位角 | manifest 与预览 GeoJSON 一律 4326 |
| 客户端渲染 | EPSG:3857 | 沿用 `workbench/client/src/map/layers.ts` 现有 `dataProjection: 'EPSG:4326'` / `featureProjection: 'EPSG:3857'` |
客户端把指针位移换成米时必须先转回 4326 再用球面距离,禁止拿 3857 坐标差当米3857 在纬度 φ 处有 `1/cos φ` 放大,直接相减会让同一次拖拽在不同纬度得到不同结果。
## 预览时序与延迟预算
```text
pointer down -> 读 handle 的 anchor建 draft operation
pointer move -> 立即更新客户端 ghost + 递增 previewSeq 的防抖请求
pointer up -> 强制发一次非防抖的最终请求
```
- 防抖:拖拽中 80ms trailing`pointerup` 不防抖。
- 时序:每个会话的 `previewSeq` 单调递增,响应回显它。客户端丢弃 `previewSeq` 小于已应用最大值的响应,并在发新请求时用 `AbortController` 取消在途请求。乱序响应绝不允许覆盖较新的预览。
- 延迟预算:单道路 / 单路口局部求解 p95 ≤ 300ms。超预算仍返回结果但标 `degraded: true`,客户端保留 ghost 与待定状态,不做几何闪烁。
- 无效草稿返回 `diagnostics` 且不替换最后一个有效预览。
## 文档、活动副本与 revision
活动工作副本包含 v1 overrides、v2 edits、area config 快照和信号数据。导入 OSM 自动创建不可变基线 revision用户命名检查点时冻结所有输入副本、hash、compiler identity 和可选产物。普通保存与预览只更新活动副本。
```ts
interface RoadEditDocument {
schema: 'native-road-edits/v2'
documentVersion: number
base: { osmSha256: string; areaConfigSha256: string; compilerGeometryVersion: string }
constraints: RoadConstraint[]
operations: RoadEditOperation[]
}
interface RoadConstraint {
id: string
kind: RoadConstraintKind
anchor: SemanticAnchor
anchorSnapshot: AnchorSnapshot
value: unknown
enabled: boolean
status: ConstraintStatus
provenance: { operationId: string; createdAt: string; author?: string }
}
type ConstraintStatus = 'exact' | 'recheck' | 'pending' | 'conflicted' | 'stale'
```
| status | 含义 | 是否参与求解 |
| --- | --- | --- |
| `exact` | 锚点 ID 精确匹配 | 是 |
| `recheck` | 锚点匹配,但编译器几何版本已变 | 是,且需用户显式确认 |
| `pending` | 需按 snapshot 重定位的候选匹配 | 否,等用户确认 |
| `conflicted` | 候选不唯一或偏差超阈值 | 否 |
| `stale` | 锚点已不存在,或参数越界 | 否 |
重导入先按 `roadId` / `segmentId` / `nodeId` 精确匹配;失败才用 `anchorSnapshot` 的 OSM node、距离与切线找候选。绝不静默模糊应用。
## 存储布局与迁移
现状是每次导入创建一个 `workbench-data/import-<id>/` 目录(`workbench/server.js``fs.mkdtempSync`),内含 `source.osm``native-road-overrides.json``native-traffic-signals.json``outputs/`。新结构在该目录内扩展,不改动既有文件语义:
```text
workbench-data/import-<id>/
source.osm 保留v1 路径兼容
native-road-overrides.json 保留,仍是 v1 覆盖的权威文件
native-traffic-signals.json 保留
outputs/ 保留
osm/<sha256>.osm 内容寻址的 OSM 副本,多 revision 共享
active/
native-road-edits.json v2 直接编辑文档
area-config.snapshot.json 区域配置快照(含 nativeRoad.junctionTemplates
state.json documentVersion、activeRevisionId
revisions/
rev-0001/manifest.json 导入基线
rev-0002/manifest.json 命名检查点
```
迁移是惰性的:新版本首次打开既有 import 目录时创建 `active/`,并把当前 `source.osm` + overrides + signals + area config 冻结为 `rev-0001`。不移动、不重写任何既有文件,旧版本仍能读原路径。
留存revision 永不自动删除。OSM 内容寻址后,重复导入同一文件不产生副本。`outputs/` 缓存可显式清理,清理不影响可复现性——复现来源始终是输入文档加 compiler identity。
## area config 所有权
`nativeRoad.junctionTemplates` 目前住在项目级区域配置文件里,`/api/junction-clusters` 直接改写它(`workbench/server.js:224-243`),旧客户端还提示用户手工复制回配置文件。这让 revision 不可复现:同一份 OSM + overrides 在不同 area config 下编译结果不同。
规则:
- 每个 revision 与活动副本各持有一份 `area-config.snapshot.json`
- 编译与预览只读快照,不在编译期读外部配置文件。
- `/api/junction-clusters` 改为写活动副本快照,响应仍返回可复制到项目配置的片段,保留现有导出提示的价值。
- `RoadEditDocument.base.areaConfigSha256` 指向快照。
- cluster 的高级编辑延后,但快照合约现在建立,避免最小 JunctionTools 落地后返工。
## 编译器几何版本变更
`base.compilerGeometryVersion` 与当前不一致时,约束仍按精确锚点重放,但全部标 `recheck` 并要求一次显式"确认重放"不自动改值也不自动判失效。rebase 响应必须给出各 status 的计数与逐条明细。
## 编辑所有权
主地图 Road editor 只编辑两个 junction reserve 之间的道路内部 interval。reserve 内的 approach width、transition、cutback 和 corner 由 `JunctionTools` 独占。同一进口上 `junction-approach-width` 显式约束优先于道路 profile求解器负责边界连续。
`JunctionTools` 是单 `JunctionRef` session`{type:'node'|'cluster', id}`):拖拽立即 ghost服务端返回完整局部派生图层"应用到工作区"把有效草稿合并为活动副本的未保存约束,"保存"才持久化,"取消"回到进入前状态。切换相邻路口前必须应用或取消。首个交付仅支持普通 node junction 的进口宽度、cutback 与一个角部圆角。
## 不变量
- 基线 OSM topology 与 centerline 不由直接编辑改变。
- 单车道最小宽度 2.4m;横断面总宽等于车道、边缘与步行带之和。
- 同一站点左右外缘不得交叉;相邻 profile 之间必须有可计算的过渡。
- 进口截面在 cutback 处与路口边界连续;路口面不自交。
- connector、停止线、人行横道必须落在所属道路 / 路口可用面内,否则产生阻塞性诊断。
- 无效草稿不替换最后一个有效预览。
- 每次提交到活动副本是单个可撤销 operation已保存编辑的撤销追加反向操作或禁用约束历史不重写。
- 普通路口锚定 node复杂 cluster 锚定 cluster 与 arm二者不共用低层约束。
## API 合约
新增端点:
| 端点 | 行为 |
| --- | --- |
| `GET /api/edit-state` | 活动文档、`documentVersion`、revision 元数据、约束状态、handle manifest |
| `POST /api/edit-preview` | 传 `previewSeq` + draft operations返回局部 preview 图层、diagnostics、manifest、`degraded`;不写文件 |
| `POST /api/edits` | 校验并保存活动 v2 文档,要求 `expectedDocumentVersion` |
| `POST /api/revisions` | 从活动副本创建命名不可变 revision |
| `POST /api/revisions/:id/rebase` | 对目标 revision 显式重放,返回各 status 计数与明细 |
单写者保护:`POST /api/edits``expectedDocumentVersion` 与服务端不一致时返回 409 与当前版本,不写入。这挡住同机多标签页的静默互相覆盖;多人协作仍延后。
现有端点的变化(字段只增不改):
- `/api/state``/api/session` 增量返回 `activeRevisionId``documentVersion`
- `/api/import` 导入成功后自动创建基线 revision返回其 id。
- `/api/overrides` 保持 v1 语义;只有无法用 v1 表达的局部编辑进入 v2。
- `/api/junction-clusters` 改写活动副本快照而非外部配置文件。
- 所有写入沿用现有 staging 目录原子替换模式。
## 上线与回滚形态
客户端编辑能力全程挂在 `directEdit` 开关后,默认关闭,直到交付级验收标准全部通过。开关关闭时工作台行为与当前 main 完全一致:不注册编辑 interaction、不请求 manifest、不创建 handles/ghost/preview 图层。
服务端是纯增量:新端点独立于既有路由;`active/``revisions/` 不存在时旧代码路径照常工作v1 `native-road-overrides.json` 始终保持权威且格式不变。因此任何一步回滚都不会让既有 `import-*` 目录无法打开,也不需要数据迁移回退脚本。
## 客户端边界
`workbench/client/index.html` 只加载 `src/main.tsx``sendWorkbenchApp()` 优先 `dist/index.html` 并回退到同一个 index.html。因此遗留的 `workbench/client/app.js`(约 1400 行 vanilla已不是任何入口。本任务只在 React 应用内实现编辑能力,不同步 app.js删除它另开任务。
客户端目前没有单元测试运行器(`test:client` 只是 `tsc --noEmit`)。本任务引入 `vitest`node 环境覆盖纯逻辑EditSession 命令栈与 undo/redo、`previewSeq` 乱序丢弃、handle 事件到约束值的投影、米制换算。OpenLayers 地图行为(不重建 Map、图层增量替换`implement.md` 的手测清单覆盖。

View File

@@ -0,0 +1,8 @@
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "本特性横跨 compiler / server / client 三层,新增 JSONL 文档、API 载荷与配置字段,正是该指南的触发条件"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "米制换算必须复用 src/geometry/lane-geometry.jsv1 overrides 与 ID 校验必须复用而非另建一套"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "现有可复用锚点、feature 回链字段与当前缺口清单"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "求解阶段的插入点、依赖链顺序与约束不变量来源"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md", "reason": "输入 adapter 的候选、ol-ext 探针退出条件与回退优先级"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/revisions.md", "reason": "revision manifest 字段与重导入冲突体验的来源"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "JunctionTools 的会话边界、预览/应用/保存状态与路口所有权划分"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/data-model-options.md", "reason": "三种数据模型的比较背景与选择理由kind 枚举以 design.md 为准"}

View File

@@ -0,0 +1,107 @@
# 实施计划
每步是一个独立提交,带自己的验证命令、门禁和回滚点。门禁不过就停在该步,不带着已知缺陷进入下一步。
约束与合约的权威定义在 `design.md`;本文只管执行顺序与验证。
## 全局回滚策略
- 任务分支上一步一提交,回滚等于 `git revert` 该提交,不做跨步大回滚。
- 客户端编辑能力全程挂在 `directEdit` 开关后,默认关闭,直到第 8 步手测清单通过才默认开启。开关关闭时工作台行为与当前 main 完全一致。
- 服务端新增端点与新文件是纯增量:`active/``revisions/` 不存在时,旧代码路径照常工作。任何一步回滚都不会让既有 import 目录无法打开。
- v1 `native-road-overrides.json` 始终保持权威且格式不变,任何步骤都不迁移它。
## 0. 前置:依赖与测试基础设施
- 目标:先把验证能力建好,避免后续步骤"写完没法测"。
- 范围:`package.json` 增加 `vitest`(固定版本)与 `test:client:unit` 脚本;建立客户端纯逻辑测试目录。不碰任何产品代码。
- 验证:`npm run test:client:unit`(空套件通过)、`npm run format:check``npm run test`
- 门禁:新脚本可跑通且不影响现有 `test` / `test:client` / `build`
- 回滚点:仅 `package.json` 与测试目录revert 无副作用。
## 1. `ol-ext` 限时探针
- 目标:只回答一个问题——能否复用通用 handle 的命中、pointer 生命周期与视觉反馈。
- 范围:隔离探针,不修改生产基线图层,不进依赖清单直到通过。
- 验证:手动跑探针页;确认 OL `Map` 未重建、proxy 拖拽稳定、基线 source 未被写入、事件能转成 draft 值。
- 门禁:三条同时成立才把 `ol-ext` 固定为依赖。超过一个工作日或任一条不成立,立刻停止排障。
- 回滚点:删除探针目录,改用原生 OL `Snap` + 小型 `PointerInteraction` adapter。`HandleManifest -> RoadEditOperation -> RoadConstraint -> preview solver` 合约不变,因此本步失败最多换输入层,不动 schema、求解器或已保存编辑。
## 2. v2 文档、活动副本与 revision 基础
- 目标:落地 `native-road-edits/v2`、operations、`anchorSnapshot` 与 5 态重放状态机。
- 范围:新增文档读写、`active/``revisions/` 布局、内容寻址 OSM 副本、`documentVersion`、命名检查点与恢复读取。惰性迁移既有 import 目录不移动既有文件。v1 overrides 完全不动。
- 验证:`npm run test`(新增 fixture文档往返、`documentVersion` 递增、惰性迁移在只有旧文件的目录上生成 `rev-0001`、内容相同的 OSM 不产生第二份副本)。
- 门禁:既有 `workbench-data/import-*` 目录在新代码下可正常打开且未被改写schema 校验拒绝越界 `boundaryIndex` 与非法 station。
- 回滚点revert 本步后 `active/``revisions/` 只是残留目录,旧代码忽略它们照常运行。
## 3. area config 快照
- 目标:切断编译对外部区域配置文件的运行期依赖,让 revision 真正可复现。
- 范围:活动副本与每个 revision 各写一份 `area-config.snapshot.json`;编译与预览改读快照;`/api/junction-clusters` 改写快照并在响应里返回可复制片段。
- 验证:`npm run test` 新增 fixture——冻结 revision 后修改外部 area config重新编译该 revision 结果不变;快照缺失时给出明确错误而非静默用外部文件。
- 门禁:现有 `/api/junction-clusters` 的用户可见行为(返回可复制片段)不退化。
- 回滚点revert 后编译回到读外部配置文件;已写的快照文件被忽略,不影响 v1 链路。
## 4. 约束求解边界
- 目标:在 `compileGeometry()` 前插入纯函数 `resolveDirectEditConstraints`,输出 editable profiles、junction plans、handle manifest 与 diagnostics。
- 范围:实现 6 个 kind 的求解interval 默认范围推导(避开两端 reservejunction reserve 计算;`junction-approach-width` 对道路 profile 的优先级与边界连续过渡。米制换算复用 `src/geometry/lane-geometry.js`,不新增第二套。
- 验证:`npm run test` 新增 fixture——每个 kind 的重放、最小车道宽 2.4m、左右外缘不交叉、道路/路口连续、路口面不自交、connector 包含性、`boundaryIndex` 越界转 `stale`、锚点缺失/歧义分别转 `stale`/`conflicted`
- 门禁:求解是纯函数,无文件写入、无网络;`npm run test` 全绿且既有 baseline 输出在无 v2 约束时逐字节不变。
- 回滚点:无 v2 约束时该步是恒等变换,因此 revert 前后编译输出一致,可安全单独回退。
## 5. 预览与编辑 API
- 目标:草稿预览与持久化分离,预览绝不写文件。
- 范围:`GET /api/edit-state``POST /api/edit-preview``POST /api/edits``POST /api/revisions``POST /api/revisions/:id/rebase``previewSeq` 回显;`degraded` 标记;`expectedDocumentVersion` 前置条件与 409`/api/state``/api/session``/api/import` 的增量字段。
- 验证:`npm run test` 新增 server integration——预览调用前后目录 mtime 与内容不变;`expectedDocumentVersion` 不匹配返回 409 且不写入原子写入在中途失败时不留半份文件rebase 返回各 status 计数;预览与正式编译对同一约束集给出相同几何。
- 门禁预览零写入409 路径有测试覆盖;现有 `/api/overrides``/api/compile``/api/state` 的既有字段与行为不变。
- 回滚点新端点是增量revert 后客户端开关已关闭,工作台回到 v1 行为。
## 6. 主地图 Road editor
- 目标:外缘、步行带、车道分隔与区间范围可拖,禁入 junction reserve。
- 范围selection → handle manifesthandles / ghost / preview 三个独立 source`EditSession` 命令栈与 undo/redo80ms 防抖与 `pointerup` 最终请求;`previewSeq` 乱序丢弃与 `AbortController` 取消;无效草稿反馈;应用/保存/取消。全部挂在 `directEdit` 开关后。
- 验证:`npm run test:client``npm run test:client:unit`(命令栈与 undo/redo、乱序响应丢弃、handle 事件到约束值的投影、米制换算的纬度正确性)、`npm run build`
- 门禁:单元测试覆盖上述四类纯逻辑;拖拽期间 `Map` 未重建且只有受影响 source 被替换(手测清单第 1-3 项)。
- 回滚点:关掉 `directEdit` 开关即可现场止损,无需回滚代码;必要时 revert 本步。
## 7. 最小 JunctionTools
- 目标:单一普通 node 路口的进口宽度、cutback、单角部圆角闭合预览/应用/取消。
- 范围:单 `JunctionRef` 会话的路由与返回主地图的上下文session draft完整局部预览应用合并到活动副本取消回到进入前状态切换路口前强制应用或取消。不做 cluster 高级编辑、不做多路口联动。
- 验证:`npm run test:client:unit`会话状态机draft → 应用 → 主工作区未保存约束;取消后状态复原;未处理草稿时切换被拒绝)、`npm run test`(局部求解与全量编译一致)、`npm run build`
- 门禁预览包含全部受影响派生对象道路面、路口面、步行带、车道中心线、connector、停止线、斑马线、标线拟合失败时返回最后有效几何加结构化诊断不把非法图形显示成已应用。
- 回滚点路由与开关独立revert 不影响第 6 步的主地图编辑能力。
## 8. 全量验证与开关默认开启
- 目标:跑完交付级验收标准,确认可默认开启 `directEdit`
- 范围:补齐 `prd.md`「验收标准(交付阶段)」逐条证据;开关默认值改为开启。
- 验证:`npm run format:check``npm run test``npm run test:client``npm run test:client:unit``npm run build`,加下方手测清单。
- 门禁:交付级验收标准全部勾选;任一条不过则开关保持关闭,任务不进入 Phase 3。
- 回滚点:只改开关默认值,回滚成本为一行。
## 手测清单
自动化测试覆盖不到的地图行为,每次进入第 8 步都要跑一遍:
1. 选中道路 → 出现手柄,浏览器 devtools 中 `Map` 实例未变(拖拽前后同一对象引用)。
2. 拖动外缘 → 只有道路面、步行带、车道线、标线、connector 的 source 被替换,底图与未受影响图层无重绘闪烁。
3. 连续快速拖拽后松手 → 最终几何与松手位置一致,无回跳(验证乱序丢弃与最终请求)。
4. 拖到违反最小车道宽 → 出现阻塞诊断,地图停在最后一个有效预览。
5. 手柄落在 junction reserve 内 → 不可拖动并提示进入 `JunctionTools`
6. 进入 `JunctionTools` 调三项 → 预览完整;应用 → 返回主地图仍显示同一几何;取消 → 状态复原。
7. 未处理草稿时尝试切换相邻路口 → 被拒绝并提示先应用或取消。
8. 保存 → 重新编译 → 几何不变,约束状态 `exact`
9. 重新导入同一份 OSM → 约束全部 `exact`;导入删掉某条道路的 OSM → 相关约束 `stale`,旧 revision 仍可打开复现原结果。
10. 两个标签页同时保存 → 后者 409 提示,前者结果保留。
11. 关闭 `directEdit` 开关 → 工作台行为与当前 main 一致。
## 步骤依赖
0 → 1 可并行于 22 → 3 → 4 → 5 是硬顺序6 依赖 1、4、57 依赖 68 依赖全部。第 1 步失败不阻塞 2-5只改变第 6 步的输入 adapter 选择。

View File

@@ -0,0 +1,107 @@
# 直接操纵道路编辑工作台
## 目标
让道路编译工作台支持类似 Drawtonomy 的直接操纵:用户能在地图上选择生成的道路面并通过有语义的控制柄调整细节,同时保留 OSM 作为输入来源、修改可解释且可审计,并能在重新编译或重新导入后可靠地重放。
## 已确认的事实
- `src/compile/native-road.js` 从 OSM 生成方向道路、车道、道路面、步行带、路口面、标线和连接路径;这些 GeoJSON 是派生输出,不是持久化编辑源。
- 现有 `native-road-overrides/v1` 已支持道路宽度/车道数/步行带开关、路口和车道连接、以及标线样式,且以稳定的 OSM 派生语义 ID 定位目标,并在 OSM 改动后识别失效项。
- 编译器使用 staging 目录原子发布输出;工作台已经具有单例 OpenLayers 地图、命令式图层 source 更新、选择高亮和暂存/保存/重新编译链路。
- Drawtonomy 的公开 SDK 表明其道路由共享 `point``linestring` 和引用左右边界的 `lane` 构成;连接关系和导出几何从该对象图派生。其编辑器核心(手势状态机、选择、历史)不在公开仓库中,不能作为源码依赖。
## 产品要求
1. 编辑目标必须是道路语义对象,而不是直接保存最终 GeoJSON 多边形。
2. 拖拽时必须即时显示预览,并清楚呈现将被同步影响的道路面、车道、标线和路口对象。
3. 每次持久化修改都必须可解释:谁在何处、以什么规则、相对什么输入锚点施加了何种约束。
4. OSM 重新导入或编译器升级后,修改应自动重放、明确报告冲突/失效,绝不能静默改写到错误道路。
5. 用户必须可以撤销/重做未保存编辑,并能查看、禁用或删除已保存编辑。
6. 新架构应复用现有道路参数 overrides、ID 校验、编译和图层更新能力,而不是并行维护第二套地图数据。
7. OSM 道路中心线和拓扑保持只读;直接操纵首期仅编辑由它派生的横断面与路口细节。
8. 首期必须同时验证道路横断面与路口细节的联合编辑道路外缘、步行带、车道分隔以及路口进口、cutback 与转弯角部都属于首批能力。
9. 道路横断面拖拽默认生成以拖拽站点为中心的局部区间,并在区间边界平滑过渡回基线;用户可以用范围控制柄修改影响区间。
10. 拖拽时浏览器立即更新控制柄、辅助线与半透明 ghost在短防抖后调用服务端权威预览服务端使用与正式编译相同的约束求解器返回受影响派生图层。浏览器不维护第二套道路几何算法。
11. 工作台必须支持可复现的存盘 revision冻结一次编译所需的 OSM 输入、区域配置、已有 overrides、直接编辑约束、信号数据和编译身份重新导入 OSM 创建新 revision不覆盖旧 revision。
12. 导入 OSM 自动创建基线 revision用户显式保存命名检查点时创建不可变 revision普通保存和预览只更新活动副本。
13. 路口点击编辑进入专用 `JunctionTools` 工作区,而非在主地图叠加完整路口控制面板;该工作区复用活动 revision 与直接编辑约束,并在操作后尽快重新求解生成道路拓扑和相关派生图层。
14. `JunctionTools` 的草稿从首次拖拽起即显示即时 ghost 和服务端权威几何预览;“应用到工作区”仅合并有效草稿到主工作区,“保存”才持久化,“取消”丢弃本次会话草稿。
15. 一次 `JunctionTools` 会话只编辑一个 node 或 cluster切换相邻路口前必须应用或取消当前草稿。
16. 主地图道路区间编辑仅拥有两个路口之间的内部区间;`JunctionTools` 独占路口保留区内的进口、cutback 与角部约束。求解器保证两侧连续,并以显式 junction approach 约束优先。
17. 首个 `JunctionTools` 交付只验证单一普通路口的进口宽度、cutback 和单个角部圆角;必须展示完整拟合预览并能应用回主工作区。
18. 约束 kind 枚举必须唯一且与首期范围一一对应;车道分隔必须有自己的约束 kind不得借用外缘偏移表达。
19. 预览必须有明确时序保证:乱序响应不得覆盖较新预览;拖拽防抖与 `pointerup` 最终请求分离;超出延迟预算时降级提示而不是几何闪烁。
20. 持久化约束只使用米与归一化 station坐标与单位换算的分层职责必须固定客户端不得用投影坐标差充当米。
21. 区域配置(含 `nativeRoad.junctionTemplates`)必须随活动副本与 revision 冻结为快照,编译与预览只读快照;否则 revision 不可复现。
22. 同机多标签页并发保存必须被显式挡住(版本前置条件加冲突提示),不得静默互相覆盖。多人协作仍延后。
23. revision 不自动删除OSM 副本按内容寻址避免重复;派生产物缓存可清理且清理不影响可复现性。
24. 编译器几何版本变化时,约束既不自动失效也不自动改值,必须经用户显式确认重放。
25. 编辑能力只进 React 工作台;遗留 vanilla 客户端不同步、不在本任务内删除。
26. 客户端纯逻辑必须可自动化测试;地图交互行为以固化在 `implement.md` 的手测清单覆盖。
## 需要完成的研究与设计
- 梳理当前模型、输出 feature 属性和现有 overrides 可直接复用的锚点。
- 明确 Drawtonomy 的公开对象图、共享几何和派生原则,以及它与 OSM 编译流程不相同的边界。
- 比较参数反推、语义几何约束、局部补丁几何三种数据模型,并给出持久化、重放、冲突、撤销和迁移策略。
- 在确认编辑边界后形成交互模型、编译边界、API 合约、版本化 schema 和分阶段实施计划。
- 在实现前验证 `ol-ext` 的 Transform interaction 能否仅操作临时代理 feature并与现有 `ol@10.10.0`、React 生命周期和局部预览稳定协作;验证结果决定是否纳入正式依赖。
- `ol-ext` 验证必须是有时间上限、可独立删除的探针;失败仅替换输入 adapter不得改变约束文档、求解器或编译架构。
- `ol-ext` 探针限时一个工作日;必须同时满足“无地图重建、无基线 source 写入、拖拽稳定”,否则停止排障并采用 OpenLayers 原生 proxy + 小型语义 adapter。
## 任务地图
本任务是父任务:持有需求集、`design.md` 权威合约、跨子任务验收标准与最终集成验证。实现落在四个子任务,顺序依赖写在各自 `prd.md` / `implement.md`,不由树结构隐含。
| 子任务 | 交付物 | 对应父 `implement.md` 步骤 | 前置 |
| --- | --- | --- | --- |
| `08-26-direct-edit-documents` | v2 文档、活动副本、revision、area config 快照、测试基础设施 | 0、2、3 | 无 |
| `08-26-direct-edit-solver-api` | `resolveDirectEditConstraints` 与预览/保存/revision/rebase API | 4、5 | documents |
| `08-26-direct-edit-map-editor` | `ol-ext` 探针与主地图道路区间编辑 | 1、6 | solver-api 第 2 步manifest |
| `08-26-direct-edit-junction-tools` | 单一普通路口的进口、cutback、单角部圆角 | 7 | solver-api、map-editor |
父任务自留第 8 步:跑完交付级验收标准并把 `directEdit` 开关默认开启。
## 延后项
- 不在当前前置架构阶段设计或实现高级复合路口模板编排、多个路口的联合会话、控制设施逐个手工布置、协作合并或全面的拓扑创作工具。
## 首个交付范围
- 版本化直接编辑文档、活动工作副本和命名 revision 检查点。
- 限时的 `ol-ext` 代理 feature 验证及原生 OpenLayers adapter 回退。
- 服务端权威预览 API 与客户端即时 ghost。
- 主地图的道路内部区间:外缘、步行带、车道分隔和范围控制。
- 最小 `JunctionTools`单一普通路口的进口、cutback、单角部圆角、预览/应用/取消。
- 约束重放的精确匹配、待确认和失效状态;旧 revision 永不被重导入覆盖。
## 验收标准(规划阶段)
- [x] 已提供当前能力与可复用边界的证据清单。
- [x] 已区分 Drawtonomy 的公开事实、可迁移原则和不可验证的编辑器内部实现。
- [x] 已确定持久化编辑数据不是最终 GeoJSON并有版本化、可重放、可失效诊断的数据方案。
- [x] 已明确拖拽预览、应用、保存、重新编译、撤销/重做、OSM 重导入和 revision 检查点的行为。
- [x] 已定义 `ol-ext` 限时探针、原生 OpenLayers 回退与不改变数据合同的止损规则。
- [x] 已定义首个 Road editor 与最小 JunctionTools 的范围、所有权和延后项。
- [x] 约束 kind 枚举唯一,且覆盖首期全部 6 项能力(含车道分隔)。
- [x] handle manifest、坐标分层、预览时序、area config 快照、单写者保护、留存策略均已在 `design.md` 定义。
- [x] 已明确遗留客户端边界与客户端测试基础设施的决定。
- [x] 已形成 `design.md``implement.md`,供用户审阅后再开始实现。
## 验收标准(交付阶段)
每条都必须可执行验证,不接受"看起来对了"。
- [ ] 拖动道路外缘 → 保存 → 重新编译,道路面、步行带、车道线、标线与 connector 一致更新,约束状态为 `exact`
- [ ] 车道分隔手柄可独立调整某条分隔线,且不等价于外缘偏移的副作用。
- [ ] 落在 junction reserve 内的道路手柄不可拖动,并给出引导进入 `JunctionTools` 的原因。
- [ ] `JunctionTools` 单路口会话可调进口宽度、cutback 与一个角部圆角,预览包含全部受影响派生对象;应用后主地图继续显示同一预览,取消后回到进入前状态。
- [ ] 违反最小车道宽 2.4m 或造成路口面自交的草稿返回阻塞性诊断,且不替换最后一个有效预览。
- [ ] 快速连续拖拽时乱序预览响应不会覆盖较新结果(可通过注入延迟的测试复现)。
- [ ] 同一 OSM 重新导入后,约束按 `exact` / `pending` / `conflicted` / `stale` 分类报告;旧 revision 仍可打开并复现原结果。
- [ ] 修改 area config 后,旧 revision 的编译结果不变(证明快照生效)。
- [ ] 两个标签页并发保存时,后者收到 409 且不覆盖前者。
- [ ] 未保存编辑可撤销/重做;已保存编辑的撤销以反向操作或禁用约束体现,历史不被重写。
- [ ] 拖拽与选择过程中 OpenLayers `Map` 未被重建,只有受影响 source 被替换。
- [ ] `npm run format:check``npm run test``npm run test:client`、新增客户端单元测试与 `npm run build` 全绿。

View File

@@ -0,0 +1,108 @@
# Canvas 与控制柄工具方案
## OpenLayers 能力确认
当前项目使用 `ol@10.10.0`。OpenLayers `VectorLayer` 默认由 Canvas renderer 绘制,当前工作台正是这种模式。类型声明可确认:
- `ol/interaction/Modify` 支持对 source/feature collection 的顶点修改,带 `modifystart` / `modifyend`、自定义 vertex style、pixel tolerance 与 hit detection。
- `ol/interaction/Snap` 支持顶点、边、交点吸附,且会改写交互事件的 coordinate/pixel供其他 pointer interaction 使用。
- `ol/interaction/Translate` 支持 feature 集合的拖动和 start/move/end 事件。
因此OL 具备 Canvas 渲染、命中测试、投影换算、视口同步与低层 pointer 交互所需的基础能力。它没有白板编辑器的命令历史、语义控制柄、约束求解、工具状态或多对象选择模型。
## 不使用原生 `Modify` 直接编辑道路面
`Modify` 会直接改变传入 feature 的 geometry 坐标,也允许插入/删除顶点。这适合通用 GIS 几何编辑,但不适合本项目:道路面和路口面是编译产物,顶点没有稳定的编辑语义,直接修改会使车道、标线和 connector 脱节。
它可被借鉴的只是事件生命周期、Canvas 命中与样式机制;道路源图层必须保持只读。
## 建议架构
```text
OpenLayers Map / VectorLayer (Canvas)
- 基线与预览 GeoJSON 图层:只读、由编译器/preview solver 提供
- editHandles VectorLayerPoint/LineString featureCanvas 画柄与影响区间
- EditPointerInteraction命中 handle -> 投影拖拽方向 -> 更新 draft command
- Snap只对批准的语义锚点/网格 source 生效
|
v
React EditSessionselection、draft、undo/redo
|
v
constraint preview solver -> 仅更新受影响 source
```
控制柄是独立的、可丢弃的 UI feature它的 properties 只携带 `handleId`,并通过 manifest 反查 semantic anchor。任何鼠标移动都先被投影为结构化约束值再重算预览绝不把 handle 的地理坐标直接写为道路 polygon 坐标。
## 引入独立 Canvas 白板库的判断
将 tldraw、Fabric、Konva 等作为覆盖层或替换 OL均会引入第二个 viewport、平移/缩放手势、坐标系与 hit-test 系统。与地理坐标、OL hit detection、原有图层开关和地图导航同步的成本很高且不能自动提供道路领域的约束模型。
除非“Quickdraw”是一个能在既有地图 Canvas 内以 OpenLayers coordinate/pointer API 运行的明确库,否则首选是继续把 OL 作为唯一 Canvas 和视口,使用它成熟的 interaction primitives加一个小而领域化的 `EditPointerInteraction`。这不是手写画布;渲染、坐标、事件和吸附都由 OL 提供,新增代码只负责道路语义。
## 成熟的 OpenLayers 扩展ol-ext
`ol-ext` 是当前最匹配的成熟扩展候选npm 最新版为 `4.0.38`2026-02-23BSD-3-Clause仓库仍持续维护。其 `interaction/Transform` 明确提供:
- 单独的 Canvas overlay layer 与可定制的 transform handles
-`Select` 交互同步选择;
- feature translate / scale / stretch / rotate
- `translatestart``translating``translateend``scalestart``scaling``scaleend` 等生命周期事件;
- 通过 `PointerInteraction` 实现,因此共享 OpenLayers 的地图坐标、事件分发与视口。
这能承担通用控制柄的视觉和 pointer 生命周期,消除自行实现 hover、命中、capture、拖拽状态机和控制柄绘制的大部分工作。
### 不能委托给 ol-ext 的部分
`Transform` 的最终行为仍是缩放、旋转或平移传入 feature 几何。其控制柄是矩形 bounding box而道路编辑需要沿道路法线的 edge offset、沿中心线的范围控制和路口特定的 cutback/corner radius这些语义并非该扩展的能力。
推荐将 ol-ext 只绑定到**短生命周期 proxy feature**
```text
语义 handle manifest -> proxy feature / ol-ext Transform
-> transform event -> 约束值法向偏移、station interval、cutback、radius
-> preview solver -> 新预览几何与新的 proxy
```
真实编译产物图层不可传给 `Transform`。对于不能表达为标准 translate/scale 的角部圆角和区间范围控制,仍需一个很小的领域 adapter但它只做“事件到约束”的投影不维护自己的 Canvas 或通用手势系统。
## 建议的验证闸门
在正式实现前完成一个隔离技术验证:将 `ol-ext` 的 Transform 绑定到单道路的临时 proxy确认它能在当前 `ol@10.10.0`、React map 生命周期下稳定工作,并验证一次拖拽只更新 proxy/preview source、不会改写基线 source、不会重建地图。通过后再将它固定为依赖不通过则保留 OpenLayers 原生 interaction 方案,避免在主分支承诺未经验证的扩展。
## 有限探针与回退策略
`ol-ext` 不是架构前提。探针只允许解决一个问题:复用通用 handle 的命中、pointer lifecycle 和视觉反馈。它有明确的时间上限和退出条件:
| 结果 | 动作 |
| --- | --- |
| 可在临时 proxy 上平稳拖拽,且不写基线 source、不重建 Map | 作为标准 transform 代理的可选依赖 |
| 与 `ol@10.10.0` 或 React 生命周期不兼容 | 删除探针,不迁移现有 MapCanvas走原生 OL 方案 |
| 能运行但矩形 scale/rotate 模型妨碍道路法线/路口约束 | 不把它用于道路手柄;仅保留可复用部分或删除 |
| 探针超过预设时间仍无法达到以上条件 | 停止排障,按回退方案推进 |
回退方案按优先顺序:
1. **OpenLayers 原生 proxy + 小型 `PointerInteraction` adapter**Point/LineString proxy 仍由 OL Canvas 绘制和命中,使用 `Snap` 处理吸附adapter 只处理已命中的少量语义 handle 到 constraint 的投影。它不重写渲染、相机、图层或通用选择,工作量受限。
2. **DOM handle overlay + 成熟手势库**:仅在当前选中道路/路口上放置少数固定像素的 React/HTML controls用成熟拖拽手势库处理 pointer capture每个地图 postrender 将它们用 `map.getPixelFromCoordinate()` 对齐。地图仍是唯一视口,未命中 handle 的事件继续交给 OL。适合复杂的范围、数值标签和路口控制但不是 Canvas 风格。
3. **不采用的方向**:迁移到 MapLibre/Leaflet 仅为获取绘制插件,或把 Quickdraw 叠到 OL 上。这会更换地图/视口基础设施,成本远高于有限 adapter且不能解决道路语义。
无论哪个输入 adapter 运行,`HandleManifest -> RoadEditOperation -> RoadConstraint -> preview solver` 的数据合同保持不变。因此探针失败最多替换输入层,不会推翻 schema、求解器或已保存编辑。
## Drawtonomy 的关系
公开 checkout 未包含 `Canvas.tsx`,也未在 package manifests 或 lockfile 中暴露 `tldraw`、Konva、Fabric、Excalidraw 或名为 Quickdraw 的依赖。因此不能从该源码确认其编辑器具体使用了哪个成熟 Canvas 库,只能借鉴其公开的对象图原则。
## Quickdraw 核查结论
检查 `/tmp/quickdraw` 后,确认它是 MIT 许可、零运行时依赖的完整无限白板 SDK而不是地图编辑器控制柄库
- `packages/core/src/editor.js` 在传入 container 内创建自己的 scene canvas 和 overlay canvas并实现独立 camera`x/y/z`、平移、缩放、pinch、pointer capture、hit test、selection 和 resize/rotate/arrow handles。
- `packages/core/src/store.js` 使用不可变 record、transaction、diff、batch undo/redo同一手势的连续更新会合并为一个 history entry。
- 内建形状和工具是封闭集合(笔、箭头、几何形状、文字等),公开资料中没有可把 OpenLayers feature 注册为原生 shape/handle 或让它共享外部 map camera 的扩展接口。
### 结论
不将 Quickdraw 作为覆盖层或替代 OpenLayers二者都会处理 pointer、wheel、pinch、屏幕到世界坐标转换和 camera接入后要持续同步两套视口且会破坏当前 OL 地图导航和图层命中。也不将 Quickdraw 的 flat drawing document 作为道路约束的存储格式。
可直接借鉴的成熟实现原则是固定像素尺寸的控制柄、overlay 与场景分离、pointer capture、一个 gesture 对应一个 transaction、不可变 diff 的 undo/redo、以及局部渲染。项目自身的 `EditSession` 应采用相同语义,但以 `RoadEditOperation` / `RoadConstraint` 为数据而非 Quickdraw shape。

View File

@@ -0,0 +1,30 @@
# 当前系统能力地图
## 编译链路
`src/compile/compiler.js:16` 读取 OSM 和 `native-road-overrides.json`,调用 `compileRoadModel()``compileGeometry()`,写入 staging 目录后原子替换产物目录。生成 GeoJSON 因此是可丢弃、可重建的派生层。
`src/compile/native-road.js:79` 将 OSM way 按共享节点拆分为方向道路;每条道路有稳定的 `road:way/<way>[:segment/<n>]:<direction>` ID、`segmentId`、OSM 节点端点和中心线。道路面/步行带/车道线/中心线/路口/连接线均由这个模型计算。
## 可直接复用
| 现有能力 | 直接操纵中的职责 |
| --- | --- |
| 道路、端点、车道与 segment ID | 约束的语义锚点和 OSM 重导入后的匹配基础 |
| `native-road-overrides/v1` 校验与 stale diagnostics | 新 schema 的版本化校验、失效检测和编辑列表 |
| `applyRoadOverrides()` | 把可反推的拖拽收敛为既有 `widthMeters`、步行带等参数 |
| `compileGeometry()` | 约束生效后的权威重算边界 |
| feature `native_id``road_id``segment_id``osm_node_id` | 渲染 feature 到语义编辑目标的回链 |
| OpenLayers 单实例 + source registry | 手柄、预览和选中态可作为独立 source/layer 增量更新,避免重建容器 |
| 暂存/保存/重新编译 API | 命令历史与持久化操作的基础链路 |
## 当前缺口
- 没有描述“一个渲染边界/顶点对应哪个语义控制点”的 handle manifest。
- 现有 road override 只能整体宽度/车道数,不表达沿道路位置变化、边缘偏移、局部过渡或路口角部约束。
- 没有操作日志、撤销/重做、预览求解器或冲突 rebase 状态。
- 路口模板在 area 配置中,不在 overrides 文件;直接操纵要明确哪些路口细节可落到 area 模板、哪些可成为用户覆盖项。
## 约束
不要将道路面 polygon、标线 polygon 或任意 OpenLayers `Feature` 坐标作为主要持久化编辑内容。它们没有足够的拓扑语义,且在 OSM 拆段、路口重算或编译器升级后很难可靠重放。

View File

@@ -0,0 +1,81 @@
# 可编辑数据模型的比较
> 本文是方案比较的探索记录。约束模型、kind 枚举、锚点类型与文档 schema 的权威定义在 `design.md`;如有冲突以 `design.md` 为准。
## 共同不变量
- OSM 原文与其解析出的道路模型是基线,不由拖拽直接覆盖。
- 所有持久化修改都必须含版本、稳定锚点、创建时基线指纹、参数、状态和解释信息。
- 每个输出 feature 都必须能给出生成它的输入语义 ID 与适用约束,供 UI 选择和诊断使用。
- 拖拽过程可使用临时求解结果;只有显式保存才写入可重放命令。
## 方案比较
| 方案 | 持久化内容 | 优点 | 根本限制 |
| --- | --- | --- | --- |
| 参数反推 | `widthMeters`、lane count、步行带宽度等 | 可最大复用 v1重编译最稳 | 不能表达沿程局部收放、独立边缘或路口角部 |
| 语义几何约束 | 对道路横断面、边缘、过渡、路口的约束 | 兼顾直接操纵与 OSM 重放;适合本项目 | 需要 solver、优先级和冲突诊断 |
| 局部 patch 几何 | 渲染 polygon 的顶点/边偏移 | 表达最自由 | 极难在 OSM 或生成算法变化后重定位,拓扑易破坏 |
## 建议方向:版本化的约束文档
不是将所有编辑统一存为一个巨型 mesh而是将它们存为带锚点的声明式约束并保留“由哪个交互生成”的命令记录。
```ts
interface RoadEditDocument {
schema: 'native-road-edits/v2'
base: {
osmSha256: string
compilerGeometryVersion: string
createdAt: string
}
constraints: RoadConstraint[]
operations: RoadEditOperation[]
}
interface RoadConstraint {
id: string
kind: RoadConstraintKind // 6 个取值见 design.md「约束模型」
anchor: SemanticAnchor
anchorSnapshot: AnchorSnapshot
value: unknown
enabled: boolean
status: ConstraintStatus // exact | recheck | pending | conflicted | stale
provenance: { operationId: string; createdAt: string; author?: string }
}
type SemanticAnchor =
| { type: 'road-station'; roadId: string; station: number; side?: 'left' | 'right' }
| { type: 'road-interval'; roadId: string; startStation: number; endStation: number; side?: 'left' | 'right' }
| { type: 'junction-approach'; nodeId: string; segmentId: string; side?: 'left' | 'right' }
| { type: 'junction-corner'; nodeId: string; incomingRoadId: string; outgoingRoadId: string }
```
`station` 是相对道路中心线归一化弧长 `0..1`,而不是绝对经纬度或数组下标。它对顶点加减和轻微 OSM 几何调整更稳定;保存时还应记录 `anchorSnapshot`(当时位置、切线、道路长度、相邻 OSM node ID作为重定位/冲突检测证据。
道路横断面拖拽默认写入 `road-interval` 约束:拖拽位置为 interval 中心,系统根据道路长度与邻近路口预留距离推导初始 `startStation` / `endStation`,并在两端使用明确的 `transition: 'smoothstep'` 回归基线。用户调整范围手柄时只更新该 interval不存储鼠标轨迹。
## 分层求解规则
1. **基线层**OSM → 当前道路模型和默认横断面。
2. **现有参数层**v1 road/connection/style overrides可由简单拖拽写入继续兼容。
3. **几何约束层**:按语义锚点计算横断面、边界和路口局部形状;冲突时按明确优先级或提示用户处理。
4. **派生层**:道路面、步行带、车道中心线、标线、停止线和 connector 必须一起重新计算,不能只移动视觉面。
## 操作、撤销和重导入
- UI 维护本地命令栈pointer down 生成草稿pointer move 更新临时 constraintpointer up 合并为一个 `operation`undo/redo 仅移动指针,不写文件。
- 保存后写入完整约束文档和不可变 `operations` 记录;已保存编辑的撤销创建反向操作或禁用约束,不修改历史。
- 重导入时先按 `roadId`/`nodeId` 精确匹配;失败时使用 `anchorSnapshot` 的 OSM node、距离和切线作候选匹配。匹配不唯一或偏差超阈值即标记 `conflicted`,不自动应用。
- v1 覆盖项保留并逐步迁移:全路宽度仍是 road override只有无法表达的局部调整进入 v2 constraints。
## 需要在产品边界确认后细化
- 路口角部是否由独立约束处理,还是将路口升级为可编辑的模板/参数对象。
- 是否需要多人协作若需要operation log 必须具备 actor、revision 和并发合并策略。
## 已确认边界
OSM 中心线与拓扑保持只读。直接操纵不会新增 `centerline-control-point`、分段、合并或连接/断开拓扑操作;这类修改通过修正 OSM 后重新导入完成。v2 约束因此只作用在由中心线派生的横断面、边缘、步行带与路口细节。
首期范围包含道路外缘、步行带、车道分隔以及路口进口、cutback 与转弯角部。路口必须是独立的 `junction-*` anchor不应伪装成某一条道路末端的 edge offset它会同时影响道路截断、路口面、connector、停止线、斑马线和标线。

View File

@@ -0,0 +1,26 @@
# Drawtonomy可验证的设计证据
本笔记仅基于本机 `/Users/que01/Project/drawtonomy` 的公开源码与文档。该 checkout 不包含编辑器 Canvas、指针事件、选择或历史实现不能据此断言它具体如何处理拖拽状态机。
## 公开事实
- `README.md` 将其描述为 topology-aware lane、snap 与 point sharing连接会随编辑保持。
- `packages/drawtonomy-sdk/src/types.ts` 定义 `point``linestring``lane`lane 用 `leftBoundaryId` / `rightBoundaryId` 引用边界,边界以 `pointIds` 引用点,`next`/`prev` 表示车道关系。
- `packages/drawtonomy-sdk/src/exporter/osmToShapes.ts` 从 Lanelet2 导入时复用相同的点和 linestring对相邻道路保留共享边界与方向反转信息。
- `packages/drawtonomy-sdk/src/exporter/laneCenterline.ts` 从左右边界按归一化弧长采样,导出中心线和宽度;中心线是派生数据。
- `docs/exporter.md` 指出可重新编辑的 SVG 内嵌完整 snapshot导出器只从对象图读取数据。
## 可迁移原则
1. **拥有关系而非复制关系**:共享点/边界只保存一次,依赖对象通过 ID 引用。因此一个动作自然波及所有关联几何。
2. **编辑源与展示/导出分离**:编辑对象图是源,中心线、边界渲染、导出格式是派生物。
3. **显式拓扑**:车道连接不是靠距离猜测,而是用 `next` / `prev` 表示。
4. **可重新打开的完整状态**:持久化内容足以重新构建编辑场景,而不是一张结果图。
## 不应直接照搬
Drawtonomy 是从空白画布或 Lanelet2 形状开始编辑的创作工具;本项目以 OSM 为权威输入、编译器推导道路横断面和路口。若完全改为点/边界对象图,会失去现有 OSM 重导入、规则推导和可追溯性,且制造第二个不一致的地图源。
## 对本项目的结论
借鉴其“语义对象图 + 依赖重算 + 共享锚点”的原则,但将持久化内容定义为附着在 OSM 派生语义 ID 上的约束,而不是迁移到 Drawtonomy 的自由形状 snapshot。

View File

@@ -0,0 +1,56 @@
# 道路与路口的联合求解边界
## 当前依赖已经存在
`compileGeometry()` 目前以同一 `model``junctionPlans` 依次生成道路面、车道中心线、边缘线、控制设施、中心线/车道标线、步行带、connector 与路口面。它们不是孤立图层:
```text
道路横断面 / 进口尺寸
-> 道路面、步行带、车道偏移
-> 车道线与 connector
-> 路口边界、cutback、转弯圆角
-> 停止线、斑马线、箭头与诊断
```
普通路口目前由 `compileJunctionPlans()` 的 approach、`cutbackMeters` 和 boundary 驱动;复合路口则由 `junctionTemplates.clusters``complex-junction.js` 的 core radius、进口包络和角部岛生成。直接操纵不能在最终 `roadSurface` / `intersectionSurface` 上独立移动顶点,否则会破坏这条依赖链。
## 提议的新增阶段
```text
OSM -> compileRoadModel -> 现有 v1 参数覆盖
-> resolveDirectEditConstraints
-> editableRoadModel + editableJunctionPlans
-> 既有图层编译器(逐步接收扩展参数) -> GeoJSON
```
`resolveDirectEditConstraints` 的职责是:
1.`road-station` / `road-interval` 约束整理为道路横断面 profile
2. 将道路末端 profile 与 `junction-approach` 约束合并,构建一致的进口截面;
3.`junction-corner` / `junction-cutback` 约束生成可验证的 junction plan
4. 在违反最小车道宽、相邻道路相交、交叉口连接线包络等不变量时,返回明确冲突而非偷偷修复;
5. 输出一个 handle manifest使地图手柄能从同一语义模型读取位置、可拖动方向、受影响对象和可见的值。
道路 interval 的默认范围由求解器决定,而不是 UI 预设像素:避开两端的 junction cutback优先取拖拽站点两侧可用长度的有限比例若道路过短或与另一个约束重叠预览返回可调整范围或冲突信息。
## 交互预览
拖动不触发文件写入或完整工作台重建。地图维护 `EditSession`
```text
pointer down: 读取 handle 的 semantic anchor创建 draft command
pointer move: 投影鼠标位置 -> 立即更新 ghost 与 draft constraint -> 防抖服务端预览求解
pointer up: 验证成功则压入本地命令栈;失败保留提示并回退到上一个有效预览
save: 批量持久化 constraints + operations
compile: 用已保存文档运行权威全量编译
```
浏览器 ghost 仅包含控制柄、辅助线与半透明预估轮廓,不承担权威道路几何。服务端复用正式编译的约束求解逻辑,返回受影响的少数 OpenLayers source道路面、步行带、路口、标线、connector 和 handles客户端替换它们而非卸载 `MapCanvas` 或重新创建 `Map`。这与现有图层可见性和选择修复一致。
## 约束不变量
- 单车道最小宽度,例如 2.4m;道路横断面总宽度等于车道、边缘和步行带之和。
- 一个站点的左右外缘不得交叉;相邻 profile 之间必须有可计算的过渡。
- 进口截面必须在路口 cutback 处与路口边界连续。
- connector、停止线和人行横道必须保持在所属道路/路口可用面内,否则产生阻塞性诊断。
- 普通路口与复杂 cluster 不共用相同低层约束:前者锚定 node后者锚定 cluster 和 arm避免将 cluster 缩减为多个互相冲突的普通路口编辑。

View File

@@ -0,0 +1,71 @@
# JunctionTools 专用路口编辑工作区
## 结论
路口应从主地图的上下文控制柄升级为专用编辑工作区。主地图负责发现、选择和进入;`JunctionTools` 负责高密度的局部几何、拓扑和诊断操作。这不改变“OSM 中心线/拓扑只读”和“约束驱动派生输出”的原则。
## 入口与范围
从路口面或诊断进入,路由参数使用语义引用:
```ts
type JunctionRef =
| { type: 'node'; id: string }
| { type: 'cluster'; id: string }
```
工作区加载活动 revision 中该 junction 的 context路口面、外部进口、相邻道路的短上下文、车道/connector、步行带、停止线、斑马线、诊断和当前 constraints。保留返回主地图的入口与位置避免用户失去全局方位。
## 统一 UI、不同求解器
普通 node 和复合 cluster 仍共享“进口、角部、控制设施、诊断”的用户心智模型;但 `JunctionTools` 根据 `JunctionRef` 提供不同内部投影:
- node按 OSM node 的 incoming/outgoing approaches 和 corners 编辑。
- cluster按对外 arms 编辑;内部短连接不作为用户可拖的普通路口边缘,避免约束彼此冲突。
这比强迫主地图显示一套通用控制柄更可靠,也比暴露多套产品工具更易理解。
## 快速拓扑拟合与更新
此处“拓扑更新”指重新求解**生成道路的可行拓扑**,不修改 OSM 图拓扑进口横断面、车道分配、connector/movement、路口边界、步行带、停止线、斑马线和标线必须一起重算。
```text
JunctionTools 草稿操作
-> 客户端即时 ghost
-> 防抖服务端局部 junction solve
-> 返回 fit 后的 junction plan、派生 GeoJSON、拓扑诊断
-> 当前编辑器局部刷新;返回主地图后复用同一 preview
```
局部解必须校验最小车道宽、连接线包含性、surface 自交、控制设施可放置性与 cluster arm 连续性。无法拟合时返回最后有效几何与结构化诊断;不能把不合法的图形显示成已应用。
## 与 revision 的关系
`JunctionTools` 的草稿属于活动工作副本的 `EditSession`,不是独立导出文件。用户可以取消并丢弃草稿,或将有效操作合并到主工作区;之后仍通过普通保存和命名检查点进入 revision 历史。
## 预览与提交状态
“预览”“应用”“保存”是三个不同状态,不能把保存当作看见效果的前提:
```text
首次拖拽 -> session draft + 即时 ghost
防抖服务端返回 -> JunctionTools 显示权威局部几何/诊断预览
应用到工作区 -> 将有效 draft 合并为主工作区的未保存约束;主地图继续显示预览
保存 -> 将活动副本写入持久化文档
保存检查点 -> 冻结为不可变 revision
取消 -> 丢弃未应用的 JunctionTools draft恢复进入前的工作区状态
```
预览必须包含所有受影响的派生对象道路面、路口面、步行带、车道中心线、connector/movement、停止线、斑马线与标线以及拟合失败时的诊断和最后一个有效结果。
## 会话边界
一次 `JunctionTools` 会话严格聚焦一个 `JunctionRef`。进入相邻路口前,用户必须应用或取消当前草稿;系统不允许两个路口草稿在同一个局部 solver session 内并存。已应用但未保存的编辑属于主工作区,可在主地图的普通 undo/redo 中管理。
## 与道路编辑的所有权
主地图 road editor 只拥有两个 junction reserve 之间的内部 road interval。路口两端的 reserve 在地图上可见但不可由道路区间 handle 覆盖;`JunctionTools` 独占 reserve 内的 approach width、transition、cutback 和 corner 约束。对同一进口,显式 `junction-approach` constraint 优先于道路 profilesolver 在边界自动求连续过渡。
## 当前阶段的延后边界
`JunctionTools` 会成为核心竞争力,但当前任务只设计其对象边界、会话、预览和约束合同。高级复合路口模板、多个路口联动编辑、控制设施的单对象创作、协作与全面拓扑创作均延后,避免这些问题遮蔽直接编辑基础链路的验证。

View File

@@ -0,0 +1,64 @@
# 可复现存盘与 OSM 重导入
## 问题
仅让最新的 `native-road-overrides.json` 跟随当前导入 OSM会使用户无法安全比较“原始 OSM + 精修”和“新的 OSM + 重放精修”。输出 GeoJSON 又不足以解释或再次编辑场景。
## 建议:不可变 revision活动工作副本
工作区有一个可编辑的活动副本;每个 revision 都是可重新编译、可审计的冻结快照。OSM 重导入创建新的候选 revision 或工作分支,永不原地覆盖已保存 revision。
```text
工作区
active/ 当前暂存编辑
revisions/
rev-0001/ 不可变的“导入基线”
rev-0002/ 命名检查点
rev-0003/ 基于新 OSM 的候选版本
```
每个 revision 至少保存:
```ts
interface RoadRevisionManifest {
schema: 'road-workbench-revision/v1'
id: string
createdAt: string
label?: string
parentRevisionId?: string
source: {
osmFile: string
osmSha256: string
areaConfigFile: string
areaConfigSha256: string
}
documents: {
nativeRoadOverrides: string
directEdits: string
trafficSignals: string
}
compiler: {
packageVersion: string
gitCommit?: string
geometrySchema: string
}
outputs?: { manifest: string; compiledSha256: string }
}
```
OSM、area config 和各 JSON 文档均在 revision 目录中复制manifest 保存 digest。派生输出可作为缓存/审阅证据保存,但“复现来源”始终是输入文档和 compiler identity恢复时应可重新编译验证 digest并提示编译器版本差异。
## 对冲突体验的作用
- 在当前 revision 内修改 OSM 并重导入,不会破坏旧 revision 的可用性。
- 用户可以在新 revision 上尝试约束重放并获得精确/可疑/失效状态,同时随时返回旧版本查看原效果。
- 确认 rebase 后才将新 revision 设为活动版本;失败也不会丢失已精修场景。
- 保存操作记录同样随 revision 冻结,便于审计;活动副本的未保存 undo/redo 不进入不可变 revision。
## 已确定的创建策略
- 导入 OSM 自动创建不可变基线 revision。
- 用户明确执行“保存检查点”时创建带名称的不可变 revision。
- 普通保存、草稿预览和重新编译只更新活动副本,不自动制造 revision。
这一策略以可审阅的检查点承载长期历史,避免把高频拖拽/保存变成难以浏览的版本噪声。

View File

@@ -0,0 +1,46 @@
{
"id": "direct-manipulation-road-editor",
"name": "direct-manipulation-road-editor",
"title": "直接操纵道路编辑工作台",
"description": "探索类似 Drawtonomy 的直接操纵道路编辑,保持结构化 overrides、可审计性和可重编译性",
"status": "planning",
"dev_type": "feature",
"scope": "road-editor",
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [
"08-26-direct-edit-documents",
"08-26-direct-edit-solver-api",
"08-26-direct-edit-map-editor",
"08-26-direct-edit-junction-tools",
"08-27-junction-dominated-roads"
],
"parent": null,
"relatedFiles": [
"src/compile/compiler.js",
"src/compile/native-road.js",
"src/compile/complex-junction.js",
"src/compile/layer-manifest.js",
"src/geometry/lane-geometry.js",
"workbench/server.js",
"workbench/client/src/App.tsx",
"workbench/client/src/components/MapCanvas.tsx",
"workbench/client/src/map/layers.ts",
"workbench/client/src/lib/api.ts",
"workbench/client/src/types/state.ts",
"test/index.js",
"test/fixtures.js"
],
"notes": "父任务持有需求集、design.md 权威合约、跨子任务验收与第 8 步集成验证。实现落在三个子任务。",
"meta": {}
}

View File

@@ -0,0 +1,127 @@
# Bootstrap Task: Fill Project Development Guidelines
**You (the AI) are running this task. The developer does not read this file.**
The developer just ran `trellis init` on this project for the first time.
`.trellis/` now exists with empty spec scaffolding, and this bootstrap task
exists under `.trellis/tasks/`. When they want to work on it, they should start
this task from a session that provides Trellis session identity.
**Your job**: help them populate `.trellis/spec/` with the team's real
coding conventions. Every future AI session — this project's
`trellis-implement` and `trellis-check` sub-agents — auto-loads spec files
listed in per-task jsonl manifests. Empty spec = sub-agents write generic
code. Real spec = sub-agents match the team's actual patterns.
Don't dump instructions. Open with a short greeting, figure out if the repo
has any existing convention docs (CLAUDE.md, .cursorrules, etc.), and drive
the rest conversationally.
---
## Status (update the checkboxes as you complete each item)
- [x] Fill frontend guidelines
- [x] Add code examples
---
## Spec files to populate
### Frontend guidelines
| File | What to document |
|------|------------------|
| `.trellis/spec/frontend/directory-structure.md` | Component/page/hook organization |
| `.trellis/spec/frontend/component-guidelines.md` | Component patterns, props conventions |
| `.trellis/spec/frontend/hook-guidelines.md` | Custom hook naming, patterns |
| `.trellis/spec/frontend/state-management.md` | State library, patterns, what goes where |
| `.trellis/spec/frontend/type-safety.md` | TypeScript conventions, type organization |
| `.trellis/spec/frontend/quality-guidelines.md` | Linting, testing, accessibility |
### Thinking guides (already populated)
`.trellis/spec/guides/` contains general thinking guides pre-filled with
best practices. Customize only if something clearly doesn't fit this project.
---
## How to fill the spec
### Step 1: Import from existing convention files first (preferred)
Search the repo for existing convention docs. If any exist, read them and
extract the relevant rules into the matching `.trellis/spec/` files —
usually much faster than documenting from scratch.
| File / Directory | Tool |
|------|------|
| `CLAUDE.md` / `CLAUDE.local.md` | Claude Code |
| `AGENTS.md` | Codex / Claude Code / agent-compatible tools |
| `.cursorrules` | Cursor |
| `.cursor/rules/*.mdc` | Cursor (rules directory) |
| `.windsurfrules` | Windsurf |
| `.clinerules` | Cline |
| `.roomodes` | Roo Code |
| `.github/copilot-instructions.md` | GitHub Copilot |
| `.vscode/settings.json``github.copilot.chat.codeGeneration.instructions` | VS Code Copilot |
| `CONVENTIONS.md` / `.aider.conf.yml` | aider |
| `CONTRIBUTING.md` | General project conventions |
| `.editorconfig` | Editor formatting rules |
### Step 2: Analyze the codebase for anything not covered by existing docs
Scan real code to discover patterns. Before writing each spec file:
- Find 2-3 real examples of each pattern in the codebase.
- Reference real file paths (not hypothetical ones).
- Document anti-patterns the team clearly avoids.
### Step 3: Document reality, not ideals
**Critical**: write what the code *actually does*, not what it should do.
Sub-agents match the spec, so aspirational patterns that don't exist in the
codebase will cause sub-agents to write code that looks out of place.
If the team has known tech debt, document the current state — improvement
is a separate conversation, not a bootstrap concern.
---
## Quick explainer of the runtime (share when they ask "why do we need spec at all")
- Every AI coding task spawns two sub-agents: `trellis-implement` (writes
code) and `trellis-check` (verifies quality).
- Each task has `implement.jsonl` / `check.jsonl` manifests listing which
spec files to load.
- The platform hook auto-injects those spec files + the task's `prd.md`
into every sub-agent prompt, so the sub-agent codes/reviews per team
conventions without anyone pasting them manually.
- Source of truth: `.trellis/spec/`. That's why filling it well now pays
off forever.
---
## Completion
When the developer confirms the checklist items above are done with real
examples (not placeholders), guide them to run:
```bash
python3 ./.trellis/scripts/task.py finish
python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines
```
After archive, every new developer who joins this project will get a
`00-join-<slug>` onboarding task instead of this bootstrap task.
---
## Suggested opening line
"Welcome to Trellis! Your init just set me up to help you fill the project
spec — a one-time setup so every future AI session follows the team's
conventions instead of writing generic code. Before we start, do you have
any existing convention docs (CLAUDE.md, .cursorrules, CONTRIBUTING.md,
etc.) I can pull from, or should I scan the codebase from scratch?"

View File

@@ -0,0 +1,28 @@
{
"id": "00-bootstrap-guidelines",
"name": "00-bootstrap-guidelines",
"title": "Bootstrap Guidelines",
"description": "Fill in project development guidelines for AI agents",
"status": "completed",
"dev_type": "docs",
"scope": null,
"package": null,
"priority": "P1",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": "2026-08-26",
"branch": null,
"base_branch": null,
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/spec/frontend/"
],
"notes": "First-time setup task created by trellis init (frontend project)",
"meta": {}
}

View File

@@ -0,0 +1,3 @@
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "逐条核对文档 schema、5 态 status、存储布局与快照规则是否与权威合约一致"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否出现第二套 hash、第二套 ID 校验或重复的 overrides 读写逻辑"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "核对 v1 overrides 文件格式与既有 import 目录未被改写"}

View File

@@ -0,0 +1,18 @@
# 设计
技术合约不在本文重复定义。权威定义见父任务 `.trellis/tasks/08-26-direct-manipulation-road-editor/design.md` 的以下小节:
- 「约束模型」— `RoadConstraintKind` 6 个取值、`SemanticAnchor` 4 种锚点、`transition``boundaryIndex` 范围、`station` 语义。
- 「文档、活动副本与 revision」— `RoadEditDocument``RoadConstraint``ConstraintStatus` 5 态表。
- 「存储布局与迁移」— 目录结构、惰性迁移、内容寻址与留存策略。
- 「area config 所有权」— 快照规则与 `areaConfigSha256`
- 「编译器几何版本变更」— `recheck` 策略。
- 「上线与回滚形态」— 服务端纯增量、v1 文件不动。
## 本子任务的局部决定
- 文档读写与 schema 校验放在编译器侧(`src/compile/` 下新增模块),不放在 `workbench/server.js`:预览、正式编译与 CLI 导出都要用它,放服务器会造成第二套实现。
- 校验只做结构与取值范围,不做几何可行性。几何不变量(最小车道宽、外缘不交叉等)属求解器职责,见 `direct-edit-solver-api`
- 惰性迁移在读取入口触发,不提供独立迁移命令:既有 import 目录数量少,且迁移是纯增量创建,失败可重试。
- `ConstraintStatus` 在本子任务只落地存储与校验;实际重放匹配逻辑属 `direct-edit-solver-api`。本任务的测试用手工构造的 status 值覆盖读写路径。
- 内容寻址采用 `sha256`,与 `RoadRevisionManifest.source.osmSha256` 同一摘要,避免两套 hash。

View File

@@ -0,0 +1,5 @@
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "父任务权威合约:约束模型、文档 schema、存储布局与迁移、area config 快照、编译器版本策略"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/revisions.md", "reason": "RoadRevisionManifest 字段来源与不可变 revision 的设计理由"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "现有 ID 语义、v1 overrides 校验与 stale diagnostics 的可复用边界"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "sha256 摘要、ID 校验与 overrides 应用必须复用既有实现,不得新建第二套"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "新增 JSONL 文档与配置字段跨编译器/服务端/客户端,属该指南触发条件"}

View File

@@ -0,0 +1,40 @@
# 实施计划
对应父任务 `implement.md` 的第 0、2、3 步。每步一个提交,门禁不过就停。
## 1. 测试基础设施
- 目标:先建验证能力,避免后续"写完没法测"。
- 范围:`package.json` 增加固定版本 `vitest``test:client:unit` 脚本;建立客户端纯逻辑测试目录。不碰产品代码。
- 验证:`npm run test:client:unit`(空套件通过)、`npm run format:check``npm run test``npm run test:client``npm run build`
- 门禁:新脚本可跑通且不影响任何既有脚本。
- 回滚点:仅 `package.json` 与测试目录revert 无副作用。
## 2. v2 文档与 schema 校验
- 目标:`native-road-edits/v2` 的读写与结构校验。
- 范围:`src/compile/` 下新增文档模块6 个 kind、4 种锚点、`anchorSnapshot``ConstraintStatus``documentVersion``operations`。仅结构与取值范围校验。
- 验证:`npm run test` 新增 fixture——文档往返无损`boundaryIndex` 非整数或 `<1` 被拒;非法 station超出 `0..1``start>=end`)被拒;未知 kind 被拒kind 与锚点类型不匹配被拒provenance 指向未记录的 operation 被拒;每次写入 `documentVersion` 递增 1。
- 注意:`boundaryIndex >= laneCount` **不**属于 schema 校验——车道数是模型信息schema 层无从得知。按 `design.md`,该越界在重放时由求解器判定并转 `stale`,归 `direct-edit-solver-api`
- 门禁:校验错误信息能指出具体约束 id 与字段,不是笼统失败。
- 回滚点纯新增模块无调用方revert 无影响。
## 3. 存储布局、惰性迁移与 revision
- 目标:`active/``revisions/`、内容寻址 OSM 副本落地,既有目录可平滑接入。
- 范围:目录布局读写;读取入口触发的惰性迁移,把当前 `source.osm` + overrides + signals + area config 冻结为 `rev-0001`命名检查点创建revision 恢复读取;`RoadRevisionManifest` 写入。
- 验证:`npm run test` 新增 fixture——只含旧文件的目录被打开后生成 `rev-0001` 且既有文件字节不变;内容相同的 OSM 重复导入不产生第二份副本命名检查点冻结后可完整恢复读取manifest digest 与实际文件一致。
- 门禁:迁移对既有 `workbench-data/import-*` 只增不改revert 后旧代码仍能打开这些目录。
- 回滚点revert 后 `active/``revisions/` 只是残留目录,旧代码忽略它们。
## 4. area config 快照
- 目标:切断编译对外部区域配置文件的运行期依赖。
- 范围:活动副本与每个 revision 各写一份 `area-config.snapshot.json`;编译改读快照;`base.areaConfigSha256` 指向快照。
- 验证:`npm run test` 新增 fixture——冻结 revision 后修改外部 area config重新编译该 revision 结果不变;快照缺失时报明确错误而非静默读外部文件。
- 门禁:无 v2 文档时编译输出与快照一致。注意:仓库现有 `test/baseline/*.json` 已过期(`contract``native-road-package/v1`,当前为 `v1.1`18 个文件 hash 全部不符),且 `test/fixtures.js:26` 只断言文件存在、从不比对内容,仓库内也没有生成器。因此本步须先从当前 main 重新生成快照并接上真实比对,再谈"不变"。
- 回滚点revert 后编译回到读外部配置文件;已写快照被忽略。
## 步骤依赖
1 → 2 → 3 → 4 是硬顺序。第 4 步的 baseline 比对是本子任务能否交付的总门禁:无 v2 约束时编译输出必须与当前 main 完全一致。

View File

@@ -0,0 +1,38 @@
# 直接编辑文档与 revision 基础
父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。需求来源与权威合约在父任务的 `prd.md` / `design.md`
## 目标
建立 `native-road-edits/v2` 文档、活动工作副本、不可变 revision 与 area config 快照,使后续求解、预览和编辑能力有可复现的持久化基础。本子任务不含任何 UI也不含约束求解。
## 顺序依赖
无前置。是 `direct-edit-solver-api``direct-edit-map-editor``direct-edit-junction-tools` 三者的前置:它们依赖本任务定义的文档 schema、`documentVersion` 与快照读取路径。
## 范围
- `vitest` 依赖与 `test:client:unit` 脚本(后续子任务的验证基础)。
- `native-road-edits/v2` 文档读写与 schema 校验6 个约束 kind、4 种锚点、`anchorSnapshot`、5 态 `ConstraintStatus``documentVersion`
- `workbench-data/import-<id>/` 内的 `active/``revisions/`、内容寻址 `osm/<sha256>.osm` 布局。
- 既有 import 目录的惰性迁移:首次打开时冻结 `rev-0001`,不移动或重写既有文件。
- `area-config.snapshot.json` 的写入与读取;编译与预览改读快照。
- 命名检查点创建与 revision 恢复读取。
## 不做
- `resolveDirectEditConstraints` 与任何几何求解(属 `direct-edit-solver-api`)。
- 预览、保存、rebase 的 HTTP 端点(属 `direct-edit-solver-api`)。
- 任何客户端改动。
- v1 `native-road-overrides.json` 的迁移或格式变更。
## 验收标准
- [ ] `vitest``test:client:unit` 可跑通空套件,且不影响既有 `test` / `test:client` / `build`
- [ ] v2 文档往返读写无损schema 校验拒绝越界 `boundaryIndex`、非法 station 与未知 kind。
- [ ] 每次成功写入使 `documentVersion` 递增 1。
- [ ] 只含旧文件的既有 `workbench-data/import-*` 目录,在新代码下可正常打开且未被改写,并生成 `rev-0001`
- [ ] 内容相同的 OSM 重复导入不产生第二份副本。
- [ ] 冻结 revision 后修改外部 area config重新编译该 revision 结果不变。
- [ ] 快照缺失时给出明确错误,不静默回退到读外部配置文件。
- [ ] `npm run format:check``npm run test``npm run test:client``npm run build` 全绿。

View File

@@ -0,0 +1,26 @@
{
"id": "direct-edit-documents",
"name": "direct-edit-documents",
"title": "直接编辑文档与 revision 基础",
"description": "native-road-edits/v2 文档、活动副本、不可变 revision、area config 快照与惰性迁移",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": "2026-08-27",
"branch": "feat/direct-edit-documents",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-26-direct-manipulation-road-editor",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,5 @@
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "核对坐标分层(禁止用 3857 坐标差当米)、预览时序仲裁位置与开关关闭时的零残留要求"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md", "reason": "核对基线图层保持只读、手柄只带 handleId、未把地理坐标直接写成道路 polygon"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查客户端是否出现第二份约束模型或对 manifest 字段的局部 cast"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否重复实现图层更新、选择高亮或米制换算"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "核对 Map 单例未被重建、既有图层可见性与选择行为未回归"}

View File

@@ -0,0 +1,22 @@
# 设计
技术合约不在本文重复定义。权威定义见父任务 `.trellis/tasks/08-26-direct-manipulation-road-editor/design.md` 的以下小节:
- 「Handle manifest」— 客户端读取的字段与 `reserves` 禁入区语义。
- 「坐标与单位分层」— 客户端渲染用 EPSG:3857指针位移换米必须先回 4326 再用球面距离。
- 「预览时序与延迟预算」— 80ms 防抖、`pointerup` 最终请求、`previewSeq` 乱序丢弃、`degraded` 处理。
- 「交互与预览」「编辑所有权」— 只读基线图层、三个独立 source、道路区间与 junction reserve 的边界。
- 「客户端边界」— 遗留 `app.js` 不在范围、`vitest` 覆盖范围。
- 「上线与回滚形态」— `directEdit` 开关默认关闭时的行为。
输入 adapter 的候选、探针退出条件与回退优先级见父任务 `research/canvas-integration.md`
## 本子任务的局部决定
- 三个 source 严格分离:`editHandles`(手柄)、`editGhost`(客户端即时轮廓与辅助线)、`editPreview`(服务端权威几何)。基线图层始终只读,绝不写入。
- 手柄 feature 的 properties 只带 `handleId`,语义一律回 manifest 查。这样客户端没有第二份约束模型。
- ghost 只画手柄、辅助线与半透明预估轮廓,不承担权威几何。服务端预览到达后替换 `editPreview`ghost 随即淡出。
- `EditSession` 是纯逻辑对象,不持有 OpenLayers 引用命令栈、undo/redo、`previewSeq` 仲裁都可在 node 环境下单测。OL 只作为它的输入输出适配层。
- `previewSeq` 仲裁放在 `EditSession` 而非请求层:请求层只负责发与取消,丢弃决策要可测。
- 探针独立成可删除目录,不进 `MapCanvas`。探针结论只影响"手柄命中与拖拽由谁实现",不影响上面任何一条。
- 开关关闭时不注册 interaction、不请求 manifest、不创建三个 source确保零残留开销。

View File

@@ -0,0 +1,6 @@
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "父任务权威合约handle manifest 字段、坐标分层、预览时序、编辑所有权、客户端边界、directEdit 开关"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md", "reason": "ol-ext 探针的退出条件、回退优先级,以及为何不引入第二个 Canvas/视口"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "EditSession 的交互序列与浏览器 ghost 的职责边界"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "现有单例 Map、source registry 与选择高亮的可复用点"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "manifest 字段从服务端到客户端类型的流转,避免局部 cast 与重复状态"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "图层与 source 更新必须复用现有 layers.ts 模式,米制换算不得重写"}

View File

@@ -0,0 +1,49 @@
# 实施计划
对应父任务 `implement.md` 的第 1、6 步。每步一个提交,门禁不过就停。
前置:`direct-edit-solver-api` 第 2 步handle manifest已可用。完整验收需其预览与保存端点就绪。
## 1. `ol-ext` 限时探针
- 目标:只回答一个问题——能否复用通用 handle 的命中、pointer 生命周期与视觉反馈。
- 范围:隔离探针目录,不修改生产基线图层,不进依赖清单直到通过。
- 验证:手动跑探针页,确认 OL `Map` 未重建、proxy 拖拽稳定、基线 source 未被写入、事件能转成 draft 值。
- 门禁:三条同时成立才把 `ol-ext` 固定为依赖。超过一个工作日或任一条不成立,立刻停止排障。
- 回滚点:删除探针目录,改用原生 OL `Snap` + 小型 `PointerInteraction` adapter。数据合约不变本步失败只换输入层。
## 2. `EditSession` 纯逻辑
- 目标:先把可单测的部分做完,与地图解耦。
- 范围:命令栈与 undo/redo`previewSeq` 仲裁handle 事件到约束值的投影;指针位移到米的换算。不含任何 OpenLayers 引用。
- 验证:`npm run test:client:unit` —— 命令栈压入/撤销/重做序列正确;乱序响应(后发先到)被丢弃且不覆盖较新预览;投影在四种 kind 上给出预期约束值;同一像素位移在低纬与高纬得到一致的米值(纬度缩放正确)。
- 门禁:四类纯逻辑全部有测试;`EditSession` 不 import `ol`
- 回滚点纯新增模块无调用方revert 无影响。
## 3. 手柄渲染与开关
- 目标:选中道路后能看到正确的手柄,且禁入区可见可解释。
- 范围:`directEdit` 开关默认关闭selection → manifest 读取;`editHandles` source 渲染reserve 内手柄置灰并带原因提示。只读不可拖。
- 验证:`npm run test:client``npm run build`;手测——选中道路出现手柄,拖拽前后 `Map` 为同一实例reserve 内手柄置灰并提示进入 `JunctionTools`;关闭开关后无手柄、无 manifest 请求。
- 门禁:`Map` 未被重建;基线 source 未被写入。
- 回滚点:开关关闭即止损。
## 4. 拖拽、ghost 与预览
- 目标:四类能力真正可拖,预览权威且不闪烁。
- 范围:外缘偏移、步行带宽度、车道分隔位置、区间范围控制;`editGhost` 即时反馈80ms 防抖与 `pointerup` 最终请求;`AbortController` 取消在途请求;`editPreview` 替换;无效草稿诊断与保留最后有效预览。
- 验证:`npm run test:client:unit``npm run test:client``npm run build`;手测——拖动外缘时道路面/步行带/车道线/标线/connector 一致更新且只有受影响 source 被替换;车道分隔可独立调整;区间范围手柄改变影响区间且两端平滑;连续快速拖拽松手后无回跳;违反不变量时停在最后有效预览并显示诊断。
- 门禁:只有受影响 source 被替换,无全图层重建;无回跳。
- 回滚点:按能力分批提交,可单独回退某一类手柄。
## 5. 应用、保存与撤销
- 目标:闭合编辑生命周期。
- 范围:应用/保存/取消undo/redo 接到命令栈;保存走 `expectedDocumentVersion`409 冲突提示。
- 验证:`npm run test:client:unit`(保存冲突分支)、`npm run test:client``npm run build`;手测——未保存编辑可撤销/重做;保存后重新编译几何不变且状态 `exact`;两标签页同时保存后者收到 409 提示且前者结果保留。
- 门禁撤销不重写已保存历史409 有明确用户提示而非静默失败。
- 回滚点revert 后回到第 4 步的预览-only 状态。
## 步骤依赖
1 与 2 可并行3 依赖 1 的结论与 24 依赖 35 依赖 4。第 1 步失败不阻塞 2-5只改变第 3、4 步的命中与拖拽实现方式。

View File

@@ -0,0 +1,57 @@
# 主地图道路区间编辑器
父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。需求来源与权威合约在父任务的 `prd.md` / `design.md`
## 目标
在主地图上实现道路内部区间的直接操纵:外缘、步行带、车道分隔与区间范围可拖,拖拽即时有 ghost服务端返回权威预览改动可撤销、可保存。
## 顺序依赖
前置:`direct-edit-solver-api` 的第 2 步handle manifest交付后即可开工完整验收需要其预览与保存端点全部就绪。
后继:`direct-edit-junction-tools` 复用本任务的 `EditSession`、ghost/preview 图层与命令栈。
## 范围
- `ol-ext` Transform 的限时隔离探针,以及不通过时的原生 OpenLayers 回退。
- selection → handle manifest 的读取与渲染handles / ghost / preview 三个独立 source。
- 四类可拖能力:外缘偏移、步行带宽度、车道分隔位置、区间范围控制。
- `EditSession`命令栈、undo/redo、应用/保存/取消。
- 80ms 防抖预览、`pointerup` 强制最终请求、`previewSeq` 乱序丢弃、`AbortController` 取消在途请求。
- 无效草稿的诊断反馈,保留最后一个有效预览。
- `directEdit` 开关,默认关闭。
## 不做
- 路口 reserve 内的任何编辑(属 `direct-edit-junction-tools`)。
- 遗留 `workbench/client/app.js` 的同步或删除。
- OSM 中心线与拓扑的编辑。
## 验收标准
- [ ] 选中道路后出现手柄,拖拽前后 OpenLayers `Map` 为同一实例,未被重建。
- [ ] 拖动外缘 → 道路面、步行带、车道线、标线、connector 一致更新,只有受影响 source 被替换。
- [ ] 车道分隔手柄可独立调整某条分隔线,效果不等价于外缘偏移的副作用。
- [ ] 区间范围手柄可修改影响区间,两端平滑过渡回基线。
- [ ] 落在 junction reserve 内的手柄不可拖动,并提示进入 `JunctionTools`
- [ ] 连续快速拖拽后松手,最终几何与松手位置一致,无回跳。
- [ ] 违反不变量的草稿显示阻塞诊断,地图停在最后一个有效预览。
- [ ] 未保存编辑可撤销/重做;保存后重新编译几何不变,约束状态 `exact`
- [ ] 客户端单元测试覆盖:命令栈与 undo/redo、`previewSeq` 乱序丢弃、handle 事件到约束值的投影、米制换算在不同纬度的正确性。
- [ ] 关闭 `directEdit` 开关后,工作台行为与当前 main 一致。
- [ ] `npm run format:check``npm run test``npm run test:client``npm run test:client:unit``npm run build` 全绿。
## 交付状态2026-08-28 收口)
11 条里 9 条通过2 条**显式接受为未通过**,不勾选:
| 条目 | 结论 |
| --- | --- |
| 区间范围手柄可修改影响区间,两端平滑过渡回基线 | **阻塞**`compileGeometry()` 不读 `profile.interval` / `profile.transitions`,每次编辑都作用于整条路。纯逻辑(`intervalRangeHandles``projectIntervalEnd``coordinateAtStation`已交付并单测UI 由 `intervalEditingSupported = false` 隐藏——拖了没效果的控件比没有控件更糟。详见 `research/interval-not-applied.md`,含实现路径。 |
| 落在 junction reserve 内的手柄不可拖动,并提示进入 `JunctionTools` | **实现完成但无法在用户数据上演示**。判据(可编辑带 < 道路宽度)已实现,`fengshu-er-road.osm` 上实测 6 段置灰共 35 个手柄;用户导入的片区 125 个手柄全部可编辑,因为不存在被两个路口填满的短路段。由 `test/direct-edit-solver.js` 的 sandwich fixture 覆盖。 |
其余 9 条1、2、3、6、7、10 由用户浏览器手测确认8 的保存与重放由用户手测加 `test/workbench-edit-api.js` 断言9 为 97 个客户端单测11 为门禁。
本任务实施期间修掉的三个缺陷,均为跨层接线而非逻辑错误,已写入 spec
道路手柄左右放错边、约束未随 operation 发送、`/api/import` 未安装 `compileFresh`
后者早于直接编辑存在,一直让 UI 导入的会话无法重新生成。

View File

@@ -0,0 +1,63 @@
# 区间范围手柄由客户端合成
第 4 步的实现决定,与 `design.md`「Handle manifest」小节的字面规则有偏离故单独记录。
决定人dingkang2026-08-27。
## 偏离了什么
`design.md` 写明:
> 服务端从同一语义模型生成手柄清单,**客户端不自行推导手柄位置或可拖方向**。
而区间范围手柄(第 4 类可拖能力)由客户端 `selection.ts``intervalRangeHandles()` 合成,
位置由 `meters.ts``coordinateAtStation()` 从中心线插值得到。
## 为什么这样定
**1. manifest schema 装不下它。**
`EditHandle.kind` 的类型是 `RoadConstraintKind`,而 `design.md` 的 kind 表明确声明
「这 6 个 kind 与 PRD 首期范围一一对应,没有多余项也没有缺口」。
范围手柄不是一种约束——它改的是既有约束 `anchor``startStation` / `endStation`
要下发就得加第 7 个求解器根本不认的假 kind或加一个平行数组。为派生数据改合约不划算。
**2. 它的位置是服务端已发数据的纯函数。**
区间来自服务端算好并下发的 `anchor.startStation` / `endStation`
合法窗口来自 `manifest.reserves`,拖拽轴是道路切线。
服务端下发等于把自己刚发的东西再算一遍回显,正是 `cross-layer-thinking-guide.md`
警告的「derived state 另立第二个游标」。
**3. 决定性的一条:客户端本来就需要 station → 坐标的插值。**
`design.md` 要求 ghost 画「半透明预估轮廓」,即在道路上标出受影响的区间带。
画这条带子必须把 station 插值成坐标,**与范围手柄由谁产出无关**。
`coordinateAtStation()` 因此是客户端的既有需求;有了它,合成范围手柄几乎免费,
服务端改动买不到任何东西。
**4. `projectIntervalEnd()` 已交付并测试**,签名恰好就是这些输入。
## 为什么认为符合规则的意图
那条规则防的是客户端发明**语义**——哪个约束、哪个方向、什么范围合法。
范围手柄一样都没发明:
| 语义 | 来源 |
| --- | --- |
| 合法窗口 | `manifest.reserves`(服务端) |
| 区间当前值 | 约束 `anchor` 的 station服务端 |
| 拖拽轴 | 道路中心线切线(编译模型,服务端) |
| 屏幕位置 | 客户端插值 ← **仅此一项是派生的** |
客户端只做「把服务端已选定的 station 插值成屏幕位置」这一件事。
## 已接受的代价
`coordinateAtStation()``src/compile/direct-edit-solver.js``coordinateAt()`
第二份实现。二者都用弧长插值,但分属不同 runtimeCommonJS 服务端 / ESM 浏览器),
且客户端那份只服务于 ghost 渲染,不参与任何持久化或求解。
`meters.ts` 的注释已标明这层关系,防止后来者误以为可以随意改动其中一份。
## 若要改回服务端下发
新增 manifest 字段 `rangeHandles: IntervalRangeHandle[]`(不要塞进 `handles`
由服务端用既有 `coordinateAt()` 算位置。客户端删掉 `intervalRangeHandles()` 即可,
`projectIntervalEnd()` 与拖拽链路不受影响。
`coordinateAtStation()` 仍需保留,因为 ghost 的区间带还要用。

View File

@@ -0,0 +1,60 @@
# 几何编译器不施加 `road-interval` 区间
第 4 步手测时发现:区间范围手柄拖动有 ghost 反馈,但松手后几何毫无变化。
经排查这是服务端的能力缺口,不是客户端拖拽逻辑的问题。
## 事实
`grep "\.interval\b" src/compile/native-road.js` 返回空。
求解器写入了区间与过渡,几何阶段一个都不读:
| profile 字段 | solver 写入 | `compileGeometry()` 读取 |
| --- | --- | --- |
| `edgeOffsets.left/right` | ✅ | ✅ `centerlineShift`native-road.js:825 |
| `widthMeters` | ✅ | ✅ native-road.js:832 |
| `sidewalkWidths.left/right` | ✅ | ✅ 真实宽度,`sidewalkRing`native-road.js:1666-1671 |
| `laneDividerOffsets` | ✅ | ✅ native-road.js:1946 |
| **`interval`** | ✅ direct-edit-solver.js:274-277、418 | ❌ **零消费者** |
| **`transitions`** | ✅ direct-edit-solver.js:293、303、322 | ❌ **零消费者** |
## 后果
1. **每一次直接编辑都作用于整条路。** `anchor.startStation` / `endStation` 被存储、被
`validateEditDocument()` 校验、被带进 `resolveDirectEditConstraints()` 的结果,然后被忽略。
2. **区间范围手柄在几何上不可能有效果。** 客户端的 `intervalRangeHandles()`
`projectIntervalEnd()``coordinateAtStation()` 都正确且有单测,但下游无人接收。
3. **`transition: 'smoothstep' | 'linear'` 是死字段。** 父任务 design.md 承诺
「作用于 interval 两端回归基线的过渡段」,实际没有任何过渡。
## 已采取的处置
`workbench/client/src/edit/flag.ts` 增加 `intervalEditingSupported = false`
范围手柄的 UI 据此隐藏。纯逻辑与测试全部保留。
理由:一个拖起来有反馈、松手却没效果的控件比没有这个控件更糟——它会持续产生
bug 报告,并让人怀疑整个编辑器的其余部分。区间生效的那个提交把这个常量翻成 `true` 即可,
客户端不需要其他改动。
## 对验收标准的影响
父任务 prd.md 的这条**当前无法通过**
> 区间范围手柄可修改影响区间,两端平滑过渡回基线。
`08-26-direct-edit-map-editor/prd.md` 的同名条目同理。这不是客户端欠工,
而是需要 `compileGeometry()` 具备按 station 施加横断面 profile 的能力。
## 若要实现(未排期)
用户 2026-08-27 判断影响不大,故未开任务,仅记录。真要做时的形状:
1. `native-road.js` 的横断面生成需要接受「沿中心线变化的 profile」而非单一常量宽度。
目前 `applyDirectEditProfiles()`(约 818-838 行)返回的是整条路一个 `widthMeters`
和一次 `offsetLine()` 整体平移,没有沿 station 变化的余地。
2. 区间两端按 `transitions[kind]` 做 smoothstep / linear 插值回基线值。
3. 车道线、标线、connector、步行带都由横断面派生必须一并跟随否则会脱节
(父任务 research/joint-solver.md 的依赖链)。
4. `test/fixtures` 基线会变化,需要显式 update-baseline 并人工核对。
这是 `compileGeometry` 的实质改动,属已归档的 `direct-edit-solver-api` 任务范围。

View File

@@ -0,0 +1,73 @@
# ol-ext Transform 探针结论
`implement.md` 第 1 步的门禁结果。人工验证由 dingkang 在浏览器完成2026-08-27。
## 门禁结果:三条全过
| 门禁 | 结果 | 证据 |
| --- | --- | --- |
| OL `Map` 未重建 | 通过 | 实例 #2 / 累计构造 2 次StrictMode 双挂载2438 次 React 渲染下未重建 |
| 基线 source 未被写入 | 通过 | 60 次拖拽,写入 0 次,几何指纹未变 |
| proxy 拖拽稳定且事件能转成 draft 值 | 通过 | 1377 个 `translating` 事件;跟手连续、不跳数;松手不回弹;反复几十次不失手、不报错 |
投影链路同时被验证:钳位范围内 `raw 3.924 / draft 3.924` 完全相等,说明
`signedMetersAlongAxis()``projectHandleValue()` 的换算与钳位都正确。
## 但不把 ol-ext 用于道路手柄
门禁通过只意味着 ol-ext **可以**用(决策表第一行的「可选依赖」),不意味着它是更好的选择。
探针同时暴露了决策表第三行的情况,因此按该行处置。
### 决定性证据:手柄与约束值脱钩
```
translateend -> raw -24.146 / draft -5.400
```
`Transform` 的 translate 分支按原始 delta 调 `geometry.translate()`,不知道也不关心我们的钳位。
于是手柄被拖到 -24.1 米处,而约束只到 -5.4 米——手柄停在道路永远不会变成的位置上。
生产要求相反:手柄必须贴着钳位边界停下,位置由**约束值反算**而来,而不是跟随光标。
这意味着位置更新必须由我们自己拥有。用 ol-ext 就得每帧撤销它刚做的 translate
自己写 `PointerInteraction` 则直接不移动过界。
### 其余理由
- 我们实际用到的只有「Point proxy 上的 translate + start/move/end 生命周期 + hitTolerance 命中」。
这些 `ol/interaction/Translate` 原生就有,且自带一等 TypeScript 类型。
ol-ext 的增量价值是 bounding box 的 scale / stretch / rotate —— 而道路法线偏移、
区间范围、路口 cutback 都用不上它,`canvas-integration.md` 早已指出这点。
- ol-ext 4.0.38 不带类型,也没有 `@types/ol-ext`。采用它就要长期自己维护一份声明文件。
探针期间已经踩到一次:它的自定义事件名不在 OL 的事件类型联合里,
`on` / `un` 无法直接声明在类上,只能另设接口做一次转换。
- 每个 mousemove 都会走 `handleMoveEvent_ → getFeatureAtPixel_ → forEachFeatureAtPixel`
触发 OL 的 canvas 回读(控制台 `Canvas2D getImageData` 警告即来自此)。
这不是崩溃原因,但属于固有开销。
## 探针期间发现的、与 ol-ext 无关的教训
浏览器白屏一度被误当成 ol-ext 的稳定性问题,实际是探针自身的 bug
```
at projectHandleValue (projection.ts:49)
at ProbeMap.tsx:133
at basicStateReducer → updateReducer → useState
```
几何计算被写在了 `setState` 的 updater 函数里。React 会延迟、且在 StrictMode 下重复调用
updater真正执行时闭包里的 `start` 已被 `translateend` 置为 `null`,于是 `toLonLat(null)` 抛错。
`start!` 的非空断言正是把运行时问题藏过类型检查的地方。
**教训(适用于第 3、4 步)**:绝不在 `setState` updater 内做几何计算或读取实时 OL 状态。
先在事件处理器里算出普通值,再传进 updater。
另一条:逐个 `translating` 事件更新 React state 会产生上千次渲染。探针用
`requestAnimationFrame` 合并后才可用。生产要更进一步——ghost 直接写自己的 OL source
绝不为每次指针移动重渲染 React 树。
## 结论
按回退方案 1 推进:原生 OL `Snap` + 小型 `PointerInteraction` adapter位置由约束值反算。
`HandleManifest → RoadEditOperation → RoadConstraint → preview solver` 数据合约不变,
第 2 步已交付的 `EditSession` / `projection` / `meters` 全部保留,探针已验证它们可用。

View File

@@ -0,0 +1,70 @@
# reserve 内手柄在当前服务端实现下不可达
第 3 步人工验证时发现:点遍所有道路都看不到灰色(不可拖)手柄。经实测确认这是**正确行为**,不是漏测。
## 实测数据
`test/fixtures/fengshu-er-road.osm`24 条方向道路、32 个 reserve
```
road handles : 126
road NOT editable : 0
with disabledReason : 0
junction handles : 96 JunctionTools 所有,主地图已过滤)
```
## 为什么不可达
`makeRoadHandles()``editable: false` 的唯一来源是 `unavailable`
```js
const start = Math.min(1, startReserve);
const end = Math.max(0, 1 - endReserve);
const unavailable = start >= end;
```
`junctionReserves()` 给每端的保留区比例有硬上限:
```js
const fraction = Math.min(0.45, cutback / length);
```
于是 `startReserve ≤ 0.45``endReserve ≤ 0.45`,即 `start ≤ 0.45``end ≥ 0.55`
`start >= end` 结构上永远为假,`unavailable` 分支是死代码。
实测输出里能直接看到上限生效的样子——两端都撞到 0.45 的短路段:
```
segment:way/858770822/1 window=[0.450,0.550]
segment:way/117947564/0 window=[0.450,0.550]
segment:way/851989492/2 window=[0.450,0.550]
```
另外手柄的 station 取自 `(start + end) / 2`,即无保留区窗口的正中,
所以三类道路手柄的位置也永远不会落进 reserve。
## 对验收标准的影响
prd.md 的「落在 junction reserve 内的手柄不可拖动,并提示进入 JunctionTools」
对当前三类道路手柄是**空真**:它们永远不落在 reserve 内,所以约束自动成立。
客户端一侧的置灰与原因提示路径有单测覆盖(`selection.test.ts`disabled 手柄被保留、
`disabledReasonOf` 始终给出原因),只是服务端从不产生这种输入。因此第 3 步不需要改动。
reserve 边界真正变得用户可见是在第 4 步的**区间范围手柄**:它可以被拖向 reserve 边界,
`projectIntervalEnd()` 钳到 `window.minStation` / `maxStation`。届时这条边界才有可演示的行为。
## 留给用户决定的设计问题
那个死分支暗示原作者期望「短路段应完全归 JunctionTools」。但 0.45 的上限把结果改成了:
一条几何上被两个大路口主导的短路,仍然会在正中间得到一条仅占全长 10% 的可编辑带
(上面 `[0.450,0.550]` 那几条)。
两种取向都讲得通,需要产品判断:
- 保持现状:任何道路都留一条可编辑带,哪怕很窄。
- 改为:当 `cutback / length` 两端之和超过某阈值时,整条路标 `editable: false`
引导用户进入 JunctionTools。这会让死分支复活也让验收标准变成可演示的。
改动落在 `src/compile/direct-edit-solver.js`(属已归档的 `direct-edit-solver-api` 任务范围),
不在本任务范围内,故此处只记录不实施。

View File

@@ -0,0 +1,26 @@
{
"id": "direct-edit-map-editor",
"name": "direct-edit-map-editor",
"title": "主地图道路区间编辑器",
"description": "ol-ext 限时探针与主地图外缘/步行带/车道分隔/区间范围拖拽",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": "2026-08-28",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-26-direct-manipulation-road-editor",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,5 @@
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "逐字段核对 handle manifest、API 合约、409 前置条件与 degraded 标记是否与权威合约一致"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "逐条核对约束不变量:最小车道宽 2.4m、外缘不交叉、路口连续、路口面不自交、connector 包含性"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否出现第二套米制换算或重复的 overrides 应用逻辑"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查约束 kind 与 manifest 字段在编译器、服务端响应之间是否各写一套"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "核对既有 /api/overrides、/api/compile、/api/state 行为与 baseline 输出未回归"}

View File

@@ -0,0 +1,22 @@
# 设计
技术合约不在本文重复定义。权威定义见父任务 `.trellis/tasks/08-26-direct-manipulation-road-editor/design.md` 的以下小节:
- 「架构边界」— `resolveDirectEditConstraints``compileGeometry()` 之前的插入位置与数据流。
- 「约束模型」— 6 个 kind 与各自的锚点、value、单位。
- 「Handle manifest」— `HandleManifest` / `EditHandle` / `JunctionReserve` 字段。
- 「坐标与单位分层」— 服务端在局部米制框架求解,传输一律 EPSG:4326 加方位角。
- 「预览时序与延迟预算」— `previewSeq` 回显、`degraded` 标记、p95 ≤ 300ms。
- 「编辑所有权」「不变量」— 两域划分与必须验证的几何不变量。
- 「API 合约」— 5 个新端点、409 前置条件、既有端点的增量字段。
- 「编译器几何版本变更」— `recheck` 策略。
## 本子任务的局部决定
- 求解器是纯函数,放在 `src/compile/` 下独立模块,不依赖 `fs``http`:预览、正式编译与 CLI 导出共用同一实现,这是"浏览器不维护第二套几何算法"的落点。
- 米制换算全部走 `src/geometry/lane-geometry.js` 现有的 `metersAt()` / `haversineMeters()` / `projectedDistanceAlong()` / `lateralOffsetFrom()`。禁止新增换算函数;缺能力就扩展该模块。
- handle manifest 与 diagnostics 是求解器的返回值,不是编译产物文件:预览不写盘,正式编译才落 GeoJSON。
- `axisAzimuth` 由服务端算好,客户端只做投影。这样"哪个方向可拖"是语义决定而非 UI 猜测。
- 重放匹配与求解分两个阶段:先把约束解析成 `exact` / `recheck` / `pending` / `conflicted` / `stale`,只有前两态进入求解。这样诊断与几何互不污染。
- `POST /api/edit-preview` 复用现有请求体读取与 `sendJson` 工具,不引入新的 HTTP 框架。
- 端点顺序:先 `GET /api/edit-state``POST /api/edit-preview`(只读/无写入,风险最低),再 `POST /api/edits` 与 revision 写入端点。

View File

@@ -0,0 +1,7 @@
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "父任务权威合约约束模型、handle manifest 字段、坐标分层、预览时序、不变量、API 合约"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "求解阶段插入点、图层依赖链顺序与约束不变量的完整来源"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "compileGeometry 现有输入输出、feature 回链字段与 v1 overrides 复用点"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/data-model-options.md", "reason": "分层求解规则与重放匹配策略的推导过程"}
{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "junction reserve 所有权划分与 approach 约束优先级的依据"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "米制换算必须复用 src/geometry/lane-geometry.js禁止新增第二套换算函数"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "新增 API 载荷与 manifest 字段跨编译器/服务端/客户端三层"}

View File

@@ -0,0 +1,57 @@
# 实施计划
对应父任务 `implement.md` 的第 4、5 步。每步一个提交,门禁不过就停。
前置:`direct-edit-documents` 已交付v2 文档 schema、活动副本、area config 快照可读)。
## 1. 求解器骨架与恒等性
- 目标:插入 `resolveDirectEditConstraints` 阶段,且在无 v2 约束时是恒等变换。
- 范围:`src/compile/` 新增纯函数模块;在 `compileGeometry()` 前接入;输出 editable profiles、junction plans、handle manifest、diagnostics 四件套的空实现。
- 验证:`npm run test`,与 `direct-edit-documents` 建立的快照逐字节比对无差异。
- 门禁:无 v2 约束时编译输出与快照完全一致。这条不过不许继续。快照必须是 `direct-edit-documents` 从当前 main 重新生成的那份,不是仓库原有的过期 `test/baseline/*.json`
- 回滚点:恒等变换,可安全单独 revert。
## 2. junction reserve 与 handle manifest
- 目标:先产出"能拖什么、不能拖什么",为客户端解锁并行开发。
- 范围junction reserve 计算6 个 kind 的 handle 生成,含 `position``axisAzimuth``value.min/max``affects``editable``disabledReason`
- 验证:`npm run test` 新增 fixture——reserve 内的道路 handle 一律 `editable: false` 且带原因;`affects` 覆盖道路面、步行带、车道线、标线与 connector`axisAzimuth` 与道路法线/切线方向一致。
- 门禁manifest 字段与父任务 `design.md`「Handle manifest」逐字段一致客户端可据此开工。
- 回滚点:纯新增返回值,无调用方时 revert 无影响。
## 3. 六个 kind 的求解与不变量
- 目标:约束真正改变几何,且违规时给阻塞性诊断。
- 范围:`road-edge-offset``road-sidewalk-width``road-lane-divider``junction-approach-width``junction-cutback``junction-corner-radius`interval 默认范围推导(避开两端 reserve`smoothstep` 过渡;`junction-approach-width` 优先级与两域边界连续。
- 验证:`npm run test` 新增 fixture——每个 kind 一条重放用例;最小车道宽 2.4m、左右外缘不交叉、道路/路口连续、路口面不自交、connector 包含性各一条违规用例返回阻塞诊断。
- 门禁:违规一律诊断,绝不静默修复或钳制到合法值。
- 回滚点:按 kind 分批提交,可单独回退某个 kind。
## 4. 重放匹配与状态机
- 目标OSM 重导入后约束的 5 态分类可靠。
- 范围:精确匹配(`roadId` / `segmentId` / `nodeId`)优先;失败才用 `anchorSnapshot` 的 OSM node、距离与切线找候选编译器版本不一致时全部标 `recheck`
- 验证:`npm run test` 新增 fixture——同一 OSM 全 `exact`;删掉某条道路后相关约束 `stale``boundaryIndex` 越界 `stale`;候选歧义或偏差超阈值 `conflicted`;版本变化全 `recheck` 且值未变。
- 门禁:`pending` / `conflicted` / `stale` 一律不进入求解,不存在静默模糊应用的路径。
- 回滚点匹配模块独立revert 后退回仅精确匹配。
## 5. 只读端点
- 目标:先上无写入风险的两个端点。
- 范围:`GET /api/edit-state``POST /api/edit-preview``previewSeq` 回显、`degraded` 标记;复用现有请求体读取与 `sendJson`
- 验证:`npm run test` 新增 server integration——预览调用前后工作区目录内容与 mtime 不变;`previewSeq` 原样回显;预览与正式编译对同一约束集给出相同几何;超预算时 `degraded: true` 而非报错。
- 门禁:预览零写入,有测试证明。
- 回滚点新增路由revert 后其余端点不受影响。
## 6. 写入端点与并发保护
- 目标保存、revision 与 rebase 落地,并挡住多标签页覆盖。
- 范围:`POST /api/edits`(要求 `expectedDocumentVersion`)、`POST /api/revisions``POST /api/revisions/:id/rebase``/api/state``/api/session``/api/import` 增量字段。
- 验证:`npm run test` 新增 server integration——版本不匹配返回 409 且不写入原子写入中途失败不留半份文件rebase 返回各 status 计数与明细;`/api/import` 成功后返回基线 revision id既有 `/api/overrides``/api/compile``/api/state` 字段与行为不变。
- 门禁409 路径与原子失败路径都有测试覆盖;既有端点无回归。
- 回滚点写入端点独立revert 后回到只读预览。
## 步骤依赖
1 → 2 → 3 硬顺序4 可与 3 并行5 依赖 26 依赖 5 与 `direct-edit-documents`。第 2 步交付后客户端子任务即可并行开工。

View File

@@ -0,0 +1,42 @@
# 约束求解器与编辑 API
父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。需求来源与权威合约在父任务的 `prd.md` / `design.md`
## 目标
把约束解析从几何生成中抽成纯函数边界并用它同时支撑草稿预览与正式编译对外提供预览、保存、revision 与 rebase 的服务端能力。本子任务不含任何 UI。
## 顺序依赖
前置:`direct-edit-documents`(需要 v2 文档 schema、`documentVersion`、活动副本与 area config 快照读取)。
后继:`direct-edit-map-editor``direct-edit-junction-tools` 依赖本任务的 handle manifest 与预览 API。
## 范围
- `resolveDirectEditConstraints` 纯函数:输入基线模型 + v1 覆盖 + v2 约束,输出 editable profiles、junction plans、handle manifest、diagnostics。
- 6 个约束 kind 的求解interval 默认范围推导(避开两端 junction reservejunction reserve 计算。
- `junction-approach-width` 对道路 profile 的优先级,以及两域边界的连续过渡。
- 约束重放匹配:精确匹配优先,失败才按 `anchorSnapshot` 找候选,产出 5 态 `ConstraintStatus`
- 端点:`GET /api/edit-state``POST /api/edit-preview``POST /api/edits``POST /api/revisions``POST /api/revisions/:id/rebase`
- `previewSeq` 回显、`degraded` 标记、`expectedDocumentVersion` 前置条件与 409。
- `/api/state``/api/session``/api/import` 的增量字段。
## 不做
- 任何客户端改动、手柄渲染或拖拽交互。
- cluster 的高级求解(只保证 cluster 不被当成多个普通路口处理)。
- 多人协作的并发合并策略。
## 验收标准
- [ ] 求解是纯函数:无文件写入、无网络调用,可用单元 fixture 直接驱动。
- [ ] 无 v2 约束时编译输出与当前 main 逐字节一致baseline fixture 比对)。
- [ ] 6 个 kind 各有重放 fixture预览与正式编译对同一约束集给出相同几何。
- [ ] 违反最小车道宽 2.4m、左右外缘交叉、路口面自交、connector 越界时返回阻塞性诊断而非静默修复。
- [ ] `boundaryIndex` 越界转 `stale`;锚点缺失转 `stale`;候选歧义或偏差超阈值转 `conflicted`
- [ ] 编译器几何版本不一致时约束全部标 `recheck`,不自动改值也不自动失效。
- [ ] `POST /api/edit-preview` 调用前后工作区目录内容与 mtime 不变。
- [ ] `expectedDocumentVersion` 不匹配返回 409 且不写入;原子写入中途失败不留半份文件。
- [ ] rebase 返回各 status 的计数与逐条明细。
- [ ] 既有 `/api/overrides``/api/compile``/api/state` 的字段与行为不变。
- [ ] `npm run format:check``npm run test``npm run test:client``npm run build` 全绿。

View File

@@ -0,0 +1,26 @@
{
"id": "direct-edit-solver-api",
"name": "direct-edit-solver-api",
"title": "约束求解器与编辑 API",
"description": "resolveDirectEditConstraints 纯函数边界,以及预览/保存/revision/rebase API",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": "2026-08-27",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-26-direct-manipulation-road-editor",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,4 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file":".trellis/spec/frontend/index.md","reason":"Review frontend quality, component, hook, and state conventions."}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Verify API-to-React-to-OpenLayers data flow and cleanup boundaries."}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Check that migration does not leave duplicate DOM and React implementations or duplicate map logic."}

View File

@@ -0,0 +1,32 @@
# Technical Design
## Frontend Boundary
Create a Vite app under `workbench/client/` (or a clearly scoped `workbench/frontend/` directory) with React 19 and TypeScript. Keep Node/server code CommonJS and keep `/api/*` payloads unchanged. The production build should emit a deterministic directory consumed by `workbench/server.js`; development mode may use a Vite proxy to the workbench API.
## OpenLayers Migration Strategy
- Import OpenLayers modules directly from the installed `ol` package in TypeScript; remove the runtime `/vendor/ol/*.js` import map from the React build.
- Keep one `Map` instance per mounted map component. A `useOpenLayersMap` hook creates it once after the container ref is available, registers interactions/listeners, and calls `map.setTarget(undefined)` plus listener cleanup on unmount.
- Keep the layer registry in an adapter module/hook (`map/layers.ts`), created once with stable `VectorSource` and `VectorLayer` instances. React state changes update sources, styles, visibility, and selection overlays through explicit adapter methods rather than recreating the map.
- Use typed GeoJSON/read helpers at the API boundary; preserve EPSG:4326 to EPSG:3857 conversion and current fit/selection behavior.
- OpenLayers event callbacks publish typed selection/pointer events to React state. React panels render from that state and issue API mutations; they do not query or mutate DOM nodes owned by OpenLayers.
- Import `ol/ol.css` from the Vite entry and keep map container dimensions in application CSS.
## UI and State
- Use shadcn/ui primitives for buttons, inputs, checkboxes, select, tabs/segmented filters, dialogs, sheets, alerts, and toast feedback. Keep the existing dense three-column workbench layout.
- Define API/state types in `src/types/` based on the actual `/api/state` and mutation payloads. Use a small typed client in `src/lib/api.ts`.
- Use React hooks/context for session and editor state. Keep transient map selection and staged overrides separate from server state; avoid introducing a large state library unless the migration proves one necessary.
- Split components by existing workflow boundaries: `ImportScreen`, `WorkbenchShell`, `LayerPanel`, `DiagnosticsPanel`, `MapCanvas`, `RoadInspector`, `SignalEditor`, and shared `ui/` primitives.
## Static Hosting
- Update `workbench/server.js` to serve Vite `index.html` and hashed assets in production, with a safe fallback for the SPA route while retaining `/vendor/*` only for compatibility during transition.
- Keep `npm run road:workbench` self-contained for production assets. Add Vite dev/proxy scripts without requiring the Node workbench server to become an ESM application.
## Risks and Compatibility
- OpenLayers owns imperative objects and must not be recreated on every React render; the hook/adapter boundary is mandatory.
- Existing style functions close over selected road state. Convert them to adapter-managed mutable selection refs or call `layer.changed()` after selection updates.
- The first migration should preserve behavior and layout; visual redesign is explicitly deferred.

View File

@@ -0,0 +1,4 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file":".trellis/spec/frontend/index.md","reason":"Load the project frontend conventions before introducing Vite, React, TypeScript, and shadcn/ui."}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"The migration changes the browser build, static hosting, API client, and imperative map lifecycle together."}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Reuse current OpenLayers styles, API payloads, and editor behavior instead of duplicating domain logic."}

View File

@@ -0,0 +1,20 @@
# Implementation Plan
1. Add Vite, React 19, TypeScript, Tailwind/shadcn/ui dependencies and scripts; establish `tsconfig`, Vite config, aliases, CSS variables, and production output location.
2. Define typed API/state contracts and a fetch client from the existing `/api/*` payloads; add focused serialization tests.
3. Build the OpenLayers adapter and `useOpenLayersMap` hook. Port the current layer registry, styles, source updates, selection interaction, fit behavior, and cleanup tests before moving inspector UI.
4. Port the import-first flow and shell/layout to React, then migrate layer controls, summaries, diagnostics, road inspector, connection editor, centerline editor, signal editor, and save/compile actions.
5. Replace the server's legacy `/app.js`/`/app.css` production serving with Vite build output while retaining a compatibility path only where needed for rollback.
6. Remove the old DOM-driven entry after parity is demonstrated; keep OpenLayers vendor serving only if legacy mode still needs it.
7. Add browser/component smoke coverage for no-session import, active-session map render, a road selection/edit, and compile/export request wiring.
8. Run `npm run build`, frontend tests, existing `npm test`, and a manual workbench smoke check at desktop/mobile widths; fix parity regressions.
Validation commands:
- `npm run build`
- `npm run format:check`
- frontend test command added by this task
- `npm test`
- `node --check workbench/server.js`
Rollback point: retain the legacy client in a separate compatibility path until the React build serves the same API workflow and OpenLayers interaction checks pass.

View File

@@ -0,0 +1,43 @@
# 迁移工作台到 React 前端
## Goal
将现有原生 HTML/CSS/JS 工作台迁移到 Vite、React 19、TypeScript 和 shadcn/ui获得可维护、可扩展且具备类型安全的成熟前端工程结构同时保留当前 OSM 导入、地图查看、道路参数编辑、诊断、信号灯编辑、重新编译和道路包导出能力。
## Confirmed Facts
- 当前前端位于 `workbench/client/index.html``workbench/client/app.js``workbench/client/app.css`
- `workbench/client/app.js` 使用 OpenLayers 浏览器模块,并动态创建部分控制面板和图层开关。
- `workbench/server.js` 提供静态资源和 `/api/state``/api/import``/api/overrides``/api/traffic-signals``/api/compile``/api/export.zip` 等接口。
- 当前项目是 CommonJS Node 包尚无前端构建脚本、TypeScript 配置、Vite 配置或 shadcn/ui 依赖。
- 地图核心继续使用已有的 OpenLayers 10本任务是前端工程迁移不替换地图引擎。
## Requirements
1. 建立独立、可构建的 Vite React 19 TypeScript 前端入口并迁移工作台代码。
2. 配置 shadcn/ui 所需的 CSS 变量、组件基础设施和图标/交互约定,优先使用其组件承载按钮、表单、面板、弹层和提示。
3. 将页面状态、API 调用、地图图层/选择逻辑和编辑表单拆分为 React 组件、hooks、类型和服务模块避免主要业务 UI 继续依赖全局 `querySelector` 和动态 `innerHTML`
4. 保留现有服务端 API 契约和 OSM 导入工作流;必要时只调整静态资源托管和开发/生产构建入口。
5. 保留 OpenLayers 地图的现有图层、样式、选择和交互行为,并确保状态变化正确驱动地图与检查器更新。
6. 提供开发模式和生产构建命令Node 工作台服务能提供构建后的前端资源。
7. 为核心 API 客户端、状态转换和至少一个关键交互增加 TypeScript/自动化验证。
## Acceptance Criteria
- `npm run build` 能在干净依赖安装后生成前端生产构建产物。
- `npm run dev`(或等价命令)能启动 Vite 开发服务器并加载工作台页面。
- `npm run road:workbench` 使用构建产物时仍能打开工作台;无活动工作区时显示 OSM 导入界面,有活动工作区时显示地图和编辑器。
- OSM 导入、图层开关、道路选择/参数暂存、诊断筛选、交通信号编辑、保存、重新编译和 ZIP 导出均保持可用。
- 页面不再通过原生 `querySelector`/`innerHTML` 组织主要业务 UI业务状态和 API 数据具有明确 TypeScript 类型。
- 现有 Node 编译器测试继续通过;新增前端构建和关键工作流验证通过。
## Out Of Scope
- 不修改道路编译算法、OSM 解析规则、GeoJSON 数据结构或现有 HTTP API 语义。
- 不更换 OpenLayers 为其他地图引擎。
- 不引入认证、多用户协作、服务端渲染或远程部署平台。
- 不在本任务内重新定义道路参数模型或增加新的业务功能。
## Open Product Decision
迁移是否同时允许明显的视觉和信息架构重设计?推荐第一阶段以“功能和工作流等价 + shadcn/ui 统一视觉”为目标,保留现有三栏地图工作台布局;这样可以把风险集中在工程迁移,后续再单独做 UX 重构。若本任务同时重做布局,交付周期和回归范围会显著增加。

View File

@@ -0,0 +1,26 @@
{
"id": "react-workbench-migration",
"name": "react-workbench-migration",
"title": "迁移工作台到 React 前端",
"description": "将现有原生 HTML/CSS/JS 工作台迁移到 Vite、React 19、TypeScript 和 shadcn/ui保持现有工作流能力",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": "2026-08-26",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,15 @@
# 修复场景效果预览
## Goal
恢复 React 工作台的场景效果:开启后道路面显示无边线的深灰沥青效果,步行带显示灰色填充;关闭后回到编辑样式。
## Cause
`MapCanvas` 只在挂载时创建 OpenLayers 图层,其 style callback 捕获了首次渲染的 `scene` 值,后续切换无法读取当前状态。
## Acceptance Criteria
- 切换场景效果后,原生道路面和步行带立即切换到对应场景样式。
- 切换不重建 `Map`、图层或 feature source。
- `npm run format:check``npm run test:client``npm run build` 通过。

View File

@@ -0,0 +1,26 @@
{
"id": "scene-preview-fix",
"name": "scene-preview-fix",
"title": "修复场景效果预览",
"description": "修复 React OpenLayers 图层样式捕获初始 scene 状态导致沥青场景效果未生效的问题",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": "2026-08-26",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,2 @@
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Verify upload-to-compile data flow and session boundary."}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Verify no duplicate compiler or parser logic was introduced."}

View File

@@ -0,0 +1,39 @@
# Technical Design
## Boundaries
- `bin/road-workbench.js` owns process startup and creates an import session/workspace.
- `workbench/server.js` owns HTTP routes and session-local files; it continues to call `compileInput()` for compilation.
- `workbench/client/index.html` and `workbench/client/app.js` add the upload/bootstrap state only; existing editing panels remain unchanged.
- `src/compile/compiler.js` and `src/osm.js` remain the source of truth for input validation and OSM processing.
## Data Flow
1. CLI starts a workbench with an optional existing `--input` for compatibility, or with no input for the new import-first flow.
2. The browser submits multipart OSM content (bounded by a server upload limit) to a new session/import endpoint.
3. The server validates the filename/content, creates a unique workspace directory under a configured workbench data root, and writes:
- `source.osm`
- `native-road-overrides.json` with the existing empty schema
- `native-traffic-signals.json` with the existing empty schema
- `RoadCompilerInput.json` using conservative defaults and workspace-relative output paths
4. The server calls `compileInput()` and swaps the active session from the import screen to the existing state/map view.
5. Existing routes operate against the active session's `area` object. A failed import/compile removes only the new staging directory and leaves any prior active session untouched.
## Defaults and Compatibility
- Area id is derived from a sanitized user-provided name or uploaded basename, with a unique suffix when necessary.
- `options.edgeLines` and `options.junctionTemplates` use the same shape required by `validateInput()`; no new compiler options are introduced.
- Existing `--input RoadCompilerInput.json` startup remains supported by adapting it into the same active-session context.
- The server binds to localhost as it does today and does not add authentication or remote persistence.
## Error and Recovery
- Reject empty/non-XML uploads, oversized bodies, malformed OSM, and compile failures with JSON errors suitable for the browser.
- Stage all files before switching the active session; cleanup on failure.
- Keep workspace directories recoverable on disk; do not delete an existing user workspace during a new import.
## Testing Strategy
- Unit/integration tests for workspace initialization, default input generation, upload size/content validation, and failed-import cleanup.
- HTTP smoke test for import -> state -> compile/export using the existing fixture OSM.
- Existing compiler and fixture tests remain the regression gate.

View File

@@ -0,0 +1,2 @@
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Import spans CLI, HTTP server, filesystem workspace, compiler, and browser state."}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Reuse existing compiler input validation, atomic JSON writes, and workbench state routes."}

View File

@@ -0,0 +1,19 @@
# Implementation Plan
1. Read frontend/backend project specs and map the current workbench startup contract.
2. Extract small, testable helpers for session workspace creation, default `RoadCompilerInput`, and bounded OSM request handling.
3. Repair `bin/road-workbench.js` to support import-first startup while adapting legacy `--input` files.
4. Add server endpoints for upload/bootstrap and active-session state, preserving all existing edit routes.
5. Add a minimal import screen and transition in the existing client; keep the map/editor UI intact.
6. Add tests for helper validation and an HTTP import smoke path using `test/fixtures/fengshu-er-road.osm`.
7. Run `npm test`, targeted workbench tests, and manual localhost smoke checks; fix issues found.
8. Run Trellis quality check, update relevant specs if a durable convention is discovered, then commit.
Validation commands:
- `npm test`
- `node --check bin/road-workbench.js`
- `node --check workbench/server.js`
- targeted workbench test command added by this task
Rollback points: CLI/server changes can be reverted independently of compiler sources; workspace staging ensures an import failure does not alter an existing session.

View File

@@ -0,0 +1,40 @@
# Web OSM 导入工作流
## Goal
让用户无需预先编写 `RoadCompilerInput.json` 或宿主区域配置,即可通过 Web 工作台上传一个 `.osm` 文件,创建一次可编译的工作区并开始查看、调整和导出道路结果。
## Confirmed Facts
- `src/compile/compiler.js` 已提供完整的 `compileInput()`,负责读取 OSM、加载 overrides、生成原生道路图层、诊断和交通信号运行时资产。
- `src/osm.js` 已提供 OSM XML 解析;`src/compile/native-road.js` 已提供道路模型、几何编译和 overrides 校验。
- `workbench/server.js` 已提供地图状态、覆盖项保存、交通信号编辑/生成、重新编译和 ZIP 导出接口,但入口假定已有 `area`、配置加载器和编译回调。
- `workbench/client/` 已有完整的 OpenLayers 编辑界面,当前通过 `/api/state` 加载既有编译结果。
- `bin/road-workbench.js` 当前要求 `--input <RoadCompilerInput.json>`,却把 `{ input, inputFile, port }` 传给期待另一种上下文的 `startWorkbench()`,无法独立启动现有工作台。
## Requirements
1. 工作台启动后提供 OSM 文件导入入口;成功导入后自动建立编译所需的工作区文件和默认参数。
2. 导入流程复用现有 `compileInput()` 与已有编辑 API不复制道路解析或几何编译逻辑。
3. 导入后自动执行首次编译,并让现有地图、诊断、覆盖项、交通信号编辑和 ZIP 导出继续可用。
4. 导入失败时返回可理解的错误,不破坏当前已加载的工作区。
5. 保留通过现有 `RoadCompilerInput.json` 启动工作台的兼容路径(若当前入口契约可修复则继续支持)。
6. 默认参数应明确、可追溯,并允许用户在首次编译后通过已有工作台控件调整;本任务不重新设计道路算法或参数模型。
## Acceptance Criteria
- 用户运行工作台命令并打开页面,可以选择 `.osm` 文件并提交。
- 服务端保存上传内容,生成有效的 overrides、traffic-signals、输出目录和 `options`,然后完成一次 `compileInput()`;页面显示道路图层和编译诊断。
- `/api/state``/api/overrides``/api/traffic-signals``/api/compile``/api/export.zip` 在导入工作区中均正常工作。
- 非法文件、空文件、超过限制的上传或编译错误不会留下半成品工作区,并在页面显示错误。
- 现有测试继续通过,并新增覆盖入口/上传/初始化链路的自动化测试。
## Out Of Scope
- 修改 OSM 解析规则、道路几何算法、交通信号生成算法或导出包格式。
- 多用户认证、远程持久化、数据库、云端 OSM 下载和在线协作。
- 重新设计现有工作台地图编辑 UI。
## Key Decision
每次导入创建独立的本地工作区目录并在当前工作台会话中使用,避免覆盖已有区域配置。工作区保留在磁盘上,后续可通过兼容的 `--input` 方式恢复。

View File

@@ -0,0 +1,26 @@
{
"id": "web-osm-import",
"name": "web-osm-import",
"title": "Web OSM 导入工作流",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": "2026-08-26",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,18 @@
# 优化工作台图层与场景标线
## Goal
移除不再需要的高德规整路口参考图层,提供可读的编译概览,并恢复场景模式下双向道路中心线的可辨识渲染。
## Confirmed Facts
- 科技园一路的三个双向路段在 `center_lines.geojson` 中都有完整的黄色虚线 feature不是编译缺失。
- React adapter 将原版中心线的属性驱动样式简化为固定样式,造成场景模式下辨识度不正确。
- 高德参考层仅在 React 前端图层 registry、图层开关和状态装载中使用不影响编译。
## Acceptance Criteria
- 工作台不显示或加载高德规整路口参考图层。
- 场景模式下保留中心线,并按线的颜色和虚/实线属性清晰渲染。
- 编译概览以中文分组指标和诊断摘要展示,不显示 schema、note、null 或 `[object Object]`
- 格式化、类型检查和生产构建通过。

View File

@@ -0,0 +1,26 @@
{
"id": "workbench-layer-scene-polish",
"name": "workbench-layer-scene-polish",
"title": "优化工作台图层与场景标线",
"description": "移除高德规整路口参考,优化编译概览,并修复场景模式下科技园一路中心线缺失",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": "2026-08-26",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,16 @@
# 修复工作台选择与保存回归
## Goal
道路选择只更新选择高亮,不重置地图数据或视图;保存修改后可靠保留并重新编译 overrides。
## Confirmed Cause
`MapCanvas` 使用 `[state, selected]` 更新全量 source 和 fit view选择道路会触发全图重载。Road override 的前后端 schema 一致,因此保存回归需在 UI 操作后验证其状态同步和编译调用。
## Acceptance Criteria
- 选择道路不会清空 source、重新 fit 地图或改变视图。
- 仅选择高亮 layer 更新;选择相关 OSM 线条样式仍刷新。
- 暂存、保存、重新编译的 overrides 正确发送并以服务端响应更新状态。
- 格式化、类型检查和构建通过。

View File

@@ -0,0 +1,26 @@
{
"id": "workbench-selection-save-fix",
"name": "workbench-selection-save-fix",
"title": "修复工作台选择与保存回归",
"description": "修复道路选择触发全图层重建,并排查修复 React 工作台保存 overrides 回归",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": "2026-08-26",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,68 @@
# 实施计划
轻量任务,一个提交即可。门禁不过就停。
前置:无。客户端置灰渲染路径已由 `08-26-direct-edit-map-editor` 第 3 步交付并有单测覆盖,
本任务只补服务端输入。
## 关键决定:置灰只影响手柄,不影响求解
`editable: false` 只改变 handle manifest**不改变 `solveConstraints()` 的行为**。
落在被置灰 segment 上的既有约束仍然照常应用、照常参与几何编译。
理由:否则本次改动会让升级后的编译静默丢掉用户已保存的编辑——一个 UI 可编辑性的判断
不该回溯否决已持久化的意图。用户想撤销这类编辑,走 undo / 禁用约束的正常路径。
这也让「几何输出逐字节不变」这条验收标准成立,从而使本步骤可安全回滚。
## 1. 判据与置灰
- 目标:可编辑带短于道路宽度的 segment其全部 `road-*` 手柄 `editable: false`
- 位置:`src/compile/direct-edit-solver.js``makeRoadHandles()`
该函数已算出 `start` / `end` / `length` / `width`,判据是纯本地计算,不需要新数据。
- 实现要点:
- 带长米数 `(end - start) * length`,与 `width`(该 segment 双向宽度之和)比较。
- 复用既有 `baseDisabled` 机制与既有文案「该道路全部位于路口保留区,请进入 JunctionTools 编辑。」,
不新增第二套提示语。
- 保留原 `unavailable` 判断作为兜底,两者取或。
- 被置灰的 segment 仍然产出手柄(位置照旧),只是 `editable: false` 且带 `disabledReason`
- 不改:`junctionReserves()` 的 0.45 上限、cutback 推导、`solveConstraints()`、junction 手柄。
## 2. 测试
- 位置:`test/direct-edit-solver.js`
- 覆盖:
- 短路段(带长 < 宽度)的全部 `road-*` 手柄 `editable: false``disabledReason` 非空。
- 正常路段 `editable: true` 且无 `disabledReason`
- 判据边界:带长略小于 / 略大于宽度两侧各一例。
- junction 手柄仍 `editable: true`
- 置灰 segment 上的既有约束仍被求解(`constraintStates``applied: true`
证明置灰没有回溯否决已保存编辑。
## 3. 验证
```bash
npm run test # 含 test/direct-edit-solver.js
npm run test:client # 客户端类型
npm run test:client:unit # 客户端纯逻辑
npm run format:check
npm run build
```
几何不变的实测(本任务的核心安全性证明):对 `test/fixtures/fengshu-er-road.osm`
在改动前后各跑一次 `compileGeometry`,比较全部输出图层的 JSON必须完全一致。
`npm run test` 里的 fixture 基线测试已覆盖这条,若基线有 diff 即为回归。
手测(可选,`directEdit` 开关打开):选中 `test/fixtures` 里那 6 段短路之一,
应看到灰色手柄且 header 显示原因。这是父任务 prd 那条验收标准第一次真正可演示。
## 4. 门禁
- 几何输出零变化fixture 基线无 diff
- 正常路段行为与当前 main 完全一致。
- 既有约束不因置灰而失效。
## 回滚点
单文件单函数改动,`git revert` 即可回到当前行为。
客户端不需要任何配合改动,回滚后灰色手柄自然消失,回到「空真」状态。

View File

@@ -0,0 +1,87 @@
# 路口主导的短路段整条置灰
父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。约束模型与编辑所有权的权威定义在父任务 `design.md`
## 问题
`src/compile/direct-edit-solver.js``editable: false` 的唯一来源是 `unavailable`
```js
const start = Math.min(1, startReserve);
const end = Math.max(0, 1 - endReserve);
const unavailable = start >= end;
```
`junctionReserves()` 给每端保留区的比例有硬上限 `Math.min(0.45, cutback / length)`
于是 `start ≤ 0.45``end ≥ 0.55``start >= end` 结构上永远为假。
**该分支是死代码,连带那句提示语「该道路全部位于路口保留区,请进入 JunctionTools 编辑。」永远不可能显示。**
实测 `test/fixtures/fengshu-er-road.osm`126 个道路手柄0 个 `editable: false`
后果是短路段拿到了一条无意义的编辑带(`test/fixtures/fengshu-er-road.osm`20 个有保留区的段):
| 路长(米) | 总宽(米) | 窗口 | 可编辑带(米) |
| --- | --- | --- | --- |
| 14.5 | 9.8 | [0.45,0.55] | 1.5 |
| 15.4 | 9.8 | [0.45,0.55] | 1.5 |
| 18.6 | 9.8 | [0.45,0.55] | 1.9 |
| 19.1 | 9.8 | [0.45,0.55] | 1.9 |
| 19.2 | 9.8 | [0.45,0.55] | 1.9 |
| 19.6 | 6.5 | [0.45,0.55] | 2.0 |
| 82.2 | 9.8 | [0.17,0.83] | 54.9 |
| 199.2 | 9.8 | [0.00,0.93] | 185.6 |
第一行那条路只有 14.5 米长、9.8 米宽cutback 是 `max(宽度) × 1.4 ≈ 13.7` 米——
**每一端**的路口都要这条路的 94%,两端合计 188%,远超全长。上限把它压成各 45%
凭空造出中间 10%1.5 米)的「可编辑带」。
这违反两条已写明的约定:
- 父任务不变量「相邻 profile 之间必须有可计算的过渡」。约束默认 `transition: 'smoothstep'`
作用于区间两端回归基线1.5 米的区间两侧紧贴保留区,没有任何余量做过渡。
- 父任务「编辑所有权」:主地图只编辑两个 junction reserve **之间**的道路内部 interval。
这种路的真实路口几何占满全长,根本不存在「之间」。
## 目标
让路口几何占满的短路段整条归 JunctionTools恢复那个分支的可达性
并使父任务 prd 的验收标准「落在 junction reserve 内的手柄不可拖动,并提示进入 JunctionTools」
从空真变成可演示。
## 判据
**可编辑带长度(米)< 道路总宽度(米)→ 该 segment 的所有道路手柄 `editable: false`。**
用物理长度而非归一化 station理由是过渡段需要的是实际距离与道路长短无关。
一条比自身宽度还短的编辑带装不下横断面的平滑变化。
在上表数据上分界干净无边界模糊样本1.5 < 9.8 置灰54.9 > 9.8 保留。
恰好切出可编辑带 < 10 米的那 6 段。
判断必须在上限生效**之后**用实际窗口算,而不是拿未截断的 `cutback / length` 之和——
后者与「过渡段放不下」这件事没有直接关系。
## 验收标准
- [ ] 可编辑带短于道路宽度的 segment其全部 `road-*` 手柄 `editable: false` 且带 `disabledReason`
- [ ] `disabledReason` 复用既有文案,引导进入 JunctionTools。
- [ ] 可编辑带不短于道路宽度的 segment行为与当前 main 完全一致(手柄数量、位置、`editable: true`)。
- [ ] 置灰不改变 `reserves` 本身,也不改变任何几何输出:`compileGeometry` 结果与置灰前逐字节一致。
- [ ] 置灰的 segment 仍然出现在 manifest 中(不是被删除),否则客户端无法解释为什么不能编辑。
- [ ] junction 手柄不受影响,仍然 `editable: true`
- [ ] `test/direct-edit-solver.js` 覆盖:短路段全部置灰、正常路段不受影响、判据边界(带长 ≈ 宽度)。
- [ ] `npm run test``npm run test:client``npm run test:client:unit``npm run format:check``npm run build` 全绿。
- [ ] 客户端无需改动即可显示灰色手柄与原因(`08-26-direct-edit-map-editor` 已实现该路径)。
## 不做
- 不改 `junctionReserves()` 的 0.45 上限本身。上限保护的是 reserve 语义,改它会牵动 junction 侧几何。
- 不改 cutback 的推导公式。
- 不实现 JunctionTools 侧的编辑能力(属 `08-26-direct-edit-junction-tools`)。
- 不做客户端改动。
- 不把置灰的 segment 从 manifest 里移除。
## 顺序依赖
`08-26-direct-edit-map-editor` 的第 4、5 步无依赖,可并行或后置。
客户端置灰渲染路径已在该任务第 3 步交付并有单测覆盖,本任务只补上服务端的输入。

View File

@@ -0,0 +1,26 @@
{
"id": "junction-dominated-roads",
"name": "junction-dominated-roads",
"title": "路口主导的短路段整条置灰",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-27",
"completedAt": "2026-08-28",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-26-direct-manipulation-road-editor",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,42 @@
# Workspace Index - dingkang
> Journal tracking for AI development sessions.
---
## Current Status
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 2
- **Last Active**: 2026-08-27
<!-- @@@/auto:current-status -->
---
## Active Documents
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~163 | Active |
<!-- @@@/auto:active-documents -->
---
## Session History
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 2 | 2026-08-27 | Archive direct-edit solver API | `a26d4be` | `feat/direct-edit-documents` |
| 1 | 2026-08-27 | Complete direct edit document and revision foundation | `93f09e3`, `3500237`, `c0c8a16`, `ba8eaba` | `feat/direct-edit-documents` |
<!-- @@@/auto:session-history -->
---
## Notes
- Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions

View File

@@ -0,0 +1,163 @@
# Journal - dingkang (Part 1)
> AI development session journal
> Started: 2026-08-26
---
## Session 1: Complete direct edit document and revision foundation
**Date**: 2026-08-27
**Task**: Complete direct edit document and revision foundation
**Branch**: `feat/direct-edit-documents`
### Summary
Completed the v2 direct-edit document, immutable revision storage, and area-config snapshot foundation.
### Main Changes
- Added validated native-road-edits/v2 documents, revisions, content-addressed OSM, and snapshot-only compilation.
### Git Commits
| Hash | Message |
|------|---------|
| `93f09e3` | (see git log) |
| `3500237` | (see git log) |
| `c0c8a16` | (see git log) |
| `ba8eaba` | (see git log) |
### Testing
- [OK] npm run format:check; npm run test; npm run test:client; npm run test:client:unit; npm run build
### Status
[OK] **Completed**
### Next Steps
- Start the direct-edit solver and API child task.
## Session 2: Direct-edit handle manifest
**Date**: 2026-08-27
**Task**: Continue `direct-edit-solver-api`, step 2
**Git Commit**: `ab63d23`
### Summary
Added junction reserve calculation, six-kind handle manifest generation, and
shared native feature ID builders. The solver remains pure and empty documents
remain an identity transform for compiled geometry.
### Testing
- [OK] npm test; npm run format:check; npm run test:client; npm run test:client:unit; npm run build
### Next Steps
- Implement the six constraint kinds and invariant diagnostics (step 3).
## Session 3: Direct-edit constraint solving
**Date**: 2026-08-27
**Task**: Continue `direct-edit-solver-api`, step 3
**Git Commit**: `0eb404d`
### Summary
Activated all six constraint kinds, added transactional validation and blocking
diagnostics, and wired road profiles and junction plans into geometry compilation.
### Testing
- [OK] npm test; npm run format:check; npm run test:client; npm run test:client:unit; npm run build
### Next Steps
- Implement exact/recheck/pending/conflicted/stale replay matching (step 4).
## Session 4: Direct-edit replay matching
**Date**: 2026-08-27
**Task**: Continue `direct-edit-solver-api`, step 4
**Git Commit**: `735ce69`
### Summary
Added exact-anchor replay classification, snapshot candidate fallback, stale
lane-boundary handling, and compiler-geometry `recheck` propagation.
### Testing
- [OK] npm test; npm run format:check; npm run test:client; npm run test:client:unit; npm run build
### Next Steps
- Add the read-only edit-state and edit-preview endpoints (step 5).
## Session 5: Read-only edit APIs
**Date**: 2026-08-27
**Task**: Continue `direct-edit-solver-api`, step 5
**Git Commit**: `41c314f`
### Summary
Added `GET /api/edit-state` and `POST /api/edit-preview`. Preview compiles from
the draft document without writing active files and returns sequence, degraded
status, diagnostics, handles, and derived layers.
### Testing
- [OK] npm test; npm run format:check; npm run test:client; npm run test:client:unit; npm run build
### Next Steps
- Implement edit/revision/rebase writes with expected-version protection (step 6).
## Session 6: Edit persistence and rebase APIs
**Date**: 2026-08-27
**Task**: Complete `direct-edit-solver-api`, step 6
**Git Commit**: `f3f763f`
### Summary
Added atomic active-document saves with expected-version 409 protection,
revision checkpoint creation, and read-only revision rebase status summaries.
Existing state/session responses now expose active revision and document version.
### Testing
- [OK] npm test; npm run format:check; npm run test:client; npm run test:client:unit; npm run build
### Next Steps
- Run the solver-api quality review, then archive the child task before starting
the map-editor child task.
## Session 2: Archive direct-edit solver API
**Date**: 2026-08-27
**Task**: Archive direct-edit solver API
**Branch**: `feat/direct-edit-documents`
### Summary
Completed the direct-edit solver/API quality gate, documented the pure resolver and preview/save/revision/rebase contracts, and archived 08-26-direct-edit-solver-api. Next: activate the map-editor child task.
### Git Commits
| Hash | Message |
|------|---------|
| `a26d4be` | (see git log) |
### Status
[OK] **Completed**

View File

@@ -6,19 +6,81 @@
"license_file": "upstream/LICENSE.md" "license_file": "upstream/LICENSE.md"
}, },
"assets": [ "assets": [
{"id":"through","source":"upstream/through.svg","anchor_x":12.5,"supported":true,"tested":true,"template":"through"}, {
{"id":"left","source":"upstream/left.svg","anchor_x":17,"supported":true,"tested":true,"template":"left"}, "id": "through",
{"id":"right","source":"upstream/left.svg","derived_from":"left","mirror_x":true,"anchor_x":17,"supported":true,"tested":true,"template":"right"}, "source": "upstream/through.svg",
{"id":"through;left","source":"upstream/left-through.svg","anchor_x":17,"supported":true,"tested":true,"template":"through_left"}, "anchor_x": 12.5,
{"id":"through;right","source":"upstream/left-through.svg","derived_from":"through;left","mirror_x":true,"anchor_x":17,"supported":true,"tested":true,"template":"through_right"}, "supported": true,
{"id":"through;left;right","source":"upstream/left-slight_left-through.svg","derived_from":"through;left + through;right","anchor_x":17,"supported":true,"tested":true,"template":"through_left_right"}, "tested": true,
{"id":"slight_left","source":"upstream/slight_left.svg","supported":false,"tested":false}, "template": "through"
{"id":"slight_left;through","source":"upstream/slight_left-through.svg","supported":false,"tested":false}, },
{"id":"left;slight_left;through","source":"upstream/left-slight_left-through.svg","supported":false,"tested":false}, {
{"id":"sharp_left","source":"upstream/sharp_left.svg","supported":false,"tested":false}, "id": "left",
{"id":"sharp_left;through","source":"upstream/sharp_left-through.svg","supported":false,"tested":false}, "source": "upstream/left.svg",
{"id":"reverse_left","source":"upstream/reverse_left.svg","supported":false,"tested":false}, "anchor_x": 17,
{"id":"reverse_left;through","source":"upstream/reverse_left-through.svg","supported":false,"tested":false}, "supported": true,
{"id":"reverse_left;left;slight_left;through","source":"upstream/reverse_left-left-slight_left-through.svg","supported":false,"tested":false} "tested": true,
"template": "left"
},
{
"id": "right",
"source": "upstream/left.svg",
"derived_from": "left",
"mirror_x": true,
"anchor_x": 17,
"supported": true,
"tested": true,
"template": "right"
},
{
"id": "through;left",
"source": "upstream/left-through.svg",
"anchor_x": 17,
"supported": true,
"tested": true,
"template": "through_left"
},
{
"id": "through;right",
"source": "upstream/left-through.svg",
"derived_from": "through;left",
"mirror_x": true,
"anchor_x": 17,
"supported": true,
"tested": true,
"template": "through_right"
},
{
"id": "through;left;right",
"source": "upstream/left-slight_left-through.svg",
"derived_from": "through;left + through;right",
"anchor_x": 17,
"supported": true,
"tested": true,
"template": "through_left_right"
},
{ "id": "slight_left", "source": "upstream/slight_left.svg", "supported": false, "tested": false },
{ "id": "slight_left;through", "source": "upstream/slight_left-through.svg", "supported": false, "tested": false },
{
"id": "left;slight_left;through",
"source": "upstream/left-slight_left-through.svg",
"supported": false,
"tested": false
},
{ "id": "sharp_left", "source": "upstream/sharp_left.svg", "supported": false, "tested": false },
{ "id": "sharp_left;through", "source": "upstream/sharp_left-through.svg", "supported": false, "tested": false },
{ "id": "reverse_left", "source": "upstream/reverse_left.svg", "supported": false, "tested": false },
{
"id": "reverse_left;through",
"source": "upstream/reverse_left-through.svg",
"supported": false,
"tested": false
},
{
"id": "reverse_left;left;slight_left;through",
"source": "upstream/reverse_left-left-slight_left-through.svg",
"supported": false,
"tested": false
}
] ]
} }

View File

@@ -9,11 +9,11 @@ and as such it is not copyrighted.
Each icon should be Each icon should be
* 25px x 25px - 25px x 25px
* SVG - SVG
* Single color fill - Single color fill
* Path outline only, no stroke - Path outline only, no stroke
* Pixel grid aligned (where possible) - Pixel grid aligned (where possible)
### Arrows ### Arrows
@@ -25,7 +25,6 @@ lane arrows.
<img alt='Standard Arrow' width='300px' src='docs/standard_arrow.png'/> <img alt='Standard Arrow' width='300px' src='docs/standard_arrow.png'/>
Arrowhead rotation angles are chosen based on the turn lane indication: Arrowhead rotation angles are chosen based on the turn lane indication:
<table> <table>
@@ -84,13 +83,12 @@ As the number of arrows increases, the arrows scale down:
</tr> </tr>
</table> </table>
Other important rules for arrows: Other important rules for arrows:
* Bottom aligned with each other, 2px from icon bottom edge
* Left-right centered
* Smooth curves, no kinks
* Arrow shaft path connects to the arrowhead anchor point
- Bottom aligned with each other, 2px from icon bottom edge
- Left-right centered
- Smooth curves, no kinks
- Arrow shaft path connects to the arrowhead anchor point
### Other Icons ### Other Icons

View File

@@ -33,27 +33,27 @@ protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited Related Rights"). Copyright and Related Rights include, but are not limited
to, the following: to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate, i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work; and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s); ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work; depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work, iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below; subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in v. rights protecting the extraction, dissemination, use and reuse of data in
a Work; a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof, protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof. based on applicable law or treaty, and any national implementations thereof.
**2. Waiver.** To the greatest extent permitted by, but not in contravention of, **2. Waiver.** To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
@@ -94,25 +94,25 @@ Affirmer's express Statement of Purpose.
**4. Limitations and Disclaimers.** **4. Limitations and Disclaimers.**
a. No trademark or patent rights held by Affirmer are waived, abandoned, a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document. surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise, of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law. discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work. or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this party to this document and has no duty or obligation with respect to this
CC0 or use of the Work. CC0 or use of the Work.
For more information, please see For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/> <http://creativecommons.org/publicdomain/zero/1.0/>

View File

@@ -1,16 +1,22 @@
#!/usr/bin/env node #!/usr/bin/env node
"use strict"; 'use strict';
const fs = require("fs"); const fs = require('fs');
const path = require("path"); const path = require('path');
const { compiler } = require("../src"); const { compiler } = require('../src');
const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const args = process.argv.slice(2); const args = process.argv.slice(2);
const index = args.indexOf("--input"); const index = args.indexOf('--input');
if (index < 0 || !args[index + 1] || index + 2 !== args.length) { const exportIndex = args.indexOf('--export-zip');
throw new Error("Usage: road-compiler --input <RoadCompilerInput.json>"); const expectedLength = exportIndex >= 0 ? 4 : 2;
if (index < 0 || !args[index + 1] || (exportIndex >= 0 && !args[exportIndex + 1]) || args.length !== expectedLength) {
throw new Error('Usage: road-compiler --input <RoadCompilerInput.json> [--export-zip <output.zip>]');
} }
const inputFile = path.resolve(args[index + 1]); const inputFile = path.resolve(args[index + 1]);
const input = JSON.parse(fs.readFileSync(inputFile, "utf8")); const input = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
const { result, comparison } = compiler.compileInput(input); const { result, comparison } = compiler.compileInput(input);
console.log(`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: input.areaId, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: input.outDir, comparison })}`); if (exportIndex >= 0) exportNativeRoadPackage(input.outDir, path.resolve(args[exportIndex + 1]));
console.log(
`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: input.areaId, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: input.outDir, comparison })}`,
);

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env node #!/usr/bin/env node
"use strict"; 'use strict';
const path = require("path"); const path = require('path');
const { startWorkbench } = require("../workbench/server"); const { startWorkbench } = require('../workbench/server');
const fs = require('fs');
const args = process.argv.slice(2); const args = process.argv.slice(2);
const index = args.indexOf("--input"); const index = args.indexOf('--input');
if (index < 0 || !args[index + 1]) throw new Error("Usage: road-workbench --input <RoadCompilerInput.json> [--port <port>]"); const inputFile = index >= 0 && args[index + 1] ? path.resolve(args[index + 1]) : null;
const inputFile = path.resolve(args[index + 1]); const input = inputFile ? JSON.parse(fs.readFileSync(inputFile, 'utf8')) : null;
const input = require(inputFile); const portIndex = args.indexOf('--port');
const portIndex = args.indexOf("--port");
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 8787; const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 8787;
startWorkbench({ input, inputFile, port }); startWorkbench({ input, inputFile, repoRoot: path.resolve(__dirname, '..'), port });

View File

@@ -1,4 +1,8 @@
# Native Road Package v1 # Native Road Package v1.1
`native-road-package/v1.1` adds a required `manifest.json` to the v1 output.
Consumers may ignore this file when reading legacy v1 packages, but v1.1
consumers must validate and consume it.
`native-road-package/v1` defines the boundary between the host area pipeline `native-road-package/v1` defines the boundary between the host area pipeline
and the native road compiler. The compiler accepts only the input below; it and the native road compiler. The compiler accepts only the input below; it
@@ -35,6 +39,12 @@ shape and owns all path derivation.
`outDir` contains these JSON documents: `outDir` contains these JSON documents:
- `manifest.json`: `{ contract: "native-road-package/v1.1", areaId, layers }`.
`layers` declares exactly the twelve GeoJSON sources. Renderable entries have
`role` (`surface` or `marking`) and `materialLayer`; semantic entries have
`role: "semantic"` and no material. Optional `splitBy` contains one property
and exact-match cases plus one default case.
- `compiled.json`: top-level keys are `schema`, `areaId`, `source`, `model`, - `compiled.json`: top-level keys are `schema`, `areaId`, `source`, `model`,
`movements`, `trafficSignals`, `diagnostics`, and `layers`. `source` has `movements`, `trafficSignals`, `diagnostics`, and `layers`. `source` has
`osm`, `overrides`, and `trafficSignals` paths. `osm`, `overrides`, and `trafficSignals` paths.
@@ -63,6 +73,20 @@ shape and owns all path derivation.
The editable signal source at `trafficSignalsFile` is a sibling of `outDir`. The editable signal source at `trafficSignalsFile` is a sibling of `outDir`.
It is included in the parity baseline because regeneration must be stable. It is included in the parity baseline because regeneration must be stable.
## ZIP Export
The Web workbench's **导出道路包** action and the CLI's optional
`--export-zip <output.zip>` emit the portable package form. The ZIP root is
flat: it contains the six JSON documents above and `layers/*.geojson`, without
an enclosing area directory. It contains no OSM input, overrides, or compiler
workspace paths. `compiled.json` retains only model and movement data needed by
the downstream preview; its internal `source` paths are removed.
The ZIP manifest adds informational `generator: { name, version }`. Consumers
must select compatibility only from `contract`, `areaId`, and declared files.
Entry order and timestamp are fixed, so unchanged compiled output exports to
the same ZIP hash.
Successful CLI execution prints exactly one completion marker: Successful CLI execution prints exactly one completion marker:
```text ```text

1582
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@osm-asset/road-compiler", "name": "@osm-asset/road-compiler",
"version": "0.1.2", "version": "0.3.0",
"private": false, "private": false,
"type": "commonjs", "type": "commonjs",
"main": "src/index.js", "main": "src/index.js",
@@ -8,10 +8,30 @@
"road-compiler": "bin/road-compiler.js" "road-compiler": "bin/road-compiler.js"
}, },
"scripts": { "scripts": {
"test": "node test/index.js && node test/fixtures.js", "test": "node test/index.js && node test/native-road-edits.js && node test/road-revisions.js && node test/direct-edit-solver.js && node test/workbench-edit-api.js && node test/workbench-import-compile.js && node test/fixtures.js",
"road:workbench": "node bin/road-workbench.js" "test:fixtures:update-baseline": "node test/update-fixture-baselines.js",
"road:workbench": "node bin/road-workbench.js",
"road:export": "node bin/road-compiler.js",
"build": "vite build --config workbench/client/vite.config.ts",
"dev": "vite --config workbench/client/vite.config.ts",
"test:client": "tsc --noEmit -p workbench/client/tsconfig.json",
"test:client:unit": "vitest run --passWithNoTests",
"format": "prettier --write \"{src,bin,test,workbench}/**/*.{js,ts,tsx,css,html}\" \"*.{json,js,ts}\"",
"format:check": "prettier --check \"{src,bin,test,workbench}/**/*.{js,ts,tsx,css,html}\" \"*.{json,js,ts}\""
}, },
"dependencies": { "dependencies": {
"ol": "10.10.0" "fflate": "0.8.3",
"lucide-react": "^0.468.0",
"ol": "10.10.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"prettier": "^3.9.6",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vitest": "4.1.11"
} }
} }

View File

@@ -1,27 +1,42 @@
"use strict"; 'use strict';
const fs = require("fs"); const fs = require('fs');
const path = require("path"); const path = require('path');
function checkOutput({ areaId, outDir }) { function checkOutput({ areaId, outDir }) {
if (typeof areaId !== "string" || !areaId) throw new Error("RoadCompilerCheckInput.areaId must be a non-empty string"); if (typeof areaId !== 'string' || !areaId)
if (typeof outDir !== "string" || !outDir) throw new Error("RoadCompilerCheckInput.outDir must be a non-empty string"); throw new Error('RoadCompilerCheckInput.areaId must be a non-empty string');
const compiledPath = path.join(outDir, "compiled.json"); if (typeof outDir !== 'string' || !outDir)
throw new Error('RoadCompilerCheckInput.outDir must be a non-empty string');
const compiledPath = path.join(outDir, 'compiled.json');
if (!fs.existsSync(compiledPath)) throw new Error(`Native road output is missing: ${compiledPath}`); if (!fs.existsSync(compiledPath)) throw new Error(`Native road output is missing: ${compiledPath}`);
const compiled = readJson(compiledPath); const compiled = readJson(compiledPath);
const connectors = readJson(path.join(outDir, "layers", "connectors.geojson")); const connectors = readJson(path.join(outDir, 'layers', 'connectors.geojson'));
const published = new Set(connectors.features.map((feature) => feature.properties.movement_id)); const published = new Set(connectors.features.map((feature) => feature.properties.movement_id));
const failures = []; const failures = [];
for (const movement of compiled.movements || []) { for (const movement of compiled.movements || []) {
if (movement.geometryPublished && !published.has(movement.id)) failures.push(`Published movement has no connector: ${movement.id}`); if (movement.geometryPublished && !published.has(movement.id))
if (!movement.geometryPublished && published.has(movement.id)) failures.push(`Non-published movement has a connector: ${movement.id}`); failures.push(`Published movement has no connector: ${movement.id}`);
if (!movement.geometryPublished && published.has(movement.id))
failures.push(`Non-published movement has a connector: ${movement.id}`);
if (!movement.geometryStatus) failures.push(`Movement has no geometry status: ${movement.id}`); if (!movement.geometryStatus) failures.push(`Movement has no geometry status: ${movement.id}`);
} }
const errors = (compiled.diagnostics || []).filter((item) => item.severity === "error"); const errors = (compiled.diagnostics || []).filter((item) => item.severity === 'error');
const warnings = (compiled.diagnostics || []).filter((item) => item.severity === "warning"); const warnings = (compiled.diagnostics || []).filter((item) => item.severity === 'warning');
return { schema: "native-road-check/v1", areaId, ok: failures.length === 0 && errors.length === 0, movementCount: (compiled.movements || []).length, connectorCount: connectors.features.length, errors: errors.map((item) => ({ id: item.id, rule: item.rule, message: item.message })), warningCount: warnings.length, failures }; return {
schema: 'native-road-check/v1',
areaId,
ok: failures.length === 0 && errors.length === 0,
movementCount: (compiled.movements || []).length,
connectorCount: connectors.features.length,
errors: errors.map((item) => ({ id: item.id, rule: item.rule, message: item.message })),
warningCount: warnings.length,
failures,
};
} }
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
module.exports = { checkOutput }; module.exports = { checkOutput };

Some files were not shown because too many files have changed in this diff Show More