From 3eea12c6ea0cea1cb08e37b0ca5f8cdc8f9c85bc Mon Sep 17 00:00:00 2001 From: que01 Date: Fri, 14 Aug 2026 16:07:55 +0800 Subject: [PATCH] fix: stabilize native road Cesium preview --- .trellis/spec/pipeline/external-tools.md | 50 ++++++++++++++++++++++++ scripts/build-area.js | 3 ++ scripts/lib/native-road.js | 1 + scripts/test-build-stages.js | 3 ++ scripts/test-native-road.js | 1 + scripts/test-road-workbench.js | 1 + scripts/workbench/app.js | 2 +- 7 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.trellis/spec/pipeline/external-tools.md b/.trellis/spec/pipeline/external-tools.md index 8e53cb4..496d294 100644 --- a/.trellis/spec/pipeline/external-tools.md +++ b/.trellis/spec/pipeline/external-tools.md @@ -120,6 +120,56 @@ SCENE_LAYERS.forEach((layer, index) => { ## Blender 调用 +### macOS Blender 4.5 的 Metal 启动兼容 + +#### 1. Scope / Trigger + +`export_cesium.py` 在 macOS 的 Blender 4.5.12 后台启动时,可能在 Python 脚本加载前的 Metal 扩展探测中崩溃;这不是场景或道路数据错误。 + +#### 2. Signatures + +Cesium 阶段的调用参数必须包含: + +```text +--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py -- ... +``` + +#### 3. Contracts + +`--debug-gpu-force-workarounds` 是 Blender 的官方 CLI 参数。它只约束导出进程的 GPU 扩展探测,不改变 `.blend`、GeoJSON 或导出脚本的输入输出契约。 + +#### 4. Validation & Error Matrix + +| 情况 | 结果 | +|---|---| +| 缺少该参数且启动时崩在 Metal 初始化 | 不应归因于道路数据;补齐参数后重跑 Cesium 阶段 | +| 参数存在且 `CESIUM_EXPORT_DONE` / stage manifest 写出 | 继续 GLB digest 与预览验证 | + +#### 5. Good / Base / Bad Cases + +- Good: 保留 `--factory-startup`,并在 Cesium 导出加入 workaround。 +- Base: Blender 场景阶段未受影响时,不额外改变其启动参数。 +- Bad: 为绕过启动崩溃删除 `--factory-startup`,这会重新引入本机偏好和 addon 的不确定性。 + +#### 6. Tests Required + +- `npm run test:build-stages` 断言导出参数仍包含 workaround。 +- 对目标区域运行 `--stages blender,cesium,preview`,并用 `glb-digest.js` 解析输出。 + +#### 7. Wrong vs Correct + +Wrong: + +```text +--background --python blender/export_cesium.py +``` + +Correct: + +```text +--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py +``` + ### 两种调用姿势 | 阶段 | 参数 | 出处 | diff --git a/scripts/build-area.js b/scripts/build-area.js index 7c9e6ee..6201ce9 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -335,6 +335,9 @@ function exportCesium(area, roadProvider) { const exporterArgs = [ "--background", "--factory-startup", + // Blender 4.5 on this macOS host can crash while probing Metal extensions + // before the exporter script runs; this is Blender's documented workaround. + "--debug-gpu-force-workarounds", "--python", path.join(repoRoot, "blender", "export_cesium.py"), "--", diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index 3d47476..5d9af52 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -503,6 +503,7 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di continue; } 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] } }); + if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node)); diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node)); } return result; diff --git a/scripts/test-build-stages.js b/scripts/test-build-stages.js index 6818c19..e097466 100644 --- a/scripts/test-build-stages.js +++ b/scripts/test-build-stages.js @@ -1,9 +1,12 @@ #!/usr/bin/env node "use strict"; const assert = require("assert"); +const fs = require("fs"); const { resolveStages, canonicalStages } = require("./lib/build-stages"); assert.deepEqual(canonicalStages(resolveStages({}, ["compress", "blender", "preview"])), ["blender", "compress", "preview"]); assert.deepEqual(canonicalStages(resolveStages({}, ["all"])), ["intermediates", "blender", "cesium", "compress", "package", "preview"]); assert.deepEqual(canonicalStages(resolveStages({ intermediates: true, blender: true, cesium: true, compress: true, package: true })), ["intermediates", "blender", "cesium", "compress", "package"]); assert.throws(() => resolveStages({}, ["intermediates", "reimport"]), /mutually exclusive/); +const buildAreaSource = fs.readFileSync(require("path").join(__dirname, "build-area.js"), "utf8"); +assert.match(buildAreaSource, /"--debug-gpu-force-workarounds"/); console.log("Build stage tests passed."); diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index 7c8d4fa..bf259e6 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -34,6 +34,7 @@ assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movemen 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-shared-cutback/v3")); assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode))); +for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback")); const crossOsm = ``; const crossCenter = [114.001, 30]; const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty)); diff --git a/scripts/test-road-workbench.js b/scripts/test-road-workbench.js index ee55409..67c4023 100644 --- a/scripts/test-road-workbench.js +++ b/scripts/test-road-workbench.js @@ -22,4 +22,5 @@ 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\)/); assert.match(app, /function selectJunction\(feature\)/); +assert.match(app, /candidate\.get\("native_id"\) === item\.subjectId/); console.log("road workbench tests passed"); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index a627f63..b4513a0 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -161,7 +161,7 @@ function stageConnection(connection, enabled) { const id = `连接:${connection. function stageLaneConnection(connector, enabled) { const fromLaneId = laneId(connector, "from"); const toLaneId = laneId(connector, "to"); const id = `车道连接:${fromLaneId}->${toLaneId}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId, toLaneId, enabled }); layers.connectors.changed(); updateDirtyState(); } function chooseManualTarget(targetRoad) { const toEndpoint = endpointFor(targetRoad, "start"); if (!endpointsCompatible(manualFromEndpoint, toEndpoint)) return message("该方向的起点与当前道路终点不兼容:必须是同一路口,或相距不超过 35 米。"); const connection = { id: `connection:${manualFromEndpoint.id}:${toEndpoint.id}`, fromEndpointId: manualFromEndpoint.id, toEndpointId: toEndpoint.id }; manualFromEndpoint = null; stageConnection(connection, true); selectRoad(selectedRoad, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); } addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad, "end"); if (!endpoint) return; manualFromEndpoint = endpoint; select.getFeatures().clear(); message("请在地图上点击目标方向的 OSM 中心线;仅同一路口或 35 米内的驶出方向可连接。"); }; -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 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 }); const junction = layers.native.getSource().getFeatures().find((candidate) => candidate.get("native_id") === item.subjectId); if (junction) return selectJunction(junction); 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); } }