fix: stabilize fengshu native road compilation

This commit is contained in:
2026-08-24 13:51:21 +08:00
parent a16400ff96
commit f1f829c2c6
6 changed files with 4501 additions and 8937 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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");