fix(preview): derive signals from OSM controls
This commit is contained in:
@@ -253,6 +253,11 @@ function reimportGpkg(area) {
|
||||
function buildBlenderScene(area) {
|
||||
ensureFile(blenderExecutable(area), "Blender executable");
|
||||
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator");
|
||||
// Traffic signal anchors are derived from the current OSM input plus the
|
||||
// normalized stop-line/intersection layers. Regenerate them for every
|
||||
// Blender build so partial runs cannot reuse a stale signal topology after
|
||||
// the source OSM has changed.
|
||||
writeTrafficSignals(area);
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true });
|
||||
@@ -527,6 +532,7 @@ function writeTrafficSignals(area) {
|
||||
const signals = readTrafficSignals(
|
||||
path.join(area.outputs.geojsonDir, "vehicle_stop_lines.geojson"),
|
||||
path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
|
||||
area.input,
|
||||
);
|
||||
fs.writeFileSync(area.outputs.trafficSignals, `${JSON.stringify(signals, null, 2)}\n`);
|
||||
console.log(`Traffic signals: ${signals.signals.length} anchors in ${area.outputs.trafficSignals}`);
|
||||
|
||||
@@ -482,6 +482,7 @@
|
||||
const state = { elapsedSeconds: 0, phase: "" };
|
||||
const entities = [];
|
||||
const nodes = new Map();
|
||||
const countdownNodes = new WeakMap();
|
||||
const node = (name) => {
|
||||
if (nodes.has(name)) return nodes.get(name);
|
||||
let value = null;
|
||||
@@ -496,6 +497,18 @@
|
||||
if (value) nodes.set(name, value);
|
||||
return value;
|
||||
};
|
||||
const countdownNode = (model, name) => {
|
||||
let modelNodes = countdownNodes.get(model);
|
||||
if (!modelNodes) {
|
||||
modelNodes = new Map();
|
||||
countdownNodes.set(model, modelNodes);
|
||||
}
|
||||
if (modelNodes.has(name)) return modelNodes.get(name);
|
||||
let value = null;
|
||||
try { value = model.getNode(name); } catch (error) { /* model node table is still loading */ }
|
||||
if (value) modelNodes.set(name, value);
|
||||
return value;
|
||||
};
|
||||
const update = (elapsedSeconds) => {
|
||||
Cesium.JulianDate.addSeconds(start, elapsedSeconds, phaseTime);
|
||||
let changed = false;
|
||||
@@ -516,7 +529,7 @@
|
||||
for (let value = 0; value < 20; value += 1) {
|
||||
const name = `TrafficSignalDynamic_${signal.id}_countdown_${String(value).padStart(2, "0")}`;
|
||||
let countdown = null;
|
||||
try { countdown = countdownModel.getNode(name); } catch (error) { /* model node table is still loading */ }
|
||||
countdown = countdownNode(countdownModel, name);
|
||||
if (countdown && countdown.show !== (String(value).padStart(2, "0") === visibleCountdown)) {
|
||||
countdown.show = String(value).padStart(2, "0") === visibleCountdown;
|
||||
changed = true;
|
||||
@@ -544,6 +557,13 @@
|
||||
// countdown must remain visibly periodic.
|
||||
const timer = setInterval(render, 250);
|
||||
render();
|
||||
// Countdown GLBs may expose their node table a few frames after the
|
||||
// model object exists. Re-apply the initial state once both models are
|
||||
// ready so every hidden digit is explicitly hidden before the first
|
||||
// user-visible frame.
|
||||
for (const model of countdownModels.values()) {
|
||||
if (model.readyPromise) model.readyPromise.then(() => update(0)).catch(() => {});
|
||||
}
|
||||
return {
|
||||
entities, count: signals.length, dynamic, state, timer,
|
||||
set show(value) {
|
||||
|
||||
98
scripts/lib/osm.js
Normal file
98
scripts/lib/osm.js
Normal file
@@ -0,0 +1,98 @@
|
||||
"use strict";
|
||||
|
||||
function parseOsm(xml) {
|
||||
const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/);
|
||||
const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {};
|
||||
const candidateBounds = {
|
||||
minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat),
|
||||
maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat),
|
||||
};
|
||||
const bounds = Object.values(candidateBounds).every(Number.isFinite) ? candidateBounds : null;
|
||||
const nodes = new Map();
|
||||
const trafficSignalControls = [];
|
||||
const nodePattern = /<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g;
|
||||
for (const match of xml.matchAll(nodePattern)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
|
||||
if (!coordinate.every(Number.isFinite)) continue;
|
||||
nodes.set(attrs.id, coordinate);
|
||||
const tags = parseTags(match[2] || "");
|
||||
if (tags.highway === "traffic_signals") {
|
||||
trafficSignalControls.push({ id: attrs.id, longitude: coordinate[0], latitude: coordinate[1], tags });
|
||||
}
|
||||
}
|
||||
const ways = [];
|
||||
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
if (attrs.action === "delete") continue;
|
||||
const body = match[2];
|
||||
const refs = [];
|
||||
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
|
||||
const ref = xmlAttrs(ndMatch[1]).ref;
|
||||
if (ref && nodes.has(ref)) refs.push(ref);
|
||||
}
|
||||
if (refs.length >= 2) ways.push({ id: attrs.id || `way-${ways.length + 1}`, refs, tags: parseTags(body) });
|
||||
}
|
||||
for (const control of trafficSignalControls) {
|
||||
const arms = [];
|
||||
for (const way of ways) {
|
||||
if (!isMotorRoad(way.tags)) continue;
|
||||
for (let index = 0; index < way.refs.length; index += 1) {
|
||||
if (way.refs[index] !== control.id) continue;
|
||||
for (const neighborIndex of [index - 1, index + 1]) {
|
||||
const neighbor = way.refs[neighborIndex];
|
||||
if (!neighbor || !nodes.has(neighbor)) continue;
|
||||
const neighborPoint = nodes.get(neighbor);
|
||||
arms.push({ headingDegrees: headingBetween(control, neighborPoint), wayId: way.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
control.arms = dedupeHeadings(arms);
|
||||
control.junctionType = control.arms.length === 3 ? "T" : control.arms.length === 4 ? "cross" : "other";
|
||||
}
|
||||
return { bounds, nodes, ways, trafficSignalControls };
|
||||
}
|
||||
|
||||
function isMotorRoad(tags) {
|
||||
const highway = tags.highway || "";
|
||||
return highway && tags.area !== "yes" && !new Set([
|
||||
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track",
|
||||
"bridleway", "corridor", "elevator", "platform", "construction",
|
||||
]).has(highway);
|
||||
}
|
||||
|
||||
function headingBetween(from, to) {
|
||||
const latitude = (from.latitude + to[1]) / 2 * Math.PI / 180;
|
||||
return Math.atan2((to[0] - from.longitude) * Math.cos(latitude), to[1] - from.latitude) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
function dedupeHeadings(arms) {
|
||||
const normalized = (value) => ((value % 360) + 360) % 360;
|
||||
const distance = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
|
||||
const result = [];
|
||||
for (const arm of arms) {
|
||||
arm.headingDegrees = normalized(arm.headingDegrees);
|
||||
if (!result.some((other) => distance(other.headingDegrees, arm.headingDegrees) <= 25)) result.push(arm);
|
||||
}
|
||||
return result.sort((a, b) => a.headingDegrees - b.headingDegrees);
|
||||
}
|
||||
|
||||
function xmlAttrs(text) {
|
||||
const attrs = {};
|
||||
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
||||
attrs[match[1]] = match[2] !== undefined ? match[2] : match[3];
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function parseTags(body) {
|
||||
const tags = {};
|
||||
for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
|
||||
const tag = xmlAttrs(match[1]);
|
||||
if (tag.k) tags[tag.k] = tag.v || "";
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
module.exports = { parseOsm };
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const { parseOsm } = require("./osm");
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
const CURB_OFFSET_METERS = 5.2;
|
||||
@@ -32,12 +33,12 @@ const SIGNAL_LAYOUT = Object.freeze({
|
||||
countdownVerticalOffsetMeters: 0.0,
|
||||
});
|
||||
|
||||
function buildTrafficSignals(stopLines, intersections) {
|
||||
function buildTrafficSignals(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 signals = [];
|
||||
const candidates = [];
|
||||
for (const feature of stopLines.features || []) {
|
||||
const center = polygonCenter(feature.geometry);
|
||||
if (!center) continue;
|
||||
@@ -53,22 +54,116 @@ function buildTrafficSignals(stopLines, intersections) {
|
||||
// The pole is on the far-side sidewalk, not at the stop line or inside
|
||||
// the intersection. Its mast then reaches back above the approach lanes.
|
||||
const point = moveMeters(farSide, right, CURB_OFFSET_METERS);
|
||||
signals.push({
|
||||
id: `signal-${signals.length + 1}`,
|
||||
candidates.push({
|
||||
intersectionId: intersection.id,
|
||||
phaseGroup: signals.length % 2,
|
||||
longitude: point[0],
|
||||
latitude: point[1],
|
||||
stopLongitude: center[0],
|
||||
stopLatitude: center[1],
|
||||
headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI,
|
||||
mastReachMeters: MAST_REACH_METERS,
|
||||
pose: buildSignalPose(point, axis, MAST_REACH_METERS),
|
||||
center,
|
||||
axis,
|
||||
point,
|
||||
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
|
||||
});
|
||||
}
|
||||
const signals = [];
|
||||
for (const control of controls) {
|
||||
const controlPoint = [Number(control.longitude), Number(control.latitude)];
|
||||
if (!controlPoint.every(Number.isFinite)) continue;
|
||||
// A traffic-signal node on a through road is not a controlled vehicle
|
||||
// junction. Its connected motor-road arms are the source of truth.
|
||||
if (!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((candidate) => candidate.intersectionId === intersection.id), controlPoint, control.arms);
|
||||
const groups = phaseGroups(arms);
|
||||
for (const [index, candidate] of arms.entries()) {
|
||||
signals.push({
|
||||
id: `signal-${signals.length + 1}`,
|
||||
controlId: String(control.id || ""),
|
||||
intersectionId: intersection.id,
|
||||
phaseGroup: groups[index],
|
||||
longitude: candidate.point[0],
|
||||
latitude: candidate.point[1],
|
||||
stopLongitude: candidate.center[0],
|
||||
stopLatitude: candidate.center[1],
|
||||
headingDegrees: candidate.headingDegrees,
|
||||
mastReachMeters: MAST_REACH_METERS,
|
||||
pose: buildSignalPose(candidate.point, candidate.axis, MAST_REACH_METERS),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { version: 3, layout: SIGNAL_LAYOUT, signals };
|
||||
}
|
||||
|
||||
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) {
|
||||
const duplicate = arms.find((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25);
|
||||
if (!duplicate) arms.push(candidate);
|
||||
}
|
||||
return arms;
|
||||
}
|
||||
|
||||
function matchOsmArms(candidates, controlPoint, osmArms) {
|
||||
const withHeadings = candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)),
|
||||
}));
|
||||
if (!Array.isArray(osmArms) || !osmArms.length) return uniqueApproachArms(withHeadings, controlPoint);
|
||||
const remaining = withHeadings.slice();
|
||||
const matched = [];
|
||||
for (const osmArm of osmArms) {
|
||||
let bestIndex = -1;
|
||||
let bestDistance = Infinity;
|
||||
for (let index = 0; index < remaining.length; index += 1) {
|
||||
const distance = angularDistance(remaining[index].armHeading, osmArm.headingDegrees);
|
||||
if (distance < bestDistance) { bestDistance = distance; bestIndex = index; }
|
||||
}
|
||||
if (bestIndex >= 0 && bestDistance <= 45) {
|
||||
matched.push(remaining.splice(bestIndex, 1)[0]);
|
||||
} else {
|
||||
matched.push(fallbackCandidate(controlPoint, osmArm));
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
function fallbackCandidate(controlPoint, osmArm) {
|
||||
const outward = headingVector(osmArm.headingDegrees);
|
||||
const axis = [-outward[0], -outward[1]];
|
||||
const stopDistance = 8.0;
|
||||
const stop = moveMeters(controlPoint, outward, stopDistance);
|
||||
const farSide = moveMeters(controlPoint, axis, 3.2);
|
||||
return {
|
||||
center: stop,
|
||||
axis,
|
||||
point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS),
|
||||
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 bestOpposition = -1;
|
||||
for (let left = 0; left < arms.length; left += 1) {
|
||||
for (let right = left + 1; right < arms.length; right += 1) {
|
||||
const opposition = angularDistance(arms[left].armHeading, arms[right].armHeading);
|
||||
if (opposition > bestOpposition) {
|
||||
bestOpposition = opposition;
|
||||
main = [left, right];
|
||||
}
|
||||
}
|
||||
}
|
||||
groups[main[0]] = 0;
|
||||
groups[main[1]] = 0;
|
||||
return groups;
|
||||
}
|
||||
|
||||
function buildSignalPose(pole, axis, mastReach) {
|
||||
const lateral = [axis[1], -axis[0]];
|
||||
const face = [-axis[0], -axis[1]];
|
||||
@@ -95,8 +190,9 @@ function buildSignalPose(pole, axis, mastReach) {
|
||||
};
|
||||
}
|
||||
|
||||
function readTrafficSignals(stopLinePath, intersectionPath) {
|
||||
return buildTrafficSignals(JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8")));
|
||||
function readTrafficSignals(stopLinePath, intersectionPath, osmPath) {
|
||||
const controls = osmPath ? parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls : [];
|
||||
return buildTrafficSignals(JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls);
|
||||
}
|
||||
|
||||
function polygonCenter(geometry) {
|
||||
@@ -143,4 +239,22 @@ function moveMeters(point, vector, meters) {
|
||||
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(headingDegrees) {
|
||||
const radians = headingDegrees * 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, buildTrafficSignals, readTrafficSignals };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const { parseOsm } = require("./osm");
|
||||
|
||||
const MAX_ROUTES = 5;
|
||||
const MAX_PATH_EDGES = 7;
|
||||
@@ -26,49 +27,6 @@ function buildVehicleRoute(osmPath) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseOsm(xml) {
|
||||
const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/);
|
||||
const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {};
|
||||
const bounds = {
|
||||
minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat),
|
||||
maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat),
|
||||
};
|
||||
const validBounds = Object.values(bounds).every(Number.isFinite) ? bounds : null;
|
||||
const nodes = new Map();
|
||||
for (const match of xml.matchAll(/<node\b([^>]*)\/?\s*>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
if (!attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||
const coord = [Number(attrs.lon), Number(attrs.lat)];
|
||||
if (coord.every(Number.isFinite)) nodes.set(attrs.id, coord);
|
||||
}
|
||||
const ways = [];
|
||||
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const body = match[2];
|
||||
const tags = {};
|
||||
for (const tagMatch of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
|
||||
const tag = xmlAttrs(tagMatch[1]);
|
||||
if (tag.k) tags[tag.k] = tag.v || "";
|
||||
}
|
||||
if (!isCruiseHighway(tags)) continue;
|
||||
const refs = [];
|
||||
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
|
||||
const ref = xmlAttrs(ndMatch[1]).ref;
|
||||
if (ref && nodes.has(ref)) refs.push(ref);
|
||||
}
|
||||
if (refs.length >= 2) ways.push({ id: attrs.id || `way-${ways.length + 1}`, refs, tags });
|
||||
}
|
||||
return { bounds: validBounds, nodes, ways };
|
||||
}
|
||||
|
||||
function xmlAttrs(text) {
|
||||
const attrs = {};
|
||||
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
||||
attrs[match[1]] = match[2] !== undefined ? match[2] : match[3];
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function isCruiseHighway(tags) {
|
||||
const highway = tags.highway || "";
|
||||
if (!highway || tags.area === "yes") return false;
|
||||
@@ -81,6 +39,7 @@ function isCruiseHighway(tags) {
|
||||
function directedRoadEdges(ways, nodes, bounds) {
|
||||
const edges = [];
|
||||
for (const way of ways) {
|
||||
if (!isCruiseHighway(way.tags)) continue;
|
||||
const refs = compactRefs(way.refs);
|
||||
if (refs.length < 2) continue;
|
||||
const coords = refs.map((ref) => nodes.get(ref));
|
||||
|
||||
@@ -10,6 +10,7 @@ const { makeVehicleGltf } = require("./lib/vehicle-model");
|
||||
const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library");
|
||||
const { allowedTurns, buildVehicleRoute, classifyConnection } = require("./lib/vehicle-route");
|
||||
const { buildTrafficSignals } = require("./lib/traffic-signals");
|
||||
const { parseOsm } = require("./lib/osm");
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
|
||||
const osmPath = path.join(tempDir, "fixture.osm");
|
||||
@@ -144,24 +145,53 @@ assert.match(previewRuntime, /scene\.requestRender/);
|
||||
assert.doesNotMatch(previewRuntime, /function addTrafficSignals\(viewer, signalData, start, placement\)/);
|
||||
assert.doesNotMatch(previewRuntime, /ellipsoid:/);
|
||||
|
||||
const signals = buildTrafficSignals(
|
||||
const trafficIntersection = { type: "FeatureCollection", features: [
|
||||
{ type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
||||
[119.9998, 29.9998], [120.0004, 29.9998], [120.0004, 30.0003], [119.9998, 30.0003], [119.9998, 29.9998],
|
||||
]] } },
|
||||
] };
|
||||
const tStopLines = { type: "FeatureCollection", features: [
|
||||
rectangle(119.99995, 30.00005, 0.00003, 0.000006),
|
||||
rectangle(120.00010, 30.00025, 0.00003, 0.000006),
|
||||
rectangle(120.00035, 30.00005, 0.00003, 0.000006),
|
||||
] };
|
||||
const control = {
|
||||
id: "traffic-t", longitude: 120.0001, latitude: 30.00005,
|
||||
arms: [{ headingDegrees: 270 }, { headingDegrees: 0 }, { headingDegrees: 90 }],
|
||||
};
|
||||
const noControlSignals = buildTrafficSignals(tStopLines, trafficIntersection);
|
||||
assert.equal(noControlSignals.signals.length, 0, "untagged intersections must not create traffic signals");
|
||||
|
||||
const tSignals = buildTrafficSignals(tStopLines, trafficIntersection, [control]);
|
||||
assert.equal(tSignals.version, 3);
|
||||
assert.equal(tSignals.signals.length, 3, "a tagged T junction has one signal per physical approach");
|
||||
assert.deepEqual(tSignals.signals.map((signal) => signal.phaseGroup).sort(), [0, 0, 1]);
|
||||
assert.ok(tSignals.signals.every((signal) => Number.isFinite(signal.headingDegrees)));
|
||||
assert.equal(tSignals.layout.countdownLateralMeters, 1.15);
|
||||
assert.equal(tSignals.layout.countdownWidthMeters, 0.82);
|
||||
assert.ok(tSignals.signals.every((signal) => signal.pose?.head && signal.pose.lenses.length === 3));
|
||||
|
||||
const crossSignals = buildTrafficSignals(
|
||||
{ type: "FeatureCollection", features: [
|
||||
rectangle(120.0000, 30.0000, 0.00003, 0.000006),
|
||||
rectangle(120.0002, 30.0000, 0.00003, 0.000006),
|
||||
] },
|
||||
{ type: "FeatureCollection", features: [
|
||||
{ type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
||||
[119.9998, 29.9998], [120.0004, 29.9998], [120.0004, 30.0003], [119.9998, 30.0003], [119.9998, 29.9998],
|
||||
]] } },
|
||||
rectangle(119.99995, 30.00005, 0.00003, 0.000006),
|
||||
rectangle(120.00010, 30.00025, 0.00003, 0.000006),
|
||||
rectangle(120.00035, 30.00005, 0.00003, 0.000006),
|
||||
rectangle(120.00010, 29.99985, 0.00003, 0.000006),
|
||||
] },
|
||||
trafficIntersection,
|
||||
[{ ...control, arms: [{ headingDegrees: 270 }, { headingDegrees: 0 }, { headingDegrees: 90 }, { headingDegrees: 180 }] }],
|
||||
);
|
||||
assert.equal(signals.version, 3);
|
||||
assert.equal(signals.signals.length, 2);
|
||||
assert.deepEqual(signals.signals.map((signal) => signal.phaseGroup), [0, 1]);
|
||||
assert.ok(signals.signals.every((signal) => Number.isFinite(signal.headingDegrees)));
|
||||
assert.equal(signals.layout.countdownLateralMeters, 1.15);
|
||||
assert.equal(signals.layout.countdownWidthMeters, 0.82);
|
||||
assert.ok(signals.signals.every((signal) => signal.pose?.head && signal.pose.lenses.length === 3));
|
||||
assert.equal(crossSignals.signals.length, 4, "a tagged cross junction retains all four approaches");
|
||||
assert.deepEqual(crossSignals.signals.map((signal) => signal.phaseGroup).sort(), [0, 0, 1, 1]);
|
||||
|
||||
const parsedSignalControls = parseOsm(`
|
||||
<osm><bounds minlon="119" minlat="29" maxlon="121" maxlat="31" />
|
||||
<node id="active" lon="120" lat="30"><tag k="highway" v="traffic_signals" /><tag k="traffic_signals:direction" v="both" /></node>
|
||||
<node id="directionless" lon="120" lat="30"><tag k="highway" v="traffic_signals" /></node>
|
||||
<node id="deleted" lon="120" lat="30" action="delete"><tag k="highway" v="traffic_signals" /></node>
|
||||
<node id="crossing" lon="120" lat="30"><tag k="highway" v="crossing" /><tag k="crossing" v="traffic_signals" /></node>
|
||||
</osm>`).trafficSignalControls;
|
||||
assert.deepEqual(parsedSignalControls.map((entry) => entry.id), ["active", "directionless"], "only active highway=traffic_signals nodes control vehicle signals");
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
console.log("Preview asset tests passed.");
|
||||
|
||||
Reference in New Issue
Block a user