feat: generate direct edit handle manifest

This commit is contained in:
2026-08-27 12:09:11 +08:00
parent 3297d0c97a
commit ab63d23896
6 changed files with 501 additions and 35 deletions

View File

@@ -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,
};

66
src/compile/native-ids.js Normal file
View File

@@ -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 `:<part>` 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,
};

View File

@@ -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(','),

View File

@@ -160,4 +160,5 @@ module.exports = {
polylineMidpoint,
projectedDistanceAlong,
stitchPolylines,
metersAt,
};

View File

@@ -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'),

View File

@@ -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 })]));