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

@@ -42,6 +42,13 @@ const MIN_LANE_WIDTH_METERS = 2.4;
// crossing it protects. // crossing it protects.
const MAX_CROSSWALK_INSET_METERS = 8; const MAX_CROSSWALK_INSET_METERS = 8;
const MIN_STOP_LINE_OFFSET_METERS = 0.5; const MIN_STOP_LINE_OFFSET_METERS = 0.5;
// Default placement, and therefore a handle's value when no constraint exists yet.
// These live here rather than in native-road.js because the solver must stay free
// of fs/path — a test asserts that — so the dependency can only point this way.
const CROSSWALK_JUNCTION_INSET_METERS = 1.5;
const STOP_LINE_OFFSET_METERS = 2.7;
/** Junction-anchored but owned by the main map, unlike the junction shape kinds. */
const CONTROL_KINDS = new Set(['junction-crosswalk-inset', 'junction-stop-line-offset']);
const GEOMETRY_VERSION = 'native-road-package/v1.1'; const GEOMETRY_VERSION = 'native-road-package/v1.1';
function emptyHandleManifest(context) { function emptyHandleManifest(context) {
@@ -681,6 +688,26 @@ function makeJunctionHandles(model, junctionData, constraints) {
tangent, tangent,
cutbackConstraint, cutbackConstraint,
); );
// Control markings sit inside the reserve but are not junction *shape*, so
// they drag along the approach tangent like the cutback does.
const crosswalkConstraint = constraintFor(constraints, 'junction-crosswalk-inset', anchor);
add(
'junction-crosswalk-inset',
numericValue(crosswalkConstraint, 'insetMeters', CROSSWALK_JUNCTION_INSET_METERS),
0,
MAX_CROSSWALK_INSET_METERS,
tangent,
crosswalkConstraint,
);
const stopLineConstraint = constraintFor(constraints, 'junction-stop-line-offset', anchor);
add(
'junction-stop-line-offset',
numericValue(stopLineConstraint, 'offsetMeters', STOP_LINE_OFFSET_METERS),
MIN_STOP_LINE_OFFSET_METERS,
Math.max(MIN_STOP_LINE_OFFSET_METERS, approach.length * 0.45),
tangent,
stopLineConstraint,
);
} }
const ordered = [...approaches].sort((a, b) => a.segmentId.localeCompare(b.segmentId)); const ordered = [...approaches].sort((a, b) => a.segmentId.localeCompare(b.segmentId));
for (let index = 0; index < ordered.length; index += 1) { for (let index = 0; index < ordered.length; index += 1) {
@@ -747,14 +774,15 @@ function selectHandles(handles, model, selection) {
if (!selection || !selection.type) return handles; if (!selection || !selection.type) return handles;
if (selection.type === 'segment') { if (selection.type === 'segment') {
const segmentOf = new Map((model.roads || []).map((road) => [road.id, road.segmentId])); const segmentOf = new Map((model.roads || []).map((road) => [road.id, road.segmentId]));
// Road kinds only: reserve interiors belong to JunctionTools, and the main map const wanted = String(selection.id);
// must not offer a second way to edit them. return handles.filter((handle) => {
return handles.filter( if (!handle.anchor) return false;
(handle) => // Control markings are junction-anchored but edited from the main map, and
handle.kind.startsWith('road-') && // they name their segment directly. Junction *shape* kinds stay out: those
handle.anchor && // are JunctionTools', and the main map must not offer a second way in.
segmentOf.get(handle.anchor.roadId) === String(selection.id), if (CONTROL_KINDS.has(handle.kind)) return handle.anchor.segmentId === wanted;
); return handle.kind.startsWith('road-') && segmentOf.get(handle.anchor.roadId) === wanted;
});
} }
if (selection.type === 'junction') if (selection.type === 'junction')
return handles.filter( return handles.filter(
@@ -791,6 +819,8 @@ function resolveDirectEditConstraints(model, editDocument = null, context = {})
} }
module.exports = { module.exports = {
CROSSWALK_JUNCTION_INSET_METERS,
STOP_LINE_OFFSET_METERS,
RESOLUTION_SCHEMA, RESOLUTION_SCHEMA,
HANDLE_MANIFEST_SCHEMA, HANDLE_MANIFEST_SCHEMA,
ACTIVE_STATUSES, ACTIVE_STATUSES,

View File

@@ -5,6 +5,9 @@ const path = require('path');
const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows'); const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows');
const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction'); const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction');
const { diagnostic } = require('./diagnostics'); const { diagnostic } = require('./diagnostics');
// Default marking placement lives with the constraint bounds in the solver, which
// is the IO-free module; importing the other way would drag fs into it.
const { CROSSWALK_JUNCTION_INSET_METERS, STOP_LINE_OFFSET_METERS } = require('./direct-edit-solver');
const { const {
roadSurfaceId, roadSurfaceId,
sidewalkId, sidewalkId,
@@ -42,9 +45,7 @@ const DEFAULT_WIDTHS = {
const DEFAULT_SIDEWALK_WIDTH_METERS = 2; const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
const DIRECTION_ARROW_INTERVAL_METERS = 32; const DIRECTION_ARROW_INTERVAL_METERS = 32;
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14; const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
const STOP_LINE_OFFSET_METERS = 2.7;
const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25; const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25;
const CROSSWALK_JUNCTION_INSET_METERS = 1.5;
const CROSSWALK_MAX_JUNCTION_INSET_METERS = 4; const CROSSWALK_MAX_JUNCTION_INSET_METERS = 4;
const CENTER_LINE_DASH_LENGTH_METERS = 2; const CENTER_LINE_DASH_LENGTH_METERS = 2;
const CENTER_LINE_DASH_GAP_METERS = 2; const CENTER_LINE_DASH_GAP_METERS = 2;

View File

@@ -81,7 +81,7 @@ const junctionModel = {
}; };
const junctionResolution = resolveDirectEditConstraints(junctionModel, document([]), { revisionId: 'rev-junction' }); const junctionResolution = resolveDirectEditConstraints(junctionModel, document([]), { revisionId: 'rev-junction' });
assert.equal(junctionResolution.handles.reserves.length, 3); 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) => handle.position.every(Number.isFinite)));
assert.ok(junctionResolution.handles.handles.every((handle) => Number.isFinite(handle.axisAzimuth))); assert.ok(junctionResolution.handles.handles.every((handle) => Number.isFinite(handle.axisAzimuth)));
assert.ok(junctionResolution.handles.handles.some((handle) => handle.affects.includes('junction:node/junction'))); assert.ok(junctionResolution.handles.handles.some((handle) => handle.affects.includes('junction:node/junction')));
@@ -117,19 +117,32 @@ const segmentScoped = resolveDirectEditConstraints(junctionModel, document([]),
}).handles.handles; }).handles.handles;
assert.ok(segmentScoped.length > 0, 'a segment selection must still publish its road 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'); 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( assert.ok(
segmentScoped.every((handle) => handle.kind.startsWith('road-')), segmentScoped.every((handle) => !SHAPE_KINDS.includes(handle.kind)),
'junction kinds belong to JunctionTools and must not reach a segment selection', 'junction shape kinds belong to JunctionTools and must not reach a segment selection',
); );
assert.ok( 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', 'a segment selection must only carry that segments handles',
); );
// Scoping must not invent or drop handles: it is a filter of the full manifest. // Scoping must not invent or drop handles: it is a filter of the full manifest.
assert.deepEqual( assert.deepEqual(
segmentScoped.map((handle) => handle.handleId).sort(), segmentScoped.map((handle) => handle.handleId).sort(),
unscoped 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) .map((handle) => handle.handleId)
.sort(), .sort(),
); );

View File

@@ -28,6 +28,10 @@ const EDITABLE_STYLE: Record<string, Style> = {
'road-edge-offset': handleStyle('#00a5cf'), 'road-edge-offset': handleStyle('#00a5cf'),
'road-sidewalk-width': handleStyle('#d49318'), 'road-sidewalk-width': handleStyle('#d49318'),
'road-lane-divider': handleStyle('#8f6fd0'), 'road-lane-divider': handleStyle('#8f6fd0'),
// Control markings are junction-anchored but main-map editable; a distinct hue
// keeps them from reading as another cross-section knob.
'junction-crosswalk-inset': handleStyle('#2f8f5b'),
'junction-stop-line-offset': handleStyle('#bf3b2e'),
}; };
/** /**

View File

@@ -104,6 +104,10 @@ export function constraintValueFor(
return { cutbackMeters: Math.max(0, scalar) }; return { cutbackMeters: Math.max(0, scalar) };
case 'junction-corner-radius': case 'junction-corner-radius':
return { radiusMeters: Math.max(0, scalar) }; return { radiusMeters: Math.max(0, scalar) };
case 'junction-crosswalk-inset':
return { insetMeters: Math.max(0, scalar) };
case 'junction-stop-line-offset':
return { offsetMeters: Math.max(0, scalar) };
} }
} }

View File

@@ -7,6 +7,7 @@
import { coordinateAtStation, polylineLengthMeters, tangentAzimuthAt, type Coordinate } from './meters'; import { coordinateAtStation, polylineLengthMeters, tangentAzimuthAt, type Coordinate } from './meters';
import { reserveWindow, type IntervalWindow } from './projection'; import { reserveWindow, type IntervalWindow } from './projection';
import { import {
isControlKind,
isRoadKind, isRoadKind,
type EditHandle, type EditHandle,
type HandleManifest, type HandleManifest,
@@ -15,15 +16,17 @@ import {
} from './types'; } from './types';
/** /**
* Road kinds only, and only for the selected road's segment. * What the main map may show for a selected road segment.
* *
* Junction kinds stay out even though the manifest carries them: reserve interiors * Two shapes of handle qualify. Road kinds are anchored to a directional road, so
* belong to JunctionTools, and the main map must not offer a second way to edit * they resolve through the model. Control markings are anchored to a junction
* them. Reserve-disabled road handles are *kept* rather than dropped, because the * approach and name their segment directly — they sit inside the reserve, but that
* user has to be able to see why that stretch refuses to move. * ownership rule is about junction *shape*, and nothing else offers this edit.
* *
* Handle anchors carry a directional `roadId`, while `reserves` are keyed by * Junction shape kinds stay out: reserve interiors belong to JunctionTools, and the
* segment id, so callers pass a resolver instead of parsing ids apart. * main map must not offer a second way to edit them. Reserve-disabled road handles
* are *kept* rather than dropped, because the user has to be able to see why that
* stretch refuses to move.
*/ */
export function handlesForSegment( export function handlesForSegment(
manifest: HandleManifest | null, manifest: HandleManifest | null,
@@ -32,6 +35,7 @@ export function handlesForSegment(
): EditHandle[] { ): EditHandle[] {
if (!manifest || !segmentId) return []; if (!manifest || !segmentId) return [];
return manifest.handles.filter((handle) => { return manifest.handles.filter((handle) => {
if (isControlKind(handle.kind)) return 'segmentId' in handle.anchor && handle.anchor.segmentId === segmentId;
if (!isRoadKind(handle.kind)) return false; if (!isRoadKind(handle.kind)) return false;
const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : undefined; const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : undefined;
return typeof roadId === 'string' && segmentOf(roadId) === segmentId; return typeof roadId === 'string' && segmentOf(roadId) === segmentId;

View File

@@ -14,11 +14,25 @@ export type Transition = 'smoothstep' | 'linear';
export const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1'; export const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1';
export const EDITS_SCHEMA = 'native-road-edits/v2'; export const EDITS_SCHEMA = 'native-road-edits/v2';
/** Kinds the main map owns. Junction kinds belong to JunctionTools. */ /** Kinds the main map owns. Junction *shape* belongs to JunctionTools. */
export const ROAD_KINDS = ['road-edge-offset', 'road-sidewalk-width', 'road-lane-divider'] as const; export const ROAD_KINDS = ['road-edge-offset', 'road-sidewalk-width', 'road-lane-divider'] as const;
export const JUNCTION_KINDS = ['junction-approach-width', 'junction-cutback', 'junction-corner-radius'] as const; /**
* Control markings are anchored to a junction approach but edited from the main
* map. They sit inside the reserve, which design.md gives to JunctionTools, but
* that rule is about junction *shape* — width, cutback, corner — and nothing else
* offers this edit. Placement of the zebra and the stop line is therefore the main
* map's, and JunctionTools keeps the three shape kinds below.
*/
export const CONTROL_KINDS = ['junction-crosswalk-inset', 'junction-stop-line-offset'] as const;
export const JUNCTION_KINDS = [
'junction-approach-width',
'junction-cutback',
'junction-corner-radius',
...CONTROL_KINDS,
] as const;
export type RoadConstraintKind = (typeof ROAD_KINDS)[number]; export type RoadConstraintKind = (typeof ROAD_KINDS)[number];
export type ControlConstraintKind = (typeof CONTROL_KINDS)[number];
export type JunctionConstraintKind = (typeof JUNCTION_KINDS)[number]; export type JunctionConstraintKind = (typeof JUNCTION_KINDS)[number];
export type ConstraintKind = RoadConstraintKind | JunctionConstraintKind; export type ConstraintKind = RoadConstraintKind | JunctionConstraintKind;
@@ -85,7 +99,8 @@ export type ConstraintValue =
| { widthMeters: number; transition?: Transition } | { widthMeters: number; transition?: Transition }
| { boundaryIndex: number; offsetMeters: number; transition?: Transition } | { boundaryIndex: number; offsetMeters: number; transition?: Transition }
| { cutbackMeters: number } | { cutbackMeters: number }
| { radiusMeters: number }; | { radiusMeters: number }
| { insetMeters: number };
export interface RoadConstraint { export interface RoadConstraint {
id: string; id: string;
@@ -219,6 +234,11 @@ export function isRoadKind(kind: ConstraintKind): kind is RoadConstraintKind {
return (ROAD_KINDS as readonly string[]).includes(kind); return (ROAD_KINDS as readonly string[]).includes(kind);
} }
/** Control markings are junction-anchored but edited from the main map. */
export function isControlKind(kind: ConstraintKind): kind is ControlConstraintKind {
return (CONTROL_KINDS as readonly string[]).includes(kind);
}
/** True when a diagnostic must block committing the draft. */ /** True when a diagnostic must block committing the draft. */
export function isBlocking(diagnostic: EditDiagnostic): boolean { export function isBlocking(diagnostic: EditDiagnostic): boolean {
return diagnostic.severity === 'error'; return diagnostic.severity === 'error';