fix: expose and apply control marking handles

This commit is contained in:
2026-08-28 17:45:03 +08:00
parent 7c45c21f8c
commit 11f4d748a7
3 changed files with 41 additions and 4 deletions

View File

@@ -653,12 +653,23 @@ function makeJunctionHandles(model, junctionData, constraints) {
const normal = (tangent + 90) % 360; const normal = (tangent + 90) % 360;
const anchor = { type: 'junction-approach', nodeId: String(nodeId), segmentId: approach.segmentId }; const anchor = { type: 'junction-approach', nodeId: String(nodeId), segmentId: approach.segmentId };
const affects = junctionAffects(model, nodeId, segmentIds); 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({ handles.push({
handleId: `handle:${kind}:node/${nodeId}:${approach.segmentId}`, handleId: `handle:${kind}:node/${nodeId}:${approach.segmentId}`,
kind, kind,
anchor, anchor,
position: point, position,
axisAzimuth: ((axis % 360) + 360) % 360, axisAzimuth: ((axis % 360) + 360) % 360,
value: { current: value, min, max, unit: 'meter' }, value: { current: value, min, max, unit: 'meter' },
...(constraint ? { constraintId: constraint.id } : {}), ...(constraint ? { constraintId: constraint.id } : {}),
@@ -696,8 +707,12 @@ function makeJunctionHandles(model, junctionData, constraints) {
numericValue(crosswalkConstraint, 'insetMeters', CROSSWALK_JUNCTION_INSET_METERS), numericValue(crosswalkConstraint, 'insetMeters', CROSSWALK_JUNCTION_INSET_METERS),
0, 0,
MAX_CROSSWALK_INSET_METERS, 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, crosswalkConstraint,
controlPoint(numericValue(crosswalkConstraint, 'insetMeters', CROSSWALK_JUNCTION_INSET_METERS), -1),
); );
const stopLineConstraint = constraintFor(constraints, 'junction-stop-line-offset', anchor); const stopLineConstraint = constraintFor(constraints, 'junction-stop-line-offset', anchor);
add( add(
@@ -707,6 +722,7 @@ function makeJunctionHandles(model, junctionData, constraints) {
Math.max(MIN_STOP_LINE_OFFSET_METERS, approach.length * 0.45), Math.max(MIN_STOP_LINE_OFFSET_METERS, approach.length * 0.45),
tangent, tangent,
stopLineConstraint, stopLineConstraint,
controlPoint(numericValue(stopLineConstraint, 'offsetMeters', STOP_LINE_OFFSET_METERS), 1),
); );
} }
const ordered = [...approaches].sort((a, b) => a.segmentId.localeCompare(b.segmentId)); const ordered = [...approaches].sort((a, b) => a.segmentId.localeCompare(b.segmentId));

View File

@@ -106,6 +106,20 @@ assert.ok(
northSidewalks.find((handle) => handle.anchor.side === 'left').position[0] < 113, northSidewalks.find((handle) => handle.anchor.side === 'left').position[0] < 113,
'the sidewalk handle must follow the same side convention as the edge handle', '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 // 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 // manifest is 844 KB on a 41-road workspace, 89% of it `affects` id lists, while

View File

@@ -204,8 +204,15 @@ function App() {
}, []); }, []);
/** A drag becomes one drafted constraint layered over the session's document. */ /** A drag becomes one drafted constraint layered over the session's document. */
const draftFor = (handle: EditHandle, value: number): PreviewDraft | null => { 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 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 // Without a centerline there is no snapshot to record, so drop the drag rather
// than send a constraint the server would have to reject. // than send a constraint the server would have to reject.
if (!session || !road || road.centerline.length < 2) return null; if (!session || !road || road.centerline.length < 2) return null;