feat: add native preview traffic simulation

This commit is contained in:
2026-08-18 16:57:27 +08:00
parent 0bc949bc24
commit 5403936ae4
21 changed files with 777 additions and 45 deletions

View File

@@ -187,7 +187,8 @@ transcode成功后替换**package staging** 中的主 GLB 与 manifest
静态发布路径另有 `packageDir``packageStagingDir``packageManifest`
`packageStagingManifest``packageModelDir``packageStagingModelDir`
`packagePrimaryGlb`;预览路径另有 `previewDir``previewDescriptor`。除非在迁移旧调用,
`packagePrimaryGlb`;预览路径另有 `previewDir``previewDescriptor`
`trafficSimulation`native preview 的可迁移仿真描述符)。除非在迁移旧调用,
不要覆盖 `glb` / `metadata`:它们是 staging 内部路径,不是下游资产入口。
**优先改 `fileStem` 或 `areaDir`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,70 @@
# Design
## Authority And Boundaries
The native compiler remains the only road authority. The existing traffic-signal runtime remains the
only signal authority. The new feature adds a route/simulation layer beside those artifacts; it does
not alter signal geometry, phase grouping, or Blender static assets.
```text
native-road/compiled.json + native-road/layers/*
-> native route compiler
-> _preview/<area>-traffic-simulation.json
-> Cesium preview runtime
package/runtime/traffic-signals.json
-> signal phase clock + stop events
```
The descriptor is intentionally package-adjacent, not embedded in the GLB. A consumer can copy the
package and descriptor to another platform and reproduce the simulation without running Node.
## Descriptor Contract
Schema: `native-preview-traffic-simulation/v1`.
Required top-level fields:
- `areaId`, `coordinateSystem` (`WGS84` route coordinates, `ENU` model placement), `generatedAt`
- `source` with relative artifact references and SHA-256 records
- `settings` with speed, acceleration, deceleration, reaction time, vehicle length, and minimum gap
- `routes[]` with ordered WGS84 coordinates, cumulative distances, connector/maneuver IDs, and
`stops[]`
- `signals[]` copied by stable `signal_uid` from the runtime contract, including phase group and
stop-line association; no regenerated pose fields
- `migration` describing schema version, coordinate conversion, update-loop expectations, and
compatibility notes
The descriptor is deterministic for the same OSM, overrides, native compiler version, and settings.
## Route Construction
Use native directional roads and published connector geometry. Build a small number of deterministic
closed demonstration routes that cross real connectors and retain lane/road IDs. Route generation
must reject routes with missing geometry or disabled connectors and write structured diagnostics.
Native stop lines are matched by `road_id`/`lane_id` where available, with a bounded geometric
fallback recorded in the stop record. No fixed-width or legacy polygon fallback is allowed.
## Browser Simulation
Keep the existing signal phase functions and `signalData` payload. Replace the current per-vehicle
independent distance advance with a shared simulation state:
- each vehicle has route distance, speed, desired speed, status, and active stop reason;
- signal stop constraints are evaluated before the native stop line for red/yellow phases;
- leader constraints are evaluated on the same route and wrap around the loop;
- acceleration/deceleration clamps advance toward the minimum of desired speed, signal limit, and
leader-safe speed;
- vehicle position and orientation remain Cesium callback properties, with no per-frame allocation
of heavyweight Cesium objects;
- incident-card behavior remains local to each vehicle and must not mutate the descriptor or package.
Diagnostics expose route count, signal count, stopped vehicles, and queue length in the existing
preview diagnostics panel.
## Migration And Rollback
Document the descriptor, runtime signal schema, coordinate conversion, and a reference update loop in
`docs/native-preview-traffic-simulation.md`. Include a JSON example and a consumer checklist. Legacy
preview route files remain readable only through the existing legacy provider path; native generation
must not depend on them. Removing the new descriptor restores the existing no-cruise native preview.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,37 @@
# Implementation Plan
1. Add a native route compiler module that consumes `native-road/compiled.json` and native layers,
emits `native-preview-traffic-simulation/v1`, and records source hashes and migration metadata.
2. Wire native preview generation to write the descriptor and keep legacy osm2streets route
generation unchanged.
3. Extend the browser preview with shared deterministic vehicle state, signal stop constraints,
leader following, queue diagnostics, and graceful empty-route behavior.
4. Preserve the existing signal phase, `signal_uid`, pose, dynamic lens, countdown, disabled-signal,
and incident-card contracts; add focused regression tests for each boundary.
5. Add `docs/native-preview-traffic-simulation.md` with schema, example, coordinate rules, update
loop, migration checklist, and rollback notes.
6. Run focused Node tests, native compile/check, a native build with no legacy directory, and browser
preview validation in the available Blender/Cesium environment.
## Validation Commands
```bash
node --check scripts/lib/native-preview-traffic-simulation.js
node --check scripts/build-area.js
npm run test:native-road
npm run test:traffic-signals
npm run test:preview-assets
npm run test:native-preview-traffic
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,compress,package,preview
```
## Risk Controls
- Do not change `scripts/lib/traffic-signals.js` geometry or phase semantics unless a test proves a
migration bug; prefer adapter code.
- Do not add route data to the static package manifest; it belongs beside `_preview/` and is optional.
- If native route generation produces no valid route, emit a valid descriptor with diagnostics and
keep preview usable.
- Keep all route and simulation math unit-testable without Cesium.

View File

@@ -0,0 +1,38 @@
# Native preview traffic simulation and migration contract
## Goal
Extend the native road compiler preview with a deterministic, portable vehicle traffic demonstration that consumes native road topology and the already-validated traffic-signal runtime. The result must be useful as a visual validation tool and as a migration reference for Cesium, Blender, or another platform consuming the generated area package.
## Confirmed Baseline
- Native road compilation is the production authority; QGIS/osm2streets is not a runtime input.
- The previous traffic-signal work is the source of truth and must remain compatible: deterministic `signal_uid`, `phase_group`, `mast_heading_deg`, `face_heading_deg`, stop-line anchors, shared `pose.*`, dynamic lens/countdown assets, and disabled-signal filtering.
- Existing browser preview already has signal phase timing, signal visualization, route stop matching, vehicle incident cards, and a missing-route fallback. Native preview currently omits the route.
- Native road output contains compiled roads/connectors and `vehicle_stop_lines.geojson`; the native package contains `runtime/traffic-signals.json`.
- The feature is preview-level deterministic behavior, not a legal navigation or microscopic traffic simulator.
## Requirements
- R1: Generate a native route artifact from `native-road/compiled.json` and native layer geometry; it must not read `osm2streets_web_out`, QGIS files, or legacy route files.
- R2: Preserve and consume the existing traffic-signal contract without deriving a second signal layout in the browser.
- R3: Vehicles must stop before a red/yellow signal at the native stop line, resume on green, and expose the active stop reason in preview diagnostics.
- R4: Multiple vehicles on the same route must maintain a configurable minimum gap; a stopped front vehicle must cause following vehicles to decelerate and queue rather than overlap it.
- R5: Route, signal, vehicle, stop-event, and following-distance data must be written as versioned, package-adjacent JSON with stable ENU/WGS84 and migration metadata. Another platform must be able to consume the artifact without executing the Node compiler.
- R6: The browser preview must degrade gracefully when there are no valid routes or signals; the primary scene remains usable and diagnostics explain the missing optional capability.
- R7: Documentation must describe the data flow, schemas, coordinate conventions, signal identity, stop-line association, update loop, and migration/rollback guidance.
## Acceptance Criteria
- [ ] Native build emits a versioned route/traffic-simulation descriptor under the area output and preview loads it without any legacy directory present.
- [ ] At least one route crosses a native connector and contains a native stop-line association.
- [ ] A red/yellow phase visibly stops a vehicle before the stop line; green releases it.
- [ ] Two or more vehicles never overlap and maintain the configured minimum gap while cruising and while queued at a signal.
- [ ] Disabled native signals are absent from runtime control and do not create vehicle stops.
- [ ] Preview diagnostics report route count, signal count, stopped vehicles, queue length, and the active simulation descriptor version.
- [ ] Migration documentation and focused tests allow another consumer to reproduce route, signal, stopping, and gap behavior from generated assets alone.
- [ ] Existing traffic-signal tests and native road tests remain green; legacy provider behavior is unchanged.
## Decided Scope
- Simulation fidelity: MVP is a deterministic preview simulation with configurable speed, acceleration/deceleration, reaction time, and minimum gap. A full lane-changing, collision, priority, and multi-intersection traffic engine is deferred.

View File

@@ -0,0 +1,26 @@
{
"id": "native-preview-traffic-simulation",
"name": "native-preview-traffic-simulation",
"title": "Native preview traffic simulation and migration contract",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-18",
"completedAt": null,
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -114,6 +114,22 @@ npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
`road:check` 验证已发布 connector 与语义行驶动作的一致性,并在诊断包含 error、图层缺失或
动作/几何对应关系不一致时以非零退出。warning 保留给工作台审查,不会阻止产物打开。
### Native Preview Traffic Simulation
native preview 会额外生成:
```text
outputs/<area-id>/_preview/<area-id>-traffic-simulation.json
```
该文件使用 `native-preview-traffic-simulation/v1`,记录 native 路线、connector、停止点、
信号 `signal_uid`、源文件 SHA-256、坐标系和仿真参数。它与 `package/manifest.json` 一起可
迁移到其他平台;消费方不需要运行 Node 或 QGIS。预览中的车辆会根据现有信号相位在停止线
前停车,并按最小车距跟车排队。该功能是确定性的验证预览,不是法规级导航或完整交通仿真。
迁移契约、JSON 示例、更新循环和回撤方式见
[native preview traffic simulation](docs/native-preview-traffic-simulation.md)。
启动本地浏览器工作台:
```bash

View File

@@ -0,0 +1,64 @@
# Native Preview Traffic Simulation
This document defines the portable preview contract for native road traffic demonstrations. It is
not a legal navigation format or a full microscopic traffic simulator.
## Data Flow
```text
native-road/compiled.json + layers/
-> native-preview-traffic-simulation/v1 descriptor
package/runtime/traffic-signals.json
-> signal phase clock and stop constraints
descriptor + package/manifest.json
-> Cesium preview vehicles, signals, queues, diagnostics
```
The descriptor is generated from native artifacts and is independent of QGIS/osm2streets. Copying
the descriptor, package, and preview runtime to another platform is sufficient to reproduce the
visual simulation.
## Existing Signal Contract
Signal identity is `signal_uid`; it must not be renamed when `display_id` changes. `phase_group`
selects the existing signal phase. `mast_heading_deg`, `face_heading_deg`, `pose.*`, stop-line
coordinates, and `enabled` are consumed from `package/runtime/traffic-signals.json`. Disabled signals
are absent from runtime control. Consumers must not recalculate lamp or pole geometry.
## Vehicle Rules
Each vehicle advances along a route distance. Its target speed is the minimum of desired speed,
signal-safe speed, and leader-safe speed. Red/yellow signals create a stop target before the native
stop line. Following vehicles apply the configured minimum gap and decelerate behind the leader.
The simulation is deterministic for a fixed descriptor, clock start, and settings.
The descriptor records WGS84 route coordinates. The static package remains local ENU and is placed by
the package manifest. Consumers must use the package placement/model matrix for GLB assets and WGS84
coordinates for route entities; do not mix a fixed meters-per-degree approximation into either path.
Route geometry is taken from `native-road/layers/lane_centerlines.geojson` when that native layer is
available, keyed by `road_id`; the compiled road centerline is only a compatibility fallback for old
native outputs. Each road/connector boundary is checked in meters. Candidates with a missing
connector or an endpoint gap over 35 m are omitted from `routes` and retained in `rejectedRoutes`
with structured diagnostics. This prevents a malformed movement from becoming a visible diagonal
track across an intersection.
`diagnostics` contains the route-generation warnings, while `rejectedRoutes` preserves the candidate
id and the specific movement checks that failed. A consumer should display these as data-quality
warnings rather than silently reconstructing a route from legacy QGIS files.
## Migration Checklist
1. Load `package/manifest.json` and resolve all package-relative assets.
2. Load the traffic-signal runtime and preserve `signal_uid`/`phase_group` values.
3. Load the versioned simulation descriptor and verify its source hashes if reproducibility matters.
4. Implement the update loop using the descriptor settings and stop/leader constraints.
5. Place route coordinates in the same WGS84 scene as the package placement and preserve ENU model
placement for static assets.
6. Expose route count, signal count, stopped vehicles, and queue length as diagnostics.
## Rollback
Deleting or ignoring the optional simulation descriptor returns the native preview to a usable static
scene with signal visualization but no vehicle cruise. The QGIS baseline remains available through
`qgis-original-baseline-20260818`; it is not required by this contract.

View File

@@ -25,6 +25,7 @@
"test:package-examples": "node scripts/test-package-examples.js",
"test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js",
"test:traffic-signals": "node scripts/test-traffic-signals.js",
"test:native-preview-traffic": "node scripts/test-native-preview-traffic.js",
"render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js"
},
"dependencies": {

View File

@@ -16,6 +16,7 @@ const {
} = require("./lib/scene-layers");
const { digest: glbDigest } = require("./glb-digest");
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
const { buildNativeTrafficSimulation } = require("./lib/native-preview-traffic-simulation");
const { writePreviewVehicleLibrary } = require("./lib/vehicle-library");
const { readTrafficSignals } = require("./lib/traffic-signals");
const {
@@ -338,11 +339,9 @@ function exportCesium(area, roadProvider) {
fs.rmSync(area.outputs.packageStagingDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(area.outputs.glb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.metadata), { recursive: true });
if (roadProvider === "osm2streets") {
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsDynamicGlb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown0Glb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown1Glb), { recursive: true });
}
console.log("Stage: cesium");
const started = Date.now();
@@ -363,19 +362,15 @@ function exportCesium(area, roadProvider) {
"--metadata",
area.outputs.metadata,
];
if (roadProvider === "osm2streets") {
exporterArgs.splice(-2, 0,
"--dynamic-glb", area.outputs.trafficSignalsDynamicGlb,
"--countdown-0-glb", area.outputs.trafficSignalsCountdown0Glb,
"--countdown-1-glb", area.outputs.trafficSignalsCountdown1Glb,
);
}
runCommand(blenderExecutable(area), exporterArgs, "cesium");
if (roadProvider === "osm2streets") {
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);
const finished = Date.now();
const digest = glbDigest(area.outputs.glb);
@@ -392,9 +387,9 @@ function exportCesium(area, roadProvider) {
outputs: {
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
...(roadProvider === "osm2streets" ? {
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
} : {}),
trafficSignalsCountdown0Glb: fileRecord(area.outputs.trafficSignalsCountdown0Glb),
trafficSignalsCountdown1Glb: fileRecord(area.outputs.trafficSignalsCountdown1Glb),
semanticAssets,
},
summary: {
@@ -558,6 +553,7 @@ function writeCesiumPreview(area, roadProvider) {
ensureFile(area.outputs.packageManifest, "Published asset package manifest");
ensureFile(area.outputs.packageTrafficSignals, "Published traffic signal anchors");
let vehicleRoute = null;
let routeArtifact = null;
const previewInputs = {
config: fileRecord(configPath),
osm: fileRecord(area.input),
@@ -579,6 +575,11 @@ function writeCesiumPreview(area, roadProvider) {
});
} else {
Object.assign(previewInputs, nativeRoadRecords(area));
const simulation = buildNativeTrafficSimulation(area);
fs.mkdirSync(path.dirname(area.outputs.trafficSimulation), { recursive: true });
fs.writeFileSync(area.outputs.trafficSimulation, `${JSON.stringify(simulation, null, 2)}\n`);
previewInputs.trafficSimulation = fileRecord(area.outputs.trafficSimulation);
routeArtifact = area.outputs.trafficSimulation;
}
const htmlPath = area.outputs.cesiumPreview;
const started = Date.now();
@@ -594,7 +595,9 @@ function writeCesiumPreview(area, roadProvider) {
writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
const glbName = "package/manifest.json";
const metadataName = "package/manifest.json";
const routeName = vehicleRoute ? previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute) : null;
const routeName = vehicleRoute || routeArtifact
? previewRelativePath(area.outputs.areaDir, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact)
: null;
const vehicleModelName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel);
const descriptor = { routeName, vehicleModelName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel), vehicleModelNames: vehicleModelNames.map((name) => `_preview/${name}`), trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] };
fs.mkdirSync(area.outputs.previewDir, { recursive: true });
@@ -617,10 +620,11 @@ function writeCesiumPreview(area, roadProvider) {
outputs: {
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
vehicleRoute: optionalFileRecord(area.outputs.vehicleRoute),
...(routeArtifact ? { trafficSimulation: fileRecord(routeArtifact) } : {}),
vehicleModel: fileRecord(area.outputs.vehicleModel),
trafficSignals: fileRecord(area.outputs.packageTrafficSignals),
},
summary: previewSummary(area),
summary: previewSummary(area, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact),
warnings: [],
});
}

View File

@@ -66,6 +66,7 @@ function normalizeAreaConfig(raw, options = {}) {
),
previewDir,
previewDescriptor: path.resolve(outputOverrides.previewDescriptor || path.join(previewDir, "descriptor.json")),
trafficSimulation: path.resolve(outputOverrides.trafficSimulation || path.join(previewDir, `${fileStem}-traffic-simulation.json`)),
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(previewDir, `${fileStem}-vehicle-route.json`)),
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(previewDir, `${fileStem}-vehicle-car.gltf`)),
trafficSignalAssemblies: path.resolve(

View File

@@ -473,15 +473,18 @@ function stageManifestStatus(area, configPath = null) {
network: path.join(area.outputs.geojsonDir, "network.json"),
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
}),
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"),
previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"),
},
outputs: compressionComplete ? {
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
vehicleModel: area.outputs.vehicleModel,
} : {
cesiumPreview: area.outputs.cesiumPreview,
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
vehicleModel: area.outputs.vehicleModel,
},
},

View File

@@ -123,15 +123,15 @@ function escapeScriptJson(value) {
.replaceAll("\u2029", "\\u2029");
}
function previewSummary(area) {
const route = fs.existsSync(area.outputs.vehicleRoute) ? JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8")) : null;
function previewSummary(area, routePath = area.outputs.vehicleRoute) {
const route = routePath && fs.existsSync(routePath) ? JSON.parse(fs.readFileSync(routePath, "utf8")) : null;
return {
glbName: "package/manifest.json",
metadataName: "package/manifest.json",
routeName: route ? path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/") : null,
routeName: route ? path.relative(area.outputs.areaDir, routePath).split(path.sep).join("/") : null,
vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"),
trafficSignalsName: "package/runtime/traffic-signals.json",
routeSegments: Array.isArray(route?.segments) ? route.segments.length : null,
routeSegments: Array.isArray(route?.routes) ? route.routes.length : Array.isArray(route?.segments) ? route.segments.length : null,
};
}

View File

@@ -555,16 +555,27 @@
.slice(0, 5);
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
const start = trafficStart;
const assignments = [];
segments.forEach((segment, routeIndex) => {
// Put two vehicles on the first route so the native preview visibly
// exercises leader following and queue formation.
const vehicleCount = routeIndex === 0 ? 2 : 1;
for (let vehicleIndex = 0; vehicleIndex < vehicleCount; vehicleIndex += 1) {
assignments.push({ segment, routeIndex, vehicleIndex });
}
});
const simulation = createTrafficSimulation(viewer, assignments, start, speed, signalData, routeData?.settings);
viewer.clock.startTime = start.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
viewer.clock.multiplier = 1;
viewer.clock.shouldAnimate = segments.length > 0;
viewer.clock.shouldAnimate = assignments.length > 0;
const vehicles = segments.map((segment, index) => {
const vehicles = assignments.map((assignment, index) => {
const { segment } = assignment;
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData,
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName));
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName), simulation.motions[index]);
const option = document.createElement("option");
option.value = String(index);
option.textContent = routeLabel(segment, index);
@@ -574,7 +585,8 @@
const cruise = {
vehicles,
baseSpeed: speed,
state: { selectedIndex: 0 }
state: { selectedIndex: 0 },
simulation,
};
syncSelectedRouteVisibility(cruise);
return cruise;
@@ -732,10 +744,10 @@
return usable[Math.floor(Math.random() * usable.length)] || "";
}
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
const route = prepareRoute(segment, signalData);
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName, trafficMotion) {
const route = trafficMotion.route;
let record = null;
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed, () => record?.status === "normal");
trafficMotion.setMoving(() => record?.status === "normal");
const positions = trafficMotion.positions;
const flat = [];
for (const coord of segment.coordinates) {
@@ -946,6 +958,106 @@
return [...found.values()].sort((a, b) => a.distance - b.distance);
}
function createTrafficSimulation(viewer, assignments, start, speed, signalData, settings = {}) {
const config = {
desiredSpeedMetersPerSecond: Number(settings.desiredSpeedMetersPerSecond || speed),
accelerationMetersPerSecondSquared: Number(settings.accelerationMetersPerSecondSquared || 1.8),
decelerationMetersPerSecondSquared: Number(settings.decelerationMetersPerSecondSquared || 3.5),
reactionTimeSeconds: Number(settings.reactionTimeSeconds || 0.8),
vehicleLengthMeters: Number(settings.vehicleLengthMeters || 4.6),
minimumGapMeters: Number(settings.minimumGapMeters || 7),
};
const motions = assignments.map((assignment) => {
const route = prepareRoute(assignment.segment, signalData);
const initialGap = config.vehicleLengthMeters + config.minimumGapMeters;
const state = {
distance: assignment.vehicleIndex * -initialGap,
speed: 0,
targetSpeed: config.desiredSpeedMetersPerSecond,
stopReason: null,
queueAhead: 0,
lastTime: start.clone(),
};
state.distance = (state.distance % route.length + route.length) % route.length;
return {
route,
state,
setMoving(callback) { state.isMoving = callback; },
positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false),
};
});
const update = (clock) => {
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, motions[0]?.state.lastTime || start);
if (!(elapsed > 0) || elapsed > 2) {
for (const motion of motions) Cesium.JulianDate.clone(clock.currentTime, motion.state.lastTime);
return;
}
const grouped = new Map();
assignments.forEach((assignment, index) => {
const key = assignment.routeIndex;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(index);
});
for (const indexes of grouped.values()) {
indexes.sort((a, b) => motions[a].state.distance - motions[b].state.distance);
}
motions.forEach((motion, index) => {
const state = motion.state;
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
if (state.isMoving && !state.isMoving()) {
state.speed = 0;
state.stopReason = "incident";
return;
}
const route = motion.route;
let target = config.desiredSpeedMetersPerSecond;
let stopReason = null;
const nextStop = nextRouteStop(route, state.distance);
if (nextStop) {
const phase = signalPhase(nextStop.signal.phaseGroup, clock.currentTime, start).active;
const untilStop = (nextStop.distance - state.distance + route.length) % route.length;
if (phase !== "green" && untilStop < Math.max(30, state.speed * config.reactionTimeSeconds + 8)) {
target = Math.min(target, Math.max(0, (untilStop - 1.7) / Math.max(config.reactionTimeSeconds, 0.1)));
stopReason = "traffic-signal";
}
}
const peers = grouped.get(assignments[index].routeIndex) || [];
const peerPosition = peers.indexOf(index);
if (peerPosition >= 0 && peers.length > 1) {
const leaderIndex = peers[(peerPosition + 1) % peers.length];
if (leaderIndex !== index) {
const leader = motions[leaderIndex].state;
const gap = (leader.distance - state.distance + route.length) % route.length - config.vehicleLengthMeters;
const safeGap = config.minimumGapMeters + state.speed * config.reactionTimeSeconds;
if (gap < safeGap + 12) {
target = Math.min(target, Math.max(0, (gap - config.minimumGapMeters) / Math.max(config.reactionTimeSeconds, 0.1)));
if (target < 0.2) stopReason = "leader-gap";
state.queueAhead = leader.stopReason ? 1 : 0;
} else {
state.queueAhead = 0;
}
}
}
state.targetSpeed = target;
const limit = (target >= state.speed ? config.accelerationMetersPerSecondSquared : config.decelerationMetersPerSecondSquared) * elapsed;
state.speed += Math.sign(target - state.speed) * Math.min(Math.abs(target - state.speed), limit);
state.distance = (state.distance + Math.max(0, state.speed) * elapsed) % route.length;
state.stopReason = state.speed < 0.2 ? stopReason : null;
});
};
viewer.clock.onTick.addEventListener(update);
return {
motions,
settings: config,
diagnostics() {
return {
stoppedVehicles: motions.filter((motion) => motion.state.stopReason).length,
queueLength: motions.filter((motion) => motion.state.stopReason === "leader-gap").length,
};
},
};
}
function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) {
const state = { distance: 0, lastTime: start.clone() };
viewer.clock.onTick.addEventListener((clock) => {
@@ -1230,6 +1342,12 @@
"Trees: " + Number(stats.trees || 0),
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")
];
if (cruise.simulation) {
const simulation = cruise.simulation.diagnostics();
lines.push("Simulation: native-preview-traffic-simulation/v1");
lines.push("Stopped: " + simulation.stoppedVehicles);
lines.push("Queue: " + simulation.queueLength);
}
if (failed.length) {
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
}

View File

@@ -0,0 +1,259 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const SCHEMA = "native-preview-traffic-simulation/v1";
const MAX_ROUTES = 5;
const MIN_ROAD_COUNT = 3;
const MAX_ROAD_COUNT = 7;
const MAX_CONNECTION_GAP_METERS = 35;
const DEFAULT_SETTINGS = {
desiredSpeedMetersPerSecond: 8,
accelerationMetersPerSecondSquared: 1.8,
decelerationMetersPerSecondSquared: 3.5,
reactionTimeSeconds: 0.8,
vehicleLengthMeters: 4.6,
minimumGapMeters: 7,
};
function buildNativeTrafficSimulation(area, options = {}) {
const root = area.outputs.nativeRoadDir;
const compiledPath = path.join(root, "compiled.json");
const signalPath = path.join(root, "traffic-signals.json");
const connectorPath = path.join(root, "layers", "connectors.geojson");
const laneCenterlinePath = path.join(root, "layers", "lane_centerlines.geojson");
const stopLinePath = path.join(root, "layers", "vehicle_stop_lines.geojson");
for (const file of [compiledPath, signalPath, connectorPath, stopLinePath]) {
if (!fs.existsSync(file)) throw new Error(`Native traffic simulation input not found: ${file}`);
}
const compiled = readJson(compiledPath, "native compiled roads");
const signals = readJson(signalPath, "native traffic signals");
const connectors = readFeatureCollection(connectorPath, "native connectors");
const laneCenterlines = fs.existsSync(laneCenterlinePath) ? readFeatureCollection(laneCenterlinePath, "native lane centerlines") : null;
const stopLines = readFeatureCollection(stopLinePath, "native stop lines");
const settings = { ...DEFAULT_SETTINGS, ...(options.settings || {}) };
validateSettings(settings);
const routeBuild = buildRoutes(compiled, connectors, signals.signals || [], stopLines.features, laneCenterlines);
return {
schema: SCHEMA,
areaId: area.id,
coordinateSystem: { route: "WGS84", model: "ENU", units: "meters", axes: "X east / Y north / Z up" },
generatedAt: new Date().toISOString(),
speedMetersPerSecond: settings.desiredSpeedMetersPerSecond,
source: sourceRecords(area, [compiledPath, signalPath, connectorPath, stopLinePath, ...(laneCenterlines ? [laneCenterlinePath] : [])]),
settings,
routes: routeBuild.routes,
signals: (signals.signals || []).filter((signal) => signal && signal.id).map(signalDescriptor),
diagnostics: [
...routeBuild.diagnostics,
...(routeBuild.routes.length ? [] : [{ severity: "warning", reason: "no_native_cruise_route", message: "No deterministic native route crossed three or more directional roads." }]),
],
rejectedRoutes: routeBuild.rejectedRoutes,
migration: {
schema: SCHEMA,
updateLoop: "advance route distance using settings, stop before red/yellow signal, then apply leader minimum gap",
signalIdentity: "signals[].id is the existing traffic runtime signal_uid",
coordinates: "routes[].coordinates are WGS84; static package remains local ENU placed by package manifest",
legacyDependency: false,
},
};
}
function buildRoutes(compiled, connectorCollection, signals, stopLineFeatures, laneCenterlineCollection) {
const roads = new Map((compiled.model?.roads || []).map((road) => [road.id, road]));
const movements = (compiled.movements || []).filter((movement) => movement.geometryPublished !== false && roads.has(movement.fromRoadId) && roads.has(movement.toRoadId));
const connectorByMovement = new Map(connectorCollection.features.map((feature) => [feature.properties?.movement_id, feature.geometry?.coordinates || []]));
const laneCenterlineById = new Map();
for (const feature of laneCenterlineCollection?.features || []) {
const properties = feature.properties || {};
const coordinates = feature.geometry?.coordinates || [];
if (properties.native_id) laneCenterlineById.set(properties.native_id, coordinates);
if (properties.road_id && Number.isFinite(Number(properties.lane_index))) laneCenterlineById.set(`${properties.road_id}:${properties.lane_index}`, coordinates);
}
const outgoing = new Map();
for (const movement of movements) {
if (!outgoing.has(movement.fromRoadId)) outgoing.set(movement.fromRoadId, []);
outgoing.get(movement.fromRoadId).push(movement);
}
for (const list of outgoing.values()) list.sort((a, b) => a.id.localeCompare(b.id));
const candidates = [];
const seen = new Set();
for (const start of [...roads.keys()].sort()) {
findCycles(start, start, [], [], outgoing, seen, candidates);
if (candidates.length >= MAX_ROUTES * 3) break;
}
const diagnostics = [];
const rejectedRoutes = [];
const routes = candidates
.map((candidate) => {
const result = makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById);
if (!result.route) {
rejectedRoutes.push({ id: `native-loop:${candidate.roadIds.join("-")}`, diagnostics: result.diagnostics });
diagnostics.push(...result.diagnostics);
}
return result.route;
})
.filter(Boolean)
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, MAX_ROUTES);
return { routes, diagnostics, rejectedRoutes };
}
function findCycles(start, current, roadIds, movements, outgoing, seen, candidates) {
const nextRoadIds = [...roadIds, current];
if (nextRoadIds.length > MAX_ROAD_COUNT) return;
for (const movement of outgoing.get(current) || []) {
if (movement.toRoadId === start && nextRoadIds.length >= MIN_ROAD_COUNT) {
if (!laneSequenceCompatible(movements.at(-1), movement) || !laneSequenceCompatible(movement, movements[0])) continue;
const signature = [...nextRoadIds, start].join("|");
if (!seen.has(signature)) {
seen.add(signature);
candidates.push({ roadIds: nextRoadIds, movements: [...movements, movement] });
}
continue;
}
if (nextRoadIds.includes(movement.toRoadId)) continue;
if (!laneSequenceCompatible(movements.at(-1), movement)) continue;
findCycles(start, movement.toRoadId, nextRoadIds, [...movements, movement], outgoing, seen, candidates);
}
}
function laneSequenceCompatible(previous, next) {
if (!previous || !previous.toLaneId || !next?.fromLaneId) return true;
return previous.toLaneId === next.fromLaneId;
}
function makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById) {
const coordinates = [];
const connectors = [];
const diagnostics = [];
for (let index = 0; index < candidate.roadIds.length; index += 1) {
const road = roads.get(candidate.roadIds[index]);
const incomingMovement = candidate.movements[(index - 1 + candidate.movements.length) % candidate.movements.length];
const incomingLine = selectLaneCenterline(laneCenterlineById, road, incomingMovement?.toLaneId);
const roadLine = incomingLine || road.centerline;
append(coordinates, roadLine);
const movement = candidate.movements[index];
const connector = connectorByMovement.get(movement.id) || [];
if (connector.length < 2) {
diagnostics.push(routeDiagnostic(candidate, movement, "missing_connector_geometry", "Native movement has no usable connector geometry."));
return { route: null, diagnostics };
}
const startGap = distanceMeters(coordinates.at(-1), connector[0]);
if (startGap > MAX_CONNECTION_GAP_METERS) {
diagnostics.push(routeDiagnostic(candidate, movement, "road_connector_gap", `Road to connector endpoint gap is ${round(startGap)}m.`));
return { route: null, diagnostics };
}
append(coordinates, connector);
if (index < candidate.roadIds.length - 1) {
const nextRoad = roads.get(candidate.roadIds[index + 1]);
const nextLine = selectLaneCenterline(laneCenterlineById, nextRoad, movement.toLaneId) || nextRoad.centerline;
const endGap = distanceMeters(connector.at(-1), nextLine[0]);
if (endGap > MAX_CONNECTION_GAP_METERS) {
diagnostics.push(routeDiagnostic(candidate, movement, "connector_road_gap", `Connector to road endpoint gap is ${round(endGap)}m.`));
return { route: null, diagnostics };
}
}
connectors.push({ id: movement.connectorId, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId, turn: movement.turn });
}
if (coordinates.length < 4) return { route: null, diagnostics };
const distances = cumulativeDistances(coordinates);
const lengthMeters = distances.at(-1);
if (!(lengthMeters > 30)) return { route: null, diagnostics };
const stops = matchStops(coordinates, distances, signals, stopLineFeatures);
return { route: {
id: `native-loop:${candidate.roadIds.join("-")}`,
coordinates,
centerlineCoordinates: coordinates,
distances,
lengthMeters: round(lengthMeters),
edgeIds: candidate.roadIds,
laneSegments: candidate.roadIds.map((roadId) => ({ roadId, laneId: `lane:${roadId}:1` })),
connectors,
maneuvers: connectors.map((connector) => connector.turn).filter(Boolean),
stops,
}, diagnostics };
}
function selectLaneCenterline(laneCenterlineById, road, laneId) {
if (!laneCenterlineById || !road) return null;
if (laneId && laneCenterlineById.has(laneId)) return laneCenterlineById.get(laneId);
const fallback = laneCenterlineById.get(`${road.id}:1`);
return fallback || null;
}
function routeDiagnostic(candidate, movement, reason, message) {
return { severity: "warning", reason, message, routeRoadIds: candidate.roadIds, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId };
}
function matchStops(coordinates, distances, signals, stopLineFeatures) {
const stopRoadIds = new Set(stopLineFeatures.map((feature) => feature.properties?.road_id).filter(Boolean));
return signals.map((signal) => {
if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) return null;
let best = { distance: Infinity, routeDistance: 0 };
for (let index = 0; index < coordinates.length; index += 1) {
const distance = haversine(coordinates[index], [signal.stopLongitude, signal.stopLatitude]);
if (distance < best.distance) best = { distance, routeDistance: distances[index] };
}
if (best.distance > 15 || !stopRoadIds.size) return null;
return { signalId: signal.id, phaseGroup: signal.phaseGroup, distance: round(best.routeDistance), matchDistanceMeters: round(best.distance), stopReason: "traffic-signal" };
}).filter(Boolean).sort((a, b) => a.distance - b.distance);
}
function signalDescriptor(signal) {
return { id: signal.id, phaseGroup: signal.phaseGroup, approachId: signal.approachId, sourceWayId: signal.sourceWayId, stopLongitude: signal.stopLongitude, stopLatitude: signal.stopLatitude, enabled: true };
}
function sourceRecords(area, files) {
return files.map((file) => ({ path: path.relative(area.outputs.areaDir, file).split(path.sep).join("/"), bytes: fs.statSync(file).size, sha256: sha256(file) }));
}
function append(target, points) {
for (const point of points || []) {
const coordinate = point.slice(0, 2).map(Number);
if (!coordinate.every(Number.isFinite)) continue;
const previous = target.at(-1);
if (!previous || previous[0] !== coordinate[0] || previous[1] !== coordinate[1]) target.push(coordinate);
}
}
function cumulativeDistances(coordinates) {
const distances = [0];
for (let index = 1; index < coordinates.length; index += 1) distances.push(distances[index - 1] + haversine(coordinates[index - 1], coordinates[index]));
return distances;
}
function haversine(a, b) {
const radians = Math.PI / 180;
const dLat = (b[1] - a[1]) * radians;
const dLon = (b[0] - a[0]) * radians;
const lat1 = a[1] * radians;
const lat2 = b[1] * radians;
const value = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 6371008.8 * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
function distanceMeters(a, b) {
return a && b ? haversine(a, b) : Infinity;
}
function readJson(file, label) {
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`); }
}
function readFeatureCollection(file, label) {
const value = readJson(file, label);
if (value?.type !== "FeatureCollection" || !Array.isArray(value.features)) throw new Error(`Invalid ${label} '${file}': expected FeatureCollection`);
return value;
}
function validateSettings(settings) {
for (const [key, value] of Object.entries(settings)) if (!Number.isFinite(value) || value <= 0) throw new Error(`Native traffic simulation setting '${key}' must be positive`);
}
function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
function round(value) { return Math.round(value * 100) / 100; }
module.exports = { SCHEMA, DEFAULT_SETTINGS, buildNativeTrafficSimulation };

View File

@@ -267,7 +267,7 @@ function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics);
const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls);
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides, junctionPlans);
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics);
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
return { roadSurface: { type: "FeatureCollection", features }, edgeLines: { type: "FeatureCollection", features: edgeLines }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, centerLines: { type: "FeatureCollection", features: centerLines }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, crosswalks: { type: "FeatureCollection", features: controls.crosswalks }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
@@ -653,7 +653,7 @@ function compileLaneCenterlines(model, diagnostics, junctionPlans) {
return { features, byRoadId };
}
function compileConnectors(model, lanes, diagnostics, overrides) {
function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) {
const features = [];
const movements = [];
for (const connection of model.connections.filter((item) => item.enabled)) {
@@ -669,8 +669,12 @@ function compileConnectors(model, lanes, diagnostics, overrides) {
const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id);
if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false) continue;
const from = defaultFromLane.coordinates.at(-1); const to = defaultToLane.coordinates[0];
const control = connectorControlPoint(model, connection, from, to);
const coordinates = quadraticCurve(from, control, to, 12);
const plan = junctionPlans.get(connection.nodeId);
// Cross intersections retain the earlier center-node curve while T junctions
// use lane tangents so their through movement does not bow toward the stem.
const coordinates = plan?.segmentIds.size === 4
? quadraticCurve(from, endpointCoordinate(model, connection.fromEndpointId), to, 12)
: connectorCurve(defaultFromLane.coordinates, defaultToLane.coordinates, turn);
const length = lineLengthMeters(coordinates);
const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`;
const provenance = override ? `override:${override.id}` : connection.provenance;
@@ -712,12 +716,47 @@ function targetLaneIndex(turn, sourceIndex, sourceCount, targetCount) {
if (turn === "uturn") return 0;
return Math.min(targetCount - 1, Math.round(sourceIndex / Math.max(1, sourceCount - 1) * Math.max(0, targetCount - 1)));
}
function connectorControlPoint(model, connection, from, to) {
const node = endpointCoordinate(model, connection.fromEndpointId);
if (!node) return [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2];
// Nearby manual joins may not share exactly the same point. The midpoint
// keeps their curve smooth without rewriting the authoritative OSM geometry.
return node;
function connectorCurve(incoming, outgoing, turn) {
const start = incoming.at(-1);
const end = outgoing[0];
if (turn === "through") return lineCurve(start, end, 12);
const incomingHeading = headingDegrees(incoming.at(-2), start);
const outgoingHeading = headingDegrees(end, outgoing[1]);
const chord = distanceMeters(start, end);
const incomingSpan = distanceMeters(incoming.at(-2), start);
const outgoingSpan = distanceMeters(end, outgoing[1]);
const tangentIntersection = intersectTangentRays(start, end, incomingHeading, outgoingHeading);
const fallbackDistance = Math.min(8, Math.max(.75, Math.min(chord * .42, incomingSpan * .8, outgoingSpan * .8)));
const firstDistance = tangentIntersection && tangentIntersection.incoming >= 0 ? Math.min(tangentIntersection.incoming, Math.min(8, Math.max(.75, incomingSpan * 2.4))) / 3 : fallbackDistance;
const secondDistance = tangentIntersection && tangentIntersection.outgoing >= 0 ? Math.min(tangentIntersection.outgoing, Math.min(8, Math.max(.75, outgoingSpan * 2.4))) / 3 : fallbackDistance;
const firstControl = offsetCoordinate(start, incomingHeading, firstDistance);
const secondControl = offsetCoordinate(end, outgoingHeading + 180, secondDistance);
return cubicBezier(start, firstControl, secondControl, end, 12);
}
function intersectTangentRays(start, end, incomingHeading, outgoingHeading) {
const incoming = headingVector(incomingHeading);
const outgoing = headingVector(outgoingHeading);
const delta = project(end, start);
const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0];
if (Math.abs(cross) < 1e-6) return null;
return {
incoming: (delta[0] * outgoing[1] - delta[1] * outgoing[0]) / cross,
outgoing: (delta[0] * incoming[1] - delta[1] * incoming[0]) / cross,
};
}
function lineCurve(start, end, segments) {
return Array.from({ length: segments + 1 }, (_, index) => interpolate(start, end, index / segments));
}
function cubicBezier(a, firstControl, secondControl, b, segments) {
const result = [];
for (let index = 0; index <= segments; index += 1) {
const t = index / segments; const u = 1 - t;
result.push([u ** 3 * a[0] + 3 * u * u * t * firstControl[0] + 3 * u * t * t * secondControl[0] + t ** 3 * b[0], u ** 3 * a[1] + 3 * u * u * t * firstControl[1] + 3 * u * t * t * secondControl[1] + t ** 3 * b[1]]);
}
return result;
}
function quadraticCurve(a, control, b, segments) {

View File

@@ -16,7 +16,8 @@ assert.match(qgisBuildSource, /QgsFieldConstraints\.Constraint\.ConstraintNotNul
assert.match(qgisBuildSource, /QgsFieldConstraints\.ConstraintNotNull/);
assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/);
assert.match(areaBuildSource, /exportCesium\(area, roadProvider\)/);
assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{\n exporterArgs\.splice/);
assert.match(areaBuildSource, /"--dynamic-glb", area\.outputs\.trafficSignalsDynamicGlb/);
assert.match(areaBuildSource, /trafficSignalsCountdown0Glb: fileRecord/);
assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{[\s\S]*?buildPreviewVehicleRoute/);
assert.match(areaBuildSource, /Object\.assign\(previewInputs, nativeRoadRecords\(area\)\)/);
assert.match(areaBuildSource, /vehicleRoute: optionalFileRecord\(area\.outputs\.vehicleRoute\)/);
@@ -56,6 +57,10 @@ assert.equal(
normalizeAreaConfig(base).outputs.trafficSignalAssemblies,
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signal_assemblies.geojson"),
);
assert.equal(
normalizeAreaConfig(base).outputs.trafficSimulation,
path.join(tempDir, "test-area", "_preview", "test-area-traffic-simulation.json"),
);
assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
assert.equal(normalizeAreaConfig(base).stages.intermediates, false);
assert.equal(normalizeAreaConfig(base).blender.roadProvider, "native");

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { SCHEMA, buildNativeTrafficSimulation } = require("./lib/native-preview-traffic-simulation");
const areaDir = fs.mkdtempSync(path.join(os.tmpdir(), "native-preview-traffic-"));
const nativeRoadDir = path.join(areaDir, "native-road");
const layersDir = path.join(nativeRoadDir, "layers");
fs.mkdirSync(layersDir, { recursive: true });
const roads = [
{ id: "road:a", centerline: [[120, 30], [120.001, 30]], sourceNodeIds: ["a", "b"] },
{ id: "road:b", centerline: [[120.001, 30], [120.001, 30.001]], sourceNodeIds: ["b", "c"] },
{ id: "road:c", centerline: [[120.001, 30.001], [120, 30.001]], sourceNodeIds: ["c", "d"] },
];
const movements = [
["road:a", "road:b", "left"], ["road:b", "road:c", "through"], ["road:c", "road:a", "right"],
].map(([fromRoadId, toRoadId, turn], index) => ({ id: `movement:${index}`, connectorId: `connector:${index}`, fromRoadId, toRoadId, turn, geometryPublished: true }));
fs.writeFileSync(path.join(nativeRoadDir, "compiled.json"), JSON.stringify({ model: { roads }, movements }));
fs.writeFileSync(path.join(nativeRoadDir, "traffic-signals.json"), JSON.stringify({ signals: [{ id: "osm-signal-1", phaseGroup: 0, approachId: "way:a", sourceWayId: "a", stopLongitude: 120.0009, stopLatitude: 30, enabled: true }] }));
fs.writeFileSync(path.join(layersDir, "connectors.geojson"), JSON.stringify({ type: "FeatureCollection", features: movements.map((movement, index) => ({ type: "Feature", properties: { movement_id: movement.id }, geometry: { type: "LineString", coordinates: [[[120.001, 30], [120.001, 30.0001]], [[120.001, 30.001], [120.0009, 30.001]], [[120, 30.001], [120, 30.0009]]][index] } })) }));
fs.writeFileSync(path.join(layersDir, "vehicle_stop_lines.geojson"), JSON.stringify({ type: "FeatureCollection", features: [{ type: "Feature", properties: { road_id: "road:a" }, geometry: { type: "Polygon", coordinates: [] } }] }));
fs.writeFileSync(path.join(layersDir, "lane_centerlines.geojson"), JSON.stringify({ type: "FeatureCollection", features: roads.map((road) => ({ type: "Feature", properties: { road_id: road.id, lane_index: 1 }, geometry: { type: "LineString", coordinates: road.centerline } })) }));
const descriptor = buildNativeTrafficSimulation({ id: "fixture", outputs: { areaDir, nativeRoadDir } });
assert.equal(descriptor.schema, SCHEMA);
assert.equal(descriptor.coordinateSystem.route, "WGS84");
assert.ok(descriptor.routes.length >= 1);
assert.ok(descriptor.routes.some((route) => route.connectors.length === 3));
assert.ok(descriptor.routes.some((route) => route.stops.some((stop) => stop.signalId === "osm-signal-1")));
assert.equal(descriptor.migration.legacyDependency, false);
assert.equal(descriptor.source.length, 5);
fs.rmSync(areaDir, { recursive: true, force: true });
console.log("Native preview traffic tests passed.");

View File

@@ -148,6 +148,14 @@ assert.equal(sharedInteriorGeometry.intersectionSurface.features.length, 1);
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.osm_node_id, "2");
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t");
assert.ok(sharedInteriorGeometry.connectors.features.length >= 4);
const throughConnector = sharedInteriorGeometry.connectors.features.find((feature) => feature.properties.turn === "through");
assert.ok(throughConnector, "T junction emits a through connector");
const throughCoordinates = throughConnector.geometry.coordinates;
const throughStart = throughCoordinates[0]; const throughEnd = throughCoordinates.at(-1);
for (const point of throughCoordinates.slice(1, -1)) {
const area = Math.abs((throughEnd[0] - throughStart[0]) * (point[1] - throughStart[1]) - (throughEnd[1] - throughStart[1]) * (point[0] - throughStart[0]));
assert.ok(area < 1e-12, "through connector stays on its lane-to-lane chord instead of bending through the junction node");
}
assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "continuation" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id)));
const connection = initial.connections[0];
assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start")));

View File

@@ -278,6 +278,9 @@ assert.match(previewRuntime, /\(\) => record\?\.status === "normal"/);
assert.match(previewRuntime, /Cesium\.SceneTransforms\.worldToWindowCoordinates/);
assert.match(previewRuntime, /TrafficSignalDynamic_/);
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
assert.match(previewRuntime, /native-preview-traffic-simulation\/v1/);
assert.match(previewRuntime, /leader-gap/);
assert.match(previewRuntime, /stopReason = "traffic-signal"/);
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
assert.match(previewRuntime, /setBuildingGhost/);
assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/);