208 lines
11 KiB
JavaScript
208 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { cesiumPreviewHtml } = require("./lib/area-preview");
|
|
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");
|
|
|
|
fs.writeFileSync(osmPath, `<?xml version="1.0"?>
|
|
<osm version="0.6">
|
|
<bounds minlon="120" minlat="30" maxlon="120.01" maxlat="30.01"/>
|
|
<node id="1" lon="120.001" lat="30.001"/>
|
|
<node id="2" lon="120.003" lat="30.001"/>
|
|
<node id="3" lon="120.005" lat="30.002"/>
|
|
<node id="4" lon="120.007" lat="30.002"/>
|
|
<node id="5" lon="120.009" lat="30.002"/>
|
|
<way id="west-road">
|
|
<nd ref="1"/><nd ref="2"/>
|
|
<tag k="highway" v="primary"/><tag k="turn:lanes:forward" v="left|through"/>
|
|
</way>
|
|
<way id="turn-road">
|
|
<nd ref="2"/><nd ref="3"/>
|
|
<tag k="highway" v="residential"/><tag k="turn:lanes:forward" v="through|left"/>
|
|
</way>
|
|
<way id="east-road">
|
|
<nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/>
|
|
</way>
|
|
<way id="oneway-spur">
|
|
<nd ref="4"/><nd ref="5"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/>
|
|
</way>
|
|
<way id="footpath">
|
|
<nd ref="1"/><nd ref="5"/><tag k="highway" v="footway"/>
|
|
</way>
|
|
</osm>
|
|
`);
|
|
|
|
const route = buildVehicleRoute(osmPath);
|
|
assert.equal(route.source, osmPath);
|
|
assert.deepEqual(route.bounds, {
|
|
minLon: 120,
|
|
minLat: 30,
|
|
maxLon: 120.01,
|
|
maxLat: 30.01,
|
|
});
|
|
assert.equal(route.speedMetersPerSecond, 8);
|
|
assert.equal(route.loop, true);
|
|
assert.equal(route.segments, route.routes, "legacy segments remains an alias for the new route list");
|
|
assert.ok(route.routes.length >= 1 && route.routes.length <= 5);
|
|
assert.ok(route.routes.every((segment) => segment.edgeIds.length >= 6));
|
|
assert.ok(route.routes.every((segment) => segment.maneuvers.includes("u_turn")));
|
|
assert.ok(route.routes.every((segment) => segment.maneuvers.some((value) => ["left", "right", "through"].includes(value))));
|
|
assert.ok(route.routes.every((segment) => JSON.stringify(segment.coordinates[0]) === JSON.stringify(segment.coordinates.at(-1))));
|
|
assert.ok(route.routes.every((segment) => !segment.edgeIds.includes("oneway-spur:backward")));
|
|
assert.notDeepEqual(route.routes[0].coordinates, route.routes[0].centerlineCoordinates);
|
|
assert.deepEqual(
|
|
[...allowedTurns({ "turn:lanes:forward": "left|through;right" }, "forward")].sort(),
|
|
["left", "right", "through"],
|
|
);
|
|
const incoming = { wayId: "in", coordinates: [[120, 30], [120.001, 30]] };
|
|
assert.equal(classifyConnection(incoming, { wayId: "left", coordinates: [[120.001, 30], [120.001, 30.001]] }), "left");
|
|
assert.equal(classifyConnection(incoming, { wayId: "right", coordinates: [[120.001, 30], [120.001, 29.999]] }), "right");
|
|
assert.equal(classifyConnection(incoming, { wayId: "through", coordinates: [[120.001, 30], [120.002, 30]] }), "through");
|
|
|
|
const vehicle = makeVehicleGltf();
|
|
assert.equal(vehicle.asset.version, "2.0");
|
|
assert.equal(vehicle.asset.generator, "osm-asset-pipeline vehicle preview");
|
|
assert.equal(vehicle.scene, 0);
|
|
assert.equal(vehicle.meshes.length, vehicle.nodes.length);
|
|
assert.equal(vehicle.scenes[0].nodes.length, vehicle.nodes.length);
|
|
assert.deepEqual(
|
|
vehicle.materials.map((material) => material.name),
|
|
["paint red", "dark roof", "glass", "tire", "wheel hub", "headlight", "tail light"],
|
|
);
|
|
assert.ok(vehicle.meshes.some((mesh) => mesh.name === "body"));
|
|
assert.ok(vehicle.meshes.some((mesh) => mesh.name === "wheel_-1.55_-1.02"));
|
|
assert.match(vehicle.buffers[0].uri, /^data:application\/octet-stream;base64,/);
|
|
assert.equal(
|
|
Buffer.from(vehicle.buffers[0].uri.split(",")[1], "base64").length,
|
|
vehicle.buffers[0].byteLength,
|
|
);
|
|
|
|
const vehicleLibraryRoot = path.join(__dirname, "..", "assets", "models", "custom", "lowpoly_cars");
|
|
if (fs.existsSync(vehicleLibraryRoot)) {
|
|
const libraryDir = path.join(tempDir, "vehicle-library");
|
|
const models = writePreviewVehicleLibrary(libraryDir, "fixture");
|
|
assert.deepEqual(models, VEHICLE_IDS.map((id) => `fixture-vehicle-${id}.gltf`));
|
|
assert.equal(models.length, 6);
|
|
assert.ok(fs.existsSync(path.join(libraryDir, "fixture-vehicle-texture.jpg")));
|
|
for (const model of models) {
|
|
const gltf = JSON.parse(fs.readFileSync(path.join(libraryDir, model), "utf8"));
|
|
assert.deepEqual(gltf.images.map((image) => image.uri), ["fixture-vehicle-texture.jpg"]);
|
|
assert.equal(gltf.buffers[0].uri, model.replace(/\.gltf$/, ".bin"));
|
|
assert.deepEqual(gltf.materials[0].pbrMetallicRoughness.baseColorFactor, [2.1, 2.1, 2.1, 1]);
|
|
assert.deepEqual(gltf.materials[0].emissiveFactor, [0.25, 0.25, 0.25]);
|
|
assert.equal(gltf.materials[0].emissiveTexture.index, gltf.materials[0].pbrMetallicRoughness.baseColorTexture.index);
|
|
const reversed = model.includes("truck_a03_001");
|
|
assert.equal(reversed, REVERSED_MODEL_IDS.has(model.match(/vehicle-(.+)\.gltf$/)[1]));
|
|
assert.equal(gltf.nodes.every((node) => JSON.stringify(node.rotation || []) === JSON.stringify([0, 1, 0, 0])), reversed);
|
|
}
|
|
}
|
|
|
|
const html = cesiumPreviewHtml(
|
|
"scene<&>.glb",
|
|
"scene.json",
|
|
"route.json",
|
|
"vehicle.gltf",
|
|
"north<&>\u2028valley",
|
|
["car-a.gltf", "truck-a.gltf"],
|
|
"traffic-signals.json",
|
|
);
|
|
assert.match(html, /<title>north<&>\u2028valley Cesium Preview<\/title>/);
|
|
assert.match(html, /Loading scene<&>\.glb/);
|
|
assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/);
|
|
assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/);
|
|
assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/);
|
|
assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/);
|
|
assert.match(html, /id="toggleSignals"/);
|
|
assert.match(html, /id="toggleBuildingGhost"/);
|
|
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, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
|
|
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
|
|
assert.match(previewRuntime, /setBuildingGhost/);
|
|
assert.match(previewRuntime, /buildings\.model\.color = Cesium\.Color\.WHITE\.withAlpha\(0\.22\)/);
|
|
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 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(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(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.");
|
|
|
|
function rectangle(lon, lat, halfWidth, halfHeight) {
|
|
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
|
[lon - halfWidth, lat - halfHeight], [lon + halfWidth, lat - halfHeight],
|
|
[lon + halfWidth, lat + halfHeight], [lon - halfWidth, lat + halfHeight], [lon - halfWidth, lat - halfHeight],
|
|
]] } };
|
|
}
|