From 1c077a312ea3551ccf79c909919b36672141703c Mon Sep 17 00:00:00 2001 From: que01 Date: Fri, 7 Aug 2026 09:05:27 +0800 Subject: [PATCH] fix(preview): derive signals from OSM controls --- .../spec/guides/cross-layer-thinking-guide.md | 15 ++ scripts/build-area.js | 6 + scripts/lib/cesium-preview.js | 22 ++- scripts/lib/osm.js | 98 ++++++++++++ scripts/lib/traffic-signals.js | 142 ++++++++++++++++-- scripts/lib/vehicle-route.js | 45 +----- scripts/test-preview-assets.js | 60 ++++++-- 7 files changed, 315 insertions(+), 73 deletions(-) create mode 100644 scripts/lib/osm.js diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md index e8573bc..d9bec4e 100644 --- a/.trellis/spec/guides/cross-layer-thinking-guide.md +++ b/.trellis/spec/guides/cross-layer-thinking-guide.md @@ -144,6 +144,21 @@ OSM way 的端点不一定在原始 XML 中有三个以上相连 way;osm2stree **教训**:**跨阶段契约必须随产物保存;兼容旧产物的字符串回退也要被审查**。 +### 坑 6:部分构建复用了过期的交通信号锚点 + +`scripts/lib/traffic-signals.js` 根据当前 OSM 的 `highway=traffic_signals` 控制节点、 +`vehicle_stop_lines.geojson` 和 `intersection_surface.geojson` 派生 +`osm2streets_web_out/traffic_signals.json`。Blender 将这份文件中的每个 signal id 导出为 +三个动态灯节点和一组倒计时节点,`scripts/lib/cesium-preview.js` 再按相同 id 控制它们。 + +因此执行 `blender,cesium,preview` 这类部分构建时,必须在 Blender stage 入口重新写入 +交通信号文件。若沿用旧文件,GLB 可能已经包含新增 T/十字路口,但预览仍只控制旧的 +signal id,表现为灯不切换、数字叠加或路口整体异常。该刷新由 +`scripts/build-area.js:buildBlenderScene()` 负责。 + +**教训**:**任何由输入 OSM 或归一化图层派生、又被多个后续 stage 共享的中间 JSON, +都必须在最早消费它的 stage 重新生成,不能只在完整 `intermediates` 构建时生成。** + --- ## 加东西时的检查清单 diff --git a/scripts/build-area.js b/scripts/build-area.js index b976f9b..8156306 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -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}`); diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 4f5bdb6..7fabdb5 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -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) { diff --git a/scripts/lib/osm.js b/scripts/lib/osm.js new file mode 100644 index 0000000..c192df3 --- /dev/null +++ b/scripts/lib/osm.js @@ -0,0 +1,98 @@ +"use strict"; + +function parseOsm(xml) { + const boundsMatch = xml.match(/]*)\/?\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 = /]*?)(?:\/>|>([\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(/]*)>([\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(/]*)\/?\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(/]*)\/?\s*>/g)) { + const tag = xmlAttrs(match[1]); + if (tag.k) tags[tag.k] = tag.v || ""; + } + return tags; +} + +module.exports = { parseOsm }; diff --git a/scripts/lib/traffic-signals.js b/scripts/lib/traffic-signals.js index 6e1064f..1101fe6 100644 --- a/scripts/lib/traffic-signals.js +++ b/scripts/lib/traffic-signals.js @@ -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 }; diff --git a/scripts/lib/vehicle-route.js b/scripts/lib/vehicle-route.js index a3148ca..ea78d50 100644 --- a/scripts/lib/vehicle-route.js +++ b/scripts/lib/vehicle-route.js @@ -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(/]*)\/?\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(/]*)\/?\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(/]*)>([\s\S]*?)<\/way>/g)) { - const attrs = xmlAttrs(match[1]); - const body = match[2]; - const tags = {}; - for (const tagMatch of body.matchAll(/]*)\/?\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(/]*)\/?\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)); diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 3896773..90da8f2 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -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(` + + + + + + `).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.");