diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js index 072a473..771e586 100755 --- a/scripts/build-osm2streets-qgis.js +++ b/scripts/build-osm2streets-qgis.js @@ -530,8 +530,10 @@ function filteredSidewalkCorners(intersections, maxDimensionMeters) { function buildSidewalkCorners(intersections, plain, network, maxDimensionMeters) { const out = filteredSidewalkCorners(intersections, maxDimensionMeters); - const fallback = synthesizeMissingSidewalkCorners(out, plain, network, maxDimensionMeters); - out.features.push(...fallback); + const missing = synthesizeMissingSidewalkCorners(out, plain, network, maxDimensionMeters); + out.features.push(...missing); + const caps = synthesizeTJunctionSidewalkCaps(out, plain, network, maxDimensionMeters); + out.features.push(...caps); return out; } @@ -554,13 +556,11 @@ function synthesizeMissingSidewalkCorners(existing, plain, network, maxDimension const edges = buildIntersectionEdges(intersection, roads, roadFeatures, intersectionFeature); if (!edges.length) continue; const qualifyingPairs = qualifyingCornerPairs(edges); - if (qualifyingPairs.length < 3) continue; + if (!qualifyingPairs.length) continue; const current = existingByIntersection.get(intersectionId) || []; - if (qualifyingPairs.length - current.length !== 1) continue; const currentCenters = current.map((entry) => entry.center); - const targetDimension = median(current.map((entry) => maxFeatureDimensionMeters(entry.feature)).filter(Number.isFinite)); - const candidates = []; + const missing = []; for (const [one, two] of qualifyingPairs) { const candidate = synthesizeCornerFeature(one, two, intersectionFeature, maxDimensionMeters); @@ -572,17 +572,67 @@ function synthesizeMissingSidewalkCorners(existing, plain, network, maxDimension if (dimension === null || dimension > maxDimensionMeters) continue; if (polygonAreaMeters2(candidate) < 0.4) continue; if (currentCenters.some((point) => pointDistance(point, candidateCenter) <= 0.6)) continue; - candidates.push({ - feature: candidate, - center: candidateCenter, - dimension, - score: Math.abs(dimension - targetDimension), - }); + missing.push(candidate); } - if (!candidates.length) continue; - candidates.sort((a, b) => a.score - b.score || a.dimension - b.dimension); - synthesized.push(candidates[0].feature); + if (missing.length !== 1) continue; + synthesized.push(missing[0]); + } + + return synthesized; +} + +function synthesizeTJunctionSidewalkCaps(existing, plain, network, maxDimensionMeters) { + const roadFeatures = new Map((plain.features || []) + .filter((feature) => feature.properties?.type === "road") + .map((feature) => [Number(feature.properties.id), feature])); + const intersectionFeatures = new Map((plain.features || []) + .filter((feature) => feature.properties?.type === "intersection") + .map((feature) => [Number(feature.properties.id), feature])); + const roads = new Map((network.roads || []).map(([id, road]) => [Number(id), road])); + const intersections = new Map((network.intersections || []).map(([id, intersection]) => [Number(id), intersection])); + const existingByIntersection = assignCornersToIntersections(existing.features || [], intersectionFeatures); + const synthesized = []; + + for (const [intersectionId, intersection] of intersections.entries()) { + const intersectionFeature = intersectionFeatures.get(intersectionId); + if (!intersectionFeature) continue; + if (intersectionFeature.properties?.intersection_kind !== "Intersection") continue; + if (new Set(intersection.roads || []).size !== 3) continue; + + const edges = buildIntersectionEdges(intersection, roads, roadFeatures, intersectionFeature); + if (!edges.length) continue; + const current = existingByIntersection.get(intersectionId) || []; + const smallCurrent = current.filter((entry) => { + const dimension = maxFeatureDimensionMeters(entry.feature); + return Number.isFinite(dimension) && dimension <= maxDimensionMeters; + }); + if (smallCurrent.length !== 2) continue; + + const candidates = []; + for (const [one, two] of qualifyingCornerPairs(edges)) { + const candidate = synthesizeCornerFeature(one, two, intersectionFeature, 100); + if (!candidate) continue; + const center = featureCenter(candidate); + const dimension = maxFeatureDimensionMeters(candidate); + const area = polygonAreaMeters2(candidate); + if (!center || !Number.isFinite(dimension) || !Number.isFinite(area)) continue; + candidates.push({ feature: candidate, center, dimension, area }); + } + + // For a true T-junction, the remaining large candidate is the sidewalk "cap" + // opposite the side street, not another curb-return corner. + const caps = candidates.filter(({ dimension, area, center }) => ( + dimension > maxDimensionMeters && + dimension <= 12 && + area >= 6 && + area <= 20 && + pointInPolygon(center, intersectionFeature.geometry.coordinates) && + !smallCurrent.some((entry) => pointDistance(entry.center, center) <= 1) + )); + if (caps.length !== 1) continue; + caps[0].feature.properties.source = "fallback_t_cap"; + synthesized.push(caps[0].feature); } return synthesized; @@ -905,12 +955,6 @@ function polygonAreaMeters2(feature) { return Math.abs(area) / 2; } -function median(values) { - if (!values.length) return 0; - const sorted = [...values].sort((a, b) => a - b); - return sorted[Math.floor(sorted.length / 2)]; -} - function maxFeatureDimensionMeters(feature) { const points = []; collectCoords(feature.geometry?.coordinates, points);