Add multi-vehicle cruise selection preview
This commit is contained in:
@@ -116,7 +116,7 @@ python3 -m http.server 8765
|
|||||||
|
|
||||||
## 实验:车辆巡航
|
## 实验:车辆巡航
|
||||||
|
|
||||||
`preview` 和 `cesium` 阶段会额外生成 `<area-id>-vehicle-route.json` 和 `<area-id>-vehicle-car.gltf`。路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线,避免车辆压道路中心线。Cesium 预览页会加载最长道路段并显示一辆实验车辆循环巡航。
|
`preview` 和 `cesium` 阶段会额外生成 `<area-id>-vehicle-route.json` 和 `<area-id>-vehicle-car.gltf`。路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线,避免车辆压道路中心线。Cesium 预览页会加载多条道路段并显示多辆实验车辆循环巡航;`Vehicle` 下拉框决定 `Follow` 跟随哪一辆车。
|
||||||
|
|
||||||
这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 主资产本身。车辆模型是无 logo 的轻量预览模型,生成在输出目录中。
|
这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 主资产本身。车辆模型是无 logo 的轻量预览模型,生成在输出目录中。
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `<area-id>-vehicle-route.json`,并在预览页中驱动车辆循环移动。
|
- 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `<area-id>-vehicle-route.json`,并在预览页中驱动车辆循环移动。
|
||||||
- 巡航路线从道路中心线向右偏移约 1.3 米,车辆模型改为无 logo 的轻量预览车。
|
- 巡航路线从道路中心线向右偏移约 1.3 米,车辆模型改为无 logo 的轻量预览车。
|
||||||
|
- 车辆巡航预览支持多辆车同时行驶,并通过 `Vehicle` 下拉框选择 Follow 目标。
|
||||||
- 将项目主入口重构为区域资产管线:`scripts/build-area.js`。
|
- 将项目主入口重构为区域资产管线:`scripts/build-area.js`。
|
||||||
- 新增 `config/areas/nantaizi-lake-innovation-valley.json` 和 `config/areas/hanyang-block.json`,支持按 OSM 输入生成独立输出目录。
|
- 新增 `config/areas/nantaizi-lake-innovation-valley.json` 和 `config/areas/hanyang-block.json`,支持按 OSM 输入生成独立输出目录。
|
||||||
- `npm run build` 现在默认走区域资产管线;旧 QGIS 管线保留为 `npm run build:qgis`。
|
- `npm run build` 现在默认走区域资产管线;旧 QGIS 管线保留为 `npm run build:qgis`。
|
||||||
|
|||||||
@@ -661,6 +661,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #d9e0e2;
|
||||||
}
|
}
|
||||||
#status {
|
#status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -708,6 +709,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
|||||||
<div id="controls">
|
<div id="controls">
|
||||||
<button id="toggleCruise">Pause</button>
|
<button id="toggleCruise">Pause</button>
|
||||||
<button id="toggleFollow">Follow</button>
|
<button id="toggleFollow">Follow</button>
|
||||||
|
<label>Vehicle <select id="vehicleSelect"></select></label>
|
||||||
<label>Speed <input id="speedControl" type="range" min="2" max="22" step="1" value="8"></label>
|
<label>Speed <input id="speedControl" type="range" min="2" max="22" step="1" value="8"></label>
|
||||||
<span id="speedLabel">8 m/s</span>
|
<span id="speedLabel">8 m/s</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -716,6 +718,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
|||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
const toggleCruise = document.getElementById("toggleCruise");
|
const toggleCruise = document.getElementById("toggleCruise");
|
||||||
const toggleFollow = document.getElementById("toggleFollow");
|
const toggleFollow = document.getElementById("toggleFollow");
|
||||||
|
const vehicleSelect = document.getElementById("vehicleSelect");
|
||||||
const speedControl = document.getElementById("speedControl");
|
const speedControl = document.getElementById("speedControl");
|
||||||
const speedLabel = document.getElementById("speedLabel");
|
const speedLabel = document.getElementById("speedLabel");
|
||||||
Cesium.Ion.defaultAccessToken = "";
|
Cesium.Ion.defaultAccessToken = "";
|
||||||
@@ -743,6 +746,12 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
|||||||
baseLayer: false
|
baseLayer: false
|
||||||
});
|
});
|
||||||
viewer.scene.globe.depthTestAgainstTerrain = true;
|
viewer.scene.globe.depthTestAgainstTerrain = true;
|
||||||
|
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#d9e0e2");
|
||||||
|
viewer.scene.skyAtmosphere.show = false;
|
||||||
|
viewer.scene.skyBox.show = false;
|
||||||
|
viewer.scene.sun.show = false;
|
||||||
|
viewer.scene.moon.show = false;
|
||||||
|
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#d4dcde");
|
||||||
|
|
||||||
const p = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
|
const p = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
|
||||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);
|
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);
|
||||||
@@ -754,83 +763,42 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
|||||||
scale: 1.0
|
scale: 1.0
|
||||||
});
|
});
|
||||||
viewer.scene.primitives.add(model);
|
viewer.scene.primitives.add(model);
|
||||||
const cruise = addVehicleCruise(viewer, routeData);
|
const cruise = addVehicleCruises(viewer, routeData);
|
||||||
|
|
||||||
const center = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
|
const center = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
|
||||||
viewer.camera.flyToBoundingSphere(
|
viewer.camera.flyToBoundingSphere(
|
||||||
new Cesium.BoundingSphere(center, 900.0),
|
new Cesium.BoundingSphere(center, 900.0),
|
||||||
{ duration: 0.0 }
|
{ duration: 0.0 }
|
||||||
);
|
);
|
||||||
statusEl.textContent = "${escapeJs(glbName)} | route " + cruise.segment.id + " | " + Math.round(cruise.segment.lengthMeters) + "m";
|
statusEl.textContent = "${escapeJs(glbName)} | vehicles " + cruise.vehicles.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addVehicleCruise(viewer, routeData) {
|
function addVehicleCruises(viewer, routeData) {
|
||||||
const segment = (routeData.segments || [])[0];
|
const segments = (routeData.segments || [])
|
||||||
if (!segment || !segment.coordinates || segment.coordinates.length < 2) {
|
.filter((segment) => segment.coordinates && segment.coordinates.length >= 2)
|
||||||
|
.slice(0, 5);
|
||||||
|
if (!segments.length) {
|
||||||
throw new Error("No drivable route found in ${escapeJs(routeName)}");
|
throw new Error("No drivable route found in ${escapeJs(routeName)}");
|
||||||
}
|
}
|
||||||
const speed = Number(routeData.speedMetersPerSecond || 8);
|
const speed = Number(routeData.speedMetersPerSecond || 8);
|
||||||
const start = Cesium.JulianDate.now();
|
const start = Cesium.JulianDate.now();
|
||||||
const duration = Math.max(8, segment.lengthMeters / speed);
|
|
||||||
const stop = Cesium.JulianDate.addSeconds(start, duration, new Cesium.JulianDate());
|
|
||||||
const positions = new Cesium.SampledPositionProperty();
|
|
||||||
let elapsed = 0;
|
|
||||||
let previous = segment.coordinates[0];
|
|
||||||
positions.addSample(start, Cesium.Cartesian3.fromDegrees(previous[0], previous[1], 1.15));
|
|
||||||
for (let i = 1; i < segment.coordinates.length; i += 1) {
|
|
||||||
const coord = segment.coordinates[i];
|
|
||||||
elapsed += distanceMeters(previous, coord) / speed;
|
|
||||||
const time = Cesium.JulianDate.addSeconds(start, elapsed, new Cesium.JulianDate());
|
|
||||||
positions.addSample(time, Cesium.Cartesian3.fromDegrees(coord[0], coord[1], 1.15));
|
|
||||||
previous = coord;
|
|
||||||
}
|
|
||||||
positions.setInterpolationOptions({
|
|
||||||
interpolationDegree: 1,
|
|
||||||
interpolationAlgorithm: Cesium.LinearApproximation
|
|
||||||
});
|
|
||||||
|
|
||||||
viewer.clock.startTime = start.clone();
|
viewer.clock.startTime = start.clone();
|
||||||
viewer.clock.stopTime = stop.clone();
|
|
||||||
viewer.clock.currentTime = start.clone();
|
viewer.clock.currentTime = start.clone();
|
||||||
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP;
|
viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
|
||||||
viewer.clock.multiplier = 1;
|
viewer.clock.multiplier = 1;
|
||||||
viewer.clock.shouldAnimate = true;
|
viewer.clock.shouldAnimate = true;
|
||||||
|
|
||||||
const flat = [];
|
const vehicles = segments.map((segment, index) => {
|
||||||
for (const coord of segment.coordinates) {
|
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed);
|
||||||
flat.push(coord[0], coord[1], 1.05);
|
const option = document.createElement("option");
|
||||||
}
|
option.value = String(index);
|
||||||
viewer.entities.add({
|
option.textContent = "#" + (index + 1) + " " + segment.name + " " + Math.round(segment.lengthMeters) + "m";
|
||||||
name: "Cruise route",
|
vehicleSelect.appendChild(option);
|
||||||
polyline: {
|
return vehicle;
|
||||||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(flat),
|
|
||||||
width: 4,
|
|
||||||
material: Cesium.Color.CYAN.withAlpha(0.85),
|
|
||||||
clampToGround: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const vehicleOrientation = correctedVehicleOrientation(positions, -90.0);
|
|
||||||
const vehicle = viewer.entities.add({
|
|
||||||
name: "Cruise vehicle",
|
|
||||||
availability: new Cesium.TimeIntervalCollection([
|
|
||||||
new Cesium.TimeInterval({ start, stop })
|
|
||||||
]),
|
|
||||||
position: positions,
|
|
||||||
orientation: vehicleOrientation,
|
|
||||||
model: {
|
|
||||||
uri: "${escapeJs(vehicleModelName)}",
|
|
||||||
scale: 1.0,
|
|
||||||
minimumPixelSize: 24,
|
|
||||||
maximumScale: 80
|
|
||||||
},
|
|
||||||
path: {
|
|
||||||
resolution: 1,
|
|
||||||
leadTime: 4,
|
|
||||||
trailTime: 10,
|
|
||||||
width: 2,
|
|
||||||
material: Cesium.Color.ORANGE.withAlpha(0.9)
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
const state = { selectedIndex: 0 };
|
||||||
|
|
||||||
toggleCruise.addEventListener("click", () => {
|
toggleCruise.addEventListener("click", () => {
|
||||||
viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate;
|
viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate;
|
||||||
toggleCruise.textContent = viewer.clock.shouldAnimate ? "Pause" : "Play";
|
toggleCruise.textContent = viewer.clock.shouldAnimate ? "Pause" : "Play";
|
||||||
@@ -840,7 +808,14 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
|||||||
viewer.clock.multiplier = value / speed;
|
viewer.clock.multiplier = value / speed;
|
||||||
speedLabel.textContent = value + " m/s";
|
speedLabel.textContent = value + " m/s";
|
||||||
});
|
});
|
||||||
const follow = createChaseFollow(viewer, positions);
|
vehicleSelect.addEventListener("change", () => {
|
||||||
|
state.selectedIndex = Number(vehicleSelect.value || 0);
|
||||||
|
statusEl.textContent = selectedVehicle().label;
|
||||||
|
});
|
||||||
|
function selectedVehicle() {
|
||||||
|
return vehicles[state.selectedIndex] || vehicles[0];
|
||||||
|
}
|
||||||
|
const follow = createChaseFollow(viewer, () => selectedVehicle().positions);
|
||||||
toggleFollow.addEventListener("click", () => {
|
toggleFollow.addEventListener("click", () => {
|
||||||
if (follow.enabled) {
|
if (follow.enabled) {
|
||||||
follow.stop();
|
follow.stop();
|
||||||
@@ -850,23 +825,118 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
|||||||
toggleFollow.textContent = "Free";
|
toggleFollow.textContent = "Free";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { segment, vehicle };
|
statusEl.textContent = selectedVehicle().label;
|
||||||
|
return { vehicles };
|
||||||
}
|
}
|
||||||
|
|
||||||
function correctedVehicleOrientation(positions, yawDegrees) {
|
function addCruiseVehicle(viewer, segment, index, start, speed) {
|
||||||
const velocityOrientation = new Cesium.VelocityOrientationProperty(positions);
|
const route = prepareRoute(segment);
|
||||||
|
const positions = new Cesium.CallbackProperty((time, result) => {
|
||||||
|
return routePosition(route, start, time, speed, result);
|
||||||
|
}, false);
|
||||||
|
const flat = [];
|
||||||
|
for (const coord of segment.coordinates) {
|
||||||
|
flat.push(coord[0], coord[1], 1.05);
|
||||||
|
}
|
||||||
|
const routeColor = [Cesium.Color.CYAN, Cesium.Color.LIME, Cesium.Color.YELLOW, Cesium.Color.ORANGE, Cesium.Color.DEEPSKYBLUE][index % 5];
|
||||||
|
viewer.entities.add({
|
||||||
|
name: "Cruise route " + (index + 1),
|
||||||
|
polyline: {
|
||||||
|
positions: Cesium.Cartesian3.fromDegreesArrayHeights(flat),
|
||||||
|
width: 2,
|
||||||
|
material: routeColor.withAlpha(0.75),
|
||||||
|
clampToGround: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const vehicle = viewer.entities.add({
|
||||||
|
name: "Cruise vehicle " + (index + 1),
|
||||||
|
position: positions,
|
||||||
|
orientation: routeOrientation(route, start, speed, 0.0),
|
||||||
|
model: {
|
||||||
|
uri: "${escapeJs(vehicleModelName)}",
|
||||||
|
scale: 1.0,
|
||||||
|
minimumPixelSize: 24,
|
||||||
|
maximumScale: 80
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
entity: vehicle,
|
||||||
|
positions,
|
||||||
|
segment,
|
||||||
|
label: "Vehicle #" + (index + 1) + " | route " + segment.id + " | " + Math.round(segment.lengthMeters) + "m"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareRoute(segment) {
|
||||||
|
const distances = [0.0];
|
||||||
|
for (let i = 1; i < segment.coordinates.length; i += 1) {
|
||||||
|
distances.push(distances[i - 1] + distanceMeters(segment.coordinates[i - 1], segment.coordinates[i]));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
coordinates: segment.coordinates,
|
||||||
|
distances,
|
||||||
|
length: Math.max(1.0, distances[distances.length - 1])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routePosition(route, start, time, speed, result) {
|
||||||
|
const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start));
|
||||||
|
const distance = (seconds * speed) % route.length;
|
||||||
|
let index = 1;
|
||||||
|
while (index < route.distances.length - 1 && route.distances[index] < distance) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
const prevDist = route.distances[index - 1];
|
||||||
|
const nextDist = route.distances[index];
|
||||||
|
const t = nextDist > prevDist ? (distance - prevDist) / (nextDist - prevDist) : 0;
|
||||||
|
const a = route.coordinates[index - 1];
|
||||||
|
const b = route.coordinates[index];
|
||||||
|
const lon = a[0] + (b[0] - a[0]) * t;
|
||||||
|
const lat = a[1] + (b[1] - a[1]) * t;
|
||||||
|
return Cesium.Cartesian3.fromDegrees(lon, lat, 1.15, Cesium.Ellipsoid.WGS84, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeOrientation(route, start, speed, yawDegrees) {
|
||||||
const correction = Cesium.Quaternion.fromAxisAngle(
|
const correction = Cesium.Quaternion.fromAxisAngle(
|
||||||
Cesium.Cartesian3.UNIT_Z,
|
Cesium.Cartesian3.UNIT_Z,
|
||||||
Cesium.Math.toRadians(yawDegrees)
|
Cesium.Math.toRadians(yawDegrees)
|
||||||
);
|
);
|
||||||
|
const current = new Cesium.Cartesian3();
|
||||||
|
const ahead = new Cesium.Cartesian3();
|
||||||
|
const direction = new Cesium.Cartesian3();
|
||||||
|
const up = new Cesium.Cartesian3();
|
||||||
|
const east = new Cesium.Cartesian3();
|
||||||
|
const north = new Cesium.Cartesian3();
|
||||||
return new Cesium.CallbackProperty((time, result) => {
|
return new Cesium.CallbackProperty((time, result) => {
|
||||||
const base = velocityOrientation.getValue(time);
|
routePosition(route, start, time, speed, current);
|
||||||
if (!base) return result;
|
const aheadTime = Cesium.JulianDate.addSeconds(time, 0.8, new Cesium.JulianDate());
|
||||||
|
routePosition(route, start, aheadTime, speed, ahead);
|
||||||
|
Cesium.Cartesian3.subtract(ahead, current, direction);
|
||||||
|
if (Cesium.Cartesian3.magnitudeSquared(direction) < 0.0001) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Cesium.Cartesian3.normalize(direction, direction);
|
||||||
|
Cesium.Cartesian3.normalize(current, up);
|
||||||
|
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, up, east);
|
||||||
|
if (Cesium.Cartesian3.magnitudeSquared(east) < 0.0001) {
|
||||||
|
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, east);
|
||||||
|
} else {
|
||||||
|
Cesium.Cartesian3.normalize(east, east);
|
||||||
|
}
|
||||||
|
Cesium.Cartesian3.cross(up, east, north);
|
||||||
|
Cesium.Cartesian3.normalize(north, north);
|
||||||
|
const eastComponent = Cesium.Cartesian3.dot(direction, east);
|
||||||
|
const northComponent = Cesium.Cartesian3.dot(direction, north);
|
||||||
|
const heading = Math.atan2(eastComponent, northComponent);
|
||||||
|
const base = Cesium.Transforms.headingPitchRollQuaternion(
|
||||||
|
current,
|
||||||
|
new Cesium.HeadingPitchRoll(heading, 0.0, 0.0)
|
||||||
|
);
|
||||||
return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion());
|
return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion());
|
||||||
}, false);
|
}, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChaseFollow(viewer, positions) {
|
function createChaseFollow(viewer, positionsProvider) {
|
||||||
const scratchPosition = new Cesium.Cartesian3();
|
const scratchPosition = new Cesium.Cartesian3();
|
||||||
const scratchPrevious = new Cesium.Cartesian3();
|
const scratchPrevious = new Cesium.Cartesian3();
|
||||||
const scratchDirection = new Cesium.Cartesian3();
|
const scratchDirection = new Cesium.Cartesian3();
|
||||||
@@ -878,6 +948,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
|||||||
|
|
||||||
function update(clock) {
|
function update(clock) {
|
||||||
const time = clock.currentTime;
|
const time = clock.currentTime;
|
||||||
|
const positions = positionsProvider();
|
||||||
const position = positions.getValue(time, scratchPosition);
|
const position = positions.getValue(time, scratchPosition);
|
||||||
if (!position) return;
|
if (!position) return;
|
||||||
const previousTime = Cesium.JulianDate.addSeconds(time, -0.8, new Cesium.JulianDate());
|
const previousTime = Cesium.JulianDate.addSeconds(time, -0.8, new Cesium.JulianDate());
|
||||||
|
|||||||
Reference in New Issue
Block a user