refactor: move road compiler core into package
This commit is contained in:
@@ -1,240 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const { parseOsm } = require("./osm");
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
const CURB_OFFSET_METERS = 5.2;
|
||||
const MAST_REACH_METERS = 4.5;
|
||||
const SIGNAL_LAYOUT = Object.freeze({
|
||||
poleHeightMeters: 6.7, poleRadiusMeters: 0.13, armWidthMeters: 0.21,
|
||||
mastHeightMeters: 6.25, headCenterHeightMeters: 6.25,
|
||||
headWidthMeters: 0.68, headDepthMeters: 0.30, headBodyHeightMeters: 1.62,
|
||||
lensRadiusMeters: 0.22, lensDepthMeters: 0.07, lensFaceOffsetMeters: 0.18,
|
||||
lensVerticalOffsetsMeters: [0.49, -0.01, -0.51],
|
||||
countdownLateralMeters: 1.15, countdownFaceOffsetMeters: 0.05,
|
||||
countdownWidthMeters: 0.82, countdownDepthMeters: 0.14,
|
||||
countdownHeightMeters: 0.56, countdownVerticalOffsetMeters: 0.0,
|
||||
});
|
||||
|
||||
function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
|
||||
const centers = (intersections.features || []).map((feature, index) => {
|
||||
const point = polygonCenter(feature.geometry);
|
||||
return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) };
|
||||
}).filter((entry) => entry.point);
|
||||
const clusteredStops = new Map();
|
||||
for (const feature of stopLines.features || []) {
|
||||
const clusterId = feature.properties?.cluster_id;
|
||||
const point = polygonCenter(feature.geometry);
|
||||
if (!clusterId || !point) continue;
|
||||
if (!clusteredStops.has(clusterId)) clusteredStops.set(clusterId, []);
|
||||
clusteredStops.get(clusterId).push(point);
|
||||
}
|
||||
for (const [clusterId, points] of clusteredStops) {
|
||||
if (points.length < 3) continue;
|
||||
const point = points.reduce((sum, item) => [sum[0] + item[0] / points.length, sum[1] + item[1] / points.length], [0, 0]);
|
||||
centers.push({ id: `cluster-${clusterId}`, clusterId, point, radius: Math.max(...points.map((item) => metersBetween(point, item))) });
|
||||
}
|
||||
const candidates = [];
|
||||
for (const feature of stopLines.features || []) {
|
||||
const center = polygonCenter(feature.geometry);
|
||||
if (!center) continue;
|
||||
const clusterId = feature.properties?.cluster_id;
|
||||
const intersection = clusterId ? centers.find((entry) => entry.clusterId === clusterId) : nearestCenter(center, centers);
|
||||
if (!intersection || metersBetween(center, intersection.point) > 32) continue;
|
||||
const axis = roadAxis(feature.geometry, center, intersection.point);
|
||||
if (!axis) continue;
|
||||
const right = [axis[1], -axis[0]];
|
||||
candidates.push({
|
||||
intersectionId: intersection.id, center, axis,
|
||||
point: intersection.clusterId
|
||||
? moveMeters(center, right, CURB_OFFSET_METERS)
|
||||
: moveMeters(moveMeters(intersection.point, axis, intersection.radius + 3.2), right, CURB_OFFSET_METERS),
|
||||
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
|
||||
matchHeadingDegrees: intersection.clusterId
|
||||
? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
const features = [];
|
||||
for (const control of controls) {
|
||||
const controlPoint = [Number(control.longitude), Number(control.latitude)];
|
||||
if (!controlPoint.every(Number.isFinite) || !Array.isArray(control.arms) || control.arms.length < 3) continue;
|
||||
const intersection = nearestCenter(controlPoint, centers);
|
||||
if (!intersection || metersBetween(controlPoint, intersection.point) > 32) continue;
|
||||
const arms = matchOsmArms(candidates.filter((item) => item.intersectionId === intersection.id), controlPoint, control.arms);
|
||||
const groups = phaseGroups(arms);
|
||||
arms.forEach((candidate, index) => {
|
||||
const fallbackArmId = `heading-${Math.round(normalizeDegrees(candidate.osmArm?.headingDegrees || 0) * 1000)}`;
|
||||
const sourceWayId = String(candidate.osmArm?.wayId || "legacy");
|
||||
const neighborNodeId = String(candidate.osmArm?.neighborNodeId || fallbackArmId);
|
||||
const approachId = `${sourceWayId}:${neighborNodeId}`;
|
||||
const signalUid = `osm-${String(control.id)}-${sourceWayId}-${neighborNodeId}`;
|
||||
features.push({
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: candidate.point.slice() },
|
||||
properties: {
|
||||
signal_uid: signalUid, display_id: signalUid, control_id: String(control.id),
|
||||
approach_id: approachId, source_way_id: sourceWayId,
|
||||
// These are independent assembly controls. heading_deg remains a
|
||||
// migration hint for older native documents only.
|
||||
mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90),
|
||||
face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180),
|
||||
phase_group: groups[index],
|
||||
mast_reach_m: MAST_REACH_METERS,
|
||||
stop_lon: candidate.center[0], stop_lat: candidate.center[1],
|
||||
enabled: true, z_offset_m: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
return validateTrafficSignalFeatures({ type: "FeatureCollection", features });
|
||||
}
|
||||
|
||||
function validateTrafficSignalFeatures(collection) {
|
||||
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
|
||||
throw new Error("Traffic signal assemblies must be a FeatureCollection");
|
||||
}
|
||||
const uids = new Set();
|
||||
const displayIds = new Set();
|
||||
const features = collection.features.map((feature, index) => {
|
||||
const label = `traffic signal feature ${index + 1}`;
|
||||
if (feature?.geometry?.type !== "Point" || !Array.isArray(feature.geometry.coordinates) ||
|
||||
feature.geometry.coordinates.length < 2 || !feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)) {
|
||||
throw new Error(`${label}: geometry must be a finite Point`);
|
||||
}
|
||||
const input = feature.properties || {};
|
||||
const text = (key, required = true) => {
|
||||
const value = input[key] == null ? "" : String(input[key]).trim();
|
||||
if (required && !value) throw new Error(`${label}: missing ${key}`);
|
||||
return value;
|
||||
};
|
||||
const number = (key, options = {}) => {
|
||||
if (input[key] === null || input[key] === undefined || input[key] === "") {
|
||||
throw new Error(`${label}: missing ${key}`);
|
||||
}
|
||||
const value = Number(input[key]);
|
||||
if (!Number.isFinite(value) || (options.min != null && value < options.min) || (options.max != null && value > options.max)) {
|
||||
throw new Error(`${label}: invalid ${key} '${input[key]}'`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const signalUid = text("signal_uid");
|
||||
if (!/^osm-[A-Za-z0-9_.:-]+$/.test(signalUid)) throw new Error(`${label}: invalid signal_uid '${signalUid}'`);
|
||||
if (uids.has(signalUid)) throw new Error(`Duplicate signal_uid '${signalUid}'`);
|
||||
uids.add(signalUid);
|
||||
const displayId = text("display_id", false);
|
||||
if (displayId && displayIds.has(displayId)) throw new Error(`Duplicate display_id '${displayId}'`);
|
||||
if (displayId) displayIds.add(displayId);
|
||||
const phaseGroup = number("phase_group", { min: 0, max: 1 });
|
||||
if (!Number.isInteger(phaseGroup)) throw new Error(`${label}: phase_group must be 0 or 1`);
|
||||
const enabled = normalizeBoolean(input.enabled, label);
|
||||
const controlId = text("control_id");
|
||||
const approachId = text("approach_id");
|
||||
const sourceWayId = text("source_way_id");
|
||||
if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`);
|
||||
const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`;
|
||||
if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`);
|
||||
const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg"));
|
||||
if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) {
|
||||
throw new Error(`${label}: missing mast_heading_deg`);
|
||||
}
|
||||
if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) {
|
||||
throw new Error(`${label}: missing face_heading_deg`);
|
||||
}
|
||||
const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === ""
|
||||
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90)
|
||||
: normalizeDegrees(number("mast_heading_deg"));
|
||||
const faceHeading = input.face_heading_deg == null || input.face_heading_deg === ""
|
||||
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180)
|
||||
: normalizeDegrees(number("face_heading_deg"));
|
||||
return {
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) },
|
||||
properties: {
|
||||
...input, signal_uid: signalUid, display_id: displayId,
|
||||
control_id: controlId, approach_id: approachId,
|
||||
source_way_id: sourceWayId,
|
||||
// Retain the legacy value only for migration compatibility. Runtime
|
||||
// geometry is entirely defined by mast_heading_deg and face_heading_deg.
|
||||
heading_deg: legacyHeading,
|
||||
mast_heading_deg: mastHeading, face_heading_deg: faceHeading,
|
||||
phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }),
|
||||
stop_lon: number("stop_lon", { min: -180, max: 180 }),
|
||||
stop_lat: number("stop_lat", { min: -90, max: 90 }),
|
||||
enabled, z_offset_m: number("z_offset_m", { min: -20, max: 100 }),
|
||||
},
|
||||
};
|
||||
});
|
||||
return { type: "FeatureCollection", features };
|
||||
}
|
||||
|
||||
function buildTrafficSignalsFromFeatures(collection) {
|
||||
const normalized = validateTrafficSignalFeatures(collection);
|
||||
const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => {
|
||||
const p = feature.properties;
|
||||
const point = feature.geometry.coordinates;
|
||||
const mastAxis = headingVector(p.mast_heading_deg);
|
||||
return {
|
||||
id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id,
|
||||
nodeKey: signalNodeKey(p.signal_uid),
|
||||
controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id,
|
||||
phaseGroup: p.phase_group, longitude: point[0], latitude: point[1],
|
||||
stopLongitude: p.stop_lon, stopLatitude: p.stop_lat,
|
||||
// Existing Blender readers require headingDegrees. It is a compatibility
|
||||
// alias only; the independent mast/face fields below define all geometry.
|
||||
headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg,
|
||||
mastHeadingDegrees: p.mast_heading_deg,
|
||||
faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m,
|
||||
zOffsetMeters: p.z_offset_m,
|
||||
pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m),
|
||||
};
|
||||
});
|
||||
return { version: 3, layout: SIGNAL_LAYOUT, signals };
|
||||
}
|
||||
|
||||
function signalNodeKey(signalUid) {
|
||||
return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function reconcileTrafficSignalSourceReferences(collection, controls) {
|
||||
const normalized = validateTrafficSignalFeatures(collection);
|
||||
const approachesByControl = new Map((controls || []).map((control) => [
|
||||
String(control.id),
|
||||
new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)),
|
||||
]));
|
||||
const kept = [];
|
||||
const dropped = [];
|
||||
for (const [index, feature] of normalized.features.entries()) {
|
||||
const { control_id: controlId, approach_id: approachId, signal_uid: signalUid } = feature.properties;
|
||||
const approaches = approachesByControl.get(controlId);
|
||||
if (!approaches) {
|
||||
dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-control", message: `control_id '${controlId}' is not present in the current OSM` });
|
||||
continue;
|
||||
}
|
||||
if (!approaches.has(approachId)) {
|
||||
dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-approach", message: `approach_id '${approachId}' is not present on OSM control '${controlId}'` });
|
||||
continue;
|
||||
}
|
||||
kept.push(feature);
|
||||
}
|
||||
return { collection: { ...normalized, features: kept }, dropped };
|
||||
}
|
||||
|
||||
function validateTrafficSignalSourceReferences(collection, controls) {
|
||||
const { collection: reconciled, dropped } = reconcileTrafficSignalSourceReferences(collection, controls);
|
||||
if (dropped.length) throw new Error(`traffic signal feature ${dropped[0].index}: ${dropped[0].message}`);
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
function buildTrafficSignals(stopLines, intersections, controls = []) {
|
||||
return buildTrafficSignalsFromFeatures(buildTrafficSignalFeatures(stopLines, intersections, controls));
|
||||
}
|
||||
const { parseOsm } = require("../../packages/road-compiler/src/osm");
|
||||
const trafficSignals = require("../../packages/road-compiler/src/traffic-signals");
|
||||
|
||||
function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) {
|
||||
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
|
||||
return buildTrafficSignalFeatures(
|
||||
return trafficSignals.buildTrafficSignalFeatures(
|
||||
JSON.parse(fs.readFileSync(stopLinePath, "utf8")),
|
||||
JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls,
|
||||
);
|
||||
@@ -244,87 +16,9 @@ function readTrafficSignals(editablePath, osmPath = null) {
|
||||
const collection = JSON.parse(fs.readFileSync(editablePath, "utf8"));
|
||||
if (osmPath) {
|
||||
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
|
||||
validateTrafficSignalSourceReferences(collection, controls);
|
||||
trafficSignals.validateTrafficSignalSourceReferences(collection, controls);
|
||||
}
|
||||
return buildTrafficSignalsFromFeatures(collection);
|
||||
return trafficSignals.buildTrafficSignalsFromFeatures(collection);
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, label) {
|
||||
if (value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true" || String(value).toLowerCase() === "yes") return true;
|
||||
if (value === false || value === 0 || value === "0" || String(value).toLowerCase() === "false" || String(value).toLowerCase() === "no") return false;
|
||||
throw new Error(`${label}: invalid enabled '${value}'`);
|
||||
}
|
||||
|
||||
function uniqueApproachArms(candidates, controlPoint) {
|
||||
const sorted = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)), controlDistance: metersBetween(controlPoint, candidate.center) }))
|
||||
.sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance);
|
||||
const arms = [];
|
||||
for (const candidate of sorted) if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate);
|
||||
return arms;
|
||||
}
|
||||
|
||||
function matchOsmArms(candidates, controlPoint, osmArms) {
|
||||
const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: candidate.matchHeadingDegrees ?? normalizeDegrees(headingBetween(controlPoint, candidate.center)) }));
|
||||
if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint);
|
||||
return osmArms.map((osmArm) => {
|
||||
let bestIndex = -1; let bestDistance = Infinity;
|
||||
remaining.forEach((item, index) => {
|
||||
const directedDistance = angularDistance(item.armHeading, osmArm.headingDegrees);
|
||||
const distance = item.matchHeadingDegrees == null
|
||||
? directedDistance
|
||||
: Math.min(
|
||||
angularDistance(item.matchHeadingDegrees, osmArm.headingDegrees),
|
||||
angularDistance(item.matchHeadingDegrees + 180, osmArm.headingDegrees),
|
||||
);
|
||||
if (distance < bestDistance) { bestDistance = distance; bestIndex = index; }
|
||||
});
|
||||
const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm);
|
||||
return { ...candidate, osmArm };
|
||||
});
|
||||
}
|
||||
|
||||
function fallbackCandidate(controlPoint, osmArm) {
|
||||
const outward = headingVector(osmArm.headingDegrees); const axis = [-outward[0], -outward[1]];
|
||||
const center = moveMeters(controlPoint, outward, 8); const farSide = moveMeters(controlPoint, axis, 3.2);
|
||||
return { center, axis, point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS), armHeading: normalizeDegrees(osmArm.headingDegrees), headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), fallback: true };
|
||||
}
|
||||
|
||||
function phaseGroups(arms) {
|
||||
const groups = Array(arms.length).fill(1); if (arms.length < 2) return groups;
|
||||
let main = [0, 1]; let best = -1;
|
||||
for (let a = 0; a < arms.length; a += 1) for (let b = a + 1; b < arms.length; b += 1) { const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading); if (opposition > best) { best = opposition; main = [a, b]; } }
|
||||
groups[main[0]] = 0; groups[main[1]] = 0; return groups;
|
||||
}
|
||||
|
||||
function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) {
|
||||
const face = headingVector(faceHeadingDegrees);
|
||||
const head = moveMeters(pole, mastAxis, mastReach);
|
||||
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset });
|
||||
const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
|
||||
const faceRight = [-face[1], face[0]];
|
||||
const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters);
|
||||
return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } };
|
||||
}
|
||||
|
||||
function polygonCenter(geometry) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; if (!ring || ring.length < 4) return null; const points = ring.slice(0, -1); return [points.reduce((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length]; }
|
||||
function polygonRadius(geometry, center) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0; }
|
||||
function roadAxis(geometry, center, target) { const ring = geometry?.coordinates?.[0]; if (!ring || ring.length < 3) return null; let longest; for (let i = 0; i < ring.length - 1; i += 1) { const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180); const dy = ring[i + 1][1] - ring[i][1]; const length = Math.hypot(dx, dy); if (!longest || length > longest.length) longest = { dx, dy, length }; } if (!longest?.length) return null; let axis = [-longest.dy / longest.length, longest.dx / longest.length]; const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]]; if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]]; return axis; }
|
||||
function nearestCenter(point, centers) { return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null; }
|
||||
function metersBetween(a, b) { const lat = (a[1] + b[1]) / 2 * Math.PI / 180; return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS; }
|
||||
function moveMeters(point, vector, meters) { const scale = 180 / Math.PI / EARTH_RADIUS; return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale]; }
|
||||
function headingBetween(from, to) { const latitude = (from[1] + to[1]) / 2 * Math.PI / 180; return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI; }
|
||||
function headingVector(degrees) { const radians = degrees * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; }
|
||||
function normalizeDegrees(value) { return ((value % 360) + 360) % 360; }
|
||||
function angularDistance(a, b) { return Math.abs(((a - b + 540) % 360) - 180); }
|
||||
|
||||
module.exports = {
|
||||
SIGNAL_LAYOUT,
|
||||
signalNodeKey,
|
||||
buildTrafficSignalFeatures,
|
||||
validateTrafficSignalFeatures,
|
||||
validateTrafficSignalSourceReferences,
|
||||
buildTrafficSignalsFromFeatures,
|
||||
buildTrafficSignals,
|
||||
readTrafficSignalFeatures,
|
||||
readTrafficSignals,
|
||||
};
|
||||
module.exports = { ...trafficSignals, readTrafficSignalFeatures, readTrafficSignals };
|
||||
|
||||
Reference in New Issue
Block a user