feat: drag crosswalk and stop-line offsets on the map

Handles for the two control-marking kinds, completing what step 1 made solvable.
They drag along the approach tangent and reuse the whole existing chain — hit
test, projection, clamp, ghost, debounce, arbitration, preview, save, undo —
without changes to any of it. That reuse is the point of having modelled edits as
kind + anchor + axis + value.

Ownership: design.md gives junction reserve interiors to JunctionTools, but that
rule is about junction *shape* — width, cutback, corner. Placement of the zebra
and the stop line is offered nowhere else, so the main map takes it. A segment
selection therefore returns its cross-section handles plus the control markings
at its ends, and shape kinds stay out so the two editors never offer the same
edit. The test that asserted "road kinds only" now states this rule instead.

The default placements move to the solver, which owns the editable bounds and is
the module a test keeps free of fs/path; native-road.js imports them rather than
the reverse, so the IO-free property survives.

Deliberately not done: the handle-target generalisation this task's PRD listed as
step 2. These kinds persist exactly like the existing junction constraints, so
there is no second write target to abstract over yet. Building the discriminator
now would be designing for one hypothetical case; the signal pose work will
provide the real second case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 16:58:31 +08:00
parent c7425f5ed4
commit 7c45c21f8c
7 changed files with 101 additions and 25 deletions

View File

@@ -81,7 +81,7 @@ const junctionModel = {
};
const junctionResolution = resolveDirectEditConstraints(junctionModel, document([]), { revisionId: 'rev-junction' });
assert.equal(junctionResolution.handles.reserves.length, 3);
assert.equal(new Set(junctionResolution.handles.handles.map((handle) => handle.kind)).size, 6);
assert.equal(new Set(junctionResolution.handles.handles.map((handle) => handle.kind)).size, 8);
assert.ok(junctionResolution.handles.handles.every((handle) => handle.position.every(Number.isFinite)));
assert.ok(junctionResolution.handles.handles.every((handle) => Number.isFinite(handle.axisAzimuth)));
assert.ok(junctionResolution.handles.handles.some((handle) => handle.affects.includes('junction:node/junction')));
@@ -117,19 +117,32 @@ const segmentScoped = resolveDirectEditConstraints(junctionModel, document([]),
}).handles.handles;
assert.ok(segmentScoped.length > 0, 'a segment selection must still publish its road handles');
assert.ok(segmentScoped.length < unscoped.length, 'a segment selection must be smaller than the whole manifest');
// The main map owns the segment's cross-section and the control markings at its
// ends. Junction *shape* — width, cutback, corner — stays with JunctionTools, so
// the two editors never offer the same edit.
const SHAPE_KINDS = ['junction-approach-width', 'junction-cutback', 'junction-corner-radius'];
assert.ok(
segmentScoped.every((handle) => handle.kind.startsWith('road-')),
'junction kinds belong to JunctionTools and must not reach a segment selection',
segmentScoped.every((handle) => !SHAPE_KINDS.includes(handle.kind)),
'junction shape kinds belong to JunctionTools and must not reach a segment selection',
);
assert.ok(
segmentScoped.every((handle) => handle.anchor.roadId === 'road:a'),
segmentScoped.some((handle) => handle.kind === 'junction-crosswalk-inset'),
'control markings are junction-anchored but main-map editable, so they must be included',
);
assert.ok(
segmentScoped.every((handle) =>
handle.kind.startsWith('road-') ? handle.anchor.roadId === 'road:a' : handle.anchor.segmentId === 'segment:a',
),
'a segment selection must only carry that segments handles',
);
// Scoping must not invent or drop handles: it is a filter of the full manifest.
assert.deepEqual(
segmentScoped.map((handle) => handle.handleId).sort(),
unscoped
.filter((handle) => handle.kind.startsWith('road-') && handle.anchor.roadId === 'road:a')
.filter((handle) =>
handle.kind.startsWith('road-') ? handle.anchor.roadId === 'road:a' : handle.anchor.segmentId === 'segment:a',
)
.filter((handle) => !SHAPE_KINDS.includes(handle.kind))
.map((handle) => handle.handleId)
.sort(),
);