feat: add cesium traffic signal countdowns
This commit is contained in:
@@ -182,6 +182,7 @@ function buildIntermediates(area) {
|
||||
"--config",
|
||||
derivedConfigPath,
|
||||
], "intermediates");
|
||||
writeTrafficSignals(area);
|
||||
fs.rmSync(stageManifestPath(area, "reimport"), { force: true });
|
||||
const finished = Date.now();
|
||||
writeStageManifest(area, {
|
||||
@@ -199,6 +200,7 @@ function buildIntermediates(area) {
|
||||
derivedConfig: fileRecord(derivedConfigPath),
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
gpkg: fileRecord(area.outputs.gpkg),
|
||||
qgisProject: fileRecord(area.outputs.qgisProject),
|
||||
qgisPreview: optionalFileRecord(area.outputs.qgisPreview),
|
||||
@@ -221,6 +223,7 @@ function reimportGpkg(area) {
|
||||
"--config",
|
||||
derivedConfigPath,
|
||||
], "reimport");
|
||||
writeTrafficSignals(area);
|
||||
fs.rmSync(stageManifestPath(area, "intermediates"), { force: true });
|
||||
const finished = Date.now();
|
||||
writeStageManifest(area, {
|
||||
@@ -238,6 +241,7 @@ function reimportGpkg(area) {
|
||||
outputs: {
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
},
|
||||
summary: {
|
||||
geojson: geojsonFeatureCounts(area),
|
||||
@@ -249,6 +253,7 @@ function reimportGpkg(area) {
|
||||
function buildBlenderScene(area) {
|
||||
ensureFile(blenderExecutable(area), "Blender executable");
|
||||
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator");
|
||||
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 });
|
||||
|
||||
@@ -290,6 +295,7 @@ function buildBlenderScene(area) {
|
||||
osm: fileRecord(area.input),
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
},
|
||||
outputs: {
|
||||
blend: fileRecord(area.outputs.blend),
|
||||
@@ -324,9 +330,18 @@ function exportCesium(area) {
|
||||
area.outputs.blend,
|
||||
"--glb",
|
||||
area.outputs.glb,
|
||||
"--dynamic-glb",
|
||||
area.outputs.trafficSignalsDynamicGlb,
|
||||
"--countdown-0-glb",
|
||||
area.outputs.trafficSignalsCountdown0Glb,
|
||||
"--countdown-1-glb",
|
||||
area.outputs.trafficSignalsCountdown1Glb,
|
||||
"--metadata",
|
||||
area.outputs.metadata,
|
||||
], "cesium");
|
||||
ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB");
|
||||
const semanticAssets = semanticAssetRecords(area);
|
||||
writeCesiumPreview(area);
|
||||
const finished = Date.now();
|
||||
@@ -344,6 +359,7 @@ function exportCesium(area) {
|
||||
outputs: {
|
||||
glb: fileRecord(area.outputs.glb),
|
||||
metadata: fileRecord(area.outputs.metadata),
|
||||
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
|
||||
semanticAssets,
|
||||
},
|
||||
summary: {
|
||||
@@ -466,19 +482,19 @@ function runCommand(command, commandArgs, stage) {
|
||||
function writeCesiumPreview(area) {
|
||||
ensureFile(area.outputs.glb, "Cesium GLB");
|
||||
ensureFile(area.outputs.metadata, "Cesium metadata");
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
const htmlPath = area.outputs.cesiumPreview;
|
||||
const started = Date.now();
|
||||
const startedAt = new Date(started).toISOString();
|
||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||
writeVehicleRoute(area);
|
||||
writeTrafficSignals(area);
|
||||
const vehicleModelNames = writeVehicleModel(area);
|
||||
writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
|
||||
const glbName = path.basename(area.outputs.glb);
|
||||
const metadataName = path.basename(area.outputs.metadata);
|
||||
const routeName = path.basename(area.outputs.vehicleRoute);
|
||||
const vehicleModelName = path.basename(area.outputs.vehicleModel);
|
||||
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames, path.basename(area.outputs.trafficSignals)));
|
||||
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames, previewRelativePath(area.outputs.areaDir, area.outputs.trafficSignals)));
|
||||
console.log(`Cesium preview: ${htmlPath}`);
|
||||
const finished = Date.now();
|
||||
writeStageManifest(area, {
|
||||
@@ -516,6 +532,10 @@ function writeTrafficSignals(area) {
|
||||
console.log(`Traffic signals: ${signals.signals.length} anchors in ${area.outputs.trafficSignals}`);
|
||||
}
|
||||
|
||||
function previewRelativePath(fromDir, target) {
|
||||
return path.relative(fromDir, target).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function writeVehicleRoute(area) {
|
||||
const route = buildPreviewVehicleRoute(area.input);
|
||||
fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true });
|
||||
|
||||
@@ -28,15 +28,21 @@ function normalizeAreaConfig(raw, options = {}) {
|
||||
const compressedFileStem = outputOverrides.compressedFileStem ||
|
||||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
|
||||
const pipelineDir = path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline"));
|
||||
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
|
||||
const outputs = {
|
||||
areaDir,
|
||||
geojsonDir: path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out")),
|
||||
geojsonDir,
|
||||
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
|
||||
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
|
||||
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
|
||||
blend: path.resolve(outputOverrides.blend || path.join(areaDir, `${fileStem}.blend`)),
|
||||
render: path.resolve(outputOverrides.render || path.join(areaDir, `${fileStem}.png`)),
|
||||
glb: path.resolve(outputOverrides.glb || path.join(areaDir, `${fileStem}.glb`)),
|
||||
trafficSignalsDynamicGlb: path.resolve(
|
||||
outputOverrides.trafficSignalsDynamicGlb || path.join(areaDir, `${fileStem}-traffic-signals-dynamic.glb`),
|
||||
),
|
||||
trafficSignalsCountdown0Glb: path.resolve(outputOverrides.trafficSignalsCountdown0Glb || path.join(areaDir, `${fileStem}-traffic-signals-countdown-0.glb`)),
|
||||
trafficSignalsCountdown1Glb: path.resolve(outputOverrides.trafficSignalsCountdown1Glb || path.join(areaDir, `${fileStem}-traffic-signals-countdown-1.glb`)),
|
||||
metadata: path.resolve(outputOverrides.metadata || path.join(areaDir, `${fileStem}.json`)),
|
||||
cesiumPreview: path.resolve(
|
||||
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
|
||||
@@ -52,7 +58,9 @@ function normalizeAreaConfig(raw, options = {}) {
|
||||
),
|
||||
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)),
|
||||
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)),
|
||||
trafficSignals: path.resolve(outputOverrides.trafficSignals || path.join(areaDir, `${fileStem}-traffic-signals.json`)),
|
||||
// Signals are an auxiliary intermediates artifact shared by Blender and
|
||||
// the browser preview. They deliberately are not one of the QGIS layers.
|
||||
trafficSignals: path.resolve(outputOverrides.trafficSignals || path.join(geojsonDir, "traffic_signals.json")),
|
||||
pipelineDir,
|
||||
stageManifestDir: path.resolve(outputOverrides.stageManifestDir || path.join(pipelineDir, "stages")),
|
||||
};
|
||||
|
||||
@@ -108,7 +108,7 @@ function previewSummary(area) {
|
||||
metadataName: path.basename(area.outputs.metadata),
|
||||
routeName: path.basename(area.outputs.vehicleRoute),
|
||||
vehicleModelName: path.basename(area.outputs.vehicleModel),
|
||||
trafficSignalsName: path.basename(area.outputs.trafficSignals),
|
||||
trafficSignalsName: path.relative(area.outputs.areaDir, area.outputs.trafficSignals).split(path.sep).join("/"),
|
||||
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
setLoadingMessage("Loading model", config.glbName || "");
|
||||
const assets = await loadSceneAssets(viewer, metadata, placement);
|
||||
const trafficStart = Cesium.JulianDate.now();
|
||||
const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart);
|
||||
const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets);
|
||||
const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName);
|
||||
const cameras = createCameraPresets(viewer, metadata, placement, cruise);
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
toggleVehicles.addEventListener("change", () => {
|
||||
for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked;
|
||||
});
|
||||
if (!trafficSignals.entities.length) {
|
||||
if (!trafficSignals.count) {
|
||||
signalsControl.classList.add("hidden");
|
||||
} else {
|
||||
toggleSignals.addEventListener("change", () => {
|
||||
@@ -464,145 +464,91 @@
|
||||
};
|
||||
}
|
||||
|
||||
function addTrafficSignals(viewer, signalData, start) {
|
||||
const anchors = (signalData?.signals || []).filter((signal) => Number.isFinite(signal.longitude) && Number.isFinite(signal.latitude));
|
||||
const entities = [];
|
||||
for (const signal of anchors) {
|
||||
const mastReach = Number(signal.mastReachMeters) || 4.5;
|
||||
const countdownOffset = -1.15;
|
||||
const polePosition = signalPosition(signal, 0, 0, 3.35);
|
||||
const headPosition = signalPosition(signal, 0, -mastReach, 6.25);
|
||||
const frame = signalHeadFrame(signal, headPosition);
|
||||
const pole = viewer.entities.add({
|
||||
position: polePosition,
|
||||
cylinder: { length: 6.7, topRadius: 0.10, bottomRadius: 0.14, material: Cesium.Color.fromCssColorString("#273139") },
|
||||
});
|
||||
// The mast arm begins at the curbside pole and reaches above the approach
|
||||
// lanes. A separate mast at the opposite approach controls oncoming cars.
|
||||
const arm = viewer.entities.add({
|
||||
polyline: {
|
||||
positions: [signalPosition(signal, 0, 0, 6.25), headPosition],
|
||||
width: 9,
|
||||
material: Cesium.Color.fromCssColorString("#273139"),
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
},
|
||||
});
|
||||
const head = viewer.entities.add({
|
||||
position: headPosition,
|
||||
orientation: frame.orientation,
|
||||
box: { dimensions: new Cesium.Cartesian3(0.68, 0.30, 1.62), material: Cesium.Color.fromCssColorString("#182024") },
|
||||
});
|
||||
entities.push(pole, arm, head);
|
||||
for (const [index, state] of ["red", "yellow", "green"].entries()) {
|
||||
const bulb = viewer.entities.add({
|
||||
// The lens sits on the explicit approach-facing normal of the head,
|
||||
// not at an angle inferred from the box's local axes.
|
||||
position: signalLensPosition(headPosition, frame, 0.18, 0.49 - index * 0.50),
|
||||
ellipsoid: {
|
||||
radii: new Cesium.Cartesian3(0.22, 0.22, 0.22),
|
||||
material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalColor(signal.phaseGroup, state, time, start), false)),
|
||||
},
|
||||
});
|
||||
entities.push(bulb);
|
||||
}
|
||||
// The countdown board mounts on the mast between the head and pole,
|
||||
// rather than protruding beyond the signal on the roadway side.
|
||||
const counterPosition = signalPanelPosition(headPosition, frame, countdownOffset, 0.05, 0);
|
||||
const counter = viewer.entities.add({
|
||||
position: counterPosition,
|
||||
orientation: frame.orientation,
|
||||
box: {
|
||||
dimensions: new Cesium.Cartesian3(0.82, 0.14, 0.56),
|
||||
material: Cesium.Color.fromCssColorString("#251f1c"),
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220),
|
||||
},
|
||||
});
|
||||
const countdown = createSevenSegmentCountdown(
|
||||
viewer,
|
||||
signal,
|
||||
start,
|
||||
headPosition,
|
||||
frame,
|
||||
countdownOffset,
|
||||
);
|
||||
entities.push(counter, ...countdown);
|
||||
function addTrafficSignals(viewer, signalData, start, assets) {
|
||||
const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model);
|
||||
const countdownModels = new Map(assets
|
||||
.filter((asset) => asset.category === "countdown" && asset.model)
|
||||
.map((asset) => [Number(asset.phaseGroup), asset.model]));
|
||||
if (dynamic && countdownModels.size === 2) {
|
||||
const signals = (signalData?.signals || []).filter((signal) => signal && signal.id);
|
||||
if (signals.length && !viewer.clock.shouldAnimate) viewer.clock.shouldAnimate = true;
|
||||
const visualStart = performance.now();
|
||||
const phaseTime = new Cesium.JulianDate();
|
||||
const state = { elapsedSeconds: 0, phase: "" };
|
||||
const entities = [];
|
||||
const nodes = new Map();
|
||||
const node = (name) => {
|
||||
if (nodes.has(name)) return nodes.get(name);
|
||||
let value = null;
|
||||
try {
|
||||
value = dynamic.model.getNode(name);
|
||||
} catch (error) {
|
||||
console.warn("Traffic signal node unavailable:", name, error);
|
||||
}
|
||||
// Do not cache a miss. Cesium can expose the Model before its node
|
||||
// lookup table is populated; a transient miss must be retried on the
|
||||
// next clock tick rather than freezing the initial visual state.
|
||||
if (value) nodes.set(name, value);
|
||||
return value;
|
||||
};
|
||||
const update = (elapsedSeconds) => {
|
||||
Cesium.JulianDate.addSeconds(start, elapsedSeconds, phaseTime);
|
||||
let changed = false;
|
||||
const groupPhases = new Map();
|
||||
for (const signal of signals) {
|
||||
const phase = signalPhase(signal.phaseGroup, phaseTime, start);
|
||||
groupPhases.set(signal.phaseGroup, phase.active);
|
||||
if (signal === signals[0]) state.phase = `${phase.active} ${String(phase.remaining).padStart(2, "0")}`;
|
||||
for (const state of ["red", "yellow", "green"]) {
|
||||
const value = node(`TrafficSignalDynamic_${signal.id}_${state}`);
|
||||
if (value && value.show !== (state === phase.active)) {
|
||||
value.show = state === phase.active;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const visibleCountdown = String(phase.remaining).padStart(2, "0");
|
||||
const countdownModel = countdownModels.get(Number(signal.phaseGroup));
|
||||
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 */ }
|
||||
if (countdown && countdown.show !== (String(value).padStart(2, "0") === visibleCountdown)) {
|
||||
countdown.show = String(value).padStart(2, "0") === visibleCountdown;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [group, active] of groupPhases) {
|
||||
const countdownModel = countdownModels.get(Number(group));
|
||||
countdownModel.color = signalBaseColor(active);
|
||||
countdownModel.colorBlendMode = Cesium.ColorBlendMode.REPLACE;
|
||||
countdownModel.colorBlendAmount = 1.0;
|
||||
}
|
||||
if (changed && viewer.scene.requestRender) viewer.scene.requestRender();
|
||||
};
|
||||
let lastSecond = -1;
|
||||
const render = () => {
|
||||
const elapsedSeconds = Math.floor((performance.now() - visualStart) / 1000);
|
||||
if (elapsedSeconds === lastSecond) return;
|
||||
lastSecond = elapsedSeconds;
|
||||
state.elapsedSeconds = elapsedSeconds;
|
||||
update(elapsedSeconds);
|
||||
};
|
||||
// Keep signal phases independent from the Cesium simulation clock. The
|
||||
// clock may be paused while a user inspects the scene, but the lights and
|
||||
// countdown must remain visibly periodic.
|
||||
const timer = setInterval(render, 250);
|
||||
render();
|
||||
return {
|
||||
entities, count: signals.length, dynamic, state, timer,
|
||||
set show(value) {
|
||||
dynamic.model.show = value;
|
||||
for (const model of countdownModels.values()) model.show = value;
|
||||
for (const entity of entities) entity.show = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
entities,
|
||||
count: anchors.length,
|
||||
set show(value) { for (const entity of entities) entity.show = value; },
|
||||
};
|
||||
}
|
||||
|
||||
function signalPosition(signal, longitudinalMeters, lateralMeters, height) {
|
||||
const heading = Cesium.Math.toRadians(signal.headingDegrees);
|
||||
const latitude = signal.latitude + (longitudinalMeters * Math.cos(heading) - lateralMeters * Math.sin(heading)) / 110540;
|
||||
const longitude = signal.longitude + (longitudinalMeters * Math.sin(heading) + lateralMeters * Math.cos(heading)) /
|
||||
(111320 * Math.cos(Cesium.Math.toRadians(signal.latitude)));
|
||||
return Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
|
||||
}
|
||||
|
||||
function signalHeadFrame(signal, position) {
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
|
||||
// headingDegrees is the approach's travel direction into the junction.
|
||||
// The signal's front must point back toward that approaching traffic.
|
||||
const heading = Cesium.Math.toRadians(signal.headingDegrees);
|
||||
const localFace = new Cesium.Cartesian3(-Math.sin(heading), -Math.cos(heading), 0);
|
||||
const face = Cesium.Matrix4.multiplyByPointAsVector(enu, localFace, new Cesium.Cartesian3());
|
||||
Cesium.Cartesian3.normalize(face, face);
|
||||
const up = Cesium.Cartesian3.normalize(position, new Cesium.Cartesian3());
|
||||
const across = Cesium.Cartesian3.cross(face, up, new Cesium.Cartesian3());
|
||||
Cesium.Cartesian3.normalize(across, across);
|
||||
const rotation = new Cesium.Matrix3(
|
||||
across.x, face.x, up.x,
|
||||
across.y, face.y, up.y,
|
||||
across.z, face.z, up.z,
|
||||
);
|
||||
return { across, face, up, orientation: Cesium.Quaternion.fromRotationMatrix(rotation, new Cesium.Quaternion()) };
|
||||
}
|
||||
|
||||
function signalLensPosition(headPosition, frame, faceOffset, verticalOffset) {
|
||||
const point = Cesium.Cartesian3.multiplyByScalar(frame.face, faceOffset, new Cesium.Cartesian3());
|
||||
Cesium.Cartesian3.add(headPosition, point, point);
|
||||
const vertical = Cesium.Cartesian3.multiplyByScalar(frame.up, verticalOffset, new Cesium.Cartesian3());
|
||||
return Cesium.Cartesian3.add(point, vertical, point);
|
||||
}
|
||||
|
||||
function signalPanelPosition(headPosition, frame, acrossOffset, faceOffset, verticalOffset) {
|
||||
const point = signalLensPosition(headPosition, frame, faceOffset, verticalOffset);
|
||||
const across = Cesium.Cartesian3.multiplyByScalar(frame.across, acrossOffset, new Cesium.Cartesian3());
|
||||
return Cesium.Cartesian3.add(point, across, point);
|
||||
}
|
||||
|
||||
function createSevenSegmentCountdown(viewer, signal, start, headPosition, frame, boardAcross) {
|
||||
const digitMap = { "0": "abcedf", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc", "5": "afgcd", "6": "afgecd", "7": "abc", "8": "abcdefg", "9": "abfgcd" };
|
||||
const shape = {
|
||||
a: [0, 0.18, 0.20, 0.025, 0.035], b: [0.10, 0.085, 0.035, 0.025, 0.15],
|
||||
c: [0.10, -0.085, 0.035, 0.025, 0.15], d: [0, -0.18, 0.20, 0.025, 0.035],
|
||||
e: [-0.10, -0.085, 0.035, 0.025, 0.15], f: [-0.10, 0.085, 0.035, 0.025, 0.15],
|
||||
g: [0, 0, 0.20, 0.025, 0.035],
|
||||
};
|
||||
const result = [];
|
||||
for (const [digitIndex, digitAcross] of [-0.17, 0.17].entries()) {
|
||||
for (const [name, [x, z, width, depth, height]] of Object.entries(shape)) {
|
||||
result.push(viewer.entities.add({
|
||||
// The visible panel x-axis is the inverse of the signal frame's
|
||||
// across axis. Mirror the LED layout once here to keep digits normal.
|
||||
position: signalPanelPosition(headPosition, frame, boardAcross - digitAcross - x, 0.18, z),
|
||||
orientation: frame.orientation,
|
||||
box: {
|
||||
dimensions: new Cesium.Cartesian3(width, depth, height),
|
||||
material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalActiveColor(signal.phaseGroup, time, start), false)),
|
||||
show: new Cesium.CallbackProperty((time) => {
|
||||
const value = String(signalPhase(signal.phaseGroup, time, start).remaining).padStart(2, "0")[digitIndex];
|
||||
return (digitMap[value] || "").includes(name);
|
||||
}, false),
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return { entities: [], count: 0, set show(value) {} };
|
||||
}
|
||||
|
||||
function signalColor(group, state, time, start) {
|
||||
@@ -620,7 +566,11 @@
|
||||
}
|
||||
|
||||
function signalPhase(group, time, start) {
|
||||
const second = ((Cesium.JulianDate.secondsDifference(time, start) % 20) + 20) % 20;
|
||||
return signalPhaseAtElapsed(group, Cesium.JulianDate.secondsDifference(time, start));
|
||||
}
|
||||
|
||||
function signalPhaseAtElapsed(group, elapsed) {
|
||||
const second = ((elapsed % 20) + 20) % 20;
|
||||
if (group === 0) {
|
||||
if (second < 8) return { active: "green", remaining: Math.ceil(8 - second) };
|
||||
if (second < 10) return { active: "yellow", remaining: Math.ceil(10 - second) };
|
||||
|
||||
@@ -5,6 +5,32 @@ const fs = require("fs");
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
const CURB_OFFSET_METERS = 5.2;
|
||||
const MAST_REACH_METERS = 4.5;
|
||||
// This layout is serialized with the anchors so Blender's static structure and
|
||||
// Cesium's dynamic overlay cannot independently drift in size or handedness.
|
||||
// Lateral offsets use the approach travel direction: positive is the driver's
|
||||
// right. The countdown board therefore sits at +1.15m from the signal head.
|
||||
const SIGNAL_LAYOUT = Object.freeze({
|
||||
poleHeightMeters: 6.7,
|
||||
poleRadiusMeters: 0.13,
|
||||
armWidthMeters: 0.21,
|
||||
// The mast arm and the signal head share this centre elevation.
|
||||
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,
|
||||
// The countdown board is fixed on the mast arm, not hung below it.
|
||||
countdownVerticalOffsetMeters: 0.0,
|
||||
});
|
||||
|
||||
function buildTrafficSignals(stopLines, intersections) {
|
||||
const centers = (intersections.features || []).map((feature, index) => {
|
||||
@@ -37,9 +63,36 @@ function buildTrafficSignals(stopLines, intersections) {
|
||||
stopLatitude: center[1],
|
||||
headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI,
|
||||
mastReachMeters: MAST_REACH_METERS,
|
||||
pose: buildSignalPose(point, axis, MAST_REACH_METERS),
|
||||
});
|
||||
}
|
||||
return { version: 1, signals };
|
||||
return { version: 3, layout: SIGNAL_LAYOUT, signals };
|
||||
}
|
||||
|
||||
function buildSignalPose(pole, axis, mastReach) {
|
||||
const lateral = [axis[1], -axis[0]];
|
||||
const face = [-axis[0], -axis[1]];
|
||||
const head = moveMeters(pole, lateral, -mastReach);
|
||||
const faceHeadingDegrees = Math.atan2(face[0], face[1]) * 180 / Math.PI;
|
||||
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height });
|
||||
const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
|
||||
const board = moveMeters(
|
||||
moveMeters(head, lateral, 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 readTrafficSignals(stopLinePath, intersectionPath) {
|
||||
@@ -90,4 +143,4 @@ 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];
|
||||
}
|
||||
|
||||
module.exports = { buildTrafficSignals, readTrafficSignals };
|
||||
module.exports = { SIGNAL_LAYOUT, buildTrafficSignals, readTrafficSignals };
|
||||
|
||||
@@ -36,6 +36,10 @@ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-budget-"));
|
||||
const input = path.join(tempDir, "input.osm");
|
||||
fs.writeFileSync(input, "<osm/>");
|
||||
const base = { id: "test-area", input, outputRoot: tempDir };
|
||||
assert.equal(
|
||||
normalizeAreaConfig(base).outputs.trafficSignals,
|
||||
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signals.json"),
|
||||
);
|
||||
assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }),
|
||||
|
||||
@@ -127,6 +127,23 @@ assert.match(html, /id="viewMode"/);
|
||||
assert.match(html, /data-view-mode="inspect"/);
|
||||
assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/);
|
||||
|
||||
const previewRuntime = fs.readFileSync(path.join(__dirname, "lib", "cesium-preview.js"), "utf8");
|
||||
const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf");
|
||||
assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project");
|
||||
assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/);
|
||||
assert.doesNotMatch(previewRuntime, /Traffic Signal Housing/);
|
||||
assert.match(previewRuntime, /asset\.category === "dynamic"/);
|
||||
assert.match(previewRuntime, /TrafficSignalDynamic_/);
|
||||
assert.match(previewRuntime, /countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
|
||||
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
|
||||
assert.match(previewRuntime, /asset\.category === "countdown"/);
|
||||
assert.doesNotMatch(previewRuntime, /createCountdownDigits/);
|
||||
assert.doesNotMatch(previewRuntime, /digitMap/);
|
||||
assert.match(previewRuntime, /Do not cache a miss/);
|
||||
assert.match(previewRuntime, /scene\.requestRender/);
|
||||
assert.doesNotMatch(previewRuntime, /function addTrafficSignals\(viewer, signalData, start, placement\)/);
|
||||
assert.doesNotMatch(previewRuntime, /ellipsoid:/);
|
||||
|
||||
const signals = buildTrafficSignals(
|
||||
{ type: "FeatureCollection", features: [
|
||||
rectangle(120.0000, 30.0000, 0.00003, 0.000006),
|
||||
@@ -138,10 +155,13 @@ const signals = buildTrafficSignals(
|
||||
]] } },
|
||||
] },
|
||||
);
|
||||
assert.equal(signals.version, 1);
|
||||
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));
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
console.log("Preview asset tests passed.");
|
||||
|
||||
Reference in New Issue
Block a user