diff --git a/src/compile/direct-edit-solver.js b/src/compile/direct-edit-solver.js index b493240..3d4a5cd 100644 --- a/src/compile/direct-edit-solver.js +++ b/src/compile/direct-edit-solver.js @@ -653,12 +653,23 @@ function makeJunctionHandles(model, junctionData, constraints) { const normal = (tangent + 90) % 360; const anchor = { type: 'junction-approach', nodeId: String(nodeId), segmentId: approach.segmentId }; const affects = junctionAffects(model, nodeId, segmentIds); - const add = (kind, value, min, max, axis, constraint) => { + // Control markings are positioned by their distance back from the junction, + // rather than at the approach cutback point shared by shape handles. Keeping + // these positions distinct is important: otherwise the later stop-line + // feature covers the crosswalk handle and neither target is discoverable. + const controlPoint = (offsetMeters, lateralMeters = 0) => { + const coordinate = coordinateAt( + line, + Math.min(0.98, Math.max(0.02, offsetMeters / Math.max(approach.length, 1))), + ); + return lateralMeters ? offsetCoordinate(coordinate, normal, lateralMeters) : coordinate; + }; + const add = (kind, value, min, max, axis, constraint, position = point) => { handles.push({ handleId: `handle:${kind}:node/${nodeId}:${approach.segmentId}`, kind, anchor, - position: point, + position, axisAzimuth: ((axis % 360) + 360) % 360, value: { current: value, min, max, unit: 'meter' }, ...(constraint ? { constraintId: constraint.id } : {}), @@ -696,8 +707,12 @@ function makeJunctionHandles(model, junctionData, constraints) { numericValue(crosswalkConstraint, 'insetMeters', CROSSWALK_JUNCTION_INSET_METERS), 0, MAX_CROSSWALK_INSET_METERS, - tangent, + // The approach tangent points away from the junction. Increasing an + // inset moves the crosswalk toward the junction, so expose the inverse + // axis to keep positive drag values aligned with the handle's meaning. + (tangent + 180) % 360, crosswalkConstraint, + controlPoint(numericValue(crosswalkConstraint, 'insetMeters', CROSSWALK_JUNCTION_INSET_METERS), -1), ); const stopLineConstraint = constraintFor(constraints, 'junction-stop-line-offset', anchor); add( @@ -707,6 +722,7 @@ function makeJunctionHandles(model, junctionData, constraints) { Math.max(MIN_STOP_LINE_OFFSET_METERS, approach.length * 0.45), tangent, stopLineConstraint, + controlPoint(numericValue(stopLineConstraint, 'offsetMeters', STOP_LINE_OFFSET_METERS), 1), ); } const ordered = [...approaches].sort((a, b) => a.segmentId.localeCompare(b.segmentId)); diff --git a/test/direct-edit-solver.js b/test/direct-edit-solver.js index 4886594..e12bbd9 100644 --- a/test/direct-edit-solver.js +++ b/test/direct-edit-solver.js @@ -106,6 +106,20 @@ assert.ok( northSidewalks.find((handle) => handle.anchor.side === 'left').position[0] < 113, 'the sidewalk handle must follow the same side convention as the edge handle', ); +const controlHandles = junctionResolution.handles.handles.filter((handle) => handle.anchor.segmentId === 'segment:a'); +const crosswalkHandle = controlHandles.find((handle) => handle.kind === 'junction-crosswalk-inset'); +const stopLineHandle = controlHandles.find((handle) => handle.kind === 'junction-stop-line-offset'); +assert.ok(crosswalkHandle && stopLineHandle, 'both control-marking handles must be published'); +assert.notDeepEqual( + crosswalkHandle.position, + stopLineHandle.position, + 'crosswalk and stop-line handles must not overlap at the shared cutback point', +); +assert.notEqual( + crosswalkHandle.axisAzimuth, + stopLineHandle.axisAzimuth, + 'crosswalk inset and stop-line offset use opposite positive directions along the approach', +); // A selection scopes the manifest to the object being edited. The whole-area // manifest is 844 KB on a 41-road workspace, 89% of it `affects` id lists, while diff --git a/workbench/client/src/App.tsx b/workbench/client/src/App.tsx index f535f33..1d5907e 100644 --- a/workbench/client/src/App.tsx +++ b/workbench/client/src/App.tsx @@ -204,8 +204,15 @@ function App() { }, []); /** A drag becomes one drafted constraint layered over the session's document. */ const draftFor = (handle: EditHandle, value: number): PreviewDraft | null => { + // Road-anchored edits carry a directional road id. Junction-approach + // controls are keyed by segment instead, so resolve them through the + // compiled model before building the anchor snapshot. Without this branch + // control handles silently returned null and never scheduled a preview. const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : null; - const road = roadId ? state?.compiled.model.roads.find((item) => item.id === roadId) : undefined; + const segmentId = 'segmentId' in handle.anchor ? handle.anchor.segmentId : null; + const road = state?.compiled.model.roads.find( + (item) => (roadId ? item.id === roadId : false) || (segmentId ? item.segmentId === segmentId : false), + ); // Without a centerline there is no snapshot to record, so drop the drag rather // than send a constraint the server would have to reject. if (!session || !road || road.centerline.length < 2) return null;