Add OSM area preflight

This commit is contained in:
2026-08-04 11:58:12 +08:00
parent 34153a31ab
commit e1f5207e97
13 changed files with 434 additions and 5 deletions

View File

@@ -67,6 +67,7 @@ function parseOsm(xml) {
const nodePattern = /<node\b([^>]*?)\/>|<node\b([^>]*?)>([\s\S]*?)<\/node>/g;
for (const match of xml.matchAll(nodePattern)) {
const attrs = xmlAttrs(match[1] || match[2] || "");
if (attrs.action === "delete") continue;
if (attrs.id) nodeIds.add(attrs.id);
nodeStats.total += 1;
const tags = parseTags(match[3] || "");
@@ -80,6 +81,8 @@ function parseOsm(xml) {
buildingsWithHeight: 0,
buildingsWithLevels: 0,
buildingsWithBadHeight: 0,
buildingsWithBadLevels: 0,
buildingGeometryIssues: [],
missingNodeRefs: 0,
grass: 0,
scrub: 0,
@@ -87,6 +90,7 @@ function parseOsm(xml) {
};
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete") continue;
const body = match[2];
const tags = parseTags(body);
const refs = [];
@@ -110,6 +114,14 @@ function parseOsm(xml) {
if (isExplicitHeight(tags)) wayStats.buildingsWithHeight += 1;
if (tags["building:levels"]) wayStats.buildingsWithLevels += 1;
if (tags.height && !parseHeightMeters(tags.height)) wayStats.buildingsWithBadHeight += 1;
if (tags["building:levels"] && !parseBuildingLevels(tags["building:levels"])) {
wayStats.buildingsWithBadLevels += 1;
}
if (refs.length < 4) {
wayStats.buildingGeometryIssues.push({ id: way.id, issue: "has fewer than 4 node refs" });
} else if (!way.closed) {
wayStats.buildingGeometryIssues.push({ id: way.id, issue: "is not closed" });
}
}
if (tags.landuse === "grass") wayStats.grass += 1;
if (tags.natural === "scrub") wayStats.scrub += 1;
@@ -122,11 +134,13 @@ function parseOsm(xml) {
buildingsWithHeight: 0,
buildingsWithLevels: 0,
buildingsWithBadHeight: 0,
buildingsWithBadLevels: 0,
healthyBuildingMultipolygons: 0,
issues: [],
};
for (const match of xml.matchAll(/<relation\b([^>]*)>([\s\S]*?)<\/relation>/g)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete") continue;
const body = match[2];
const tags = parseTags(body);
const members = [];
@@ -139,6 +153,9 @@ function parseOsm(xml) {
if (isExplicitHeight(tags)) relationStats.buildingsWithHeight += 1;
if (tags["building:levels"]) relationStats.buildingsWithLevels += 1;
if (tags.height && !parseHeightMeters(tags.height)) relationStats.buildingsWithBadHeight += 1;
if (tags["building:levels"] && !parseBuildingLevels(tags["building:levels"])) {
relationStats.buildingsWithBadLevels += 1;
}
const health = buildingRelationHealth(attrs.id || "", members, ways);
if (health.ok) relationStats.healthyBuildingMultipolygons += 1;
@@ -163,7 +180,10 @@ function parseBounds(xml) {
maxLon: Number(attrs.maxlon),
maxLat: Number(attrs.maxlat),
};
return Object.values(bounds).every(Number.isFinite) ? bounds : null;
return Object.values(bounds).every(Number.isFinite) &&
bounds.minLon < bounds.maxLon && bounds.minLat < bounds.maxLat
? bounds
: null;
}
function isExplicitHeight(tags) {
@@ -177,6 +197,48 @@ function parseHeightMeters(value) {
return Number.isFinite(height) && height > 0 ? height : null;
}
function parseBuildingLevels(value) {
const levels = Number(String(value).trim());
return Number.isFinite(levels) && levels > 0;
}
function analyzeOsmPreflight(osm) {
const errors = [];
const warnings = [];
if (!osm.bounds) errors.push("OSM has no valid <bounds>.");
if (osm.ways.missingNodeRefs) {
errors.push(`OSM ways reference ${osm.ways.missingNodeRefs} missing node(s).`);
}
for (const issue of osm.ways.buildingGeometryIssues) {
errors.push(`Building way ${issue.id}: ${issue.issue}.`);
}
if (osm.ways.buildingsWithBadHeight || osm.relations.buildingsWithBadHeight) {
errors.push("Some building height tags could not be parsed as positive meters.");
}
for (const issue of osm.relations.issues) {
errors.push(`Building relation ${issue.id}: ${issue.issues.join("; ")}.`);
}
const badLevels = osm.ways.buildingsWithBadLevels + osm.relations.buildingsWithBadLevels;
if (badLevels) {
warnings.push(`${badLevels} building:levels tag(s) could not be parsed as positive numbers.`);
}
return {
errors: uniqueLines(errors),
warnings: uniqueLines(warnings),
summary: {
bounds: osm.bounds,
nodes: osm.nodes.total,
ways: osm.ways.total,
relations: osm.relations.total,
buildingWays: osm.ways.buildings,
buildingMultipolygons: osm.relations.buildingMultipolygons,
missingNodeRefs: osm.ways.missingNodeRefs,
buildingWayIssues: osm.ways.buildingGeometryIssues.length,
buildingRelationIssues: osm.relations.issues.length,
},
};
}
function buildingRelationHealth(id, members, ways) {
const issues = [];
const outerMembers = members.filter((member) => member.type === "way" && member.role === "outer");
@@ -307,6 +369,15 @@ function stageManifestStatus(area, configPath = null) {
const hasReimportManifest = fs.existsSync(reimportManifest);
const derivedConfig = path.join(area.outputs.pipelineDir, "osm2streets-qgis.config.json");
const stages = [
{
stage: "preflight",
expected: fs.existsSync(stageManifestPath(area, "preflight")),
inputs: {
...(configPath ? { config: configPath } : {}),
osm: area.input,
},
outputs: {},
},
{
stage: "intermediates",
expected: (
@@ -763,6 +834,7 @@ function formatBytes(bytes) {
module.exports = {
BUDGETS,
analyzeOsmPreflight,
analyzeArea,
artifactStatus,
classifyAreaQuality,