feat: generate direct edit handle manifest
This commit is contained in:
@@ -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
66
src/compile/native-ids.js
Normal 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,
|
||||
};
|
||||
@@ -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(','),
|
||||
|
||||
@@ -160,4 +160,5 @@ module.exports = {
|
||||
polylineMidpoint,
|
||||
projectedDistanceAlong,
|
||||
stitchPolylines,
|
||||
metersAt,
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 })]));
|
||||
|
||||
Reference in New Issue
Block a user