diff --git a/README.md b/README.md index 7908004..7c29678 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ python3 -m http.server 8765 ## 实验:车辆巡航 -`preview` 和 `cesium` 阶段会额外生成 `-vehicle-route.json` 和 `-vehicle-car.gltf`。路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线,避免车辆压道路中心线。Cesium 预览页会加载最长道路段并显示一辆实验车辆循环巡航。 +`preview` 和 `cesium` 阶段会额外生成 `-vehicle-route.json` 和 `-vehicle-car.gltf`。路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线,避免车辆压道路中心线。Cesium 预览页会加载多条道路段并显示多辆实验车辆循环巡航;`Vehicle` 下拉框决定 `Follow` 跟随哪一辆车。 这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 主资产本身。车辆模型是无 logo 的轻量预览模型,生成在输出目录中。 diff --git a/docs/changelog.md b/docs/changelog.md index 1456ef8..16f0e52 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,7 @@ - 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `-vehicle-route.json`,并在预览页中驱动车辆循环移动。 - 巡航路线从道路中心线向右偏移约 1.3 米,车辆模型改为无 logo 的轻量预览车。 +- 车辆巡航预览支持多辆车同时行驶,并通过 `Vehicle` 下拉框选择 Follow 目标。 - 将项目主入口重构为区域资产管线:`scripts/build-area.js`。 - 新增 `config/areas/nantaizi-lake-innovation-valley.json` 和 `config/areas/hanyang-block.json`,支持按 OSM 输入生成独立输出目录。 - `npm run build` 现在默认走区域资产管线;旧 QGIS 管线保留为 `npm run build:qgis`。 diff --git a/scripts/build-area.js b/scripts/build-area.js index b6e6066..16d538b 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -661,6 +661,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a margin: 0; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #d9e0e2; } #status { position: absolute; @@ -708,6 +709,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
+ 8 m/s
@@ -716,6 +718,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a const statusEl = document.getElementById("status"); const toggleCruise = document.getElementById("toggleCruise"); const toggleFollow = document.getElementById("toggleFollow"); + const vehicleSelect = document.getElementById("vehicleSelect"); const speedControl = document.getElementById("speedControl"); const speedLabel = document.getElementById("speedLabel"); Cesium.Ion.defaultAccessToken = ""; @@ -743,6 +746,12 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a baseLayer: false }); 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 enu = Cesium.Transforms.eastNorthUpToFixedFrame(p); @@ -754,83 +763,42 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a scale: 1.0 }); viewer.scene.primitives.add(model); - const cruise = addVehicleCruise(viewer, routeData); + const cruise = addVehicleCruises(viewer, routeData); const center = Cesium.Cartesian3.fromDegrees(longitude, latitude, height); viewer.camera.flyToBoundingSphere( new Cesium.BoundingSphere(center, 900.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) { - const segment = (routeData.segments || [])[0]; - if (!segment || !segment.coordinates || segment.coordinates.length < 2) { + function addVehicleCruises(viewer, routeData) { + const segments = (routeData.segments || []) + .filter((segment) => segment.coordinates && segment.coordinates.length >= 2) + .slice(0, 5); + if (!segments.length) { throw new Error("No drivable route found in ${escapeJs(routeName)}"); } const speed = Number(routeData.speedMetersPerSecond || 8); 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.stopTime = stop.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.shouldAnimate = true; - const flat = []; - for (const coord of segment.coordinates) { - flat.push(coord[0], coord[1], 1.05); - } - viewer.entities.add({ - name: "Cruise route", - polyline: { - 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 vehicles = segments.map((segment, index) => { + const vehicle = addCruiseVehicle(viewer, segment, index, start, speed); + const option = document.createElement("option"); + option.value = String(index); + option.textContent = "#" + (index + 1) + " " + segment.name + " " + Math.round(segment.lengthMeters) + "m"; + vehicleSelect.appendChild(option); + return vehicle; }); + const state = { selectedIndex: 0 }; + toggleCruise.addEventListener("click", () => { viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate; toggleCruise.textContent = viewer.clock.shouldAnimate ? "Pause" : "Play"; @@ -840,7 +808,14 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a viewer.clock.multiplier = value / speed; 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", () => { if (follow.enabled) { follow.stop(); @@ -850,23 +825,118 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a toggleFollow.textContent = "Free"; } }); - return { segment, vehicle }; + statusEl.textContent = selectedVehicle().label; + return { vehicles }; } - function correctedVehicleOrientation(positions, yawDegrees) { - const velocityOrientation = new Cesium.VelocityOrientationProperty(positions); + function addCruiseVehicle(viewer, segment, index, start, speed) { + 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( Cesium.Cartesian3.UNIT_Z, 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) => { - const base = velocityOrientation.getValue(time); - if (!base) return result; + routePosition(route, start, time, speed, current); + 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()); }, false); } - function createChaseFollow(viewer, positions) { + function createChaseFollow(viewer, positionsProvider) { const scratchPosition = new Cesium.Cartesian3(); const scratchPrevious = new Cesium.Cartesian3(); const scratchDirection = new Cesium.Cartesian3(); @@ -878,6 +948,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a function update(clock) { const time = clock.currentTime; + const positions = positionsProvider(); const position = positions.getValue(time, scratchPosition); if (!position) return; const previousTime = Cesium.JulianDate.addSeconds(time, -0.8, new Cesium.JulianDate());