diff --git a/src/compile/direct-edit-solver.js b/src/compile/direct-edit-solver.js index 4e1d76b..b493240 100644 --- a/src/compile/direct-edit-solver.js +++ b/src/compile/direct-edit-solver.js @@ -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, diff --git a/src/compile/native-road.js b/src/compile/native-road.js index 0255d89..8da408f 100644 --- a/src/compile/native-road.js +++ b/src/compile/native-road.js @@ -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; diff --git a/test/direct-edit-solver.js b/test/direct-edit-solver.js index 2490f53..4886594 100644 --- a/test/direct-edit-solver.js +++ b/test/direct-edit-solver.js @@ -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 segment’s 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(), ); diff --git a/workbench/client/src/edit/handle-layer.ts b/workbench/client/src/edit/handle-layer.ts index 3e0da7d..fa87998 100644 --- a/workbench/client/src/edit/handle-layer.ts +++ b/workbench/client/src/edit/handle-layer.ts @@ -28,6 +28,10 @@ const EDITABLE_STYLE: Record = { 'road-edge-offset': handleStyle('#00a5cf'), 'road-sidewalk-width': handleStyle('#d49318'), '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'), }; /** diff --git a/workbench/client/src/edit/projection.ts b/workbench/client/src/edit/projection.ts index 6f7e58e..0e44b71 100644 --- a/workbench/client/src/edit/projection.ts +++ b/workbench/client/src/edit/projection.ts @@ -104,6 +104,10 @@ export function constraintValueFor( return { cutbackMeters: Math.max(0, scalar) }; case 'junction-corner-radius': 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) }; } } diff --git a/workbench/client/src/edit/selection.ts b/workbench/client/src/edit/selection.ts index 14744bc..c5d28b6 100644 --- a/workbench/client/src/edit/selection.ts +++ b/workbench/client/src/edit/selection.ts @@ -7,6 +7,7 @@ import { coordinateAtStation, polylineLengthMeters, tangentAzimuthAt, type Coordinate } from './meters'; import { reserveWindow, type IntervalWindow } from './projection'; import { + isControlKind, isRoadKind, type EditHandle, type HandleManifest, @@ -15,15 +16,17 @@ import { } 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 - * belong to JunctionTools, and the 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. + * Two shapes of handle qualify. Road kinds are anchored to a directional road, so + * they resolve through the model. Control markings are anchored to a junction + * approach and name their segment directly — they sit inside the reserve, but that + * ownership rule is about junction *shape*, and nothing else offers this edit. * - * Handle anchors carry a directional `roadId`, while `reserves` are keyed by - * segment id, so callers pass a resolver instead of parsing ids apart. + * Junction shape kinds stay out: reserve interiors belong to JunctionTools, and the + * 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( manifest: HandleManifest | null, @@ -32,6 +35,7 @@ export function handlesForSegment( ): EditHandle[] { if (!manifest || !segmentId) return []; return manifest.handles.filter((handle) => { + if (isControlKind(handle.kind)) return 'segmentId' in handle.anchor && handle.anchor.segmentId === segmentId; if (!isRoadKind(handle.kind)) return false; const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : undefined; return typeof roadId === 'string' && segmentOf(roadId) === segmentId; diff --git a/workbench/client/src/edit/types.ts b/workbench/client/src/edit/types.ts index fe8c4b2..329700d 100644 --- a/workbench/client/src/edit/types.ts +++ b/workbench/client/src/edit/types.ts @@ -14,11 +14,25 @@ export type Transition = 'smoothstep' | 'linear'; export const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1'; 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 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 ControlConstraintKind = (typeof CONTROL_KINDS)[number]; export type JunctionConstraintKind = (typeof JUNCTION_KINDS)[number]; export type ConstraintKind = RoadConstraintKind | JunctionConstraintKind; @@ -85,7 +99,8 @@ export type ConstraintValue = | { widthMeters: number; transition?: Transition } | { boundaryIndex: number; offsetMeters: number; transition?: Transition } | { cutbackMeters: number } - | { radiusMeters: number }; + | { radiusMeters: number } + | { insetMeters: number }; export interface RoadConstraint { id: string; @@ -219,6 +234,11 @@ export function isRoadKind(kind: ConstraintKind): kind is RoadConstraintKind { 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. */ export function isBlocking(diagnostic: EditDiagnostic): boolean { return diagnostic.severity === 'error';