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 = `