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.
const MAX_CROSSWALK_INSET_METERS = 8;
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';
function emptyHandleManifest(context) {
@@ -681,6 +688,26 @@ function makeJunctionHandles(model, junctionData, constraints) {
tangent,
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));
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.type === 'segment') {
const segmentOf = new Map((model.roads || []).map((road) => [road.id, road.segmentId]));
// Road kinds only: reserve interiors belong to JunctionTools, and the main map
// must not offer a second way to edit them.
return handles.filter(
(handle) =>
handle.kind.startsWith('road-') &&
handle.anchor &&
segmentOf.get(handle.anchor.roadId) === String(selection.id),
);
const wanted = String(selection.id);
return handles.filter((handle) => {
if (!handle.anchor) return false;
// Control markings are junction-anchored but edited from the main map, and
// they name their segment directly. Junction *shape* kinds stay out: those
// are JunctionTools', and the main map must not offer a second way in.
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')
return handles.filter(
@@ -791,6 +819,8 @@ function resolveDirectEditConstraints(model, editDocument = null, context = {})
}
module.exports = {
CROSSWALK_JUNCTION_INSET_METERS,
STOP_LINE_OFFSET_METERS,
RESOLUTION_SCHEMA,
HANDLE_MANIFEST_SCHEMA,
ACTIVE_STATUSES,

View File

@@ -5,6 +5,9 @@ const path = require('path');
const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows');
const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction');
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 {
roadSurfaceId,
sidewalkId,
@@ -42,9 +45,7 @@ const DEFAULT_WIDTHS = {
const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
const DIRECTION_ARROW_INTERVAL_METERS = 32;
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
const STOP_LINE_OFFSET_METERS = 2.7;
const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25;
const CROSSWALK_JUNCTION_INSET_METERS = 1.5;
const CROSSWALK_MAX_JUNCTION_INSET_METERS = 4;
const CENTER_LINE_DASH_LENGTH_METERS = 2;
const CENTER_LINE_DASH_GAP_METERS = 2;