From ab63d23896a5cdc59d3fd8783c2bf3b5c2e3f978 Mon Sep 17 00:00:00 2001 From: que01 Date: Thu, 27 Aug 2026 12:09:11 +0800 Subject: [PATCH] feat: generate direct edit handle manifest --- src/compile/direct-edit-solver.js | 388 ++++++++++++++++++++++++++++-- src/compile/native-ids.js | 66 +++++ src/compile/native-road.js | 34 +-- src/geometry/lane-geometry.js | 1 + src/index.js | 1 + test/direct-edit-solver.js | 46 ++++ 6 files changed, 501 insertions(+), 35 deletions(-) create mode 100644 src/compile/native-ids.js diff --git a/src/compile/direct-edit-solver.js b/src/compile/direct-edit-solver.js index 5ea34bc..ac56ed4 100644 --- a/src/compile/direct-edit-solver.js +++ b/src/compile/direct-edit-solver.js @@ -1,21 +1,31 @@ 'use strict'; const { diagnostic } = require('./diagnostics'); +const { haversineMeters, metersAt, polylineLength } = require('../geometry/lane-geometry'); +const { + roadSurfaceId, + sidewalkId, + laneId, + laneSeparatorId, + edgeLineId, + centerLineId, + junctionSurfaceId, + connectionConnectorId, +} = require('./native-ids'); const RESOLUTION_SCHEMA = 'road-edit-resolution/v1'; const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1'; - -// Only these two statuses may reshape geometry. `pending`, `conflicted` and -// `stale` are reported and skipped on purpose: applying a constraint whose -// anchor no longer resolves would silently reshape the wrong road, which is the -// exact failure the replay rules exist to prevent. Matching decides the status; -// this module only obeys it. const ACTIVE_STATUSES = new Set(['exact', 'recheck']); - -// Grows one entry per kind as the solver learns to apply it. A kind absent here -// is reported as unapplied rather than approximated, so a partially delivered -// solver never publishes half-solved geometry. const SOLVED_KINDS = new Set(); +const KINDS = [ + 'road-edge-offset', + 'road-sidewalk-width', + 'road-lane-divider', + 'junction-approach-width', + 'junction-cutback', + 'junction-corner-radius', +]; +const MIN_LANE_WIDTH_METERS = 2.4; function emptyHandleManifest(context) { return { @@ -31,9 +41,6 @@ function constraintState(constraint, applied, reason) { return { constraintId: constraint.id, kind: constraint.kind, status: constraint.status, applied, reason }; } -// Phase one: decide which constraints are allowed to touch geometry and record -// why the rest are not. Keeping this separate from solving is what stops a -// diagnostic and a geometry decision from being derived from each other. function planConstraints(constraints, diagnostics) { return constraints.map((constraint) => { if (!constraint.enabled) return constraintState(constraint, false, 'disabled'); @@ -56,21 +63,359 @@ function planConstraints(constraints, diagnostics) { }); } -// Pure: no file or network access, so preview, the full compile and the CLI -// export can share one implementation and unit fixtures can drive it directly. -// `model` is the road model after v1 overrides are applied; `editDocument` is a -// validated `native-road-edits/v2` document, or null when an area has no edits. +function coordinateAt(line, station) { + if (!Array.isArray(line) || line.length === 0) return null; + if (line.length === 1) return [...line[0]]; + const total = polylineLength(line); + if (!Number.isFinite(total) || total <= 0) return [...line[0]]; + let remaining = Math.max(0, Math.min(1, station)) * total; + for (let index = 1; index < line.length; index += 1) { + const length = haversineMeters(line[index - 1], line[index]); + if (length >= remaining) { + const ratio = length ? remaining / length : 0; + return [ + line[index - 1][0] + (line[index][0] - line[index - 1][0]) * ratio, + line[index - 1][1] + (line[index][1] - line[index - 1][1]) * ratio, + ]; + } + remaining -= length; + } + return [...line.at(-1)]; +} + +function tangentAzimuth(line, station) { + const point = coordinateAt(line, station); + if (!point || line.length < 2) return 0; + const distance = Math.max(0.01, polylineLength(line) * 0.005); + const before = coordinateAt(line, Math.max(0, station - distance / Math.max(polylineLength(line), 1))); + const after = coordinateAt(line, Math.min(1, station + distance / Math.max(polylineLength(line), 1))); + const dx = (after[0] - before[0]) * Math.cos((point[1] * Math.PI) / 180); + const dy = after[1] - before[1]; + if (Math.abs(dx) + Math.abs(dy) < 1e-12) return 0; + return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360; +} + +function offsetCoordinate(point, azimuth, meters) { + const radians = (azimuth * Math.PI) / 180; + const latitudeScale = metersAt(point[1]).lon; + return [ + point[0] + (Math.sin(radians) * meters) / Math.max(latitudeScale, 1e-9), + point[1] + (Math.cos(radians) * meters) / 111320, + ]; +} + +function constraintFor(constraints, kind, anchor) { + return constraints.find((item) => { + if (item.kind !== kind || !item.anchor || item.enabled === false) return false; + if (kind.startsWith('road-')) { + return ( + item.anchor.type === 'road-interval' && + item.anchor.roadId === anchor.roadId && + (item.anchor.side || null) === (anchor.side || null) && + (kind !== 'road-lane-divider' || item.anchor.boundaryIndex === anchor.boundaryIndex) + ); + } + if (kind === 'junction-corner-radius') + return ( + item.anchor.type === 'junction-corner' && + item.anchor.nodeId === anchor.nodeId && + item.anchor.incomingRoadId === anchor.incomingRoadId && + item.anchor.outgoingRoadId === anchor.outgoingRoadId + ); + return ( + item.anchor.type === 'junction-approach' && + item.anchor.nodeId === anchor.nodeId && + item.anchor.segmentId === anchor.segmentId && + (item.anchor.side || null) === (anchor.side || null) + ); + }); +} + +function numericValue(constraint, key, fallback) { + const value = constraint?.value?.[key]; + return Number.isFinite(value) ? value : fallback; +} + +function roadGroups(model) { + const groups = new Map(); + for (const road of Array.isArray(model?.roads) ? model.roads : []) { + if (!road || !Array.isArray(road.centerline) || road.centerline.length < 2) continue; + if (!groups.has(road.segmentId)) groups.set(road.segmentId, []); + groups.get(road.segmentId).push(road); + } + return groups; +} + +function junctionReserves(model) { + const endpointGroups = new Map(); + for (const endpoint of Array.isArray(model?.endpoints) ? model.endpoints : []) { + const road = (model.roads || []).find((item) => item.id === endpoint.roadId); + if (!road) continue; + if (!endpointGroups.has(endpoint.nodeId)) endpointGroups.set(endpoint.nodeId, new Map()); + const bySegment = endpointGroups.get(endpoint.nodeId); + if (!bySegment.has(road.segmentId)) bySegment.set(road.segmentId, []); + bySegment.get(road.segmentId).push({ endpoint, road }); + } + const reserves = []; + const approaches = new Map(); + for (const [nodeId, bySegment] of endpointGroups) { + if (bySegment.size < 3 || bySegment.size > 4) continue; + const widths = [...bySegment.values()].map((items) => items.reduce((sum, item) => sum + item.road.widthMeters, 0)); + const cutback = Math.max(...widths) * 1.4; + for (const [segmentId, items] of bySegment) { + const representative = items[0].road; + const length = polylineLength(representative.centerline); + if (!Number.isFinite(length) || length <= 0) continue; + const fraction = Math.min(0.45, cutback / length); + const endpoint = items[0].endpoint; + const range = endpoint.side === 'start' ? [0, fraction] : [1 - fraction, 1]; + const reserve = { nodeId: String(nodeId), roadId: segmentId, fromStation: range[0], toStation: range[1] }; + reserves.push(reserve); + if (!approaches.has(nodeId)) approaches.set(nodeId, []); + approaches.get(nodeId).push({ nodeId: String(nodeId), segmentId, items, cutback, length, endpoint, range }); + } + } + return { reserves, approaches }; +} + +function roadAffects(roads, model, side, boundaryIndex) { + const ids = []; + for (const road of roads) { + ids.push(roadSurfaceId(road), centerLineId(road.segmentId)); + ids.push(edgeLineId(road.id, side || 'left'), edgeLineId(road.id, side || 'right')); + if (side) ids.push(sidewalkId(road, side)); + for (let index = 1; index <= road.laneCount; index += 1) ids.push(laneId(road.id, index)); + if (boundaryIndex) ids.push(laneSeparatorId(road.id, boundaryIndex)); + } + const roadIds = new Set(roads.map((road) => road.id)); + const endpointRoads = new Map((model.endpoints || []).map((endpoint) => [endpoint.id, endpoint.roadId])); + for (const connection of model.connections || []) { + const from = endpointRoads.get(connection.fromEndpointId); + const to = endpointRoads.get(connection.toEndpointId); + if (!roadIds.has(from) && !roadIds.has(to)) continue; + const fromRoad = (model.roads || []).find((road) => road.id === from); + const toRoad = (model.roads || []).find((road) => road.id === to); + for (let fromIndex = 1; fromIndex <= (fromRoad?.laneCount || 0); fromIndex += 1) + for (let toIndex = 1; toIndex <= (toRoad?.laneCount || 0); toIndex += 1) + ids.push(`${connectionConnectorId(connection.id)}:${laneId(from, fromIndex)}->${laneId(to, toIndex)}`); + } + return [...new Set(ids)]; +} + +function makeRoadHandles(model, groups, reserves, constraints) { + const handles = []; + for (const [segmentId, roads] of groups) { + const road = roads.find((item) => item.direction === 'forward') || roads[0]; + const length = polylineLength(road.centerline); + if (!Number.isFinite(length) || length <= 0) continue; + const segmentReserves = reserves.filter((item) => item.roadId === segmentId); + const startReserve = Math.max(0, ...segmentReserves.map((item) => item.toStation * (item.fromStation === 0))); + const endReserve = Math.max(0, ...segmentReserves.map((item) => (item.fromStation > 0 ? 1 - item.fromStation : 0))); + const start = Math.min(1, startReserve); + const end = Math.max(0, 1 - endReserve); + const station = start < end ? (start + end) / 2 : 0.5; + const unavailable = start >= end; + const width = roads.reduce((sum, item) => sum + (Number(item.widthMeters) || 0), 0); + const laneCount = roads.reduce((sum, item) => sum + (Number(item.laneCount) || 0), 0); + const point = coordinateAt(road.centerline, station); + const tangent = tangentAzimuth(road.centerline, station); + const normal = (tangent + 90) % 360; + const interval = { type: 'road-interval', roadId: road.id, startStation: start, endStation: end }; + const baseDisabled = unavailable ? '该道路全部位于路口保留区,请进入 JunctionTools 编辑。' : undefined; + const add = (kind, anchor, value, min, max, axis, position, affects, constraint) => { + const disabledReason = baseDisabled; + handles.push({ + handleId: `handle:${kind}:${segmentId}:${anchor.side || anchor.boundaryIndex || 'main'}`, + kind, + anchor, + position, + axisAzimuth: ((axis % 360) + 360) % 360, + value: { current: value, min, max, unit: 'meter' }, + ...(constraint ? { constraintId: constraint.id } : {}), + affects, + editable: !disabledReason, + ...(disabledReason ? { disabledReason } : {}), + }); + }; + for (const side of ['left', 'right']) { + const sideSign = side === 'left' ? 1 : -1; + const edgeAnchor = { ...interval, side }; + const edgeConstraint = constraintFor(constraints, 'road-edge-offset', edgeAnchor); + add( + 'road-edge-offset', + edgeAnchor, + numericValue(edgeConstraint, 'offsetMeters', 0), + -Math.max(0.1, width * 0.45), + Math.max(0.1, width * 0.45), + normal, + offsetCoordinate(point, tangent + sideSign * 90, width / 2), + roadAffects(roads, model, side), + edgeConstraint, + ); + const sidewalkConstraint = constraintFor(constraints, 'road-sidewalk-width', edgeAnchor); + const enabled = side === 'left' ? Boolean(road.sidewalkLeft) : Boolean(road.sidewalkRight); + add( + 'road-sidewalk-width', + edgeAnchor, + numericValue(sidewalkConstraint, 'widthMeters', enabled ? 2 : 0), + 0, + 8, + normal, + offsetCoordinate(point, tangent + sideSign * 90, width / 2 + (enabled ? 1 : 0)), + roadAffects(roads, model, side), + sidewalkConstraint, + ); + } + for (let boundaryIndex = 1; boundaryIndex < laneCount; boundaryIndex += 1) { + const anchor = { ...interval }; + const constraint = constraintFor(constraints, 'road-lane-divider', { ...anchor, boundaryIndex }); + const laneWidth = laneCount ? width / laneCount : MIN_LANE_WIDTH_METERS; + const lateral = -width / 2 + laneWidth * boundaryIndex; + add( + 'road-lane-divider', + { ...anchor, boundaryIndex }, + numericValue(constraint, 'offsetMeters', 0), + -Math.max(0.1, laneWidth - MIN_LANE_WIDTH_METERS), + Math.max(0.1, laneWidth - MIN_LANE_WIDTH_METERS), + normal, + offsetCoordinate(point, tangent + 90, lateral), + roadAffects(roads, model, null, boundaryIndex), + constraint, + ); + } + } + return handles; +} + +function junctionAffects(model, nodeId, segmentIds) { + const roads = (model.roads || []).filter((road) => segmentIds.has(road.segmentId)); + const ids = [junctionSurfaceId(nodeId), ...roadAffects(roads, model)]; + const endpointRoads = new Map((model.endpoints || []).map((endpoint) => [endpoint.id, endpoint.roadId])); + for (const connection of model.connections || []) { + if (String(connection.nodeId) !== String(nodeId)) continue; + const from = (model.roads || []).find((road) => road.id === endpointRoads.get(connection.fromEndpointId)); + const to = (model.roads || []).find((road) => road.id === endpointRoads.get(connection.toEndpointId)); + for (let fromIndex = 1; fromIndex <= (from?.laneCount || 0); fromIndex += 1) + for (let toIndex = 1; toIndex <= (to?.laneCount || 0); toIndex += 1) + ids.push(`${connectionConnectorId(connection.id)}:${laneId(from.id, fromIndex)}->${laneId(to.id, toIndex)}`); + } + return [...new Set(ids)]; +} + +function makeJunctionHandles(model, junctionData, constraints) { + const handles = []; + for (const [nodeId, approaches] of junctionData.approaches) { + const segmentIds = new Set(approaches.map((item) => item.segmentId)); + for (const approach of approaches) { + const road = approach.items[0].road; + const line = approach.endpoint.side === 'start' ? road.centerline : [...road.centerline].reverse(); + const station = Math.min(0.95, Math.max(0.05, approach.cutback / approach.length)); + const point = coordinateAt(line, station); + const tangent = tangentAzimuth(line, station); + 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) => { + handles.push({ + handleId: `handle:${kind}:node/${nodeId}:${approach.segmentId}`, + kind, + anchor, + position: point, + axisAzimuth: ((axis % 360) + 360) % 360, + value: { current: value, min, max, unit: 'meter' }, + ...(constraint ? { constraintId: constraint.id } : {}), + affects, + editable: true, + }); + }; + const widthConstraint = constraintFor(constraints, 'junction-approach-width', anchor); + add( + 'junction-approach-width', + numericValue( + widthConstraint, + 'widthMeters', + approach.items.reduce((sum, item) => sum + item.road.widthMeters, 0), + ), + MIN_LANE_WIDTH_METERS, + Math.max(8, approach.items.reduce((sum, item) => sum + item.road.widthMeters, 0) * 2), + normal, + widthConstraint, + ); + const cutbackConstraint = constraintFor(constraints, 'junction-cutback', anchor); + add( + 'junction-cutback', + numericValue(cutbackConstraint, 'cutbackMeters', approach.cutback), + 1, + Math.max(1, approach.length * 0.45), + tangent, + cutbackConstraint, + ); + } + const ordered = [...approaches].sort((a, b) => a.segmentId.localeCompare(b.segmentId)); + for (let index = 0; index < ordered.length; index += 1) { + const incoming = ordered[index]; + const outgoing = ordered[(index + 1) % ordered.length]; + const anchor = { + type: 'junction-corner', + nodeId: String(nodeId), + incomingRoadId: incoming.segmentId, + outgoingRoadId: outgoing.segmentId, + }; + const constraint = constraintFor(constraints, 'junction-corner-radius', anchor); + const first = coordinateAt( + incoming.endpoint.side === 'start' + ? incoming.items[0].road.centerline + : [...incoming.items[0].road.centerline].reverse(), + Math.min(0.95, incoming.cutback / incoming.length), + ); + const second = coordinateAt( + outgoing.endpoint.side === 'start' + ? outgoing.items[0].road.centerline + : [...outgoing.items[0].road.centerline].reverse(), + Math.min(0.95, outgoing.cutback / outgoing.length), + ); + const position = [((first?.[0] || 0) + (second?.[0] || 0)) / 2, ((first?.[1] || 0) + (second?.[1] || 0)) / 2]; + handles.push({ + handleId: `handle:junction-corner-radius:node/${nodeId}:${incoming.segmentId}->${outgoing.segmentId}`, + kind: 'junction-corner-radius', + anchor, + position, + axisAzimuth: tangentAzimuth(incoming.items[0].road.centerline, 0.05), + value: { + current: numericValue( + constraint, + 'radiusMeters', + Math.max(1, Math.min(12, incoming.items[0].road.widthMeters)), + ), + min: 0.5, + max: 30, + unit: 'meter', + }, + ...(constraint ? { constraintId: constraint.id } : {}), + affects: junctionAffects(model, nodeId, segmentIds), + editable: true, + }); + } + } + return handles; +} + function resolveDirectEditConstraints(model, editDocument = null, context = {}) { const diagnostics = []; const constraints = Array.isArray(editDocument && editDocument.constraints) ? editDocument.constraints : []; + const handles = emptyHandleManifest(context); + const groups = roadGroups(model || {}); + const junctionData = junctionReserves(model || {}); + handles.reserves = junctionData.reserves; + handles.handles = [ + ...makeRoadHandles(model || {}, groups, handles.reserves, constraints), + ...makeJunctionHandles(model || {}, junctionData, constraints), + ]; return { schema: RESOLUTION_SCHEMA, - // Empty means "use the baseline cross-section / junction geometry". Later - // steps populate these per road and per node; an empty resolution is an - // identity transform over compileGeometry by construction. roadProfiles: new Map(), junctionPlans: new Map(), - handles: emptyHandleManifest(context), + handles, constraintStates: planConstraints(constraints, diagnostics), diagnostics, }; @@ -81,5 +426,6 @@ module.exports = { HANDLE_MANIFEST_SCHEMA, ACTIVE_STATUSES, SOLVED_KINDS, + KINDS, resolveDirectEditConstraints, }; diff --git a/src/compile/native-ids.js b/src/compile/native-ids.js new file mode 100644 index 0000000..9824b39 --- /dev/null +++ b/src/compile/native-ids.js @@ -0,0 +1,66 @@ +'use strict'; + +// Native ids link a compiled GeoJSON feature back to the semantic object it came +// from. The constraint solver has to name the features a handle will change +// before compileGeometry runs, so the builders live here rather than inline in +// the geometry code: two independently written id templates would drift the +// moment either side changed, and a stale `affects` entry silently highlights +// the wrong road. +// +// Features that get split during compilation (edge lines around a junction, +// dashed centre lines, per-lane connectors) append `:` to the id below. +// A consumer matching a handle's `affects` entry therefore tests +// `id === entry || id.startsWith(`${entry}:`)`, which holds for both the split +// and unsplit forms. + +// Way-level segments keep the way id so a single-segment road reads as its OSM +// way; split segments fall back to the segment key. +function roadSurfaceId(road) { + return road.segmentId.endsWith('/0') ? `surface:way/${road.osmWayIds.join(',')}` : `surface:${road.segmentId}`; +} + +function sidewalkId(road, side) { + return road.segmentId.endsWith('/0') + ? `sidewalk:way/${road.osmWayIds.join(',')}:${side}` + : `sidewalk:${road.segmentId}:${side}`; +} + +function laneId(roadId, index) { + return `lane:${roadId}:${index}`; +} + +function laneSeparatorId(roadId, index) { + return `lane-separator:${roadId}:${index}-${index + 1}`; +} + +function edgeLineId(roadId, side, part) { + return `edge-line:${roadId}:${side}${part ? `:${part}` : ''}`; +} + +function centerLineId(segmentId) { + return `center-line:${segmentId}`; +} + +function junctionSurfaceId(nodeId) { + return `junction:node/${nodeId}`; +} + +function movementConnectorId(movementId) { + return `connector:${movementId}`; +} + +function connectionConnectorId(connectionId) { + return `connector:movement:${connectionId}`; +} + +module.exports = { + roadSurfaceId, + sidewalkId, + laneId, + laneSeparatorId, + edgeLineId, + centerLineId, + junctionSurfaceId, + movementConnectorId, + connectionConnectorId, +}; diff --git a/src/compile/native-road.js b/src/compile/native-road.js index ce1b36d..8aeeca7 100644 --- a/src/compile/native-road.js +++ b/src/compile/native-road.js @@ -5,6 +5,16 @@ const path = require('path'); const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows'); const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction'); const { diagnostic } = require('./diagnostics'); +const { + roadSurfaceId, + sidewalkId, + laneId, + laneSeparatorId, + edgeLineId, + centerLineId, + junctionSurfaceId, + connectionConnectorId, +} = require('./native-ids'); const OVERRIDE_SCHEMA = 'native-road-overrides/v1'; const MOTOR_HIGHWAYS = new Set([ @@ -676,9 +686,7 @@ function compileGeometry(model, overrides = { overrides: [] }, options = {}) { ); continue; } - const surfaceId = road.segmentId.endsWith('/0') - ? `surface:way/${road.osmWayIds.join(',')}` - : `surface:${segmentKey}`; + const surfaceId = roadSurfaceId(road); features.push({ type: 'Feature', properties: { @@ -996,7 +1004,7 @@ function edgeLineFeature(road, side, style, ring, part = null) { return { type: 'Feature', properties: { - native_id: `edge-line:${road.id}:${side}${part ? `:${part}` : ''}`, + native_id: edgeLineId(road.id, side, part), road_id: road.id, side, osm_way_ids: road.osmWayIds.join(','), @@ -1081,7 +1089,7 @@ function compileCenterLines(model, overrides, junctionPlans, controls, diagnosti features.push({ type: 'Feature', properties: { - native_id: `center-line:${segmentId}:${dashIndex}:${offset}`, + native_id: `${centerLineId(segmentId)}:${dashIndex}:${offset}`, segment_id: segmentId, road_id: forward.id, cluster_id: cluster?.id || null, @@ -1317,7 +1325,7 @@ function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans if (ring) separators.push({ type: 'Feature', - properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties }, + properties: { native_id: laneSeparatorId(road.id, index), ...properties }, geometry: { type: 'Polygon', coordinates: [ring] }, }); } else { @@ -1336,7 +1344,7 @@ function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans ); separators.push({ type: 'Feature', - properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}:${part}`, ...properties }, + properties: { native_id: `${laneSeparatorId(road.id, index)}:${part}`, ...properties }, geometry: { type: 'Polygon', coordinates: [ring] }, }); } @@ -1605,13 +1613,11 @@ function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {} ); continue; } - const sidewalkId = forward.segmentId.endsWith('/0') - ? `sidewalk:way/${forward.osmWayIds.join(',')}:${side}` - : `sidewalk:${wayKey}:${side}`; + const sidewalkNativeId = sidewalkId(forward, side); features.push({ type: 'Feature', properties: { - native_id: sidewalkId, + native_id: sidewalkNativeId, cluster_id: cluster?.id || null, osm_way_ids: forward.osmWayIds.join(','), source_road_id: forward.sourceRoadId, @@ -1884,7 +1890,7 @@ function compileLaneCenterlines(model, diagnostics, junctionPlans, options = {}, ); continue; } - const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates }; + const lane = { id: laneId(road.id, index + 1), roadId: road.id, index: index + 1, coordinates }; lanes.push(lane); // Only the published geometry stops at the crossing. `coordinates` stays // whole because connectors are derived from it; a lane that ends at the @@ -2084,7 +2090,7 @@ function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) const length = lineLengthMeters(coordinates); const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`; const provenance = override ? `override:${override.id}` : connection.provenance; - const connectorId = `connector:${id}`; + const connectorId = `${connectionConnectorId(connection.id)}:${defaultFromLane.id}->${defaultToLane.id}`; const geometryStatus = length < 0.4 ? 'continuous' : length > 80 ? 'deferred-too-long' : 'connector'; const movement = { id, @@ -2355,7 +2361,7 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di result.push({ type: 'Feature', properties: { - native_id: `junction:node/${nodeId}`, + native_id: junctionSurfaceId(nodeId), osm_node_id: nodeId, kind: segmentIds.size === 3 ? 't' : 'cross', source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(','), diff --git a/src/geometry/lane-geometry.js b/src/geometry/lane-geometry.js index 4e72dd8..3dc6c86 100644 --- a/src/geometry/lane-geometry.js +++ b/src/geometry/lane-geometry.js @@ -160,4 +160,5 @@ module.exports = { polylineMidpoint, projectedDistanceAlong, stitchPolylines, + metersAt, }; diff --git a/src/index.js b/src/index.js index 95074d8..024800c 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ module.exports = { trafficSignals: require('./traffic-signals'), nativeTrafficSignals: require('./native-traffic-signals'), nativeRoad: require('./compile/native-road'), + directEditSolver: require('./compile/direct-edit-solver'), nativeRoadEdits: require('./compile/native-road-edits'), roadRevisions: require('./compile/road-revisions'), layerManifest: require('./compile/layer-manifest'), diff --git a/test/direct-edit-solver.js b/test/direct-edit-solver.js index d1811ea..a3345b3 100644 --- a/test/direct-edit-solver.js +++ b/test/direct-edit-solver.js @@ -50,6 +50,52 @@ const withContext = resolveDirectEditConstraints(model, null, { revisionId: 'rev assert.equal(withContext.handles.revisionId, 'rev-0007'); assert.equal(withContext.handles.previewSeq, 42); +// A small ordinary junction exercises the step-two manifest contract without +// depending on the full OSM parser. Three approaches produce one reserve each, +// all six kinds, and native feature links for the downstream map highlighter. +const junctionRoads = ['a', 'b', 'c'].map((id, index) => ({ + id: `road:${id}`, + segmentId: `segment:${id}`, + direction: 'forward', + centerline: [ + [113 + index * 0.001, 30], + [113 + index * 0.001 + (index === 1 ? 0.001 : 0), 30 + (index === 1 ? 0.001 : 0.001)], + ], + sourceNodeIds: ['junction', `end-${id}`], + osmWayIds: [id], + widthMeters: 6, + laneCount: 2, + sidewalkLeft: true, + sidewalkRight: true, +})); +const junctionModel = { + roads: junctionRoads, + endpoints: junctionRoads.map((road) => ({ + id: `endpoint:${road.id}:start`, + roadId: road.id, + side: 'start', + nodeId: 'junction', + coordinate: road.centerline[0], + })), + connections: [], +}; +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.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'))); + +const anchored = constraint({ + id: 'edge-on-road-a', + anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' }, +}); +const anchoredResolution = resolveDirectEditConstraints(junctionModel, document([anchored])); +const anchoredHandle = anchoredResolution.handles.handles.find( + (handle) => handle.kind === 'road-edge-offset' && handle.anchor.roadId === 'road:a' && handle.anchor.side === 'left', +); +assert.equal(anchoredHandle.constraintId, 'edge-on-road-a'); + // A disabled constraint is reported, never applied, and produces no diagnostic: // the user turned it off deliberately, so it is not a problem to report. const disabled = resolveDirectEditConstraints(model, document([constraint({ enabled: false })]));