fix: stabilize fengshu native road compilation
This commit is contained in:
@@ -39,7 +39,12 @@
|
||||
"coreRadiusMeters": 28,
|
||||
"cornerRadiusMeters": 25,
|
||||
"outerRadiusExtraMeters": 24,
|
||||
"nodeIds": ["8005332807", "8024512135", "8024512145", "8024512147"]
|
||||
"nodeIds": [
|
||||
"8005332807",
|
||||
"8024512135",
|
||||
"8024512145",
|
||||
"8024512147"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
13362
inputs/osm/枫树二路.osm
13362
inputs/osm/枫树二路.osm
File diff suppressed because it is too large
Load Diff
@@ -23,9 +23,23 @@ function compileArea(configPath) {
|
||||
const area = readAreaConfig(configPath, { repoRoot });
|
||||
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
|
||||
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
|
||||
validateOverrides(overrides, model);
|
||||
// Editing the source OSM retires the ids some overrides point at. Those
|
||||
// entries can no longer match anything, so drop them with a diagnostic rather
|
||||
// than aborting the whole compile — otherwise every OSM edit blocks the
|
||||
// pipeline until the file is hand-pruned, one error message at a time.
|
||||
const validated = validateOverrides(overrides, model, { skipStaleTargets: true });
|
||||
for (const item of validated.stale) console.warn(`[warning] 忽略失效的 override(目标已不存在):${item.id} -> ${item.target}`);
|
||||
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
|
||||
const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines, junctionTemplates: area.nativeRoad.junctionTemplates });
|
||||
compiled.diagnostics.push(...validated.stale.map((item) => ({
|
||||
id: `diagnostic:stale-override:${item.id}`,
|
||||
severity: "warning",
|
||||
subjectId: item.id,
|
||||
sourceIds: [],
|
||||
rule: "stale-override-target",
|
||||
message: `该设置指向的 ${item.kind} 目标 ${item.target} 已不存在(OSM 改动后 id 失效),本次编译已忽略。可在工作台重新设置,或从 native-road-overrides.json 中删除。`,
|
||||
geometry: null,
|
||||
})));
|
||||
const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface);
|
||||
const signalRuntime = runtime(signalDocument);
|
||||
// Persist validation normalization, including one-time legacy heading migration.
|
||||
|
||||
@@ -261,9 +261,15 @@ function readReferenceCalibration(cluster) {
|
||||
function complexJunctionMetrics(cluster) {
|
||||
if (metricsCache.has(cluster)) return metricsCache.get(cluster);
|
||||
const calibration = readReferenceCalibration(cluster);
|
||||
// Without a reference geometry `coreRadiusMeters` is the only size input the
|
||||
// template has, so honour it literally within the schema's own 12..80 range.
|
||||
// It used to be scaled by .52 and clamped to 17, which silently capped every
|
||||
// unreferenced junction at a core far smaller than its own approach envelope
|
||||
// — a 24 m dual-carriageway arm was being fitted into a 16.6 m core no matter
|
||||
// what the config asked for. The calibrated branch is unchanged.
|
||||
const coreRadius = calibration
|
||||
? Math.max(12, Math.min(24, calibration.shortSpanMeters * .14))
|
||||
: Math.max(11, Math.min(17, cluster.coreRadiusMeters * .52));
|
||||
: Math.max(12, Math.min(80, Number(cluster.coreRadiusMeters) || 28));
|
||||
const metrics = { calibration, coreRadius, approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18) };
|
||||
metricsCache.set(cluster, metrics);
|
||||
return metrics;
|
||||
|
||||
@@ -154,7 +154,26 @@ function loadOverrides(file) {
|
||||
return validateOverrides(JSON.parse(fs.readFileSync(file, "utf8")));
|
||||
}
|
||||
|
||||
function validateOverrides(value, model) {
|
||||
// An override points at an id derived from OSM. Editing the source can retire
|
||||
// that id — a way deleted, or split differently so `segment/6` no longer
|
||||
// exists — which leaves the entry pointing at nothing. That is stale data, not
|
||||
// a malformed override, so callers that merely consume overrides can ask to
|
||||
// skip them and keep going. Callers that *save* overrides still use the default
|
||||
// strict mode: writing a reference that cannot resolve is a real error.
|
||||
function staleOverrideTarget(item, sets) {
|
||||
if (!sets.roadIds) return null;
|
||||
if (item.kind === "road" && typeof item.roadId === "string" && !sets.roadIds.has(item.roadId)) return item.roadId;
|
||||
if (item.kind === "lane-separator-style" && typeof item.roadId === "string" && !sets.roadIds.has(item.roadId)) return item.roadId;
|
||||
if (item.kind === "edge-line-style" && typeof item.roadId === "string" && !sets.directionalRoadIds.has(item.roadId)) return item.roadId;
|
||||
if (item.kind === "center-line-style" && typeof item.segmentId === "string" && !sets.segmentIds.has(item.segmentId)) return item.segmentId;
|
||||
if (item.kind === "junction-connection" && typeof item.fromEndpointId === "string" && typeof item.toEndpointId === "string"
|
||||
&& (!sets.endpointIds.has(item.fromEndpointId) || !sets.endpointIds.has(item.toEndpointId))) return `${item.fromEndpointId} → ${item.toEndpointId}`;
|
||||
if (item.kind === "lane-connection" && typeof item.fromLaneId === "string" && typeof item.toLaneId === "string"
|
||||
&& (!sets.laneIds.has(item.fromLaneId) || !sets.laneIds.has(item.toLaneId))) return `${item.fromLaneId} → ${item.toLaneId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateOverrides(value, model, options = {}) {
|
||||
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.flatMap((road) => [road.id, road.sourceRoadId])) : null;
|
||||
@@ -162,9 +181,16 @@ function validateOverrides(value, model) {
|
||||
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;
|
||||
const segmentIds = model ? new Set(model.roads.map((road) => road.segmentId)) : null;
|
||||
const sets = { roadIds, directionalRoadIds, endpointIds, laneIds, segmentIds };
|
||||
const kept = [];
|
||||
const stale = [];
|
||||
for (const item of value.overrides) {
|
||||
if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id.");
|
||||
ids.add(item.id);
|
||||
if (options.skipStaleTargets) {
|
||||
const target = staleOverrideTarget(item, sets);
|
||||
if (target) { stale.push({ id: item.id, kind: item.kind, target }); continue; }
|
||||
}
|
||||
if (item.kind === "road") {
|
||||
if (typeof item.roadId !== "string" || roadIds && !roadIds.has(item.roadId)) throw new Error(`Unknown road override target: ${item.roadId}`);
|
||||
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined && (!Number.isFinite(item[key]) || item[key] <= 0 || (key === "laneCount" && !Number.isInteger(item[key])))) throw new Error(`Invalid road override ${key}.`);
|
||||
@@ -181,8 +207,9 @@ function validateOverrides(value, model) {
|
||||
} else if (item.kind === "edge-line-style") {
|
||||
if (typeof item.roadId !== "string" || (directionalRoadIds && !directionalRoadIds.has(item.roadId)) || !["left", "right"].includes(item.side) || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid edge line style override.");
|
||||
} else throw new Error(`Unsupported override kind: ${item.kind}`);
|
||||
kept.push(item);
|
||||
}
|
||||
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
|
||||
return { schema: OVERRIDE_SCHEMA, overrides: kept, stale };
|
||||
}
|
||||
|
||||
function applyRoadOverrides(road, overrides, diagnostics) {
|
||||
|
||||
@@ -379,6 +379,20 @@ const laneOverrides = validateOverrides({ schema: "native-road-overrides/v1", ov
|
||||
assert.equal(compileGeometry(turnModel, laneOverrides).connectors.features.length, 0);
|
||||
assert.equal(compileGeometry(turnModel, laneOverrides).movements.length, 0);
|
||||
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/);
|
||||
// Editing OSM retires ids. Saving such a reference is still an error, but a
|
||||
// consumer may ask to skip it so one stale entry cannot block the whole compile.
|
||||
const staleDoc = { schema: "native-road-overrides/v1", overrides: [
|
||||
{ id: "stale-road", kind: "road", roadId: "road:way/does-not-exist:forward", widthMeters: 4 },
|
||||
{ id: "live-road", kind: "road", roadId: target.id, widthMeters: 9 },
|
||||
] };
|
||||
assert.throws(() => validateOverrides(staleDoc, initial), /Unknown road/);
|
||||
const skipped = validateOverrides(staleDoc, initial, { skipStaleTargets: true });
|
||||
assert.equal(skipped.overrides.length, 1);
|
||||
assert.equal(skipped.overrides[0].id, "live-road");
|
||||
assert.deepEqual(skipped.stale.map((item) => item.id), ["stale-road"]);
|
||||
assert.equal(skipped.stale[0].target, "road:way/does-not-exist:forward");
|
||||
// Skipping only forgives missing targets; a malformed entry still throws.
|
||||
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-width", kind: "road", roadId: target.id, widthMeters: -1 }] }, initial, { skipStaleTargets: true }), /Invalid road override/);
|
||||
const freshArea = fs.mkdtempSync(path.join(os.tmpdir(), "native-road-fresh-area-"));
|
||||
try {
|
||||
const input = path.join(freshArea, "input.osm");
|
||||
|
||||
Reference in New Issue
Block a user