From 65cf8b96d99a1b228ffb0438a916e831f156d7db Mon Sep 17 00:00:00 2001 From: que01 Date: Fri, 14 Aug 2026 15:19:57 +0800 Subject: [PATCH] feat: add native road compiler provider --- .trellis/spec/config/index.md | 1 + blender/export_cesium.py | 2 + blender/generate_scene.py | 22 +++- blender/osmassets/catalog.py | 8 ++ blender/tests/test_pure.py | 20 +++- config/examples/template.json | 3 +- scripts/build-area.js | 114 +++++++++++++----- scripts/lib/area-config.js | 9 ++ scripts/lib/native-road.js | 211 +++++++++++++++++++++++++++------ scripts/test-asset-budgets.js | 9 ++ scripts/test-native-road.js | 21 +++- scripts/test-road-workbench.js | 8 +- scripts/workbench/app.js | 36 +++++- scripts/workbench/index.html | 4 +- 14 files changed, 385 insertions(+), 83 deletions(-) diff --git a/.trellis/spec/config/index.md b/.trellis/spec/config/index.md index 6695cf6..dcb5a72 100644 --- a/.trellis/spec/config/index.md +++ b/.trellis/spec/config/index.md @@ -132,6 +132,7 @@ cp config/examples/template.json config/areas/my-area.json |---|---|---| | `treeStyle` | `"natural"` | 合法值见 `generate_scene.py` 的 `TREE_STYLES`(`natural`、`procedural`、`shapespark`) | | `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 | +| `roadProvider` | `"osm2streets"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;可由 `build:area --road-provider native` 临时覆盖。 | ### `compress` diff --git a/blender/export_cesium.py b/blender/export_cesium.py index c8becb3..d627d62 100644 --- a/blender/export_cesium.py +++ b/blender/export_cesium.py @@ -657,6 +657,8 @@ def export(args): raise RuntimeError("Dynamic traffic signal collection is empty") export_glb(args["dynamic_glb"], dynamic_meshes) for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")): + if not args.get(key): + continue if not countdown_meshes[group]: raise RuntimeError("Traffic countdown collection %d is empty" % group) export_glb(args[key], countdown_meshes[group]) diff --git a/blender/generate_scene.py b/blender/generate_scene.py index 3461ab3..8ece1a4 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -110,7 +110,7 @@ TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLE def cli_args(): - values = {"osm": None, "geojson": None, "output": None, "render": None, + values = {"osm": None, "geojson": None, "native_road": None, "output": None, "render": None, "office_overrides": "", "tree_style": "natural"} argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] i = 0 @@ -789,8 +789,23 @@ def build(args): _features.dispatch_ways(ways, projector, way_handlers) geojson_dir = args.get("geojson") + native_road_dir = args.get("native_road") road_counts = {} - if geojson_dir and os.path.isdir(geojson_dir): + if native_road_dir: + if not os.path.isdir(native_road_dir): + raise RuntimeError("--native-road directory does not exist: " + native_road_dir) + material_layers = {layer["id"]: layer for layer in catalog.ROAD_LAYERS} + for source in catalog.NATIVE_ROAD_LAYERS: + target = source["material_layer"] + layer = material_layers[target] + source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson") + count = _roads.assemble_geojson_layer( + source_path, source["source"], projector, roads_c, + road_mats[target], layer["z"]) + if count == 0: + raise RuntimeError("Native road layer has no usable geometry: " + source_path) + road_counts[source["source"]] = count + elif geojson_dir and os.path.isdir(geojson_dir): for problem in catalog.check_layers(geojson_dir): print("Layer catalog warning:", problem) for layer in catalog.ROAD_LAYERS: @@ -799,7 +814,7 @@ def build(args): os.path.join(geojson_dir, layer_id + ".geojson"), layer_id, projector, roads_c, road_mats[layer_id], layer["z"]) - if road_counts.get("road_surface", 0) == 0: + if not native_road_dir and road_counts.get("road_surface", 0) == 0: _roads.assemble_osm_fallback( ways, projector, roads_c, road_mats["road_surface"]) @@ -910,6 +925,7 @@ def build(args): scene.render.filepath = args["render"] scene["source_osm"] = args["osm"] scene["source_geojson"] = geojson_dir or "" + scene["source_native_road"] = native_road_dir or "" scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True) scene["building_count"] = counts["building_count"] scene["industrial_building_count"] = counts["industrial_count"] diff --git a/blender/osmassets/catalog.py b/blender/osmassets/catalog.py index 764d9cf..ce306c3 100644 --- a/blender/osmassets/catalog.py +++ b/blender/osmassets/catalog.py @@ -48,6 +48,14 @@ ROAD_LAYERS = [ SCENE_STYLE_FILE = "osm2streets_scene_style.json" +# Native-road output intentionally maps into existing scene material layers. +# It is a provider adapter, not a second scene-layer registry. +NATIVE_ROAD_LAYERS = ( + {"source": "road_surface", "material_layer": "road_surface"}, + {"source": "intersection_surface", "material_layer": "intersection_surface"}, + {"source": "sidewalk_surface", "material_layer": "sidewalks"}, +) + # Material specs. `kind` selects the builder: # solid — flat base colour diff --git a/blender/tests/test_pure.py b/blender/tests/test_pure.py index 0c8937a..93c4cc3 100644 --- a/blender/tests/test_pure.py +++ b/blender/tests/test_pure.py @@ -30,7 +30,7 @@ from osmassets.geom import ( sample_tree_row, signed_polygon_area, ) -from osmassets.catalog import ROAD_LAYERS +from osmassets.catalog import NATIVE_ROAD_LAYERS, ROAD_LAYERS from osmassets.osm import Projector, parse_height, parse_osm, tags @@ -46,6 +46,24 @@ class RoadLayerCatalogTest(unittest.TestCase): self.assertNotEqual(layers["road_surface"]["z"], layers["intersection_surface"]["z"]) + def test_native_provider_maps_to_existing_material_layers(self): + layers = {layer["id"] for layer in ROAD_LAYERS} + self.assertEqual( + [(entry["source"], entry["material_layer"]) + for entry in NATIVE_ROAD_LAYERS], + [("road_surface", "road_surface"), + ("intersection_surface", "intersection_surface"), + ("sidewalk_surface", "sidewalks")]) + self.assertTrue(all(entry["material_layer"] in layers + for entry in NATIVE_ROAD_LAYERS)) + + def test_cesium_export_keeps_signal_assets_optional(self): + exporter = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "export_cesium.py") + with open(exporter, encoding="utf-8") as handle: + source = handle.read() + self.assertIn('if not args.get(key):\n continue', source) + class GeometryRingsTest(unittest.TestCase): def test_polygon_keeps_only_the_exterior_ring(self): diff --git a/config/examples/template.json b/config/examples/template.json index 006d43f..07b81c4 100644 --- a/config/examples/template.json +++ b/config/examples/template.json @@ -33,7 +33,8 @@ }, "blender": { "treeStyle": "natural", - "officeOverrides": "" + "officeOverrides": "", + "roadProvider": "osm2streets" }, "compress": { "textureSize": 768, diff --git a/scripts/build-area.js b/scripts/build-area.js index de4351c..7c9e6ee 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); const { readAreaConfig } = require("./lib/area-config"); +const { compileArea: compileNativeRoads } = require("./compile-native-roads"); const { resolveStages } = require("./lib/build-stages"); const { validateManifest, addIntegrity } = require("./lib/package-contract"); const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths"); @@ -42,6 +43,10 @@ const requestedStages = args.stages ? splitList(args.stages) : null; const stages = resolveStages(area.stages, requestedStages); +const roadProvider = args.roadProvider || area.blender.roadProvider; +if (!new Set(["osm2streets", "native"]).has(roadProvider)) { + throw new Error("--road-provider must be \"osm2streets\" or \"native\"."); +} console.log(`Area: ${area.id}`); @@ -55,10 +60,10 @@ if (stages.reimport) { reimportGpkg(area); } if (stages.blender) { - buildBlenderScene(area); + buildBlenderScene(area, roadProvider); } if (stages.cesium) { - exportCesium(area); + exportCesium(area, roadProvider); } if (stages.compress) { compressCesiumGlb(area); @@ -208,16 +213,22 @@ function reimportGpkg(area) { }); } -function buildBlenderScene(area) { +function buildBlenderScene(area, roadProvider) { ensureFile(blenderExecutable(area), "Blender executable"); ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator"); - ensureFile(area.outputs.trafficSignalAssemblies, "Editable traffic signal assemblies"); - // Blender consumes the editable assembly layer; OSM only initializes it in - // intermediates, so QGIS edits remain authoritative across later stages. - writeTrafficSignals(area); - ensureFile(area.outputs.trafficSignals, "Traffic signal anchors"); + if (roadProvider === "osm2streets") { + ensureFile(area.outputs.trafficSignalAssemblies, "Editable traffic signal assemblies"); + // Blender consumes the editable assembly layer; OSM only initializes it in + // intermediates, so QGIS edits remain authoritative across later stages. + writeTrafficSignals(area); + ensureFile(area.outputs.trafficSignals, "Traffic signal anchors"); + } fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true }); fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true }); + if (roadProvider === "native") { + compileNativeRoads(configPath); + ensureNativeRoadLayers(area); + } const blenderArgs = [ "--background", @@ -227,8 +238,6 @@ function buildBlenderScene(area) { "--", "--osm", area.input, - "--geojson", - area.outputs.geojsonDir, "--output", area.outputs.blend, "--render", @@ -236,6 +245,11 @@ function buildBlenderScene(area) { "--tree-style", area.blender.treeStyle, ]; + if (roadProvider === "native") { + blenderArgs.push("--native-road", area.outputs.nativeRoadDir); + } else { + blenderArgs.push("--geojson", area.outputs.geojsonDir); + } if (area.blender.officeOverrides) { blenderArgs.push("--office-overrides", area.blender.officeOverrides); } @@ -255,17 +269,20 @@ function buildBlenderScene(area) { inputs: { config: fileRecord(configPath), osm: fileRecord(area.input), - geojsonDir: fileRecord(area.outputs.geojsonDir), - ...sceneGeojsonRecords(area), - trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies), - trafficSignals: fileRecord(area.outputs.trafficSignals), + ...(roadProvider === "native" ? nativeRoadRecords(area) : sceneGeojsonRecords(area)), + ...(roadProvider === "osm2streets" ? { + geojsonDir: fileRecord(area.outputs.geojsonDir), + trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies), + trafficSignals: fileRecord(area.outputs.trafficSignals), + } : {}), }, outputs: { blend: fileRecord(area.outputs.blend), render: fileRecord(area.outputs.render), }, summary: { - geojson: geojsonFeatureCounts(area), + ...(roadProvider === "native" ? { nativeRoad: nativeRoadFeatureCounts(area) } : { geojson: geojsonFeatureCounts(area) }), + roadProvider, blendBytes: fileRecord(area.outputs.blend).bytes, renderBytes: fileRecord(area.outputs.render).bytes, }, @@ -273,21 +290,49 @@ function buildBlenderScene(area) { }); } -function exportCesium(area) { +function ensureNativeRoadLayers(area) { + ensureFile(path.join(area.outputs.nativeRoadDir, "compiled.json"), "Native road compilation"); + for (const file of ["road_surface.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson"]) { + ensureFile(path.join(area.outputs.nativeRoadDir, "layers", file), `Native road layer ${file}`); + } +} + +function nativeRoadRecords(area) { + const root = path.join(area.outputs.nativeRoadDir, "layers"); + return { + nativeRoadCompiled: fileRecord(path.join(area.outputs.nativeRoadDir, "compiled.json")), + nativeRoadSurface: fileRecord(path.join(root, "road_surface.geojson")), + nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")), + nativeSidewalkSurface: fileRecord(path.join(root, "sidewalk_surface.geojson")), + }; +} + +function nativeRoadFeatureCounts(area) { + const root = path.join(area.outputs.nativeRoadDir, "layers"); + return { + roadSurface: featureCount(path.join(root, "road_surface.geojson")), + intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")), + sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")), + }; +} + +function exportCesium(area, roadProvider) { ensureFile(blenderExecutable(area), "Blender executable"); ensureFile(area.outputs.blend, "Blend scene"); ensureFile(path.join(repoRoot, "blender", "export_cesium.py"), "Cesium exporter"); 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 }); - 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 }); + 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(); const startedAt = new Date(started).toISOString(); - runCommand(blenderExecutable(area), [ + const exporterArgs = [ "--background", "--factory-startup", "--python", @@ -297,18 +342,22 @@ function exportCesium(area) { area.outputs.blend, "--glb", area.outputs.glb, - "--dynamic-glb", - area.outputs.trafficSignalsDynamicGlb, - "--countdown-0-glb", - area.outputs.trafficSignalsCountdown0Glb, - "--countdown-1-glb", - area.outputs.trafficSignalsCountdown1Glb, "--metadata", area.outputs.metadata, - ], "cesium"); - ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB"); - ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB"); - ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB"); + ]; + 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); @@ -325,12 +374,15 @@ function exportCesium(area) { outputs: { glb: fileRecord(area.outputs.glb), metadata: fileRecord(area.outputs.metadata), - trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb), + ...(roadProvider === "osm2streets" ? { + trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb), + } : {}), semanticAssets, }, summary: { glb: glbSummary(digest), budget: evaluateGlbBudget(digest, area.budget), + roadProvider, }, warnings: glbBudgetWarnings("Cesium", digest, area.budget), }); diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index 9394e72..b5c373c 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -117,6 +117,7 @@ function normalizeAreaConfig(raw, options = {}) { blender: { treeStyle: raw.blender?.treeStyle || "natural", officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "", + roadProvider: roadProviderOption(raw.blender?.roadProvider), }, compress, budget, @@ -172,6 +173,14 @@ function numberOption(value, fallback, label, min, max) { return number; } +function roadProviderOption(value) { + const provider = value ?? "osm2streets"; + if (provider !== "osm2streets" && provider !== "native") { + throw new Error("blender.roadProvider must be \"osm2streets\" or \"native\"."); + } + return provider; +} + function integerOption(value, fallback, label) { const number = value === undefined ? fallback : Number(value); if (!Number.isInteger(number) || number < 1) { diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index e8b847d..3d47476 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -36,12 +36,19 @@ function compileRoadModel(xml, overrides) { const roads = []; const endpoints = []; const byNode = new Map(); - for (const way of parsed.ways) { + const sharedNodeWayIds = new Map(); + for (const way of parsed.ways) for (const nodeId of new Set(way.refs)) { + if (!sharedNodeWayIds.has(nodeId)) sharedNodeWayIds.set(nodeId, new Set()); + sharedNodeWayIds.get(nodeId).add(way.id); + } + for (const sourceWay of parsed.ways) { + const segments = splitWayAtSharedNodes(sourceWay, sharedNodeWayIds); + for (const way of segments) { const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"]; for (const direction of directions) { const base = roadAttributes(way.tags, direction); - const id = `road:way/${way.id}:${direction}`; - const road = { id, osmWayIds: [way.id], direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] }; + const id = `road:way/${way.id}${way.segmentIndex === null ? "" : `:segment/${way.segmentIndex}`}:${direction}`; + const road = { id, osmWayIds: [way.id], segmentId: `segment:way/${way.id}/${way.segmentIndex ?? 0}`, sourceRoadId: `road:way/${way.id}:${direction}`, direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] }; applyRoadOverrides(road, overrides, diagnostics); roads.push(road); for (const side of ["start", "end"]) { @@ -52,6 +59,7 @@ function compileRoadModel(xml, overrides) { byNode.get(nodeId).push(endpoint); } } + } } const connections = resolveConnections(endpoints, byNode, overrides, diagnostics); const extent = roadExtent(roads); @@ -64,6 +72,17 @@ function compileRoadModel(xml, overrides) { return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics }; } +function splitWayAtSharedNodes(way, sharedNodeWayIds) { + const splitIndexes = [0]; + for (let index = 1; index < way.refs.length - 1; index += 1) if ((sharedNodeWayIds.get(way.refs[index])?.size || 0) > 1) splitIndexes.push(index); + splitIndexes.push(way.refs.length - 1); + if (splitIndexes.length === 2) return [{ ...way, segmentIndex: null }]; + return splitIndexes.slice(1).map((end, index) => { + const start = splitIndexes[index]; + return { ...way, refs: way.refs.slice(start, end + 1), coords: way.coords.slice(start, end + 1), segmentIndex: index + 1 }; + }); +} + function roadExtent(roads) { const points = roads.flatMap((road) => road.centerline); return { minLon: Math.min(...points.map((point) => point[0])), maxLon: Math.max(...points.map((point) => point[0])), minLat: Math.min(...points.map((point) => point[1])), maxLat: Math.max(...points.map((point) => point[1])) }; @@ -113,7 +132,7 @@ function loadOverrides(file) { function validateOverrides(value, model) { if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`); const ids = new Set(); - const roadIds = model ? new Set(model.roads.map((road) => road.id)) : null; + const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null; const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null; const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null; for (const item of value.overrides) { @@ -134,7 +153,11 @@ function validateOverrides(value, model) { } function applyRoadOverrides(road, overrides, diagnostics) { - for (const item of overrides.overrides.filter((entry) => entry.kind === "road" && entry.roadId === road.id)) { + const matching = overrides.overrides.filter((entry) => entry.kind === "road" && (entry.roadId === road.sourceRoadId || entry.roadId === road.id)); + // A legacy whole-way edit remains the baseline; a segment-specific edit can + // deliberately refine it after the compiler has introduced split segments. + matching.sort((first, second) => Number(first.roadId === road.id) - Number(second.roadId === road.id)); + for (const item of matching) { for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key]; road.appliedOverrideIds.push(item.id); for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`; @@ -172,8 +195,9 @@ function resolveConnections(endpoints, byNode, overrides, diagnostics) { function endpointNode(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId; } function sameOsmWay(endpoints, firstRoadId, secondRoadId) { - const wayId = (roadId) => roadId.split(":")[1]; - return wayId(firstRoadId) === wayId(secondRoadId); + const roadFor = (roadId) => endpoints.find((endpoint) => endpoint.roadId === roadId)?.roadId; + const segmentId = (roadId) => roadId.replace(/:(forward|backward)$/, ""); + return segmentId(roadFor(firstRoadId) || firstRoadId) === segmentId(roadFor(secondRoadId) || secondRoadId); } function connectionEndpointsCompatible(model, fromId, toId) { const from = model.endpoints.find((endpoint) => endpoint.id === fromId); @@ -191,31 +215,36 @@ function nearbyManualCandidates(endpoints, from) { function compileGeometry(model, overrides = { overrides: [] }) { const diagnostics = [...model.diagnostics]; + const junctionPlans = compileJunctionPlans(model); const features = []; - const emittedWays = new Set(); + const emittedSegments = new Set(); for (const road of model.roads) { - const wayKey = road.osmWayIds.join(","); - if (emittedWays.has(wayKey)) continue; - emittedWays.add(wayKey); - const directions = model.roads.filter((item) => item.osmWayIds.join(",") === wayKey); + const segmentKey = road.segmentId; + if (emittedSegments.has(segmentKey)) continue; + emittedSegments.add(segmentKey); + const directions = model.roads.filter((item) => item.segmentId === segmentKey); const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0); + // Road and junction asphalt share one final material. Keep the carriageway + // continuous through the semantic junction overlay; cutting it back creates + // visible wedges/gaps without improving the rendered result. const ring = roadRing(road.centerline, totalWidth); if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; } - features.push({ type: "Feature", properties: { native_id: `surface:way/${wayKey}`, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: wayKey, width_m: totalWidth, lane_count: directions.reduce((sum, item) => sum + item.laneCount, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); + const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`; + features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); } - const lanes = compileLaneCenterlines(model, diagnostics); - const sidewalks = compileSidewalkSurfaces(model, diagnostics); + const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans); + const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans); const connectorResult = compileConnectors(model, lanes, diagnostics, overrides); - const junctionFeatures = compileJunctionSurfaces(model, lanes, connectorResult.features, connectorResult.movements, diagnostics); + const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics); validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics); return { roadSurface: { type: "FeatureCollection", features }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics }; } -function compileSidewalkSurfaces(model, diagnostics) { +function compileSidewalkSurfaces(model, diagnostics, junctionPlans) { const features = []; const byWay = new Map(); for (const road of model.roads) { - const key = road.osmWayIds.join(","); + const key = road.segmentId; if (!byWay.has(key)) byWay.set(key, []); byWay.get(key).push(road); } @@ -229,14 +258,85 @@ function compileSidewalkSurfaces(model, diagnostics) { ]; for (const [side, enabled] of sides) { if (!enabled) continue; - const ring = sidewalkRing(forward.centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1); + const centerline = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans); + const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1); if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; } - features.push({ type: "Feature", properties: { native_id: `sidewalk:way/${wayKey}:${side}`, osm_way_ids: wayKey, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); + const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`; + features.push({ type: "Feature", properties: { native_id: sidewalkId, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); } } + features.push(...compileSidewalkCorners(model, junctionPlans)); return features; } +function compileSidewalkCorners(model, junctionPlans) { + const result = []; + for (const [nodeId, plan] of junctionPlans) { + const candidates = []; + for (const approach of plan.approaches) { + const directions = model.roads.filter((road) => road.segmentId === approach.segmentId); + const forward = directions.find((road) => road.direction === "forward") || directions[0]; + if (!forward) continue; + const outwardIsForward = forward.sourceNodeIds[0] === nodeId; + const sideStates = outwardIsForward + ? { left: forward.sidewalkLeft, right: forward.sidewalkRight } + : { left: forward.sidewalkRight, right: forward.sidewalkLeft }; + const cutback = pointAlongLine(approach.line, plan.cutbackMeters); + if (!cutback) continue; + const heading = headingAtEndpoint(approach.line); + const halfWidth = approach.widthMeters / 2; + for (const [side, enabled] of Object.entries(sideStates)) { + if (!enabled) continue; + // offsetLine's positive normal is driver's left, which is heading -90 + // in this north-based heading convention. + const sideHeading = heading + (side === "left" ? -90 : 90); + candidates.push({ + wayKey: approach.segmentId, + sourceWayKey: forward.osmWayIds.join(","), + side, + normalDegrees: sideHeading, + curb: offsetCoordinate(cutback, sideHeading, halfWidth), + outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS), + }); + } + } + candidates.sort((a, b) => angleAround(plan.node, a.curb) - angleAround(plan.node, b.curb)); + for (let index = 0; index < candidates.length; index += 1) { + const first = candidates[index]; + const second = candidates[(index + 1) % candidates.length]; + if (first.wayKey === second.wayKey) continue; + const ring = [first.curb, first.outer, second.outer, second.curb, first.curb]; + if (hasSelfIntersection(ring)) continue; + if (first.sourceWayKey === second.sourceWayKey && (!samePhysicalSide(first, second) || cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches))) continue; + result.push({ + type: "Feature", + properties: { + native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`, + osm_node_id: nodeId, + kind: "corner", + width_m: DEFAULT_SIDEWALK_WIDTH_METERS, + provenance: "native-road-sidewalk-corner/v1", + }, + geometry: { type: "Polygon", coordinates: [ring] }, + }); + } + } + return result; +} + +function samePhysicalSide(first, second) { + const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180; + return Math.cos(radians) >= 0.98; +} + +function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) { + const center = ring.slice(0, -1).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]); + return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => { + const carriageway = roadRing(approach.line, approach.widthMeters); + return carriageway && pointInPolygon(center, carriageway); + }); +} + function validateConnectorContainment(connectors, junctionFeatures, diagnostics) { const junctionByNode = new Map(junctionFeatures.map((feature) => [feature.properties.osm_node_id, feature])); for (const connector of connectors) { @@ -265,13 +365,13 @@ function pointOnSegment(point, a, b) { return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12; } -function compileLaneCenterlines(model, diagnostics) { +function compileLaneCenterlines(model, diagnostics, junctionPlans) { const features = []; const byRoadId = new Map(); for (const road of model.roads) { const lanes = []; const laneWidth = road.widthMeters / road.laneCount; - const siblings = model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(",")); + const siblings = model.roads.filter((item) => item.segmentId === road.segmentId); const opposite = siblings.find((item) => item.id !== road.id); // OSM centerline is the shared carriageway center. On a two-way road, // offset each directed carriageway to its own side before placing lanes. @@ -280,7 +380,7 @@ function compileLaneCenterlines(model, diagnostics) { // OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the // driver's left so tag positions and generated lane IDs have one meaning. const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5)); - const coordinates = offsetLine(road.centerline, offset); + const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset); if (!coordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; } const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates }; lanes.push(lane); @@ -381,20 +481,10 @@ function offsetLine(line, offsetMeters) { function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); } -function compileJunctionSurfaces(model, lanes, connectors, movements, diagnostics) { - const byNode = new Map(); - for (const endpoint of model.endpoints) { - if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []); - byNode.get(endpoint.nodeId).push(endpoint); - } +function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) { const result = []; - for (const [nodeId, endpoints] of byNode) { - const wayIds = new Set(endpoints.map((endpoint) => endpoint.roadId.split(":")[1])); - if (wayIds.size < 3 || wayIds.size > 4) continue; - const node = endpoints[0].coordinate; - const approaches = junctionApproaches(model, endpoints); - const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4; - const boundary = junctionBoundary(approaches, node, cutbackMeters); + for (const [nodeId, plan] of junctionPlans) { + const { segmentIds, node, approaches, cutbackMeters, boundary } = plan; const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId); const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId); if (boundary.length < 3 || !junctionMovements.length) { @@ -412,24 +502,45 @@ function compileJunctionSurfaces(model, lanes, connectors, movements, diagnostic diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node)); continue; } - result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, rule: "junction-approach-envelope/v2" }, geometry: { type: "Polygon", coordinates: [ring] } }); + result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } }); diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node)); } return result; } +function compileJunctionPlans(model) { + const byNode = new Map(); + for (const endpoint of model.endpoints) { + if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []); + byNode.get(endpoint.nodeId).push(endpoint); + } + const plans = new Map(); + for (const [nodeId, endpoints] of byNode) { + const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, ""))); + if (segmentIds.size < 3 || segmentIds.size > 4) continue; + const approaches = junctionApproaches(model, endpoints); + if (approaches.length !== segmentIds.size) continue; + const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4; + const node = endpoints[0].coordinate; + const boundary = junctionBoundary(approaches, node, cutbackMeters); + if (boundary.length < 3) continue; + plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary }); + } + return plans; +} + function junctionApproaches(model, endpoints) { const groups = new Map(); for (const endpoint of endpoints) { const road = model.roads.find((item) => item.id === endpoint.roadId); if (!road) continue; - const key = road.osmWayIds.join(","); + const key = road.segmentId; if (!groups.has(key)) groups.set(key, []); groups.get(key).push({ endpoint, road }); } return [...groups.values()].map((directions) => { const { endpoint, road } = directions[0]; - return { line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) }; + return { segmentId: road.segmentId, sourceWayKey: road.osmWayIds.join(","), line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) }; }); } @@ -456,9 +567,31 @@ function pointAlongLine(line, meters) { return line.at(-1); } +function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) { + const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0; + const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0; + if (!startCutback && !endCutback) return line; + const total = lineLengthMeters(line); + // Short OSM fragments cannot safely lose both ends. Keep their source + // geometry intact and let the junction diagnostic surface the ambiguity. + if (startCutback + endCutback >= total - 0.5) return line; + const result = []; + let traversed = 0; + const start = pointAlongLine(line, startCutback); + const end = pointAlongLine(line, total - endCutback); + result.push(start); + for (let index = 1; index < line.length - 1; index += 1) { + traversed += distanceMeters(line[index - 1], line[index]); + if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]); + } + result.push(end); + return result; +} + function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); } function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; } function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; } +function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); } function sortAround(center, points) { return points.sort((a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0])); } function convexHull(points) { const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]); diff --git a/scripts/test-asset-budgets.js b/scripts/test-asset-budgets.js index 3e8d1c6..f2e4d21 100644 --- a/scripts/test-asset-budgets.js +++ b/scripts/test-asset-budgets.js @@ -11,9 +11,12 @@ const { digestGltf } = require("./glb-digest"); const { evaluateGlbBudget, BUDGETS, fileRecord, writeStageManifest } = require("./lib/stage-manifest"); const qgisBuildSource = fs.readFileSync(path.join(__dirname, "build-osm2streets-qgis.js"), "utf8"); +const areaBuildSource = fs.readFileSync(path.join(__dirname, "build-area.js"), "utf8"); assert.match(qgisBuildSource, /QgsFieldConstraints\.Constraint\.ConstraintNotNull/); 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/); const gltf = { nodes: [ @@ -51,6 +54,12 @@ assert.equal( path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signal_assemblies.geojson"), ); assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800); +assert.equal(normalizeAreaConfig(base).blender.roadProvider, "osm2streets"); +assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native"); +assert.throws( + () => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }), + /blender\.roadProvider/, +); assert.throws( () => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }), /budget.reason is required/, diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index 43f3f88..7c8d4fa 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -32,8 +32,27 @@ assert.ok(geometry.connectors.features.every((feature) => feature.properties.nod assert.ok(geometry.movements.length >= geometry.connectors.features.length); assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:"))); assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus))); -assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-approach-envelope/v2")); +assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v3")); assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode))); +const crossOsm = ``; +const crossCenter = [114.001, 30]; +const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty)); +assert.equal(crossGeometry.intersectionSurface.features.length, 1); +assert.ok(crossGeometry.roadSurface.features.some((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) < 4)); +const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]); +assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5)); +assert.equal(crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner").length, 4); +const sharedInteriorNodeOsm = ``; +const sharedInteriorModel = compileRoadModel(sharedInteriorNodeOsm, empty); +assert.equal(sharedInteriorModel.roads.length, 6); +assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/1:forward")); +assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/2:forward")); +const sharedInteriorGeometry = compileGeometry(sharedInteriorModel); +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); +assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "corner" && /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"))); assert.equal(initial.connections.length, new Set(initial.connections.map((item) => `${item.fromEndpointId}->${item.toEndpointId}`)).size); diff --git a/scripts/test-road-workbench.js b/scripts/test-road-workbench.js index b3e595f..f9fc580 100644 --- a/scripts/test-road-workbench.js +++ b/scripts/test-road-workbench.js @@ -7,10 +7,16 @@ const path = require("path"); const html = fs.readFileSync(path.join(__dirname, "workbench", "index.html"), "utf8"); assert.match(html, /id="width" type="number" min="1" step="0\.01"/); -assert.match(html, /data-layer="sidewalks" type="checkbox" checked/); +assert.match(html, /data-layer="sidewalks" type="checkbox" checked> 路缘与步行带/); +assert.match(html, /id="scene-preview" type="checkbox"/); const app = fs.readFileSync(path.join(__dirname, "workbench", "app.js"), "utf8"); assert.match(app, /async function saveStagedChanges\(\)/); assert.match(app, /if \(!await saveStagedChanges\(\)\) return;/); assert.match(app, /function stageRoadOverride\(road, changes\)/); +assert.match(app, /road\.segmentId === selectedRoad\.segmentId/); assert.match(app, /sidewalkLeft: rightInput\.checked, sidewalkRight: leftInput\.checked/); +assert.match(app, /function nativeSurfaceStyle\(feature\)/); +assert.match(app, /scene mode must render fills only/); +assert.match(app, /scenePreviewToggle\.onchange/); +assert.match(app, /layers\.sidewalks\.setVisible\(document\.querySelector\('\[data-layer="sidewalks"\]'\)\.checked\)/); console.log("road workbench tests passed"); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index 88b502c..6cbac39 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -38,6 +38,7 @@ const addConnectionButton = document.querySelector("#add-connection"); const saveButton = document.querySelector("#save"); const compileButton = document.querySelector("#compile"); const dirtyState = document.querySelector("#dirty-state"); +const scenePreviewToggle = document.querySelector("#scene-preview"); let state; let selectedRoad = null; @@ -45,11 +46,12 @@ let selectedMovement = null; let staged = []; let manualFromEndpoint = null; let diagnosticFilter = "all"; +let scenePreview = false; const source = () => new VectorSource(); const layers = { reference: new VectorLayer({ source: source(), style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }), - native: new VectorLayer({ source: source(), style: (feature) => feature.get("native_id")?.startsWith("junction:") ? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) }) : new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) }) }), - sidewalks: new VectorLayer({ source: source(), style: new Style({ fill: new Fill({ color: "rgba(218, 191, 137, .6)" }), stroke: new Stroke({ color: "#9b7c40", width: 1 }) }) }), + native: new VectorLayer({ source: source(), style: nativeSurfaceStyle }), + sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }), osm: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#263630", width: feature.get("road_id") === selectedRoad?.id ? 5 : 2 }) }) }), lanes: new VectorLayer({ source: source(), style: laneStyle }), osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }), @@ -77,6 +79,19 @@ function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.sli function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); } function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; } function laneStyle(feature) { const selected = feature.get("road_id") === selectedRoad?.id; return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : 1.3, lineDash: [5, 4] }) }); } +function nativeSurfaceStyle(feature) { + // Split road features meet at OSM junction nodes. Their per-feature outlines + // are editing aids, not physical seams, so scene mode must render fills only. + if (scenePreview) return new Style({ fill: new Fill({ color: "#3f4b50" }) }); + return feature.get("native_id")?.startsWith("junction:") + ? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) }) + : new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) }); +} +function sidewalkSurfaceStyle() { + return scenePreview + ? new Style({ fill: new Fill({ color: "#b7b9ad" }) }) + : new Style({ fill: new Fill({ color: "rgba(218, 191, 137, .6)" }), stroke: new Stroke({ color: "#9b7c40", width: 1 }) }); +} function directionArrowFeature(geometry) { const middle = geometry.getCoordinateAt(.5); const before = geometry.getCoordinateAt(.48); const after = geometry.getCoordinateAt(.52); const length = Math.hypot(after[0] - before[0], after[1] - before[1]); if (length < .01) return null; return new Feature({ geometry: new Point(middle), rotation: Math.atan2(after[1] - before[1], after[0] - before[0]) }); } function refreshOsmDirection() { const directionSource = layers.osmDirection.getSource(); directionSource.clear(); if (!selectedRoad) return; const centerline = new LineString(selectedRoad.centerline).transform("EPSG:4326", "EPSG:3857"); const arrow = directionArrowFeature(centerline); if (arrow) directionSource.addFeature(arrow); } function refreshSelectedMovement() { const movementSource = layers.selectedMovement.getSource(); movementSource.clear(); if (!selectedMovement?.geometryPublished || !effectiveConnectorEnabled({ connection_id: selectedMovement.connectionId, fromLaneId: selectedMovement.fromLaneId, toLaneId: selectedMovement.toLaneId })) return; const feature = layers.connectors.getSource().getFeatures().find((candidate) => candidate.get("movement_id") === selectedMovement.id); if (feature) movementSource.addFeature(new Feature({ geometry: feature.getGeometry().clone() })); } @@ -138,12 +153,25 @@ addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad, function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); } function diagnosticLabel(item) { const road = state.compiled.model.roads.find((candidate) => candidate.id === item.subjectId); if (item.rule !== "unconnected-interior-road-end" || !road) return item.message; const candidateCount = item.manualCandidates?.length || 0; return `${roadLabel(road)}(${osmDirectionLabel(road)},节点 ${item.sourceIds[0]}):内部端点未连接${candidateCount ? `,附近有 ${candidateCount} 个可手工连接候选` : ""}`; } function renderDiagnostics() { const all = state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface"); const counts = { all: all.length, candidates: all.filter((item) => item.manualCandidates?.length).length, other: all.filter((item) => !item.manualCandidates?.length).length }; for (const button of diagnosticFilters.querySelectorAll("button")) { const filter = button.dataset.diagnosticFilter; button.classList.toggle("active", filter === diagnosticFilter); button.textContent = `${filter === "all" ? "全部" : filter === "candidates" ? "可连接" : "其他"}(${counts[filter]})`; } const visible = all.filter((item) => diagnosticFilter === "all" || diagnosticFilter === "candidates" ? Boolean(item.manualCandidates?.length) : !item.manualCandidates?.length).sort((a, b) => (b.manualCandidates?.length || 0) - (a.manualCandidates?.length || 0)); diagnostics.innerHTML = ""; for (const item of visible) { const button = document.createElement("button"); button.textContent = diagnosticLabel(item); button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } } -function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["人行道面", comparison.nativeSidewalkSurfaceFeatures], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } } +function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } } function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); } -form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.osmWayIds.join(",") === selectedRoad.osmWayIds.join(",")); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的人行道已按实际侧边同步" : "有未保存修改"); }; +form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); }; async function saveStagedChanges() { if (!staged.length) return true; const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; } saveButton.onclick = async () => { if (await saveStagedChanges()) message("已保存,点击“保存并重新生成”写入几何"); }; compileButton.onclick = async () => { if (!await saveStagedChanges()) return; message("正在保存修改并重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已保存并重新生成"); }; for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { layers[input.dataset.layer].setVisible(input.checked); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(input.checked); }; +scenePreviewToggle.onchange = () => { + scenePreview = scenePreviewToggle.checked; + for (const input of document.querySelectorAll("[data-layer]")) { + const layer = input.dataset.layer; + if (["osm", "lanes", "reference"].includes(layer)) layers[layer].setVisible(!scenePreview && input.checked); + } + layers.osmDirection.setVisible(!scenePreview && document.querySelector('[data-layer="osm"]').checked); + layers.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked); + layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked); + layers.diagnostics.setVisible(!scenePreview); + layers.native.changed(); layers.sidewalks.changed(); + message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览"); +}; for (const button of diagnosticFilters.querySelectorAll("button")) button.onclick = () => { diagnosticFilter = button.dataset.diagnosticFilter; renderDiagnostics(); }; fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message)); diff --git a/scripts/workbench/index.html b/scripts/workbench/index.html index c4670d0..d3645db 100644 --- a/scripts/workbench/index.html +++ b/scripts/workbench/index.html @@ -1,4 +1,4 @@ 道路编译工作台 -
道路编译工作台
-
+
道路编译工作台
+