diff --git a/eslint.config.js b/eslint.config.js index 05bf79c..e197aea 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,13 @@ import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; export default [ - { ignores: ['dist'] }, + { + ignores: [ + 'dist', + 'src/features/draft-generator/extractor/**', + 'src/features/draft-generator/shims/**', + ], + }, { files: ['**/*.{js,jsx}'], languageOptions: { diff --git a/package.json b/package.json index af5491e..836eb5d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "node --test src/features/draft-generator/matching.test.js", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\" \"**/*.{js,jsx,ts,tsx,json,css,md}\"", "preview": "vite preview" }, diff --git a/src/features/draft-generator/DraftGeneratorMap.jsx b/src/features/draft-generator/DraftGeneratorMap.jsx new file mode 100644 index 0000000..79fd52d --- /dev/null +++ b/src/features/draft-generator/DraftGeneratorMap.jsx @@ -0,0 +1,512 @@ +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import Map, { Layer, NavigationControl, Popup, ScaleControl, Source } from 'react-map-gl/maplibre'; +import { Bug, Layers3, MapPinned, Palette, Waypoints } from 'lucide-react'; +import 'maplibre-gl/dist/maplibre-gl.css'; + +const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN; +const RESULT_INTERACTIVE_LAYERS = [ + 'draft-matched', + 'draft-unsafe', + 'draft-unmatched', + 'draft-unmatched-points', +]; +const SIMULATOR_INTERACTIVE_LAYERS = ['simulator-source', 'simulator-merged']; +const DIVISION_INTERACTIVE_LAYERS = ['division-original-lines', 'division-original-points']; +const MATCHED_TYPE_COLOR = [ + 'match', + ['get', 'divisionType'], + 'lead_on', + '#34d399', + 'taxiway', + '#60a5fa', + 'stopbar', + '#f59e0b', + 'stand', + '#c084fc', + '#34d399', +]; + +const STREET_STYLE = { + version: 8, + sources: { + osm: { + type: 'raster', + tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: + '© OpenStreetMap contributors', + }, + }, + layers: [{ id: 'osm', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 22 }], +}; + +const SATELLITE_STYLE = { + version: 8, + sources: { + mapbox: { + type: 'raster', + tiles: [ + `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/{z}/{x}/{y}?access_token=${MAPBOX_TOKEN}`, + ], + tileSize: 512, + attribution: 'Imagery © Mapbox', + }, + }, + layers: [{ id: 'mapbox', type: 'raster', source: 'mapbox', minzoom: 0, maxzoom: 22 }], +}; + +const DraftGeneratorMap = memo(function DraftGeneratorMap({ + airport, + geojsonUrl, + simulatorGeojsonUrl, + divisionGeojson, + bounds, +}) { + const mapRef = useRef(null); + const [mapStyle, setMapStyle] = useState(SATELLITE_STYLE); + const [styleName, setStyleName] = useState('Satellite'); + const [showSimulatorGeometry, setShowSimulatorGeometry] = useState(false); + const [showDivisionGeometry, setShowDivisionGeometry] = useState(false); + const [colorByBarsId, setColorByBarsId] = useState(false); + const [popup, setPopup] = useState(null); + const [viewState, setViewState] = useState({ + longitude: airport?.longitude || 0, + latitude: airport?.latitude || 0, + zoom: 14, + }); + + useEffect(() => { + if (!airport) return; + setViewState((current) => ({ + ...current, + longitude: airport.longitude, + latitude: airport.latitude, + zoom: 14, + })); + }, [airport]); + + useEffect(() => { + setPopup(null); + if (!bounds || !mapRef.current) return; + mapRef.current.fitBounds( + [ + [bounds[0], bounds[1]], + [bounds[2], bounds[3]], + ], + { padding: 56, maxZoom: 19, duration: 500 } + ); + }, [bounds, geojsonUrl]); + + const handleMapClick = useCallback((event) => { + const feature = event.features?.[0]; + if (!feature) { + setPopup(null); + return; + } + setPopup({ + longitude: event.lngLat.lng, + latitude: event.lngLat.lat, + properties: feature.properties, + }); + }, []); + + const handleMouseMove = useCallback((event) => { + const canvas = mapRef.current?.getCanvas?.(); + if (canvas) canvas.style.cursor = event.features?.length ? 'pointer' : ''; + }, []); + + const toggleStyle = () => { + if (styleName === 'Satellite') { + setMapStyle(STREET_STYLE); + setStyleName('Street map'); + } else { + setMapStyle(SATELLITE_STYLE); + setStyleName('Satellite'); + } + }; + + return ( +
+ {airport ? ( + setViewState(event.viewState)} + onClick={handleMapClick} + onMouseMove={handleMouseMove} + interactiveLayerIds={ + geojsonUrl || + (showSimulatorGeometry && simulatorGeojsonUrl) || + (showDivisionGeometry && divisionGeojson) + ? [ + ...(geojsonUrl ? RESULT_INTERACTIVE_LAYERS : []), + ...(showSimulatorGeometry && simulatorGeojsonUrl + ? SIMULATOR_INTERACTIVE_LAYERS + : []), + ...(showDivisionGeometry && divisionGeojson + ? DIVISION_INTERACTIVE_LAYERS + : []), + ] + : [] + } + mapStyle={mapStyle} + style={{ width: '100%', height: '100%' }} + renderWorldCopies={false} + maxPitch={0} + > + + + + {geojsonUrl ? ( + + + + + + + + + ) : null} + + {showSimulatorGeometry && simulatorGeojsonUrl ? ( + + + + + ) : null} + + {showDivisionGeometry && divisionGeojson ? ( + + + + + ) : null} + + {popup ? ( + setPopup(null)} + offset={10} + maxWidth="280px" + > + + + ) : null} + + ) : ( +
+ +
+ )} + +
+ + {simulatorGeojsonUrl ? ( + <> + + {showSimulatorGeometry ? ( +
+ + + {!colorByBarsId ? ( + <> +
+ + + + + ) : null} +
+ ) : null} + + ) : null} + {divisionGeojson ? ( + + ) : null} + {geojsonUrl ? ( + + ) : null} +
+ + {!geojsonUrl ? ( +
+ Select a scenery folder to align the draft with simulator lighting. +
+ ) : null} +
+ ); +}); + +function DebugLegendRow({ color, dashed = false, label }) { + return ( +

+

+ ); +} + +function FeaturePopup({ properties }) { + const unmatched = properties.featureType === 'unmatched'; + const unsafe = properties.featureType === 'unsafe'; + const simulatorSource = properties.featureType === 'simulator-source'; + const simulatorMerged = properties.featureType === 'simulator-merged'; + const divisionOriginal = properties.featureType === 'division-original'; + const simulatorDebug = simulatorSource || simulatorMerged; + return ( +
+

{properties.title}

+

+ {unmatched + ? 'Manual addition required' + : unsafe + ? 'Matched · removal needs review' + : simulatorSource + ? 'Raw extracted simulator row' + : simulatorMerged + ? 'Merged matcher geometry' + : divisionOriginal + ? 'Original division geometry' + : properties.matchedViaHoldShort + ? 'Aligned to simulator hold-short position' + : 'Matched to simulator lighting'} +

+

+ {divisionOriginal + ? formatType(properties.divisionType) + : simulatorDebug + ? `${formatType(properties.simulatorType)}${ + simulatorMerged ? ` · ${properties.sourceRowCount} source rows` : '' + }` + : unmatched || unsafe + ? properties.reason + : `${formatType(properties.divisionType)} · ${properties.matchPercent}% shape match`} +

+ {!simulatorDebug && properties.divisionId ? ( +

+ BARS ID {properties.divisionId} +

+ ) : null} + {simulatorSource && properties.sourceFile ? ( +

+ {properties.sourceFile} +

+ ) : null} + {simulatorMerged && properties.sourceRowIds ? ( +

+ {properties.sourceRowIds} +

+ ) : null} +
+ ); +} + +function formatType(type) { + return String(type || 'object') + .replaceAll('_', ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +DraftGeneratorMap.propTypes = { + airport: PropTypes.shape({ + latitude: PropTypes.number.isRequired, + longitude: PropTypes.number.isRequired, + }), + geojsonUrl: PropTypes.string, + simulatorGeojsonUrl: PropTypes.string, + divisionGeojson: PropTypes.object, + bounds: PropTypes.arrayOf(PropTypes.number), +}; + +FeaturePopup.propTypes = { + properties: PropTypes.object.isRequired, +}; + +DebugLegendRow.propTypes = { + color: PropTypes.string.isRequired, + dashed: PropTypes.bool, + label: PropTypes.string.isRequired, +}; + +export default DraftGeneratorMap; diff --git a/src/features/draft-generator/README.md b/src/features/draft-generator/README.md new file mode 100644 index 0000000..2258b6f --- /dev/null +++ b/src/features/draft-generator/README.md @@ -0,0 +1,15 @@ +# Draft generator + +This feature runs the BARS scenery extractor in a browser Web Worker. Selected scenery files are represented as local `File` objects, mounted in the worker's in-memory filesystem, and never sent to an application endpoint. + +The files under `extractor/` and `shims/` are sourced from the BARS Airport Contribution Tool browser viewer. The website copy exposes `generateRemovalGeometry` and supports `buildRemovals: false` so extraction can skip the expensive all-airport remover pass. + +Matching prefers native, source-backed light rows. Simulator layers in the same lighting family are merged only when their length, centre and full-path position are nearly identical. The highest-confidence source row supplies the logical line unchanged; matching and selective removals never average or resample source geometry, and every contributing source row remains attached as provenance. Longer logical simulator lines are then clipped into non-overlapping, division-aligned sections so different segmentation between the two datasets does not force an all-or-nothing match. Each accepted logical section expands back to the real source-row sections before selective removals are built. A shorter source row may also match when the source geometry is tightly contained by the longer division shape. When a package only exposes individual source placements, division geometry may guide their ordering into a candidate row, but every removal vertex remains a decoded simulator position. After matching, reconstructed replacement-only guidance geometry may be simplified within a 0.6 metre source corridor to remove placement-to-placement wobble; stopbar, junction and row endpoints remain exact, and native simulator rows are not changed. Unsuitable candidates stay unmatched for manual placement. + +After matching, remover geometry is rebuilt from approved source rows only. Every decoded must-keep zone remains active, and unselected stopbar, lead-on, and taxiway-centerline source objects are also protected so the draft cannot remove unrelated lighting. + +`matching.js` deliberately accepts only source-backed simulator rows and sections. The detector classification is retained as provenance, while the division object supplies the output classification. Guidance rows may therefore map between decoded taxi-centreline and lead-on presets when their geometry supports it; stopbars remain a strict stopbar-only family. Each source section is assigned once to its best division object, while one division object may own several compiled source segments. Co-located selected layers are all removed but collapse to one replacement shape, and no accepted sections of the same parent row may overlap. + +The map includes intentionally small debugging toggles. `SIM geometry` appears after generation: cyan dashed lines are raw extracted simulator rows, while purple solid lines are logical geometries created by merging two or more co-located source rows before splitting and matching. `Division data` is available before and after generation and shows the original API geometry in pink, including its BARS ID in the popup. All layers remain client-side. + +Unmatched or unsafe division objects are excluded from the draft XML and emitted as red map features for manual work. diff --git a/src/features/draft-generator/draft-generator.worker.js b/src/features/draft-generator/draft-generator.worker.js new file mode 100644 index 0000000..32b9d46 --- /dev/null +++ b/src/features/draft-generator/draft-generator.worker.js @@ -0,0 +1,148 @@ +import { extractAirportLightData } from './extractor/extract.js'; +import { scanInput } from './extractor/scanner.js'; +import { mountFiles, unmountFiles } from './shims/virtual-fs.js'; +import { matchDivisionObjects } from './matching.js'; +import { buildDraftOutput } from './draft-output.js'; + +const DEFAULT_SIZES = { + library: 3, + vfx: 3, + simprop: 8, + lightrow: 2, +}; + +self.addEventListener('message', async (event) => { + const message = event.data; + if (message?.type !== 'generate') return; + + try { + const result = await generateDraft(message); + self.postMessage({ type: 'complete', id: message.id, result }); + } catch (error) { + self.postMessage({ + type: 'error', + id: message.id, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } +}); + +async function generateDraft(message) { + const startedAt = performance.now(); + postStage(message.id, 'Indexing selected package', 8); + const input = mountFiles(message.entries); + + try { + const fileScan = await scanInput(input); + if (fileScan.bglFiles.length === 0 && fileScan.xmlFiles.length === 0) { + throw new Error('No BGL or XML scenery files were found in the selected folder.'); + } + + postStage(message.id, 'Reading BGL data and detecting simulator lights', 28); + const data = await extractAirportLightData({ + input: fileScan.input, + icao: message.icao, + filesScanned: fileScan.filesScanned, + xmlFiles: fileScan.xmlFiles, + bglFiles: fileScan.bglFiles, + unsupportedFiles: fileScan.unsupportedFiles, + sizes: DEFAULT_SIZES, + buildRemovals: false, + }); + + postStage(message.id, 'Aligning division objects to simulator geometry', 70); + const matching = matchDivisionObjects( + message.divisionPoints, + data.lightRows, + data.instances, + data.topologyRows + ); + + postStage(message.id, 'Building selective, protected removal areas', 84); + const output = buildDraftOutput(data, matching, { + icao: message.icao, + altitude: message.altitude, + onProgress: ({ pass, maxPasses, remaining }) => { + postStage( + message.id, + `Checking protected lighting · pass ${pass} · ${remaining} candidates`, + 84 + Math.round(((pass - 1) / maxPasses) * 10) + ); + }, + }); + + postStage(message.id, 'Preparing draft and map preview', 95); + const xmlBlob = new Blob([output.xml], { type: 'application/xml' }); + const geojsonBlob = new Blob([JSON.stringify(output.geojson)], { + type: 'application/geo+json', + }); + const simulatorGeojsonBlob = new Blob([JSON.stringify(output.simulatorGeojson)], { + type: 'application/geo+json', + }); + const manualReview = output.unmatched.map((item) => ({ + id: String(item.division?.id ?? ''), + name: item.division?.name || String(item.division?.id || 'Unmatched object'), + type: item.division?.type || 'unknown', + reason: item.reason, + })); + const removalReview = output.removalWarnings.map((item) => ({ + id: String(item.division?.id ?? ''), + name: item.division?.name || String(item.division?.id || 'Matched object'), + type: item.division?.type || 'unknown', + reason: item.reason, + })); + + return { + xmlBlob, + geojsonBlob, + simulatorGeojsonBlob, + bounds: featureBounds(output.geojson.features), + matchedCount: new Set(output.matched.map((match) => String(match.division.id))).size, + manualCount: manualReview.length, + manualReview, + removalReview, + duplicateDivisionLeadOns: output.duplicateDivisionLeadOns.length, + duplicateSimulatorLeadOns: output.duplicateSimulatorLeadOns.length, + elapsedSeconds: round((performance.now() - startedAt) / 1000, 1), + sourceSummary: { + filesScanned: fileScan.filesScanned, + bglFilesParsed: data.meta.bglFilesParsed, + xmlFilesParsed: data.meta.xmlFilesParsed, + }, + }; + } finally { + unmountFiles(); + } +} + +function postStage(id, stage, progress) { + self.postMessage({ type: 'stage', id, stage, progress }); +} + +function featureBounds(features) { + const bounds = [Infinity, Infinity, -Infinity, -Infinity]; + for (const feature of features) { + visitCoordinates(feature.geometry?.coordinates, (lon, lat) => { + bounds[0] = Math.min(bounds[0], lon); + bounds[1] = Math.min(bounds[1], lat); + bounds[2] = Math.max(bounds[2], lon); + bounds[3] = Math.max(bounds[3], lat); + }); + } + return bounds.every(Number.isFinite) ? bounds : undefined; +} + +function visitCoordinates(value, callback) { + if (!Array.isArray(value)) return; + if (value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) { + callback(value[0], value[1]); + return; + } + for (const child of value) visitCoordinates(child, callback); +} + +function round(value, digits) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} diff --git a/src/features/draft-generator/draft-output.js b/src/features/draft-generator/draft-output.js new file mode 100644 index 0000000..593a8be --- /dev/null +++ b/src/features/draft-generator/draft-output.js @@ -0,0 +1,975 @@ +import { generateRemovalGeometry } from './extractor/extract.js'; +import { + haversineDistanceMeters, + pointInPolygon, + pointToPolylineDistanceMeters, + polylineToPolylineDistanceMeters, +} from './extractor/geo.js'; + +const DEFAULT_SIZES = { + library: 3, + vfx: 3, + simprop: 8, + lightrow: 2, +}; +const TARGET_CLASSIFICATIONS = new Set(['stopbar', 'lead-on', 'taxi-centerline']); +const GUIDANCE_CLASSIFICATIONS = new Set(['lead-on', 'taxi-centerline']); +const SECTION_PROTECTION_BOUNDARY_GAP_METERS = 1.25; +const ISOLATED_CROSSING_DISTANCE_METERS = 1.3; +const MAXIMUM_ISOLATED_CROSSING_OVERLAP_METERS = 6; +const CROSSING_SAMPLE_INTERVAL_METERS = 0.5; +const MAXIMUM_SAFETY_PASSES = 12; +const REMOVAL_COVERAGE_BOUNDARY_TOLERANCE_METERS = 0.1; +const DEBUG_ID_NEIGHBOR_COUNT = 4; +const DEBUG_ID_COLORS = [ + '#3b82f6', + '#f97316', + '#22c55e', + '#ec4899', + '#06b6d4', + '#eab308', + '#8b5cf6', + '#ef4444', + '#14b8a6', + '#f472b6', + '#84cc16', + '#6366f1', +]; + +export function buildDraftOutput(data, matching, options) { + const selective = buildSafeSelectiveRemovals(data, matching.matches, options.onProgress); + const removalWarnings = uniqueDivisionItems( + selective.rejected.map((match) => ({ + division: match.division, + reason: + 'Simulator geometry matched and the BARS object was added, but its automatic removal area needs manual review.', + })) + ); + const replacements = replacementMatches(matching.matches); + const safeReplacements = replacementMatches(selective.approved); + const safetyDivisionIds = new Set( + removalWarnings.map((item) => String(item.division?.id ?? '')) + ); + const unsafeReplacements = replacements.filter((match) => + safetyDivisionIds.has(String(match.division.id)) + ); + const safeVisualReplacements = safeReplacements.filter( + (match) => !safetyDivisionIds.has(String(match.division.id)) + ); + const xml = generateDraftXml({ + icao: options.icao, + altitude: options.altitude, + matches: replacements, + removalPolygons: selective.geometry.removalPolygons, + }); + const geojson = buildDraftGeoJson( + safeVisualReplacements, + matching.unmatched, + selective.geometry.removalPolygons, + unsafeReplacements + ); + const simulatorGeojson = buildSimulatorDebugGeoJson( + [...(data.lightRows ?? []), ...(data.topologyRows ?? [])], + matching.mergedSimulatorRows ?? [] + ); + + return { + xml, + geojson, + simulatorGeojson, + matched: matching.matches, + removalApproved: selective.approved, + replacements, + unmatched: matching.unmatched, + removalWarnings, + removals: selective.geometry.removalPolygons, + safetyRejections: selective.rejected.map((match) => ({ + divisionId: String(match.division.id), + rowId: match.row.id, + sourceParentRowId: match.row.sourceParentRowId, + sourceRangeStartMeters: match.row.sourceRangeStartMeters, + sourceRangeEndMeters: match.row.sourceRangeEndMeters, + reason: match.safetyReason, + conflictIds: match.safetyConflictIds ?? [], + pass: match.safetyPass, + })), + duplicateDivisionLeadOns: matching.duplicateDivisionLeadOns, + duplicateSimulatorLeadOns: matching.duplicateSimulatorLeadOns, + }; +} + +function replacementMatches(matches) { + const replacements = []; + const ordered = [...matches].sort( + (left, right) => + String(left.division.id).localeCompare(String(right.division.id)) || + rowLengthMeters(right.row) - rowLengthMeters(left.row) || + right.score - left.score + ); + + for (const match of ordered) { + const replacement = match.replacementRow + ? { ...match, row: match.replacementRow } + : match; + const isCovered = replacements.some( + (existing) => + String(existing.division.id) === String(replacement.division.id) && + rowFollowsRow(replacement.row, existing.row) + ); + if (!isCovered) replacements.push(replacement); + } + return replacements; +} + +function rowFollowsRow(candidate, reference) { + const candidateVertices = candidate.vertices ?? []; + const referenceVertices = reference.vertices ?? []; + return ( + candidateVertices.length >= 2 && + referenceVertices.length >= 2 && + candidateVertices.every( + (vertex) => pointToPolylineDistanceMeters(vertex, referenceVertices) <= 2 + ) + ); +} + +function rowLengthMeters(row) { + let length = 0; + for (let index = 1; index < (row.vertices?.length ?? 0); index += 1) { + length += haversineDistanceMeters(row.vertices[index - 1], row.vertices[index]); + } + return length; +} + +function buildSafeSelectiveRemovals(data, initialMatches, onProgress) { + const noRemovalMatches = initialMatches.filter( + (match) => match.row?.noRemovalRequired === true + ); + let approved = initialMatches.filter( + (match) => match.row?.noRemovalRequired !== true + ); + const originalSelectedRows = approved.map((match) => match.row); + const rejected = []; + let geometry; + + if (approved.length === 0) { + return { + approved: noRemovalMatches, + rejected, + geometry: emptyRemovalGeometry(), + }; + } + + for (let pass = 0; pass < MAXIMUM_SAFETY_PASSES; pass += 1) { + if (approved.length === 0) break; + onProgress?.({ + pass: pass + 1, + maxPasses: MAXIMUM_SAFETY_PASSES, + remaining: approved.length, + }); + geometry = generateForMatches(data, approved, originalSelectedRows); + + const unselectedTargetConflicts = new Set( + rowsCoveringSelectiveProtectionZones( + geometry.selectiveProtectionZones, + geometry.removalPolygons, + approved.map((match) => match.row) + ) + ); + const selectiveProtectionDetails = geometry.protectionConflicts.filter((conflict) => + conflict.mustKeepZoneIds?.some((id) => String(id).startsWith('selective-')) + ); + const protectedLightDetails = geometry.protectionConflicts.filter( + (conflict) => + !conflict.mustKeepZoneIds?.length || + conflict.mustKeepZoneIds.some((id) => !String(id).startsWith('selective-')) + ); + const activeRows = approved.map((match) => match.row); + const selectiveProtectionConflicts = conflictIdsBySourceRow( + selectiveProtectionDetails, + activeRows + ); + const protectedLightConflicts = conflictIdsBySourceRow(protectedLightDetails, activeRows); + const removalRings = geometry.removalPolygons.map((polygon) => + polygon.coordinates.map(([lon, lat]) => ({ lon, lat })) + ); + const unsafe = approved + .map((match) => { + const isCovered = rowIsCoveredByRemovals(match.row, removalRings); + const hasUnselectedTargetConflict = unselectedTargetConflicts.has(match.row.id); + const hasSelectiveProtectionConflict = selectiveProtectionConflicts.has(match.row.id); + const hasProtectedLightConflict = protectedLightConflicts.has(match.row.id); + if ( + isCovered && + !hasUnselectedTargetConflict && + !hasSelectiveProtectionConflict && + !hasProtectedLightConflict + ) { + return null; + } + return { + ...match, + safetyPass: pass + 1, + safetyConflictIds: hasSelectiveProtectionConflict + ? selectiveProtectionConflicts.get(match.row.id) + : hasProtectedLightConflict + ? protectedLightConflicts.get(match.row.id) + : [], + safetyReason: hasUnselectedTargetConflict + ? 'unselected-target-conflict' + : hasSelectiveProtectionConflict + ? 'unselected-target-protection-conflict' + : hasProtectedLightConflict + ? 'must-keep-conflict' + : 'incomplete-removal-coverage', + }; + }) + .filter(Boolean); + if (unsafe.length === 0) { + return { approved: [...approved, ...noRemovalMatches], rejected, geometry }; + } + + const unsafeRows = new Set(unsafe.map((match) => match.row.id)); + rejected.push(...unsafe); + approved = approved.filter((match) => !unsafeRows.has(match.row.id)); + } + + if (approved.length === 0) { + return { + approved: noRemovalMatches, + rejected, + geometry: emptyRemovalGeometry(), + }; + } + throw new Error( + 'Selective removal safety did not converge. No draft was created; add the remaining objects manually.' + ); +} + +function conflictIdsBySourceRow(conflicts, rows) { + const rowsById = new Map(rows.map((row) => [row.id, row])); + const bySourceRow = new Map(); + for (const conflict of conflicts) { + let sourceRowIds = conflict.sourceRowIds?.length + ? conflict.sourceRowIds + : [conflict.sourceRowId]; + if (Number.isFinite(conflict.lat) && Number.isFinite(conflict.lon)) { + const conflictPoint = { lat: conflict.lat, lon: conflict.lon }; + const hasKnownSourceRow = sourceRowIds.some((sourceRowId) => rowsById.has(sourceRowId)); + const localSourceRowIds = sourceRowIds.filter((sourceRowId) => { + const row = rowsById.get(sourceRowId); + return ( + row?.vertices?.length > 0 && + pointToPolylineDistanceMeters(conflictPoint, row.vertices) <= + ISOLATED_CROSSING_DISTANCE_METERS + ); + }); + if (hasKnownSourceRow) sourceRowIds = localSourceRowIds; + } + for (const sourceRowId of sourceRowIds) { + if (!sourceRowId) continue; + const ids = bySourceRow.get(sourceRowId) ?? new Set(); + for (const id of conflict.mustKeepZoneIds ?? []) ids.add(id); + bySourceRow.set(sourceRowId, ids); + } + } + return new Map( + [...bySourceRow].map(([sourceRowId, ids]) => [sourceRowId, [...ids].sort()]) + ); +} + +function rowIsCoveredByRemovals(row, removalRings) { + const vertices = row.vertices ?? []; + if (vertices.length === 0) return false; + return vertices.every((vertex) => + removalRings.some( + (ring) => + pointInPolygon(vertex, ring) || + pointToPolylineDistanceMeters(vertex, ring) <= + REMOVAL_COVERAGE_BOUNDARY_TOLERANCE_METERS + ) + ); +} + +function rowsCoveringSelectiveProtectionZones(protectionZones, removalPolygons, rows) { + const rowsById = new Map(rows.map((row) => [row.id, row])); + const protectedPoints = (protectionZones ?? []).flatMap((zone) => + zone.geometryType === 'Point' ? [zone.point] : zone.vertices ?? [] + ); + const rowIds = new Set(); + for (const polygon of removalPolygons) { + const ring = polygon.coordinates.map(([lon, lat]) => ({ lon, lat })); + if (!protectedPoints.some((point) => pointInPolygon(point, ring))) continue; + const sourceRowIds = polygon.sourceRowIds?.length + ? polygon.sourceRowIds + : [polygon.sourceRowId]; + const hasKnownSourceRow = sourceRowIds.some((rowId) => rowsById.has(rowId)); + const localSourceRowIds = sourceRowIds.filter((rowId) => { + const row = rowsById.get(rowId); + return protectedPoints.some( + (point) => + pointInPolygon(point, ring) && + row?.vertices?.length > 0 && + pointToPolylineDistanceMeters(point, row.vertices) <= + ISOLATED_CROSSING_DISTANCE_METERS + ); + }); + for (const rowId of hasKnownSourceRow ? localSourceRowIds : sourceRowIds) { + rowIds.add(rowId); + } + } + return rowIds; +} + +function generateForMatches(data, matches, originalSelectedRows = matches.map((match) => match.row)) { + const selectedRows = matches.map((match) => match.row); + const rows = consolidateRemovalRows(data.lightRows ?? [], selectedRows); + const sourceInstanceIds = new Set(selectedRows.flatMap((row) => row.sourceInstanceIds ?? [])); + const sourceInstances = (data.instances ?? []).filter((instance) => + sourceInstanceIds.has(instance.id) + ); + const originallySelectedSourceInstanceIds = new Set( + originalSelectedRows.flatMap((row) => row.sourceInstanceIds ?? []) + ); + const selectiveZones = selectiveTargetMustKeepZones( + data, + originalSelectedRows, + originallySelectedSourceInstanceIds + ); + const selectiveProtectionZones = selectiveZones.filter( + (zone) => !isIsolatedGuidanceCrossingZone(zone, selectedRows) + ); + const protectedSourceZones = (data.mustKeepZones ?? []).filter( + (zone) => !isConservativeRunwayEnvelopeSuperseded(zone, selectedRows) + ); + const mustKeepZones = [ + ...protectedSourceZones, + ...selectiveProtectionZones, + ]; + return { + ...generateRemovalGeometry(sourceInstances, rows, DEFAULT_SIZES, mustKeepZones), + selectiveProtectionZones, + }; +} + +function isIsolatedGuidanceCrossingZone(zone, selectedRows) { + if ( + !GUIDANCE_CLASSIFICATIONS.has(zone.classification) || + zone.geometryType !== 'LineString' || + !Array.isArray(zone.vertices) || + zone.vertices.length < 2 + ) { + return false; + } + return selectedRows.some( + (row) => + GUIDANCE_CLASSIFICATIONS.has(sourceClassification(row)) && + isIsolatedPolylineCrossing(row.vertices, zone.vertices) + ); +} + +function isConservativeRunwayEnvelopeSuperseded(zone, selectedRows) { + if ( + zone.lightType !== 'runway-centerline-continuous-envelope' || + zone.geometryMode !== 'continuous-procedural-envelope' || + zone.geometryType !== 'LineString' || + !Array.isArray(zone.vertices) || + zone.vertices.length < 2 + ) { + return false; + } + return selectedRows.some( + (row) => + TARGET_CLASSIFICATIONS.has(sourceClassification(row)) && + polylineNearLength(row.vertices, zone.vertices) > + MAXIMUM_ISOLATED_CROSSING_OVERLAP_METERS + ); +} + +function isIsolatedPolylineCrossing(left, right) { + if (!Array.isArray(left) || left.length < 2 || !Array.isArray(right) || right.length < 2) { + return false; + } + const leftOverlap = polylineNearLength(left, right); + const rightOverlap = polylineNearLength(right, left); + return ( + polylineToPolylineDistanceMeters(left, right) <= ISOLATED_CROSSING_DISTANCE_METERS && + leftOverlap <= MAXIMUM_ISOLATED_CROSSING_OVERLAP_METERS && + rightOverlap <= MAXIMUM_ISOLATED_CROSSING_OVERLAP_METERS + ); +} + +function polylineNearLength(source, target) { + const distances = cumulativeVertexDistances(source); + const totalLength = distances.at(-1) ?? 0; + if (totalLength <= 0) return 0; + const sampleCount = Math.max(2, Math.ceil(totalLength / CROSSING_SAMPLE_INTERVAL_METERS) + 1); + const interval = totalLength / (sampleCount - 1); + let nearLength = 0; + for (let index = 0; index < sampleCount; index += 1) { + const point = vertexAtDistance(source, distances, interval * index); + if (pointToPolylineDistanceMeters(point, target) <= ISOLATED_CROSSING_DISTANCE_METERS) { + nearLength += interval; + } + } + return nearLength; +} + +function sourceClassification(row) { + return row.sourceClassification ?? row.classification; +} + +function consolidateRemovalRows(sourceRows, selectedRows) { + const parentRows = new Map(sourceRows.map((row) => [row.id, row])); + const sectionsByParent = new Map(); + const rows = []; + + for (const row of selectedRows) { + if ( + !row.sourceParentRowId || + !Number.isFinite(row.sourceRangeStartMeters) || + !Number.isFinite(row.sourceRangeEndMeters) + ) { + rows.push(row); + continue; + } + const sections = sectionsByParent.get(row.sourceParentRowId) ?? []; + sections.push({ + start: row.sourceRangeStartMeters, + end: row.sourceRangeEndMeters, + member: row, + }); + sectionsByParent.set(row.sourceParentRowId, sections); + } + + for (const [parentId, sections] of sectionsByParent) { + const parent = parentRows.get(parentId); + if (!parent?.vertices?.length) { + rows.push(...sections.map((section) => section.member)); + continue; + } + + const groups = sections + .sort((left, right) => left.start - right.start) + .reduce((result, section) => { + const previous = result.at(-1); + if (!previous || section.start > previous.end + 0.05) { + result.push({ start: section.start, end: section.end, members: [section.member] }); + } else { + previous.end = Math.max(previous.end, section.end); + previous.members.push(section.member); + } + return result; + }, []); + const distances = cumulativeVertexDistances(parent.vertices); + + for (const [index, group] of groups.entries()) { + if (group.members.length === 1) { + rows.push({ ...group.members[0], sourceRowIds: [group.members[0].id] }); + continue; + } + const vertices = sliceVerticesByDistance(parent.vertices, distances, group.start, group.end); + vertices[0] = group.members[0].vertices[0]; + vertices[vertices.length - 1] = group.members.at(-1).vertices.at(-1); + rows.push({ + ...parent, + id: `${parent.id}:selected-sections:${index}`, + sourceRowIds: group.members.map((member) => member.id), + sourceParentRowId: parent.id, + sourceRangeStartMeters: group.start, + sourceRangeEndMeters: group.end, + sourceGeometryDerived: 'merged-source-row-subsections', + sourceInstanceIds: [ + ...new Set(group.members.flatMap((member) => member.sourceInstanceIds ?? [])), + ], + vertices, + }); + } + } + + return rows; +} + +function selectiveTargetMustKeepZones(data, selectedRows, selectedSourceInstanceIds) { + const selectedRowIds = new Set(selectedRows.map((row) => row.id)); + const selectedSectionsByParent = new Map(); + for (const row of selectedRows) { + if ( + !row.sourceParentRowId || + !Number.isFinite(row.sourceRangeStartMeters) || + !Number.isFinite(row.sourceRangeEndMeters) + ) { + continue; + } + const sections = selectedSectionsByParent.get(row.sourceParentRowId) ?? []; + sections.push({ start: row.sourceRangeStartMeters, end: row.sourceRangeEndMeters }); + selectedSectionsByParent.set(row.sourceParentRowId, sections); + } + const zones = []; + + for (const instance of data.instances ?? []) { + if ( + !TARGET_CLASSIFICATIONS.has(instance.classification) || + selectedSourceInstanceIds.has(instance.id) || + !Number.isFinite(instance.lat) || + !Number.isFinite(instance.lon) + ) { + continue; + } + zones.push({ + id: `selective-instance:${instance.id}`, + sourceId: instance.id, + sourceFile: instance.sourceFile, + sourceType: instance.sourceType, + classification: instance.classification, + geometryType: 'Point', + point: { lat: instance.lat, lon: instance.lon }, + clearanceMeters: 0.25, + reason: 'target simulator light not selected by division matching', + }); + } + + for (const row of data.lightRows ?? []) { + if ( + selectedRowIds.has(row.id) || + !TARGET_CLASSIFICATIONS.has(row.classification) || + !Array.isArray(row.vertices) || + row.vertices.length === 0 + ) { + continue; + } + const selectedSections = selectedSectionsByParent.get(row.id); + const protectedSegments = selectedSections + ? unselectedRowSegments(row.vertices, selectedSections) + : [row.vertices]; + for (const [index, vertices] of protectedSegments.entries()) { + if (vertices.length === 0) continue; + zones.push({ + id: `selective-row:${row.id}:${index}`, + sourceId: row.id, + sourceFile: row.sourceFile, + sourceType: row.sourceType, + classification: row.classification, + geometryType: vertices.length === 1 ? 'Point' : 'LineString', + ...(vertices.length === 1 ? { point: vertices[0] } : { vertices }), + clearanceMeters: 0.25, + reason: selectedSections + ? 'source row section not selected by division matching' + : 'target simulator row not selected by division matching', + }); + } + } + + return zones; +} + +function unselectedRowSegments(vertices, selectedSections) { + const distances = cumulativeVertexDistances(vertices); + const totalLength = distances.at(-1) ?? 0; + if (totalLength <= 0.05) return [vertices]; + + const merged = [...selectedSections] + .map((section) => ({ + start: Math.max(0, section.start - SECTION_PROTECTION_BOUNDARY_GAP_METERS), + end: Math.min(totalLength, section.end + SECTION_PROTECTION_BOUNDARY_GAP_METERS), + })) + .sort((left, right) => left.start - right.start) + .reduce((result, section) => { + const previous = result.at(-1); + if (!previous || section.start > previous.end) result.push(section); + else previous.end = Math.max(previous.end, section.end); + return result; + }, []); + + const ranges = []; + let cursor = 0; + for (const section of merged) { + if (section.start - cursor > 0.05) ranges.push({ start: cursor, end: section.start }); + cursor = Math.max(cursor, section.end); + } + if (totalLength - cursor > 0.05) ranges.push({ start: cursor, end: totalLength }); + + return ranges + .map((range) => sliceVerticesByDistance(vertices, distances, range.start, range.end)) + .filter((segment) => segment.length > 0); +} + +function cumulativeVertexDistances(vertices) { + const distances = [0]; + for (let index = 1; index < vertices.length; index += 1) { + distances.push( + distances.at(-1) + haversineDistanceMeters(vertices[index - 1], vertices[index]) + ); + } + return distances; +} + +function sliceVerticesByDistance(vertices, distances, start, end) { + const sliced = [vertexAtDistance(vertices, distances, start)]; + for (let index = 1; index < vertices.length - 1; index += 1) { + if (distances[index] > start && distances[index] < end) sliced.push(vertices[index]); + } + sliced.push(vertexAtDistance(vertices, distances, end)); + return sliced.filter( + (vertex, index) => index === 0 || haversineDistanceMeters(sliced[index - 1], vertex) > 0.01 + ); +} + +function vertexAtDistance(vertices, distances, target) { + if (target <= 0) return vertices[0]; + if (target >= distances.at(-1)) return vertices.at(-1); + + let index = 1; + while (index < distances.length && distances[index] < target) index += 1; + const segmentStart = distances[index - 1]; + const segmentLength = Math.max(distances[index] - segmentStart, 0.000001); + const ratio = Math.max(0, Math.min(1, (target - segmentStart) / segmentLength)); + return { + lat: vertices[index - 1].lat + (vertices[index].lat - vertices[index - 1].lat) * ratio, + lon: vertices[index - 1].lon + (vertices[index].lon - vertices[index - 1].lon) * ratio, + }; +} + +function emptyRemovalGeometry() { + return { + exclusionCandidates: [], + removalPolygons: [], + protectionConflicts: [], + selectiveProtectionZones: [], + performance: { + groupingMilliseconds: 0, + polygonBuildMilliseconds: 0, + instanceBuildMilliseconds: 0, + conflictFilterMilliseconds: 0, + }, + }; +} + +function generateDraftXml({ icao, altitude, matches, removalPolygons }) { + const polygons = []; + let groupIndex = 1; + const baseAltitude = Number.isFinite(altitude) ? altitude : 0; + + for (const match of matches) { + const coordinates = rowCoordinates(match.row); + if (coordinates.length < 4) continue; + polygons.push( + polygonXml( + String(match.division.id), + groupIndex, + baseAltitude, + coordinates, + `${icao}:${match.division.id}:${match.row.id}` + ) + ); + groupIndex += 1; + } + + for (const removal of removalPolygons) { + if (!Array.isArray(removal.coordinates) || removal.coordinates.length < 4) continue; + polygons.push( + polygonXml( + 'remove', + groupIndex, + baseAltitude, + removal.coordinates, + `${icao}:remove:${removal.id}` + ) + ); + groupIndex += 1; + } + + return `\n\n${polygons.join('\n')}\n`; +} + +function uniqueDivisionItems(items) { + const byDivision = new Map(); + for (const item of items) { + const key = String(item.division?.id ?? ''); + if (!byDivision.has(key)) byDivision.set(key, item); + } + return [...byDivision.values()]; +} + +function polygonXml(displayName, groupIndex, altitude, coordinates, guidSeed) { + const vertices = coordinates + .slice(0, -1) + .map( + ([lon, lat]) => + `\t\t` + ) + .join('\n'); + + return `\t +\t\t +${vertices} +\t`; +} + +function rowCoordinates(row) { + const coordinates = (row.vertices ?? []) + .filter((vertex) => Number.isFinite(vertex?.lat) && Number.isFinite(vertex?.lon)) + .map((vertex) => [vertex.lon, vertex.lat]); + if (coordinates.length < 2) return []; + + const open = sameCoordinate(coordinates[0], coordinates.at(-1)) + ? coordinates.slice(0, -1) + : coordinates; + if (open.length === 2) { + open.splice(1, 0, [(open[0][0] + open[1][0]) / 2, (open[0][1] + open[1][1]) / 2]); + } + return [...open, open[0]]; +} + +function buildSimulatorDebugGeoJson(simulatorRows, mergedSimulatorRows) { + const features = []; + + for (const row of simulatorRows) { + const coordinates = simulatorRowCoordinates(row); + if (coordinates.length < 2) continue; + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates }, + properties: { + featureType: 'simulator-source', + title: String(row.id || 'Extracted simulator row'), + simulatorType: row.classification || 'unknown', + sourceFile: row.sourceFile || '', + confidence: Number.isFinite(row.confidence) ? Math.round(row.confidence * 100) : null, + }, + }); + } + + for (const row of mergedSimulatorRows) { + const coordinates = simulatorRowCoordinates(row); + if (coordinates.length < 2) continue; + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates }, + properties: { + featureType: 'simulator-merged', + title: `Merged matcher geometry (${row.sourceRowCount} source rows)`, + simulatorType: row.classification || 'unknown', + sourceRowCount: row.sourceRowCount, + sourceRowIds: (row.sourceRowIds ?? []).join(', '), + }, + }); + } + + return { type: 'FeatureCollection', features }; +} + +function buildDraftGeoJson(matches, unmatched, removalPolygons, unsafeMatches = []) { + const features = []; + const divisionColors = buildDivisionColorMap(matches, unmatched, unsafeMatches); + + for (const removal of removalPolygons) { + features.push({ + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [removal.coordinates] }, + properties: { featureType: 'removal', title: 'Selective removal area' }, + }); + } + + for (const match of matches) { + const coordinates = rowCoordinates(match.row).slice(0, -1); + if (coordinates.length < 2) continue; + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates }, + properties: { + featureType: 'matched', + divisionId: String(match.division.id), + debugColor: divisionColors.get(String(match.division.id)), + title: match.division.name || String(match.division.id), + divisionType: match.division.type, + simulatorType: match.row.classification, + matchPercent: Math.round(match.score * 100), + matchedViaHoldShort: match.row.sourceType === 'bgl-hold-short-topology', + }, + }); + } + + for (const match of unsafeMatches) { + const coordinates = rowCoordinates(match.row).slice(0, -1); + if (coordinates.length < 2) continue; + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates }, + properties: { + featureType: 'unsafe', + divisionId: String(match.division.id), + debugColor: divisionColors.get(String(match.division.id)), + title: match.division.name || String(match.division.id), + divisionType: match.division.type, + simulatorType: match.row.classification, + matchPercent: Math.round(match.score * 100), + reason: 'Simulator geometry matched, but its removal area needs manual review.', + }, + }); + } + + for (const item of unmatched) { + const geometry = divisionGeometry(item.division?.coordinates); + if (!geometry) continue; + features.push({ + type: 'Feature', + geometry, + properties: { + featureType: 'unmatched', + divisionId: String(item.division?.id ?? ''), + debugColor: divisionColors.get(String(item.division?.id ?? '')), + title: item.division?.name || String(item.division?.id || 'Unmatched object'), + divisionType: item.division?.type || 'unknown', + reason: item.reason, + }, + }); + } + + return { type: 'FeatureCollection', features }; +} + +function buildDivisionColorMap(matches, unmatched, unsafeMatches) { + const divisionsById = new Map(); + for (const item of [...matches, ...unsafeMatches, ...unmatched]) { + const division = item.division; + if (!division) continue; + const id = String(division.id ?? ''); + if (!divisionsById.has(id)) divisionsById.set(id, division); + } + + const nodes = [...divisionsById].map(([id, division]) => ({ + id, + position: divisionCenter(division.coordinates), + neighbors: new Set(), + })); + const positionedNodes = nodes.filter((node) => node.position); + for (const node of positionedNodes) { + const nearest = positionedNodes + .filter((candidate) => candidate !== node) + .map((candidate) => ({ + candidate, + distance: haversineDistanceMeters(node.position, candidate.position), + })) + .sort((left, right) => left.distance - right.distance || left.candidate.id.localeCompare(right.candidate.id)) + .slice(0, DEBUG_ID_NEIGHBOR_COUNT); + for (const { candidate } of nearest) { + node.neighbors.add(candidate.id); + candidate.neighbors.add(node.id); + } + } + + const usage = new Map(DEBUG_ID_COLORS.map((color) => [color, 0])); + const colors = new Map(); + const orderedNodes = [...nodes].sort( + (left, right) => right.neighbors.size - left.neighbors.size || left.id.localeCompare(right.id) + ); + + for (const node of orderedNodes) { + const neighborColors = [...node.neighbors] + .map((id) => colors.get(id)) + .filter(Boolean); + const startIndex = stableHash(node.id) % DEBUG_ID_COLORS.length; + const color = [...DEBUG_ID_COLORS] + .sort((left, right) => { + const rightScore = debugColorScore(right, neighborColors, usage, startIndex); + const leftScore = debugColorScore(left, neighborColors, usage, startIndex); + return rightScore - leftScore; + })[0]; + colors.set(node.id, color); + usage.set(color, usage.get(color) + 1); + } + + return colors; +} + +function divisionCenter(value) { + const coordinates = (Array.isArray(value) ? value : value ? [value] : []).filter( + (coordinate) => Number.isFinite(coordinate?.lat) && Number.isFinite(coordinate?.lng) + ); + if (coordinates.length === 0) return null; + return { + lat: coordinates.reduce((sum, coordinate) => sum + coordinate.lat, 0) / coordinates.length, + lon: coordinates.reduce((sum, coordinate) => sum + coordinate.lng, 0) / coordinates.length, + }; +} + +function debugColorScore(color, neighborColors, usage, startIndex) { + const separation = neighborColors.length + ? Math.min(...neighborColors.map((neighborColor) => rgbDistance(color, neighborColor))) + : 0; + const paletteIndex = DEBUG_ID_COLORS.indexOf(color); + const preference = (paletteIndex - startIndex + DEBUG_ID_COLORS.length) % DEBUG_ID_COLORS.length; + return separation - usage.get(color) * 45 - preference * 0.01; +} + +function rgbDistance(left, right) { + const leftRgb = hexToRgb(left); + const rightRgb = hexToRgb(right); + return Math.hypot( + leftRgb.red - rightRgb.red, + leftRgb.green - rightRgb.green, + leftRgb.blue - rightRgb.blue + ); +} + +function hexToRgb(color) { + return { + red: Number.parseInt(color.slice(1, 3), 16), + green: Number.parseInt(color.slice(3, 5), 16), + blue: Number.parseInt(color.slice(5, 7), 16), + }; +} + +function stableHash(value) { + const text = String(value ?? 'unassigned'); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function simulatorRowCoordinates(row) { + return (row.vertices ?? []) + .filter((vertex) => Number.isFinite(vertex?.lat) && Number.isFinite(vertex?.lon)) + .map((vertex) => [vertex.lon, vertex.lat]); +} + +function divisionGeometry(value) { + const values = Array.isArray(value) ? value : value ? [value] : []; + const coordinates = values + .filter((coordinate) => Number.isFinite(coordinate?.lat) && Number.isFinite(coordinate?.lng)) + .map((coordinate) => [coordinate.lng, coordinate.lat]); + if (coordinates.length >= 2) return { type: 'LineString', coordinates }; + if (coordinates.length === 1) return { type: 'Point', coordinates: coordinates[0] }; + return null; +} + +function sameCoordinate(left, right) { + return Math.abs(left[0] - right[0]) < 1e-12 && Math.abs(left[1] - right[1]) < 1e-12; +} + +function escapeXml(value) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +function guidForSeed(seed) { + const words = [2166136261, 2246822507, 3266489909, 668265263]; + for (let index = 0; index < String(seed).length; index += 1) { + const code = String(seed).charCodeAt(index); + for (let word = 0; word < words.length; word += 1) { + words[word] = Math.imul(words[word] ^ (code + word * 31), 16777619) >>> 0; + } + } + const hex = words + .map((word) => word.toString(16).padStart(8, '0')) + .join('') + .toUpperCase(); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} diff --git a/src/features/draft-generator/extractor/bgl.js b/src/features/draft-generator/extractor/bgl.js new file mode 100644 index 0000000..8ce3cb8 --- /dev/null +++ b/src/features/draft-generator/extractor/bgl.js @@ -0,0 +1,2096 @@ +import { Buffer } from "node:buffer"; +import { createReadStream, promises as fs } from "node:fs"; +import path from "node:path"; +import { classifyObject, isTargetLightClassification, stableId } from "./classify.js"; +import { haversineDistanceMeters, pointToPolylineDistanceMeters } from "./geo.js"; +import { inferBglPlacementLightRows } from "./placement-rows.js"; + +const SECTION_SCENERY_OBJECTS = 0x25; +const RECORD_SCENERY_OBJECT_LIBRARY_OBJECT = 0x0b; +const HEADER_SIZE = 0x38; +const SECTION_POINTER_SIZE = 0x14; +const SUBSECTION_POINTER_SIZE = 0x10; +const MODEL_INFO_NEEDLE = Buffer.from("", "ascii"); +const MAX_BUFFERED_BGL_BYTES = 512 * 1024 * 1024; +const MAX_RETAINED_BGL_BYTES = 256 * 1024 * 1024; +const MODEL_INFO_STREAM_TAIL_BYTES = 4096; +const RECORD_AIRPORT_LIGHT_ROW = 0x31; +const RECORD_AIRPORT_RUNWAY = 0x00ce; +const RECORD_TAXIWAY_POINT_TABLE = 0x1a; +const RECORD_TAXI_NAME_TABLE = 0x1d; +const RECORD_TAXIWAY_PATH_TABLE = 0xd4; +const RECORD_TAXIWAY_PARKING_TABLE = 0xe7; +const AIRPORT_LIGHT_ROW_HEADER_SIZE = 0x18; +const AIRPORT_LIGHT_ROW_MIN_RECORD_SIZE = AIRPORT_LIGHT_ROW_HEADER_SIZE + 2 * 8; +const AIRPORT_LIGHT_ROW_MAX_RECORD_SIZE = 64 * 1024; +const AIRPORT_LIGHT_ROW_MAX_PRECEDING_NAME_BYTES = 128; +const AIRPORT_LIGHT_ROW_VERSION_MARKERS = new Set([ + 0x01000000, + 0x05000000, + 0x09000000, + 0x0d000000 +]); +const TAXIWAY_POINT_RECORD_SIZE = 12; +const TAXIWAY_POINT_TYPE_MASK = 0xff; +const TAXIWAY_POINT_ORIENTATION_MASK = 0x100; +const TAXIWAY_POINT_HOLD_SHORT_TYPES = new Set([0x02, 0x05]); +const HOLD_SHORT_REAL_LIGHT_DEDUPLICATION_METERS = 20; +const HOLD_SHORT_MINIMUM_HALF_WIDTH_METERS = 4; +const HOLD_SHORT_MAXIMUM_HALF_WIDTH_METERS = 30; +const TAXI_NAME_RECORD_SIZE = 8; +const TAXIWAY_PARKING_RECORD_SIZE = 56; +const TAXIWAY_PATH_RECORD_SIZE = 0x30; +const TAXIWAY_PATH_TYPE_MASK = 0x0f; +const TAXIWAY_PATH_TYPE_TAXI = 0x01; +const TAXIWAY_PATH_TYPE_PATH = 0x04; +const TAXIWAY_PATH_BRIDGE_TYPES = new Set([ + TAXIWAY_PATH_TYPE_TAXI, + TAXIWAY_PATH_TYPE_PATH +]); +const TAXIWAY_PATH_CENTERLINE_FLAG = 0x01; +const TAXIWAY_PATH_LIGHTED_CENTERLINE_FLAG = 0x02; +const TAXIWAY_PATH_LEFT_EDGE_TYPE_MASK = 0x0c; +const TAXIWAY_PATH_LEFT_EDGE_LIGHTED_FLAG = 0x10; +const TAXIWAY_PATH_RIGHT_EDGE_TYPE_MASK = 0x60; +const TAXIWAY_PATH_RIGHT_EDGE_LIGHTED_FLAG = 0x80; +const RUNWAY_FIXED_RECORD_SIZE = 0x60; +const RUNWAY_MAX_RECORD_SIZE = 256 * 1024; +const RUNWAY_APPROACH_PRIMARY = 0x00df; +const RUNWAY_APPROACH_SECONDARY = 0x00e0; +const RUNWAY_OFFSET_THRESHOLD_PRIMARY = 0x0005; +const RUNWAY_OFFSET_THRESHOLD_SECONDARY = 0x0006; +const RUNWAY_VASI_TYPES = new Set([0x000b, 0x000c, 0x000d, 0x000e]); +const RUNWAY_CHILD_TYPES = new Set([ + RUNWAY_OFFSET_THRESHOLD_PRIMARY, + RUNWAY_OFFSET_THRESHOLD_SECONDARY, + 0x0007, + 0x0008, + 0x0009, + 0x000a, + 0x000b, + 0x000c, + 0x000d, + 0x000e, + 0x003e, + 0x0065, + 0x0066, + 0x00cb, + RUNWAY_APPROACH_PRIMARY, + RUNWAY_APPROACH_SECONDARY +]); +const RUNWAY_LIGHT_CLEARANCE_METERS = 0.15; +const RUNWAY_CENTERLINE_END_INSET_METERS = 22.86; +const RUNWAY_CENTERLINE_SPACING_METERS = 15.24; +const RUNWAY_TOUCHDOWN_FIRST_STATION_METERS = 30.48; +const RUNWAY_TOUCHDOWN_MAX_LENGTH_METERS = 914.4; +const RUNWAY_TOUCHDOWN_BAR_SPACING_METERS = 30.48; +const RUNWAY_TOUCHDOWN_BAR_LIGHT_SPACING_METERS = 1.5; +const RUNWAY_EDGE_LIGHT_SPACING_METERS = 60; +const RUNWAY_VASI_TYPE_NAMES = new Map([ + [1, "VASI21"], + [2, "VASI22"], + [3, "VASI23"], + [4, "VASI31"], + [5, "VASI32"], + [6, "VASI33"], + [7, "PAPI2"], + [8, "PAPI4"], + [9, "TRICOLOR"], + [10, "PVASI"], + [11, "TVASI"], + [12, "BALL"], + [13, "APAP"], + [14, "PANELS"] +]); +const RUNWAY_VASI_SPACED_ROW_COUNTS = new Map([ + [1, 2], + [2, 2], + [3, 2], + [4, 3], + [5, 3], + [6, 3], + [11, 2] +]); +const RUNWAY_VASI_KNOWN_LIGHT_COUNTS = new Map([ + [1, 2], + [2, 4], + [3, 6], + [4, 3], + [5, 6], + [6, 8], + [7, 2], + [8, 4], + [9, 1], + [10, 1] +]); +const DEFAULT_TAXIWAY_CENTERLINE_SPACING_METERS = 15; +const MIN_TAXIWAY_PATH_LENGTH_METERS = 0.75; +const MAX_TAXIWAY_PATH_LENGTH_METERS = 2000; +const MAX_TAXIWAY_PATH_BRIDGE_LENGTH_METERS = 600; +const TAXIWAY_PATH_BRIDGE_ENDPOINT_TOLERANCE_METERS = 3; +const TAXIWAY_PATH_BRIDGE_MAX_ENDPOINT_EXTENSION_METERS = 15; +const TAXIWAY_PATH_BRIDGE_MAX_ALIGNMENT_DEGREES = 20; +const TAXIWAY_PATH_BRIDGE_UNCOVERED_MIDPOINT_METERS = 8; + +export async function extractBglData(bglFiles) { + const warnings = []; + const instances = []; + const lightRows = []; + const decodedHoldShortRows = []; + const runways = []; + const runwayLightZones = []; + const modelIndex = new Map(); + const buffers = []; + const fileStats = []; + const unsupportedSceneryRecordTypes = new Map(); + let retainedBglBytes = 0; + let bglFilesParsed = 0; + const taxiwayGraphStats = { + graphs: 0, + points: 0, + parkings: 0, + paths: 0, + names: 0, + lightedTaxiPaths: 0, + bridgedTaxiPaths: 0, + decodedHoldShortPoints: 0, + eligibleHoldShortPoints: 0, + suppressedHoldShortPoints: 0, + inferredPlacementRows: 0, + inferredPlacementAssignments: 0, + excludedPlacementOutliers: 0, + placementInferenceMilliseconds: 0 + }; + + for (const sourceFile of bglFiles ?? []) { + try { + const stats = await fs.stat(sourceFile); + let buffer; + let modelInfos; + if (stats.size > MAX_BUFFERED_BGL_BYTES) { + modelInfos = await extractModelInfosFromFile(sourceFile); + } else { + buffer = await fs.readFile(sourceFile); + modelInfos = extractModelInfos(buffer, sourceFile); + } + for (const modelInfo of modelInfos) { + modelIndex.set(normalizeGuid(modelInfo.guid), modelInfo); + } + + if (buffer && retainedBglBytes + buffer.length <= MAX_RETAINED_BGL_BYTES) { + buffers.push({ sourceFile, buffer }); + retainedBglBytes += buffer.length; + } else if (buffer) { + buffers.push({ sourceFile }); + } else { + warnings.push(`${sourceFile}: indexed ${modelInfos.length} model-library entries from large BGL; skipped placement parsing`); + } + } catch (error) { + warnings.push(`${sourceFile}: failed to read BGL file: ${error.message}`); + } + } + + for (let bufferIndex = 0; bufferIndex < buffers.length; bufferIndex += 1) { + const pending = buffers[bufferIndex]; + const sourceFile = pending.sourceFile; + let buffer; + try { + buffer = pending.buffer ?? await fs.readFile(sourceFile); + } catch (error) { + buffers[bufferIndex] = undefined; + warnings.push(`${sourceFile}: failed to reopen BGL file for placement parsing: ${error.message}`); + continue; + } + buffers[bufferIndex] = undefined; + if (pending.buffer) retainedBglBytes -= pending.buffer.length; + const result = extractBglPlacements(buffer, sourceFile, modelIndex); + if (result.parsed) { + bglFilesParsed += 1; + } + instances.push(...result.instances); + const airportLightRows = extractAirportLightRows(buffer, sourceFile); + const runwayResult = extractRunwayLightZones(buffer, sourceFile); + const taxiwayGraph = extractTaxiwayGraph(buffer, sourceFile); + const taxiwayBridgeRows = buildTaxiwayPathBridgeRows( + sourceFile, + taxiwayGraph.graphs, + airportLightRows + ); + lightRows.push(...airportLightRows); + lightRows.push(...taxiwayGraph.lightRows); + decodedHoldShortRows.push(...taxiwayGraph.holdShortRows); + lightRows.push(...taxiwayBridgeRows); + runways.push(...runwayResult.runways); + runwayLightZones.push(...runwayResult.zones); + fileStats.push({ + sourceFile, + parsed: result.parsed, + instances: result.instances.length, + airportLightRows: airportLightRows.length, + runways: runwayResult.runways.length, + runwayLightZones: runwayResult.zones.length, + taxiwayGraphs: taxiwayGraph.graphs.length, + taxiwayPoints: taxiwayGraph.graphs.reduce((sum, graph) => sum + graph.points.length, 0), + taxiwayParkings: taxiwayGraph.graphs.reduce((sum, graph) => sum + graph.parkings.length, 0), + taxiwayPaths: taxiwayGraph.graphs.reduce((sum, graph) => sum + graph.paths.length, 0), + taxiwayNames: taxiwayGraph.graphs.reduce((sum, graph) => sum + graph.taxiNames.length, 0), + lightedTaxiwayPaths: taxiwayGraph.lightRows.length, + bridgedTaxiwayPaths: taxiwayBridgeRows.length, + decodedHoldShortPoints: taxiwayGraph.holdShortRows.length, + sectionTypes: result.sectionTypes, + unsupportedSceneryRecordTypes: result.unsupportedSceneryRecordTypes + }); + for (const [recordType, count] of Object.entries(result.unsupportedSceneryRecordTypes ?? {})) { + unsupportedSceneryRecordTypes.set( + recordType, + (unsupportedSceneryRecordTypes.get(recordType) ?? 0) + count + ); + } + taxiwayGraphStats.graphs += taxiwayGraph.graphs.length; + taxiwayGraphStats.points += taxiwayGraph.graphs.reduce((sum, graph) => sum + graph.points.length, 0); + taxiwayGraphStats.parkings += taxiwayGraph.graphs.reduce((sum, graph) => sum + graph.parkings.length, 0); + taxiwayGraphStats.paths += taxiwayGraph.graphs.reduce((sum, graph) => sum + graph.paths.length, 0); + taxiwayGraphStats.names += taxiwayGraph.graphs.reduce((sum, graph) => sum + graph.taxiNames.length, 0); + taxiwayGraphStats.lightedTaxiPaths += taxiwayGraph.lightRows.length; + taxiwayGraphStats.bridgedTaxiPaths += taxiwayBridgeRows.length; + taxiwayGraphStats.decodedHoldShortPoints += taxiwayGraph.holdShortRows.length; + warnings.push(...result.warnings); + } + + const placementInference = inferBglPlacementLightRows(instances); + lightRows.push(...placementInference.rows); + const topologyRows = filterHoldShortTopologyRows( + decodedHoldShortRows, + lightRows, + instances + ); + taxiwayGraphStats.eligibleHoldShortPoints = topologyRows.length; + taxiwayGraphStats.suppressedHoldShortPoints = + decodedHoldShortRows.length - topologyRows.length; + taxiwayGraphStats.inferredPlacementRows = placementInference.rows.length; + taxiwayGraphStats.inferredPlacementAssignments = placementInference.stats.assignedPlacements; + taxiwayGraphStats.excludedPlacementOutliers = placementInference.stats.excludedOutliers; + taxiwayGraphStats.placementInferenceMilliseconds = placementInference.stats.elapsedMilliseconds; + taxiwayGraphStats.placementInference = placementInference.stats; + for (const fileStat of fileStats) { + const fileRows = placementInference.rows.filter((row) => row.sourceFile === fileStat.sourceFile); + fileStat.inferredPlacementRows = fileRows.length; + fileStat.inferredPlacementAssignments = fileRows.reduce( + (sum, row) => sum + (row.sourceInstanceIds?.length ?? 0), + 0 + ); + } + + return { + instances, + lightRows, + topologyRows, + runways, + runwayLightZones, + warnings, + bglFilesParsed, + modelLibraryEntries: modelIndex.size, + taxiwayGraphStats, + fileStats, + unsupportedSceneryRecordTypes: Object.fromEntries(unsupportedSceneryRecordTypes) + }; +} + +export function extractAirportLightRows(buffer, sourceFile) { + return extractNamedAirportLightRows(buffer, sourceFile); +} + +function extractNamedAirportLightRows(buffer, sourceFile) { + const rows = []; + const seenOffsets = new Set(); + let offset = 0; + + while ((offset = buffer.indexOf(RECORD_AIRPORT_LIGHT_ROW, offset)) !== -1) { + if (offset + AIRPORT_LIGHT_ROW_MIN_RECORD_SIZE > buffer.length) { + break; + } + const row = parseNamedAirportLightRowHeaderAt(buffer, sourceFile, offset); + if (!row || seenOffsets.has(row.sourceRecordOffset)) { + offset += 1; + continue; + } + + seenOffsets.add(row.sourceRecordOffset); + rows.push(row); + offset += row.recordSize; + } + + return rows; +} + +function parseNamedAirportLightRowHeaderAt(buffer, sourceFile, headerOffset) { + if (buffer.readUInt16LE(headerOffset) !== RECORD_AIRPORT_LIGHT_ROW) { + return null; + } + + const recordSize = buffer.readUInt16LE(headerOffset + 0x02); + const versionMarker = buffer.readUInt32LE(headerOffset + 0x04); + const vertexCount = buffer.readUInt16LE(headerOffset + 0x08); + const rowSubtype = buffer.readUInt16LE(headerOffset + 0x0a); + const spacing = buffer.readFloatLE(headerOffset + 0x14); + const pointOffset = headerOffset + AIRPORT_LIGHT_ROW_HEADER_SIZE; + const trailingNameOffset = pointOffset + vertexCount * 8; + const recordEndOffset = headerOffset + recordSize; + + if ( + !AIRPORT_LIGHT_ROW_VERSION_MARKERS.has(versionMarker) || + recordSize < AIRPORT_LIGHT_ROW_MIN_RECORD_SIZE || + recordSize > AIRPORT_LIGHT_ROW_MAX_RECORD_SIZE || + vertexCount < 2 || + vertexCount > 500 || + headerOffset + recordSize > buffer.length || + trailingNameOffset > recordEndOffset + ) { + return null; + } + + const preset = + trailingNameOffset < recordEndOffset + ? parseTrailingAsciiName(buffer, trailingNameOffset, recordEndOffset) + : parsePrecedingAsciiName(buffer, headerOffset); + if (!preset) { + return null; + } + + const vertices = parseAirportLightRowVertices(buffer, pointOffset, vertexCount); + if (!vertices) { + return null; + } + + const classification = classifyAirportLightRowPreset(preset); + + return { + id: stableId("bgl-airport-light-row", sourceFile, headerOffset, preset, vertexCount), + sourceFile, + sourceType: "bgl-airport-light-row", + sourceRecordOffset: headerOffset, + recordSize, + rawTag: "BGL Airport Light Row", + preset, + rowSubtype, + ...(Number.isFinite(spacing) && spacing > 0 && spacing < 100 ? { spacing } : {}), + snapToVertices: true, + vertices, + classification: classification.classification, + confidence: classification.confidence, + classificationReasons: [ + `decoded compiled airport light row preset "${preset}"`, + `decoded ${vertexCount} row vertices`, + ...classification.reasons + ] + }; +} + +function parseTrailingAsciiName(buffer, start, end) { + const bytes = buffer.subarray(start, end); + const zeroIndex = bytes.indexOf(0); + const nameBytes = zeroIndex === -1 ? bytes : bytes.subarray(0, zeroIndex); + if (nameBytes.length < 2) { + return undefined; + } + + for (const byte of nameBytes) { + if (byte < 0x20 || byte > 0x7e) { + return undefined; + } + } + + const name = nameBytes.toString("ascii").trim(); + return name.length >= 2 ? name : undefined; +} + +function parseAirportLightRowVertices(buffer, pointOffset, vertexCount) { + const vertices = []; + + for (let index = 0; index < vertexCount; index += 1) { + const pointRecordOffset = pointOffset + index * 8; + const lon = decodeBglLongitude(buffer.readUInt32LE(pointRecordOffset)); + const lat = decodeBglLatitude(buffer.readUInt32LE(pointRecordOffset + 4)); + if (!isPlausibleCoordinate(lat, lon)) { + return undefined; + } + vertices.push({ lat, lon }); + } + + return vertices; +} + +function parsePrecedingAsciiName(buffer, headerOffset) { + if (headerOffset < 2 || buffer[headerOffset - 1] !== 0) { + return undefined; + } + + const minimumStart = Math.max(0, headerOffset - AIRPORT_LIGHT_ROW_MAX_PRECEDING_NAME_BYTES - 1); + let start = headerOffset - 2; + while (start >= minimumStart && buffer[start] >= 0x20 && buffer[start] <= 0x7e) { + start -= 1; + } + + const nameBytes = buffer.subarray(start + 1, headerOffset - 1); + if (nameBytes.length < 2) { + return undefined; + } + + const name = nameBytes.toString("ascii").trim(); + return name.length >= 2 ? name : undefined; +} + +function classifyAirportLightRowPreset(preset) { + const normalizedPreset = preset + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + const compactPreset = normalizedPreset.replaceAll("_", ""); + if ( + normalizedPreset.includes("stopbar") || + normalizedPreset.includes("stop_bar") || + normalizedPreset.includes("hold_short") || + normalizedPreset.includes("holdshort") + ) { + return { + classification: "stopbar", + confidence: 0.84, + reasons: ["preset name indicates a stopbar row"] + }; + } + + if ( + normalizedPreset.includes("lead_on") || + normalizedPreset.includes("leadon") || + compactPreset.includes("taxionin") || + normalizedPreset.includes("ils_zone") || + normalizedPreset.includes("exit") || + normalizedPreset === "orangeblank" + ) { + return { + classification: "lead-on", + confidence: 0.82, + reasons: ["preset name indicates a lead-on row"] + }; + } + + if ( + normalizedPreset.includes("taxi_center") || + normalizedPreset.includes("taxi_centre") || + compactPreset.includes("taxicenter") || + compactPreset.includes("taxicentre") || + normalizedPreset.includes("taxiway_center") || + normalizedPreset.includes("taxiway_centre") || + normalizedPreset.includes("green_centre") || + normalizedPreset.includes("green_center") || + normalizedPreset === "center_lights" || + normalizedPreset === "center_lights_rev" || + normalizedPreset === "taxi_yellow" || + normalizedPreset === "greenblank" + ) { + return { + classification: "taxi-centerline", + confidence: 0.82, + reasons: ["preset name indicates a taxiway centerline row"] + }; + } + + const classification = classifyObject({ + sourceType: "lightrow", + rawTag: "BGL Airport Light Row", + name: preset + }); + return { + classification: classification.classification, + confidence: classification.confidence, + reasons: classification.reasons + }; +} + +export function extractRunwayLightZones(buffer, sourceFile) { + const runways = []; + const zones = []; + const seenOffsets = new Set(); + let offset = 0; + + while ((offset = buffer.indexOf(RECORD_AIRPORT_RUNWAY & 0xff, offset)) !== -1) { + if (offset + RUNWAY_FIXED_RECORD_SIZE > buffer.length) { + break; + } + if (buffer[offset + 1] !== (RECORD_AIRPORT_RUNWAY >> 8)) { + offset += 1; + continue; + } + + const runway = parseRunwayRecordAt(buffer, sourceFile, offset); + if (!runway || seenOffsets.has(runway.sourceRecordOffset)) { + offset += 1; + continue; + } + + seenOffsets.add(runway.sourceRecordOffset); + runways.push(runway); + zones.push(...buildRunwayLightZones(runway)); + offset += runway.recordSize; + } + + return { runways, zones }; +} + +function parseRunwayRecordAt(buffer, sourceFile, offset) { + const recordSize = buffer.readUInt32LE(offset + 0x02); + if ( + recordSize < RUNWAY_FIXED_RECORD_SIZE || + recordSize > RUNWAY_MAX_RECORD_SIZE || + offset + recordSize > buffer.length + ) { + return undefined; + } + + const primaryNumber = buffer.readUInt8(offset + 0x08); + const primaryDesignator = buffer.readUInt8(offset + 0x09); + const secondaryNumber = buffer.readUInt8(offset + 0x0a); + const secondaryDesignator = buffer.readUInt8(offset + 0x0b); + const lon = decodeBglLongitude(buffer.readUInt32LE(offset + 0x14)); + const lat = decodeBglLatitude(buffer.readUInt32LE(offset + 0x18)); + const lengthMeters = buffer.readFloatLE(offset + 0x20); + const widthMeters = buffer.readFloatLE(offset + 0x24); + const heading = buffer.readFloatLE(offset + 0x28); + const lightFlags = buffer.readUInt8(offset + 0x32); + + if ( + !isPlausibleCoordinate(lat, lon) || + !isPlausibleRunwayNumberPair(primaryNumber, secondaryNumber) || + primaryDesignator > 6 || + secondaryDesignator > 6 || + !Number.isFinite(lengthMeters) || + lengthMeters < 30 || + lengthMeters > 10000 || + !Number.isFinite(widthMeters) || + widthMeters < 3 || + widthMeters > 500 || + !Number.isFinite(heading) || + heading < 0 || + heading > 360 + ) { + return undefined; + } + + const children = parseRunwayChildren(buffer, offset, recordSize); + if (!children) { + return undefined; + } + + const primaryOffsetThresholdMeters = + children.find((child) => child.type === RUNWAY_OFFSET_THRESHOLD_PRIMARY)?.lengthMeters ?? 0; + const secondaryOffsetThresholdMeters = + children.find((child) => child.type === RUNWAY_OFFSET_THRESHOLD_SECONDARY)?.lengthMeters ?? 0; + const primaryApproach = children.find((child) => child.type === RUNWAY_APPROACH_PRIMARY); + const secondaryApproach = children.find((child) => child.type === RUNWAY_APPROACH_SECONDARY); + + return { + id: stableId("bgl-runway", sourceFile, offset, primaryNumber, primaryDesignator), + sourceFile, + sourceType: "bgl-runway", + sourceRecordOffset: offset, + recordSize, + lat, + lon, + lengthMeters, + widthMeters, + heading, + primaryNumber, + primaryDesignator, + secondaryNumber, + secondaryDesignator, + primaryLabel: runwayLabel(primaryNumber, primaryDesignator), + secondaryLabel: runwayLabel(secondaryNumber, secondaryDesignator), + edgeLightLevel: lightFlags & 0x03, + centerLightLevel: (lightFlags >> 2) & 0x03, + centerRed: (lightFlags & 0x10) !== 0, + primaryOffsetThresholdMeters, + secondaryOffsetThresholdMeters, + ...(primaryApproach ? { primaryApproach } : {}), + ...(secondaryApproach ? { secondaryApproach } : {}), + vasi: children.filter((child) => RUNWAY_VASI_TYPES.has(child.type)), + children + }; +} + +function parseRunwayChildren(buffer, runwayOffset, recordSize) { + const children = []; + const recordEnd = runwayOffset + recordSize; + let offset = runwayOffset + RUNWAY_FIXED_RECORD_SIZE; + + while (offset < recordEnd) { + if (offset + 6 > recordEnd) { + return undefined; + } + + const type = buffer.readUInt16LE(offset); + const childSize = buffer.readUInt32LE(offset + 0x02); + if (!RUNWAY_CHILD_TYPES.has(type) || childSize < 6 || offset + childSize > recordEnd) { + return undefined; + } + + const child = { + type, + sourceRecordOffset: offset, + recordSize: childSize + }; + if (type === RUNWAY_APPROACH_PRIMARY || type === RUNWAY_APPROACH_SECONDARY) { + if (childSize < 0x18) { + return undefined; + } + const flags = buffer.readUInt8(offset + 0x06); + const strobesRaw = buffer.readUInt8(offset + 0x07); + Object.assign(child, { + end: type === RUNWAY_APPROACH_PRIMARY ? "primary" : "secondary", + system: flags & 0x1f, + endLights: (flags & 0x20) !== 0, + reil: (flags & 0x40) !== 0, + touchdown: (flags & 0x80) !== 0, + strobes: strobesRaw <= 20 ? strobesRaw : 0, + ...(strobesRaw <= 20 ? {} : { strobesRaw, invalidStrobesValue: true }), + spacingMeters: buffer.readFloatLE(offset + 0x08), + offsetMeters: buffer.readFloatLE(offset + 0x0c), + slopeDegrees: buffer.readFloatLE(offset + 0x10) + }); + } else if ( + type === RUNWAY_OFFSET_THRESHOLD_PRIMARY || + type === RUNWAY_OFFSET_THRESHOLD_SECONDARY + ) { + if (childSize < 0x20) { + return undefined; + } + child.end = type === RUNWAY_OFFSET_THRESHOLD_PRIMARY ? "primary" : "secondary"; + child.lengthMeters = buffer.readFloatLE(offset + 0x18); + child.widthMeters = buffer.readFloatLE(offset + 0x1c); + } else if (RUNWAY_VASI_TYPES.has(type)) { + if (childSize < 0x18) { + return undefined; + } + Object.assign(child, { + end: type <= 0x000c ? "primary" : "secondary", + side: type === 0x000b || type === 0x000d ? "left" : "right", + vasiType: buffer.readUInt16LE(offset + 0x06), + biasXMeters: buffer.readFloatLE(offset + 0x08), + biasZMeters: buffer.readFloatLE(offset + 0x0c), + spacingMeters: buffer.readFloatLE(offset + 0x10), + pitchDegrees: buffer.readFloatLE(offset + 0x14) + }); + child.vasiTypeName = RUNWAY_VASI_TYPE_NAMES.get(child.vasiType) ?? "UNKNOWN"; + child.spacingApplicable = RUNWAY_VASI_SPACED_ROW_COUNTS.has(child.vasiType); + } + + children.push(child); + offset += childSize; + } + + return offset === recordEnd ? children : undefined; +} + +function buildRunwayLightZones(runway) { + const zones = []; + const halfLength = runway.lengthMeters / 2; + const halfWidth = runway.widthMeters / 2; + const primaryThresholdAlong = -halfLength + runway.primaryOffsetThresholdMeters; + const secondaryThresholdAlong = halfLength - runway.secondaryOffsetThresholdMeters; + const primaryThreshold = runwayPoint(runway, primaryThresholdAlong, 0); + const secondaryThreshold = runwayPoint(runway, secondaryThresholdAlong, 0); + + if (runway.centerLightLevel > 0) { + const inset = Math.min( + RUNWAY_CENTERLINE_END_INSET_METERS, + Math.max(0, runway.lengthMeters / 2 - 1) + ); + const startAlong = -halfLength + inset; + const endAlong = halfLength - inset; + for (const along of runwayStationDistances(startAlong, endAlong, RUNWAY_CENTERLINE_SPACING_METERS)) { + zones.push(runwayPointZone(runway, `runway-centerline-${Math.round((along + halfLength) * 100)}`, + runwayPoint(runway, along, 0), { + lightLevel: runway.centerLightLevel, + centerRed: runway.centerRed, + nominalSpacingMeters: RUNWAY_CENTERLINE_SPACING_METERS, + geometryMode: "source-runway-procedural-grid" + })); + } + zones.push(runwayLineZone(runway, "runway-centerline-continuous-envelope", [ + runwayPoint(runway, startAlong, 0), + runwayPoint(runway, endAlong, 0) + ], { + lightLevel: runway.centerLightLevel, + centerRed: runway.centerRed, + geometryMode: "continuous-procedural-envelope", + clearanceMeters: 0.1 + })); + } + + if (runway.edgeLightLevel > 0) { + for (const side of [-1, 1]) { + for (const along of runwayStationDistances(-halfLength, halfLength, RUNWAY_EDGE_LIGHT_SPACING_METERS)) { + zones.push(runwayPointZone( + runway, + `runway-edge-${side < 0 ? "left" : "right"}-${Math.round((along + halfLength) * 100)}`, + runwayPoint(runway, along, side * halfWidth), + { + lightLevel: runway.edgeLightLevel, + nominalSpacingMeters: RUNWAY_EDGE_LIGHT_SPACING_METERS, + geometryMode: "source-runway-procedural-grid" + } + )); + } + } + } + + for (const end of ["primary", "secondary"]) { + const approach = end === "primary" ? runway.primaryApproach : runway.secondaryApproach; + if (!approach) { + continue; + } + const thresholdAlong = end === "primary" ? primaryThresholdAlong : secondaryThresholdAlong; + const direction = end === "primary" ? 1 : -1; + const threshold = end === "primary" ? primaryThreshold : secondaryThreshold; + + if (approach.endLights) { + zones.push(runwayLineZone(runway, `runway-threshold-${end}`, [ + runwayPoint(runway, thresholdAlong, -halfWidth), + runwayPoint(runway, thresholdAlong, halfWidth) + ], { + runwayEnd: end, + geometryMode: "continuous-procedural-envelope" + })); + } + + if (approach.reil) { + const reilOffset = halfWidth + 10; + for (const side of [-1, 1]) { + zones.push(runwayPointZone(runway, `runway-reil-${end}-${side < 0 ? "left" : "right"}`, + runwayPoint(runway, thresholdAlong, side * reilOffset), { + runwayEnd: end, + geometryMode: "source-positioned-procedural-point" + })); + } + } + + if (approach.touchdown) { + zones.push(...buildTouchdownZones(runway, end, thresholdAlong, direction)); + } + + if (approach.system > 0 || approach.strobes > 0) { + const approachLength = Math.max( + 300, + Math.min(1000, Math.max(approach.spacingMeters || 0, 30) * Math.max(approach.strobes, 10)) + ); + const approachOffset = Number.isFinite(approach.offsetMeters) ? approach.offsetMeters : 0; + const outsideDirection = -direction; + const startAlong = thresholdAlong + outsideDirection * approachOffset; + const endAlong = startAlong + outsideDirection * approachLength; + const stationSpacing = Math.max(5, approach.spacingMeters || 30); + for (const along of runwayStationDistances(startAlong, endAlong, stationSpacing)) { + zones.push(runwayPointZone(runway, `runway-approach-${end}-${Math.round(Math.abs(along - thresholdAlong) * 100)}`, + runwayPoint(runway, along, 0), { + runwayEnd: end, + approachSystem: approach.system, + strobes: approach.strobes, + spacingMeters: approach.spacingMeters, + geometryMode: "source-runway-procedural-grid" + })); + } + } + } + + for (const vasi of runway.vasi) { + zones.push(buildVasiZone(runway, vasi)); + } + + if (runway.edgeLightLevel > 0) { + zones.push(runwayLineZone(runway, "runway-end-primary", [ + runwayPoint(runway, -halfLength, -halfWidth), + runwayPoint(runway, -halfLength, halfWidth) + ], { geometryMode: "continuous-procedural-envelope", physicalEnd: true })); + zones.push(runwayLineZone(runway, "runway-end-secondary", [ + runwayPoint(runway, halfLength, -halfWidth), + runwayPoint(runway, halfLength, halfWidth) + ], { geometryMode: "continuous-procedural-envelope", physicalEnd: true })); + } + + return zones; +} + +function buildTouchdownZones(runway, end, thresholdAlong, direction) { + const zones = []; + const usableLength = Math.max( + 0, + Math.min( + RUNWAY_TOUCHDOWN_MAX_LENGTH_METERS, + (runway.lengthMeters - runway.primaryOffsetThresholdMeters - runway.secondaryOffsetThresholdMeters) / 2 + ) + ); + const lateralCenter = Math.min(11, runway.widthMeters * 0.44 / 2); + + for ( + let distance = RUNWAY_TOUCHDOWN_FIRST_STATION_METERS; + distance <= usableLength + 0.01; + distance += RUNWAY_TOUCHDOWN_BAR_SPACING_METERS + ) { + const along = thresholdAlong + direction * distance; + for (const side of [-1, 1]) { + const center = side * lateralCenter; + for (const lightIndex of [-1, 0, 1]) { + zones.push(runwayPointZone( + runway, + `runway-touchdown-${end}-${side < 0 ? "left" : "right"}-${Math.round(distance * 100)}-${lightIndex + 1}`, + runwayPoint( + runway, + along, + center + lightIndex * RUNWAY_TOUCHDOWN_BAR_LIGHT_SPACING_METERS + ), + { + runwayEnd: end, + distanceFromThresholdMeters: distance, + barLightIndex: lightIndex + 1, + lightCount: 3, + nominalLightSpacingMeters: RUNWAY_TOUCHDOWN_BAR_LIGHT_SPACING_METERS, + geometryMode: "source-runway-procedural-grid" + } + )); + } + } + } + + return zones; +} + +function buildVasiZone(runway, vasi) { + const halfLength = runway.lengthMeters / 2; + const along = + vasi.end === "primary" + ? -halfLength + vasi.biasZMeters + : halfLength - vasi.biasZMeters; + const sideSign = + vasi.end === "primary" + ? (vasi.side === "right" ? 1 : -1) + : (vasi.side === "right" ? -1 : 1); + const cross = sideSign * Math.abs(vasi.biasXMeters); + const extra = { + runwayEnd: vasi.end, + side: vasi.side, + vasiType: vasi.vasiType, + vasiTypeName: vasi.vasiTypeName, + biasXMeters: vasi.biasXMeters, + biasZMeters: vasi.biasZMeters, + spacingMeters: vasi.spacingMeters, + spacingApplicable: vasi.spacingApplicable, + ...(RUNWAY_VASI_KNOWN_LIGHT_COUNTS.has(vasi.vasiType) + ? { lightCount: RUNWAY_VASI_KNOWN_LIGHT_COUNTS.get(vasi.vasiType) } + : {}) + }; + const rowCount = RUNWAY_VASI_SPACED_ROW_COUNTS.get(vasi.vasiType); + if ( + rowCount && + Number.isFinite(vasi.spacingMeters) && + vasi.spacingMeters > 0 + ) { + const halfAlong = vasi.spacingMeters * (rowCount - 1) / 2; + return runwayLineZone(runway, `runway-vasi-${vasi.end}-${vasi.side}`, [ + runwayPoint(runway, along - halfAlong, cross), + runwayPoint(runway, along + halfAlong, cross) + ], { + ...extra, + rowCount, + geometryMode: "source-positioned-procedural-row-centers" + }); + } + return runwayPointZone( + runway, + `runway-vasi-${vasi.end}-${vasi.side}`, + runwayPoint(runway, along, cross), + { + ...extra, + geometryMode: "source-positioned-procedural-reference" + } + ); +} + +function runwayLineZone(runway, lightType, vertices, extra = {}) { + return runwayZone(runway, lightType, "LineString", { vertices }, extra); +} + +function runwayPointZone(runway, lightType, point, extra = {}) { + return runwayZone(runway, lightType, "Point", { point }, extra); +} + +function runwayPolygonZone(runway, lightType, vertices, extra = {}) { + return runwayZone(runway, lightType, "Polygon", { vertices }, extra); +} + +function runwayZone(runway, lightType, geometryType, geometry, extra) { + return { + id: stableId("must-keep", runway.id, lightType), + sourceFile: runway.sourceFile, + sourceType: "bgl-runway-light-zone", + sourceRecordOffset: runway.sourceRecordOffset, + runwayId: runway.id, + runway: `${runway.primaryLabel}/${runway.secondaryLabel}`, + classification: "runway", + lightType, + geometryType, + clearanceMeters: extra.clearanceMeters ?? RUNWAY_LIGHT_CLEARANCE_METERS, + reason: `${lightType} is enabled by the compiled BGL Runway record`, + sourceBasis: "bgl-runway-record", + ...geometry, + ...extra + }; +} + +function runwayPoint(runway, alongMeters, crossMeters) { + const headingRadians = (runway.heading * Math.PI) / 180; + const eastMeters = + Math.sin(headingRadians) * alongMeters + Math.cos(headingRadians) * crossMeters; + const northMeters = + Math.cos(headingRadians) * alongMeters - Math.sin(headingRadians) * crossMeters; + const metersPerDegreeLon = + 111320 * Math.max(Math.cos((runway.lat * Math.PI) / 180), 0.000001); + return { + lat: runway.lat + northMeters / 111320, + lon: runway.lon + eastMeters / metersPerDegreeLon + }; +} + +function runwayStationDistances(start, end, spacingMeters) { + const direction = end >= start ? 1 : -1; + const length = Math.abs(end - start); + if (length <= 0 || !Number.isFinite(spacingMeters) || spacingMeters <= 0) { + return [start]; + } + const stations = []; + for (let distance = 0; distance <= length + 0.01; distance += spacingMeters) { + stations.push(start + direction * Math.min(distance, length)); + } + if (Math.abs(stations.at(-1) - end) > 0.01) { + stations.push(end); + } + return stations; +} + +function isPlausibleRunwayNumberPair(primary, secondary) { + if (primary < 1 || primary > 44 || secondary < 1 || secondary > 44) { + return false; + } + if (primary <= 36 && secondary <= 36) { + const reciprocal = ((primary + 17) % 36) + 1; + return Math.abs(reciprocal - secondary) <= 1; + } + return true; +} + +function runwayLabel(number, designator) { + const designators = ["", "L", "R", "C", "W", "A", "B"]; + const numberLabel = number <= 36 ? String(number).padStart(2, "0") : String(number); + return `${numberLabel}${designators[designator] ?? ""}`; +} + +function isPlausibleCoordinate(lat, lon) { + return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; +} + +export function extractTaxiwayGraph(buffer, sourceFile) { + const graphs = []; + const pointTables = findTaxiwayPointTables(buffer); + + for (const pointTable of pointTables) { + const nextPointTableOffset = + pointTables.find((candidate) => candidate.offset > pointTable.offset)?.offset ?? buffer.length; + const parkings = findTaxiwayParkingTables(buffer, pointTable.end, nextPointTableOffset); + const pathTable = findTaxiwayPathTable(buffer, pointTable.points.length, pointTable.end, nextPointTableOffset); + if (!pathTable) { + continue; + } + + const taxiNames = findTaxiNameTable( + buffer, + pathTable.end, + nextPointTableOffset + ); + const paths = parseTaxiwayPathTable(buffer, pathTable, pointTable.points, taxiNames); + graphs.push({ + sourceFile, + pointTableOffset: pointTable.offset, + points: pointTable.points, + parkings, + taxiNames, + pathTableOffset: pathTable.offset, + pathRunOffset: pathTable.entryOffset, + paths + }); + } + + const lightRows = graphs.flatMap((graph) => + graph.paths.flatMap((pathRecord) => [ + ...(shouldReconstructTaxiwayPath(pathRecord) + ? [buildTaxiwayPathLightRow(sourceFile, graph, pathRecord)] + : []), + ...buildTaxiwayPathEdgeLightRows(sourceFile, graph, pathRecord) + ]) + ); + const holdShortRows = graphs.flatMap((graph) => + buildHoldShortTopologyRows(sourceFile, graph) + ); + + return { graphs, lightRows, holdShortRows }; +} + +export function extractTaxiwayLightRows(buffer, sourceFile) { + return extractTaxiwayGraph(buffer, sourceFile).lightRows; +} + +export function buildHoldShortTopologyRows(sourceFile, graph) { + const rows = []; + for (const point of graph.points ?? []) { + const pointType = point.flags & TAXIWAY_POINT_TYPE_MASK; + if (!TAXIWAY_POINT_HOLD_SHORT_TYPES.has(pointType)) continue; + + const connectedPaths = (graph.paths ?? []).filter( + (pathRecord) => + pathRecord.startIndex === point.index || pathRecord.endIndex === point.index + ); + const pathVectors = connectedPaths + .map((pathRecord) => { + const neighbor = + pathRecord.startIndex === point.index ? pathRecord.end : pathRecord.start; + const vector = localMeterVector(point, neighbor); + const length = Math.hypot(vector.x, vector.y); + return length >= MIN_TAXIWAY_PATH_LENGTH_METERS + ? { ...vector, length, pathRecord } + : null; + }) + .filter(Boolean); + if (pathVectors.length === 0) continue; + + const tangent = averagedUndirectedTangent(pathVectors); + if (!tangent) continue; + const widths = pathVectors + .map(({ pathRecord }) => pathRecord.widthMeters) + .filter((width) => Number.isFinite(width) && width > 0 && width <= 250) + .sort((left, right) => left - right); + const widthMeters = widths[Math.floor(widths.length / 2)] ?? 16; + const halfWidthMeters = Math.max( + HOLD_SHORT_MINIMUM_HALF_WIDTH_METERS, + Math.min(HOLD_SHORT_MAXIMUM_HALF_WIDTH_METERS, widthMeters / 2) + ); + const perpendicular = { x: -tangent.y, y: tangent.x }; + const start = offsetGraphPoint(point, perpendicular, -halfWidthMeters); + const end = offsetGraphPoint(point, perpendicular, halfWidthMeters); + + rows.push({ + id: stableId( + "bgl-hold-short-topology", + sourceFile, + graph.pointTableOffset, + point.index, + point.flags + ), + sourceFile, + sourceType: "bgl-hold-short-topology", + rawTag: "BGL TaxiwayPoint HOLD_SHORT", + classification: "stopbar", + confidence: 0.85, + vertices: [start, end], + holdShortPoint: pointFromTaxiwayGraphPoint(point), + holdShortPointType: pointType, + holdShortOrientation: + (point.flags & TAXIWAY_POINT_ORIENTATION_MASK) === 0 ? "forward" : "reverse", + graphPointIndex: point.index, + graphPointTableOffset: graph.pointTableOffset, + connectedPathIndexes: connectedPaths.map((pathRecord) => pathRecord.index), + widthMeters, + topologyOnly: true, + noRemovalRequired: true, + classificationReasons: [ + "decoded compiled TaxiwayPoint HOLD_SHORT type", + "position and orientation derived from connected TaxiwayPath records", + "topology fallback only; no simulator light removal is required" + ] + }); + } + return rows; +} + +function averagedUndirectedTangent(vectors) { + const reference = vectors[0]; + let x = 0; + let y = 0; + for (const vector of vectors) { + let normalizedX = vector.x / vector.length; + let normalizedY = vector.y / vector.length; + if (normalizedX * reference.x + normalizedY * reference.y < 0) { + normalizedX *= -1; + normalizedY *= -1; + } + x += normalizedX; + y += normalizedY; + } + const length = Math.hypot(x, y); + return length > 0.001 ? { x: x / length, y: y / length } : null; +} + +function localMeterVector(origin, target) { + const latitudeRadians = (origin.lat * Math.PI) / 180; + return { + x: (target.lon - origin.lon) * 111_320 * Math.cos(latitudeRadians), + y: (target.lat - origin.lat) * 111_320 + }; +} + +function offsetGraphPoint(origin, direction, distanceMeters) { + const latitudeRadians = (origin.lat * Math.PI) / 180; + return { + lat: origin.lat + (direction.y * distanceMeters) / 111_320, + lon: + origin.lon + + (direction.x * distanceMeters) / + Math.max(111_320 * Math.cos(latitudeRadians), 0.001) + }; +} + +export function filterHoldShortTopologyRows(rows, lightRows, instances) { + const realStopbarRows = (lightRows ?? []).filter( + (row) => + row.classification === "stopbar" && + row.sourceType !== "bgl-hold-short-topology" && + Array.isArray(row.vertices) && + row.vertices.length > 0 + ); + const realStopbarInstances = (instances ?? []).filter( + (instance) => + instance.classification === "stopbar" && + Number.isFinite(instance.lat) && + Number.isFinite(instance.lon) + ); + return rows.filter((row) => { + const point = row.holdShortPoint; + const overlapsRealRow = realStopbarRows.some( + (realRow) => + pointToPolylineDistanceMeters(point, realRow.vertices) <= + HOLD_SHORT_REAL_LIGHT_DEDUPLICATION_METERS + ); + if (overlapsRealRow) return false; + return !realStopbarInstances.some( + (instance) => + haversineDistanceMeters(point, instance) <= + HOLD_SHORT_REAL_LIGHT_DEDUPLICATION_METERS + ); + }); +} + +function findTaxiwayPointTables(buffer) { + const tables = []; + let offset = 0; + while ((offset = buffer.indexOf(RECORD_TAXIWAY_POINT_TABLE, offset)) !== -1) { + if (offset + 8 > buffer.length) { + break; + } + if (buffer[offset + 1] !== 0) { + offset += 1; + continue; + } + + const recordSize = buffer.readUInt16LE(offset + 0x02); + const pointCount = buffer.readUInt16LE(offset + 0x06); + if ( + pointCount < 2 || + recordSize !== 8 + pointCount * TAXIWAY_POINT_RECORD_SIZE || + offset + recordSize > buffer.length + ) { + offset += 1; + continue; + } + + const points = []; + let plausiblePoints = 0; + for (let index = 0; index < pointCount; index += 1) { + const pointOffset = offset + 8 + index * TAXIWAY_POINT_RECORD_SIZE; + const flags = buffer.readUInt32LE(pointOffset); + const lon = decodeBglLongitude(buffer.readUInt32LE(pointOffset + 0x04)); + const lat = decodeBglLatitude(buffer.readUInt32LE(pointOffset + 0x08)); + if (isPlausibleCoordinate(lat, lon)) { + plausiblePoints += 1; + } + points.push({ + index, + flags, + lat, + lon + }); + } + + if (plausiblePoints / pointCount < 0.95) { + offset += 1; + continue; + } + + tables.push({ + offset, + end: offset + recordSize, + points + }); + offset += recordSize; + } + + return tables; +} + +function findTaxiwayParkingTables(buffer, searchStart, searchEnd) { + const parkings = []; + for (let offset = searchStart; offset + 8 <= searchEnd; offset += 1) { + if (buffer.readUInt16LE(offset) !== RECORD_TAXIWAY_PARKING_TABLE) { + continue; + } + + const recordSize = buffer.readUInt16LE(offset + 0x02); + const parkingCount = buffer.readUInt16LE(offset + 0x06); + if ( + parkingCount === 0 || + recordSize !== 8 + parkingCount * TAXIWAY_PARKING_RECORD_SIZE || + offset + recordSize > searchEnd + ) { + continue; + } + + for (let index = 0; index < parkingCount; index += 1) { + const parkingOffset = offset + 8 + index * TAXIWAY_PARKING_RECORD_SIZE; + const lon = decodeBglLongitude(buffer.readUInt32LE(parkingOffset + 0x1c)); + const lat = decodeBglLatitude(buffer.readUInt32LE(parkingOffset + 0x20)); + if (!isPlausibleCoordinate(lat, lon)) { + continue; + } + + parkings.push({ + index, + sourceRecordOffset: parkingOffset, + flags: buffer.readUInt32LE(parkingOffset), + radiusMeters: buffer.readFloatLE(parkingOffset + 0x04), + heading: buffer.readFloatLE(parkingOffset + 0x08), + lat, + lon + }); + } + + offset += recordSize - 1; + } + + return parkings; +} + +function findTaxiNameTable(buffer, searchStart, searchEnd) { + for (let offset = searchStart; offset + 8 <= searchEnd; offset += 1) { + if (buffer.readUInt16LE(offset) !== RECORD_TAXI_NAME_TABLE) { + continue; + } + + const recordSize = buffer.readUInt32LE(offset + 0x02); + const taxiNameCount = buffer.readUInt16LE(offset + 0x06); + if ( + taxiNameCount === 0 || + recordSize !== 8 + taxiNameCount * TAXI_NAME_RECORD_SIZE || + offset + recordSize > searchEnd + ) { + continue; + } + + const taxiNames = []; + let plausibleNames = 0; + for (let index = 0; index < taxiNameCount; index += 1) { + const nameOffset = offset + 8 + index * TAXI_NAME_RECORD_SIZE; + const name = buffer + .toString("ascii", nameOffset, nameOffset + TAXI_NAME_RECORD_SIZE) + .replace(/\0+$/g, "") + .trim(); + if (name === "" || /^[A-Z0-9-]{1,8}$/.test(name)) { + plausibleNames += 1; + } + taxiNames.push({ + index, + name + }); + } + + if (plausibleNames / taxiNameCount < 0.9) { + continue; + } + + return taxiNames; + } + + return []; +} + +function findTaxiwayPathTable(buffer, pointCount, searchStart, searchEnd) { + let bestTable = null; + + for (let offset = searchStart; offset + 8 <= searchEnd; offset += 1) { + if (buffer.readUInt16LE(offset) !== RECORD_TAXIWAY_PATH_TABLE) { + continue; + } + + const recordSize = buffer.readUInt32LE(offset + 0x02); + const pathCount = buffer.readUInt16LE(offset + 0x06); + if ( + pathCount === 0 || + recordSize !== 8 + pathCount * TAXIWAY_PATH_RECORD_SIZE || + offset + recordSize > searchEnd + ) { + continue; + } + + const table = { + offset, + entryOffset: offset + 8, + end: offset + recordSize, + count: pathCount + }; + if (!isPlausibleTaxiwayPathTable(buffer, table, pointCount)) { + continue; + } + + if (!bestTable || table.count > bestTable.count) { + bestTable = table; + } + } + + return bestTable; +} + +function isPlausibleTaxiwayPathTable(buffer, table, pointCount) { + let plausibleEntries = 0; + const sampleCount = Math.min(table.count, 200); + + for (let index = 0; index < sampleCount; index += 1) { + const offset = table.entryOffset + index * TAXIWAY_PATH_RECORD_SIZE; + const startIndex = buffer.readUInt16LE(offset); + const endIndex = buffer.readUInt16LE(offset + 0x2e); + const pathType = buffer.readUInt8(offset + 0x04) & TAXIWAY_PATH_TYPE_MASK; + const widthMeters = buffer.readFloatLE(offset + 0x08); + const recordSizeMarker = buffer.readUInt16LE(offset + 0x16); + + if ( + startIndex < pointCount && + endIndex < pointCount && + pathType <= 16 && + Number.isFinite(widthMeters) && + widthMeters > 0 && + widthMeters <= 250 && + recordSizeMarker === TAXIWAY_PATH_RECORD_SIZE + ) { + plausibleEntries += 1; + } + } + + return sampleCount > 0 && plausibleEntries / sampleCount >= 0.95; +} + +function parseTaxiwayPathTable(buffer, pathTable, points, taxiNames) { + const paths = []; + + for (let index = 0; index < pathTable.count; index += 1) { + const offset = pathTable.entryOffset + index * TAXIWAY_PATH_RECORD_SIZE; + const startIndex = buffer.readUInt16LE(offset); + const legacyEndAndRunwayDesignator = buffer.readUInt16LE(offset + 0x02); + const legacyEndIndex = legacyEndAndRunwayDesignator & 0x0fff; + const runwayDesignator = legacyEndAndRunwayDesignator >> 12; + const pathTypeRaw = buffer.readUInt8(offset + 0x04); + const pathType = pathTypeRaw & TAXIWAY_PATH_TYPE_MASK; + const nameOrNumber = buffer.readUInt8(offset + 0x05); + const markingFlags = buffer.readUInt8(offset + 0x06); + const surface = buffer.readUInt8(offset + 0x07); + const materialCount = buffer.readUInt8(offset + 0x2c); + const vegetationFlags = buffer.readUInt8(offset + 0x2d); + const endIndex = buffer.readUInt16LE(offset + 0x2e); + const start = points[startIndex]; + const end = points[endIndex]; + const taxiNameIndex = nameOrNumber; + const taxiName = taxiNames[taxiNameIndex]?.name; + const lengthMeters = haversineDistanceMeters(start, end); + + paths.push({ + index, + sourceRecordOffset: offset, + startIndex, + endIndex, + start, + end, + legacyEndIndex, + runwayDesignator, + pathTypeRaw, + pathType, + nameOrNumber, + markingFlags, + centerLine: (markingFlags & TAXIWAY_PATH_CENTERLINE_FLAG) !== 0, + centerLineLighted: (markingFlags & TAXIWAY_PATH_LIGHTED_CENTERLINE_FLAG) !== 0, + leftEdgeType: (markingFlags & TAXIWAY_PATH_LEFT_EDGE_TYPE_MASK) >> 2, + leftEdgeLighted: (markingFlags & TAXIWAY_PATH_LEFT_EDGE_LIGHTED_FLAG) !== 0, + rightEdgeType: (markingFlags & TAXIWAY_PATH_RIGHT_EDGE_TYPE_MASK) >> 5, + rightEdgeLighted: (markingFlags & TAXIWAY_PATH_RIGHT_EDGE_LIGHTED_FLAG) !== 0, + surface, + materialCount, + vegetationFlags, + taxiNameIndex, + ...(taxiName ? { taxiName } : {}), + widthMeters: buffer.readFloatLE(offset + 0x08), + lengthMeters + }); + } + + return paths; +} + +function shouldReconstructTaxiwayPath(pathRecord) { + return ( + pathRecord.centerLineLighted && + pathRecord.pathType === TAXIWAY_PATH_TYPE_TAXI && + pathRecord.lengthMeters >= MIN_TAXIWAY_PATH_LENGTH_METERS && + pathRecord.lengthMeters <= MAX_TAXIWAY_PATH_LENGTH_METERS + ); +} + +function buildTaxiwayPathLightRow(sourceFile, graph, pathRecord) { + return { + id: stableId( + "bgl-taxiway-path", + sourceFile, + pathRecord.sourceRecordOffset, + pathRecord.startIndex, + pathRecord.endIndex, + pathRecord.markingFlags + ), + sourceFile, + sourceType: "bgl-taxiway-path", + sourceRecordOffset: pathRecord.sourceRecordOffset, + rawTag: "BGL TaxiwayPath", + spacing: DEFAULT_TAXIWAY_CENTERLINE_SPACING_METERS, + snapToVertices: false, + vertices: [ + pointFromTaxiwayGraphPoint(pathRecord.start), + pointFromTaxiwayGraphPoint(pathRecord.end) + ], + classification: "taxi-centerline", + confidence: 0.9, + centerLine: pathRecord.centerLine, + centerLineLighted: true, + markingFlags: pathRecord.markingFlags, + pathType: pathRecord.pathType, + pathTypeRaw: pathRecord.pathTypeRaw, + nameOrNumber: pathRecord.nameOrNumber, + taxiNameIndex: pathRecord.taxiNameIndex, + ...(pathRecord.taxiName ? { taxiName: pathRecord.taxiName } : {}), + startPointIndex: pathRecord.startIndex, + endPointIndex: pathRecord.endIndex, + legacyEndPointIndex: pathRecord.legacyEndIndex, + runwayDesignator: pathRecord.runwayDesignator, + leftEdgeType: pathRecord.leftEdgeType, + leftEdgeLighted: pathRecord.leftEdgeLighted, + rightEdgeType: pathRecord.rightEdgeType, + rightEdgeLighted: pathRecord.rightEdgeLighted, + surface: pathRecord.surface, + materialCount: pathRecord.materialCount, + vegetationFlags: pathRecord.vegetationFlags, + widthMeters: pathRecord.widthMeters, + lengthMeters: pathRecord.lengthMeters, + graphPointTableOffset: graph.pointTableOffset, + classificationReasons: [ + "decoded compiled TaxiwayPoint and TaxiwayPath records", + "TaxiwayPath centerline-light bit is set", + "TaxiwayPath type is TAXI" + ] + }; +} + +function buildTaxiwayPathEdgeLightRows(sourceFile, graph, pathRecord) { + if ( + pathRecord.pathType !== TAXIWAY_PATH_TYPE_TAXI || + pathRecord.lengthMeters < MIN_TAXIWAY_PATH_LENGTH_METERS || + pathRecord.lengthMeters > MAX_TAXIWAY_PATH_LENGTH_METERS + ) { + return []; + } + + const rows = []; + if (pathRecord.leftEdgeLighted) { + rows.push(buildTaxiwayPathEdgeLightRow(sourceFile, graph, pathRecord, "left")); + } + if (pathRecord.rightEdgeLighted) { + rows.push(buildTaxiwayPathEdgeLightRow(sourceFile, graph, pathRecord, "right")); + } + return rows; +} + +function buildTaxiwayPathEdgeLightRow(sourceFile, graph, pathRecord, side) { + const start = pointFromTaxiwayGraphPoint(pathRecord.start); + const end = pointFromTaxiwayGraphPoint(pathRecord.end); + const sideSign = side === "left" ? 1 : -1; + const offsetMeters = sideSign * Math.max(0, pathRecord.widthMeters / 2); + const vertices = offsetSegment(start, end, offsetMeters); + return { + id: stableId( + "bgl-taxiway-path-edge", + sourceFile, + pathRecord.sourceRecordOffset, + side + ), + sourceFile, + sourceType: "bgl-taxiway-path-edge", + sourceRecordOffset: pathRecord.sourceRecordOffset, + rawTag: "BGL TaxiwayPath Edge Lights", + snapToVertices: false, + vertices, + classification: "taxi-edge", + confidence: 0.9, + edgeSide: side, + edgeType: side === "left" ? pathRecord.leftEdgeType : pathRecord.rightEdgeType, + edgeLighted: true, + pathType: pathRecord.pathType, + pathTypeRaw: pathRecord.pathTypeRaw, + nameOrNumber: pathRecord.nameOrNumber, + taxiNameIndex: pathRecord.taxiNameIndex, + ...(pathRecord.taxiName ? { taxiName: pathRecord.taxiName } : {}), + startPointIndex: pathRecord.startIndex, + endPointIndex: pathRecord.endIndex, + widthMeters: pathRecord.widthMeters, + lengthMeters: pathRecord.lengthMeters, + graphPointTableOffset: graph.pointTableOffset, + classificationReasons: [ + "decoded compiled TaxiwayPoint and TaxiwayPath records", + `TaxiwayPath ${side} edge-light bit is set`, + "TaxiwayPath type is TAXI" + ] + }; +} + +function offsetSegment(start, end, offsetMeters) { + const metersPerDegreeLon = + 111320 * Math.max(Math.cos((start.lat * Math.PI) / 180), 0.000001); + const dx = (end.lon - start.lon) * metersPerDegreeLon; + const dy = (end.lat - start.lat) * 111320; + const length = Math.sqrt(dx * dx + dy * dy); + if (length <= 0.001) { + return [start, end]; + } + const eastOffset = (-dy / length) * offsetMeters; + const northOffset = (dx / length) * offsetMeters; + return [start, end].map((point) => ({ + lat: point.lat + northOffset / 111320, + lon: point.lon + eastOffset / metersPerDegreeLon + })); +} + +function buildTaxiwayPathBridgeRows(sourceFile, graphs, sourceRows) { + const targetRows = sourceRows.filter((row) => + isTargetLightClassification(row.classification) && + Array.isArray(row.vertices) && + row.vertices.length >= 2 + ); + if (targetRows.length < 2) { + return []; + } + + return graphs.flatMap((graph) => + graph.paths + .filter((pathRecord) => shouldBridgeTaxiwayPath(pathRecord, targetRows)) + .map((pathRecord) => buildTaxiwayPathBridgeRow(sourceFile, graph, pathRecord, targetRows)) + ); +} + +function shouldBridgeTaxiwayPath(pathRecord, targetRows) { + if ( + !TAXIWAY_PATH_BRIDGE_TYPES.has(pathRecord.pathType) || + !pathRecord.taxiName || + pathRecord.centerLineLighted || + pathRecord.lengthMeters < MIN_TAXIWAY_PATH_LENGTH_METERS || + pathRecord.lengthMeters > MAX_TAXIWAY_PATH_BRIDGE_LENGTH_METERS + ) { + return false; + } + + const start = pointFromTaxiwayGraphPoint(pathRecord.start); + const end = pointFromTaxiwayGraphPoint(pathRecord.end); + const midpoint = { + lat: (start.lat + end.lat) / 2, + lon: (start.lon + end.lon) / 2 + }; + const startAnchor = findTaxiwayPathEndpointAnchor(start, end, targetRows); + const endAnchor = findTaxiwayPathEndpointAnchor(end, start, targetRows); + + return ( + startAnchor !== undefined && + endAnchor !== undefined && + startAnchor.row.id !== endAnchor.row.id && + nearestRowDistanceMeters(midpoint, targetRows) >= TAXIWAY_PATH_BRIDGE_UNCOVERED_MIDPOINT_METERS + ); +} + +function buildTaxiwayPathBridgeRow(sourceFile, graph, pathRecord, targetRows) { + const start = pointFromTaxiwayGraphPoint(pathRecord.start); + const end = pointFromTaxiwayGraphPoint(pathRecord.end); + const startAnchor = findTaxiwayPathEndpointAnchor(start, end, targetRows); + const endAnchor = findTaxiwayPathEndpointAnchor(end, start, targetRows); + + return { + id: stableId( + "bgl-taxiway-path-bridge", + sourceFile, + pathRecord.sourceRecordOffset, + pathRecord.startIndex, + pathRecord.endIndex, + pathRecord.taxiName + ), + sourceFile, + sourceType: "bgl-taxiway-path-bridge", + sourceRecordOffset: pathRecord.sourceRecordOffset, + rawTag: "BGL TaxiwayPath Bridge", + spacing: DEFAULT_TAXIWAY_CENTERLINE_SPACING_METERS, + snapToVertices: false, + vertices: [start, end], + classification: "taxi-centerline", + confidence: 0.78, + centerLine: pathRecord.centerLine, + centerLineLighted: false, + markingFlags: pathRecord.markingFlags, + pathType: pathRecord.pathType, + pathTypeRaw: pathRecord.pathTypeRaw, + nameOrNumber: pathRecord.nameOrNumber, + taxiNameIndex: pathRecord.taxiNameIndex, + taxiName: pathRecord.taxiName, + startPointIndex: pathRecord.startIndex, + endPointIndex: pathRecord.endIndex, + legacyEndPointIndex: pathRecord.legacyEndIndex, + runwayDesignator: pathRecord.runwayDesignator, + leftEdgeType: pathRecord.leftEdgeType, + leftEdgeLighted: pathRecord.leftEdgeLighted, + rightEdgeType: pathRecord.rightEdgeType, + rightEdgeLighted: pathRecord.rightEdgeLighted, + surface: pathRecord.surface, + materialCount: pathRecord.materialCount, + vegetationFlags: pathRecord.vegetationFlags, + widthMeters: pathRecord.widthMeters, + lengthMeters: pathRecord.lengthMeters, + graphPointTableOffset: graph.pointTableOffset, + nearestTargetRowEndpointDistancesMeters: { + start: startAnchor?.distanceMeters ?? nearestRowDistanceMeters(start, targetRows), + end: endAnchor?.distanceMeters ?? nearestRowDistanceMeters(end, targetRows) + }, + targetRowEndpointAnchorTypes: { + start: startAnchor?.type, + end: endAnchor?.type + }, + classificationReasons: [ + "decoded compiled TaxiwayPoint and TaxiwayPath records", + "TaxiwayPath bridges a gap between source-backed target rows", + "TaxiwayPath endpoints touch decoded target row geometry or an aligned terminal gap no longer than one source light spacing", + "TaxiwayPath midpoint is not already covered by decoded target row geometry" + ] + }; +} + +function findTaxiwayPathEndpointAnchor(point, otherPathPoint, rows) { + const nearestGeometryAnchor = rows + .map((row) => ({ + row, + distanceMeters: pointToPolylineDistanceMeters(point, row.vertices) + })) + .sort((left, right) => left.distanceMeters - right.distanceMeters)[0]; + if ( + nearestGeometryAnchor && + nearestGeometryAnchor.distanceMeters <= TAXIWAY_PATH_BRIDGE_ENDPOINT_TOLERANCE_METERS + ) { + return { + ...nearestGeometryAnchor, + type: "row-geometry" + }; + } + + let bestTerminalAnchor; + for (const row of rows) { + if (!Number.isFinite(row.spacing) || row.spacing <= 0 || row.vertices.length < 2) { + continue; + } + + const maxExtensionMeters = Math.min( + TAXIWAY_PATH_BRIDGE_MAX_ENDPOINT_EXTENSION_METERS, + row.spacing + ); + const terminalSegments = [ + [row.vertices[0], row.vertices[1]], + [row.vertices.at(-1), row.vertices.at(-2)] + ]; + + for (const [terminal, adjacent] of terminalSegments) { + const distanceMeters = haversineDistanceMeters(point, terminal); + if (distanceMeters > maxExtensionMeters) { + continue; + } + + const alignmentDegrees = segmentAlignmentDegrees( + point, + otherPathPoint, + terminal, + adjacent + ); + if (alignmentDegrees > TAXIWAY_PATH_BRIDGE_MAX_ALIGNMENT_DEGREES) { + continue; + } + + if (!bestTerminalAnchor || distanceMeters < bestTerminalAnchor.distanceMeters) { + bestTerminalAnchor = { + row, + distanceMeters, + alignmentDegrees, + type: "aligned-row-terminal" + }; + } + } + } + + return bestTerminalAnchor; +} + +function segmentAlignmentDegrees(firstStart, firstEnd, secondStart, secondEnd) { + const referenceLatitudeRadians = + ((firstStart.lat + firstEnd.lat + secondStart.lat + secondEnd.lat) / 4) * + (Math.PI / 180); + const longitudeScale = Math.cos(referenceLatitudeRadians); + const firstVector = { + x: (firstEnd.lon - firstStart.lon) * longitudeScale, + y: firstEnd.lat - firstStart.lat + }; + const secondVector = { + x: (secondEnd.lon - secondStart.lon) * longitudeScale, + y: secondEnd.lat - secondStart.lat + }; + const firstLength = Math.hypot(firstVector.x, firstVector.y); + const secondLength = Math.hypot(secondVector.x, secondVector.y); + if (firstLength === 0 || secondLength === 0) { + return 180; + } + + const cosine = Math.abs( + (firstVector.x * secondVector.x + firstVector.y * secondVector.y) / + (firstLength * secondLength) + ); + return Math.acos(Math.max(-1, Math.min(1, cosine))) * (180 / Math.PI); +} + +function nearestRowDistanceMeters(point, rows) { + return Math.min(...rows.map((row) => pointToPolylineDistanceMeters(point, row.vertices))); +} + +function pointFromTaxiwayGraphPoint(point) { + return { + lat: point.lat, + lon: point.lon + }; +} + +export function extractModelInfos(buffer, sourceFile) { + const modelInfos = []; + let offset = 0; + + while ((offset = buffer.indexOf(MODEL_INFO_NEEDLE, offset)) !== -1) { + const end = buffer.indexOf(TAG_END, offset); + if (end === -1) { + break; + } + + const tag = buffer.toString("utf8", offset, end + 1); + const guid = tag.match(/\bguid="(\{?[0-9a-fA-F-]{36}\}?)"/i)?.[1]; + const name = tag.match(/\bname="([^"]+)"/i)?.[1]; + if (guid && name) { + modelInfos.push({ + guid: normalizeGuid(guid), + name, + sourceFile + }); + } + + offset = end + 1; + } + + return modelInfos; +} + +export async function extractModelInfosFromFile(sourceFile) { + const modelInfos = []; + let carry = Buffer.alloc(0); + + for await (const chunk of createReadStream(sourceFile, { highWaterMark: 4 * 1024 * 1024 })) { + const buffer = carry.length > 0 ? Buffer.concat([carry, chunk]) : chunk; + let offset = 0; + + while ((offset = buffer.indexOf(MODEL_INFO_NEEDLE, offset)) !== -1) { + const end = buffer.indexOf(TAG_END, offset); + if (end === -1) { + break; + } + + const modelInfo = parseModelInfoTag(buffer.toString("utf8", offset, end + 1), sourceFile); + if (modelInfo) { + modelInfos.push(modelInfo); + } + offset = end + 1; + } + + carry = buffer.subarray(Math.max(0, buffer.length - MODEL_INFO_STREAM_TAIL_BYTES)); + } + + return modelInfos; +} + +function parseModelInfoTag(tag, sourceFile) { + const guid = tag.match(/\bguid="(\{?[0-9a-fA-F-]{36}\}?)"/i)?.[1]; + const name = tag.match(/\bname="([^"]+)"/i)?.[1]; + if (!guid || !name) { + return null; + } + + return { + guid: normalizeGuid(guid), + name, + sourceFile + }; +} + +export function extractBglPlacements(buffer, sourceFile, modelIndex = new Map()) { + const warnings = []; + const instances = []; + const sectionTypes = {}; + + if (buffer.length < HEADER_SIZE) { + return { + parsed: false, + instances, + warnings: [`${sourceFile}: BGL file is too small to contain a valid header`], + sectionTypes, + unsupportedSceneryRecordTypes: {} + }; + } + + const magic = buffer.readUInt16LE(0); + const headerSize = buffer.readUInt32LE(4); + const sectionCount = buffer.readUInt32LE(0x14); + + if (magic !== 0x0201 || headerSize < HEADER_SIZE) { + return { + parsed: false, + instances, + warnings: [`${sourceFile}: unsupported or unrecognized BGL header`], + sectionTypes, + unsupportedSceneryRecordTypes: {} + }; + } + + if (headerSize + sectionCount * SECTION_POINTER_SIZE > buffer.length) { + return { + parsed: false, + instances, + warnings: [`${sourceFile}: BGL section table is outside file bounds`], + sectionTypes, + unsupportedSceneryRecordTypes: {} + }; + } + + let scenerySections = 0; + const skippedRecordTypes = new Map(); + for (let index = 0; index < sectionCount; index += 1) { + const pointerOffset = HEADER_SIZE + index * SECTION_POINTER_SIZE; + const sectionType = buffer.readUInt32LE(pointerOffset); + const sectionTypeKey = hexKey(sectionType); + sectionTypes[sectionTypeKey] = (sectionTypes[sectionTypeKey] ?? 0) + 1; + if (sectionType !== SECTION_SCENERY_OBJECTS) { + continue; + } + + scenerySections += 1; + const subsectionCount = buffer.readUInt32LE(pointerOffset + 0x08); + const subsectionOffset = buffer.readUInt32LE(pointerOffset + 0x0c); + const subsectionTableSize = buffer.readUInt32LE(pointerOffset + 0x10); + const subsectionPointerSize = subsectionTableSize / Math.max(subsectionCount, 1); + + if ( + subsectionCount === 0 || + !Number.isInteger(subsectionPointerSize) || + subsectionPointerSize < SUBSECTION_POINTER_SIZE || + subsectionOffset + subsectionTableSize > buffer.length + ) { + warnings.push(`${sourceFile}: scenery-object subsection table is invalid`); + continue; + } + + for (let subsectionIndex = 0; subsectionIndex < subsectionCount; subsectionIndex += 1) { + const subsectionPointerOffset = subsectionOffset + subsectionIndex * subsectionPointerSize; + const subsectionQmid = buffer.readUInt32LE(subsectionPointerOffset); + const recordCount = buffer.readUInt32LE(subsectionPointerOffset + 0x04); + const recordsOffset = buffer.readUInt32LE(subsectionPointerOffset + 0x08); + const recordsSize = buffer.readUInt32LE(subsectionPointerOffset + 0x0c); + + if (recordsOffset + recordsSize > buffer.length) { + warnings.push(`${sourceFile}: scenery-object records are outside file bounds`); + continue; + } + + let recordOffset = recordsOffset; + for (let recordIndex = 0; recordIndex < recordCount && recordOffset < recordsOffset + recordsSize; recordIndex += 1) { + if (recordOffset + 4 > buffer.length) { + warnings.push(`${sourceFile}: truncated scenery-object record at 0x${recordOffset.toString(16)}`); + break; + } + + const recordType = buffer.readUInt16LE(recordOffset); + const recordSize = buffer.readUInt16LE(recordOffset + 0x02); + if (recordSize < 4 || recordOffset + recordSize > recordsOffset + recordsSize) { + warnings.push(`${sourceFile}: invalid scenery-object record size at 0x${recordOffset.toString(16)}`); + break; + } + + if (recordType === RECORD_SCENERY_OBJECT_LIBRARY_OBJECT && recordSize >= 0x40) { + instances.push(parseLibraryObjectRecord(buffer, sourceFile, recordOffset, modelIndex, { + sectionIndex: index, + subsectionIndex, + subsectionQmid, + recordIndex, + recordSize + })); + } else { + skippedRecordTypes.set(recordType, (skippedRecordTypes.get(recordType) ?? 0) + 1); + } + + recordOffset += recordSize; + } + } + } + + if (scenerySections === 0 && buffer.indexOf(MODEL_INFO_NEEDLE) === -1) { + warnings.push(`${sourceFile}: valid BGL parsed, but no scenery-object section was found`); + } + + for (const [recordType, count] of skippedRecordTypes) { + warnings.push(`${sourceFile}: skipped ${count} unsupported scenery-object records of type 0x${recordType.toString(16)}`); + } + + return { + parsed: true, + instances, + warnings, + sectionTypes, + unsupportedSceneryRecordTypes: Object.fromEntries( + [...skippedRecordTypes.entries()].map(([recordType, count]) => [hexKey(recordType), count]) + ) + }; +} + +function hexKey(value) { + return `0x${value.toString(16)}`; +} + +function parseLibraryObjectRecord(buffer, sourceFile, offset, modelIndex, provenance = {}) { + const rawLongitude = buffer.readUInt32LE(offset + 0x04); + const rawLatitude = buffer.readUInt32LE(offset + 0x08); + const rawAltitude = buffer.readInt32LE(offset + 0x0c); + const rawFlags = buffer.readUInt16LE(offset + 0x10); + const rawPitch = buffer.readInt16LE(offset + 0x12); + const rawBank = buffer.readInt16LE(offset + 0x14); + const rawHeading = buffer.readUInt16LE(offset + 0x16); + const rawImageComplexity = buffer.readUInt16LE(offset + 0x18); + const rawUnknown = buffer.readUInt16LE(offset + 0x1a); + const lat = decodeBglLatitude(rawLatitude); + const lon = decodeBglLongitude(rawLongitude); + const alt = rawAltitude / 1000; + const pitch = decodeAngle16(rawPitch); + const bank = decodeAngle16(rawBank); + const heading = decodeAngle16(rawHeading); + const instanceId = guidFromBytes(buffer, offset + 0x1c); + const guid = guidFromBytes(buffer, offset + 0x2c); + const scale = buffer.readFloatLE(offset + 0x3c); + const modelInfo = modelIndex.get(normalizeGuid(guid)); + const classification = classifyObject({ + sourceType: "library-object", + rawTag: "BGL SceneryObject.LibraryObject", + guid, + name: modelInfo?.name, + extraText: path.basename(sourceFile) + }); + + return { + id: stableId(sourceFile, offset, lat, lon, guid), + sourceFile, + sourceType: "library-object", + lat, + lon, + alt, + heading, + pitch, + bank, + scale, + guid, + ...(modelInfo?.name ? { name: modelInfo.name } : {}), + rawTag: "BGL SceneryObject.LibraryObject", + sourceRecordOffset: offset, + sourceRecordSize: provenance.recordSize ?? buffer.readUInt16LE(offset + 0x02), + sourceSectionType: "0x25", + sourceSectionIndex: provenance.sectionIndex, + sourceSubsectionIndex: provenance.subsectionIndex, + sourceSubsectionQmid: provenance.subsectionQmid, + sourceRecordIndex: provenance.recordIndex, + bglRawPlacement: { + longitude: rawLongitude, + latitude: rawLatitude, + altitude: rawAltitude, + flags: rawFlags, + pitch: rawPitch, + bank: rawBank, + heading: rawHeading, + imageComplexity: rawImageComplexity, + unknown: rawUnknown + }, + instanceId, + classification: classification.classification, + confidence: classification.confidence, + classificationReasons: classification.reasons + }; +} + +export function decodeBglLongitude(value) { + return (value * 360) / (3 * 0x10000000) - 180; +} + +export function decodeBglLatitude(value) { + return 90 - (value * 180) / (2 * 0x10000000); +} + +function decodeAngle16(value) { + const angle = (value * 360) / 0x10000; + return Math.round(angle * 1_000_000) / 1_000_000; +} + +function guidFromBytes(buffer, offset) { + const d1 = buffer.readUInt32LE(offset).toString(16).padStart(8, "0"); + const d2 = buffer.readUInt16LE(offset + 4).toString(16).padStart(4, "0"); + const d3 = buffer.readUInt16LE(offset + 6).toString(16).padStart(4, "0"); + const d4 = [...buffer.subarray(offset + 8, offset + 10)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + const d5 = [...buffer.subarray(offset + 10, offset + 16)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + + return `{${d1}-${d2}-${d3}-${d4}-${d5}}`; +} + +export function normalizeGuid(guid) { + const value = String(guid ?? "").trim().toLowerCase(); + if (!value) { + return ""; + } + return value.startsWith("{") ? value : `{${value}}`; +} diff --git a/src/features/draft-generator/extractor/classify.js b/src/features/draft-generator/extractor/classify.js new file mode 100644 index 0000000..e7102c1 --- /dev/null +++ b/src/features/draft-generator/extractor/classify.js @@ -0,0 +1,293 @@ +import { createHash } from "node:crypto"; + +const LIGHT_EVIDENCE_TERMS = [ + "light", + "centerlight", + "centrelight", + "stopbar", + "stop_bar", + "stop bar", + "holdlight", + "hold_light", + "hold short", + "leadon", + "lead_on", + "lead on", + "wigwag", + "lightpreset" +]; + +const AIRFIELD_CONTEXT_TERMS = [ + "taxiway", + "taxi", + "centerline", + "centreline", + "edge", + "inset", + "runway", + "rwy", + "apron" +]; + +const COLOR_TERMS = ["green", "red", "yellow", "blue", "orange", "white"]; +const EXACT_MODEL_CLASSIFICATIONS = new Map([ + ["ftlib_holdlight1", "runway-guard"], + ["ftlib_holdlight2", "runway-guard"] +]); +const TOKEN_EXPANSIONS = new Map([ + ["lgt", ["light"]], + ["lgts", ["light"]], + ["txlgt", ["taxi", "light"]], + ["txelgt", ["taxi", "edge", "light"]], + ["rwlgt", ["runway", "light"]], + ["aplgt", ["apron", "light"]], + ["gr", ["green"]], + ["grn", ["green"]], + ["gn", ["green"]], + ["go", ["green", "orange"]], + ["re", ["red"]], + ["rd", ["red"]], + ["or", ["orange"]], + ["org", ["orange"]], + ["orn", ["orange"]], + ["ye", ["yellow"]], + ["yl", ["yellow"]], + ["yw", ["yellow"]], + ["bl", ["blue"]], + ["blu", ["blue"]], + ["wt", ["white"]], + ["wh", ["white"]] +]); +const TARGET_LIGHT_CLASSIFICATIONS = new Set([ + "stopbar", + "lead-on", + "taxi-centerline" +]); +const DEBUG_LIGHT_EXCLUDED_CLASSIFICATIONS = new Set([ + "not-light", + "unknown", + "apron", + "airfield-light" +]); + +export function classifyObject({ sourceType, rawTag, guid, name, extraText }) { + const { haystack, expansionReasons } = buildHeuristicHaystack([sourceType, rawTag, guid, name, extraText] + .filter(Boolean) + .join(" ")); + const reasons = [...expansionReasons]; + + for (const term of LIGHT_EVIDENCE_TERMS) { + if (haystack.includes(term)) { + reasons.push(`matched term "${term}"`); + } + } + + if (["lightrow", "edgelights", "apronedgelights"].includes(sourceType)) { + reasons.push(`XML type ${rawTag} is a light row type`); + } + + if (sourceType === "visual-effect") { + reasons.push("XML type VisualEffectObject is treated as likely light-related"); + } + + const hasLightEvidence = + LIGHT_EVIDENCE_TERMS.some((term) => haystack.includes(term)) || + ["lightrow", "edgelights", "apronedgelights", "visual-effect"].includes(sourceType); + const hasAirfieldContext = AIRFIELD_CONTEXT_TERMS.some((term) => haystack.includes(term)); + + if (hasLightEvidence && hasAirfieldContext) { + for (const term of COLOR_TERMS) { + if (haystack.includes(term)) { + reasons.push(`matched color "${term}" with airfield light context`); + } + } + } + + const lightRelated = + hasLightEvidence || + (hasAirfieldContext && ["lightrow", "edgelights", "apronedgelights"].includes(sourceType)); + + const exactModelClassification = EXACT_MODEL_CLASSIFICATIONS.get( + String(name ?? "").trim().toLowerCase() + ); + let classification = "unknown"; + if (exactModelClassification) { + classification = exactModelClassification; + reasons.push(`exact model name identifies ${exactModelClassification} fixture`); + } else if (containsAny(haystack, ["taxisign", "taxi_sign", "taxi sign"])) { + classification = "taxi-sign"; + reasons.push("taxi-sign illumination is not a stop-bar light"); + } else if ( + containsAny(haystack, ["stopbar", "stop_bar", "stop bar", "holdlight", "hold_light", "hold short"]) || + ( + hasLightEvidence && + hasAirfieldContext && + containsAny(haystack, ["taxi", "taxiway"]) && + haystack.includes("red") && + !containsAny(haystack, ["runway", "rwy"]) + ) + ) { + classification = "stopbar"; + } else if ( + containsAny(haystack, ["leadon", "lead_on", "lead on"]) || + ( + hasLightEvidence && + hasAirfieldContext && + containsAny(haystack, ["taxi", "taxiway"]) && + haystack.includes("orange") && + !containsAny(haystack, ["runway", "rwy"]) + ) + ) { + classification = "lead-on"; + } else if ( + containsAny(haystack, [ + "wigwag", + "wig_wag", + "wig wag", + "guardlight", + "guard_light", + "guard light", + "runway guard" + ]) + ) { + classification = "runway-guard"; + } else if (containsAny(haystack, ["apronlights", "apron_lights", "apron lights"])) { + classification = "airfield-light"; + } else if ( + containsAny(haystack, ["runway", "rwy"]) && + containsAny(haystack, ["light", "centerline", "centreline", "centerlight", "centrelight", "edge", "inset"]) + ) { + classification = "runway"; + } else if ( + hasLightEvidence && + containsAny(haystack, ["centerline", "centreline", "centerlight", "centrelight", "inset"]) && + ( + containsAny(haystack, ["taxi", "taxiway"]) || + containsAny(haystack, ["green", "orange"]) || + ["lightrow", "edgelights", "apronedgelights"].includes(sourceType) + ) + ) { + classification = "taxi-centerline"; + } else if ( + hasLightEvidence && + hasAirfieldContext && + containsAny(haystack, ["taxi", "taxiway"]) && + ( + haystack.includes("green") || + (haystack.includes("yellow") && haystack.includes("taxilight")) + ) && + !containsAny(haystack, ["runway", "rwy", "edge", "blue"]) + ) { + classification = "taxi-centerline"; + } else if ( + hasLightEvidence && + containsAny(haystack, ["taxi", "taxiway", "edge"]) && + containsAny(haystack, ["edge", "blue"]) + ) { + classification = "taxi-edge"; + } else if (hasLightEvidence && haystack.includes("apron")) { + classification = "apron"; + } else if (lightRelated) { + classification = "unknown-light"; + } else if (["library-object", "visual-effect", "simprop-container", "scenery-object"].includes(sourceType)) { + classification = "not-light"; + reasons.push("no light-related heuristic matched"); + } + + const confidence = confidenceFor(classification, reasons, sourceType); + return { classification, confidence, reasons }; +} + +function confidenceFor(classification, reasons, sourceType) { + if (classification === "not-light" || classification === "unknown") { + return 0.2; + } + + if (["lightrow", "edgelights", "apronedgelights"].includes(sourceType)) { + return Math.min(0.95, 0.75 + reasons.length * 0.04); + } + + if (classification === "unknown-light") { + return Math.min(0.75, 0.45 + reasons.length * 0.05); + } + + return Math.min(0.95, 0.7 + reasons.length * 0.04); +} + +export function isLikelyLightClassification(classification) { + return !["not-light", "unknown"].includes(classification); +} + +export function isTargetLightClassification(classification) { + return TARGET_LIGHT_CLASSIFICATIONS.has(classification); +} + +export function isDebugLightClassification(classification) { + return !DEBUG_LIGHT_EXCLUDED_CLASSIFICATIONS.has(classification); +} + +function buildHeuristicHaystack(value) { + const base = String(value ?? "").toLowerCase(); + const expansionReasons = []; + const expandedTerms = []; + const seenTerms = new Set(); + + for (const token of base.match(/[a-z]+[0-9]*/g) ?? []) { + const normalizedToken = token.replace(/[0-9]+$/g, ""); + const expansions = expansionsForToken(normalizedToken); + if (expansions.length === 0) { + continue; + } + + expansionReasons.push(`expanded abbreviation "${normalizedToken}" as "${expansions.join(" ")}"`); + for (const expansion of expansions) { + if (!seenTerms.has(expansion)) { + seenTerms.add(expansion); + expandedTerms.push(expansion); + } + } + } + + return { + haystack: [base, ...expandedTerms].join(" "), + expansionReasons + }; +} + +function expansionsForToken(token) { + if (!token) { + return []; + } + + const direct = TOKEN_EXPANSIONS.get(token); + if (direct) { + return direct; + } + + if (token.endsWith("lgt")) { + const expansions = ["light"]; + if (token.startsWith("tx")) { + expansions.push("taxi"); + } + if (token.startsWith("rw")) { + expansions.push("runway"); + } + if (token.startsWith("ap")) { + expansions.push("apron"); + } + return expansions; + } + + return []; +} + +function containsAny(value, terms) { + return terms.some((term) => value.includes(term)); +} + +export function stableId(...parts) { + return createHash("sha1") + .update(parts.map((part) => String(part)).join("|")) + .digest("hex") + .slice(0, 16); +} diff --git a/src/features/draft-generator/extractor/extract.js b/src/features/draft-generator/extractor/extract.js new file mode 100644 index 0000000..fb8bae3 --- /dev/null +++ b/src/features/draft-generator/extractor/extract.js @@ -0,0 +1,2146 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { extractBglData } from "./bgl.js"; +import { + classifyObject, + isLikelyLightClassification, + isTargetLightClassification, + stableId +} from "./classify.js"; +import { + haversineDistanceMeters, + interpolatePoint, + interpolatePolyline, + nearestPointOnPolygon, + nearestPointOnPolyline, + offsetPointMeters, + pointInPolygon, + pointToPolylineDistanceMeters, + polylineCorridorPolygon, + polylineToPolylineDistanceMeters, + rectanglePolygonAround +} from "./geo.js"; +import { findDescendants, localName, parseXml, walkNodes } from "./xml.js"; + +const INSTANCE_CHILD_TAGS = new Set([ + "libraryobject", + "visualeffectobject", + "simpropcontainer" +]); + +const LIGHT_ROW_TAGS = new Set(["lightrow", "edgelights", "apronedgelights"]); +const VERTEX_TAGS = new Set(["vertex", "point", "edgevertex", "apronvertex"]); +const EXISTING_EXCLUSION_TAGS = new Set(["exclusionrectangle"]); +const SOURCE_BACKED_LIGHT_ROW_TYPES = new Set([ + "lightrow", + "edgelights", + "apronedgelights", + "bgl-airport-light-row", + "bgl-taxiway-path", + "bgl-taxiway-path-edge", + "bgl-taxiway-path-bridge", + "division-guided-source-instances" +]); +const INFERRED_REMOVAL_LIGHT_ROW_TYPES = new Set(["inferred-bgl-placement-row"]); +const METERS_PER_DEGREE_LAT = 111320; +const ROW_ENDPOINT_MERGE_TOLERANCE_METERS = 0.5; +const ROW_LINE_MERGE_TOLERANCE_METERS = 0.05; +const ROW_MERGE_ANGLE_TOLERANCE_DEGREES = 5; +const PARTIAL_OVERLAP_JOIN_MAX_METERS = 1.5; +const PARTIAL_OVERLAP_BOUNDARY_SAMPLE_METERS = 0.05; +const MUST_KEEP_ROW_CLEARANCE_METERS = 0.25; +const MUST_KEEP_INSTANCE_CLEARANCE_METERS = 0.5; +const MUST_KEEP_DISCONTINUITY_MIN_METERS = 25; +const MUST_KEEP_DISCONTINUITY_SPACING_MULTIPLIER = 3; +const MIN_SAFE_REMOVAL_HALF_WIDTH_METERS = 0.015; +const PROTECTION_GEOMETRY_TOLERANCE_METERS = 0.005; +const PROTECTION_BEND_MARGIN_METERS = 0.02; +const PROTECTION_SAMPLE_SPACING_METERS = 0.25; +const REMOVAL_SIMPLIFICATION_TOLERANCES_METERS = [0.05, 0.02, 0.01, 0.005]; + +function roundNumber(value) { + return Math.round(value * 1_000) / 1_000; +} + +export async function extractAirportLightData(options) { + const extractionStartedAt = performance.now(); + const warnings = []; + const instances = []; + const lightRows = []; + const existingExclusionRectangles = []; + + for (const unsupportedFile of options.unsupportedFiles ?? []) { + warnings.push(`${unsupportedFile}: unsupported binary/package file`); + } + + let xmlFilesParsed = 0; + for (const xmlFile of options.xmlFiles) { + let text; + try { + text = await fs.readFile(xmlFile, "utf8"); + } catch (error) { + warnings.push(`${xmlFile}: failed to read XML file: ${error.message}`); + continue; + } + + const parsed = parseXml(text, xmlFile); + warnings.push(...parsed.warnings); + xmlFilesParsed += 1; + + const fileResult = extractFromParsedXml(parsed.root, xmlFile); + instances.push(...fileResult.instances); + lightRows.push(...fileResult.lightRows); + existingExclusionRectangles.push(...fileResult.existingExclusionRectangles); + warnings.push(...fileResult.warnings); + } + + const bglStartedAt = performance.now(); + const bglResult = await extractBglData(options.bglFiles ?? []); + const bglExtractionMilliseconds = performance.now() - bglStartedAt; + instances.push(...bglResult.instances); + lightRows.push(...bglResult.lightRows); + warnings.push(...bglResult.warnings); + + const mustKeepStartedAt = performance.now(); + const mustKeepZones = buildMustKeepZones( + instances, + lightRows, + bglResult.runwayLightZones ?? [] + ); + const mustKeepBuildMilliseconds = performance.now() - mustKeepStartedAt; + const removalStartedAt = performance.now(); + const { + exclusionCandidates, + removalPolygons, + protectionConflicts, + performance: removalPerformance + } = options.buildRemovals === false + ? { + exclusionCandidates: [], + removalPolygons: [], + protectionConflicts: [], + performance: { + groupingMilliseconds: 0, + polygonBuildMilliseconds: 0, + instanceBuildMilliseconds: 0, + conflictFilterMilliseconds: 0 + } + } + : generateRemovalGeometry( + instances, + lightRows, + options.sizes, + mustKeepZones + ); + const removalGeometryMilliseconds = performance.now() - removalStartedAt; + + return { + meta: { + input: options.input, + ...(options.icao ? { icao: options.icao } : {}), + generatedAt: new Date().toISOString(), + filesScanned: options.filesScanned, + xmlFilesParsed, + bglFilesParsed: bglResult.bglFilesParsed, + modelLibraryEntries: bglResult.modelLibraryEntries, + taxiwayGraphsParsed: bglResult.taxiwayGraphStats?.graphs ?? 0, + taxiwayPointsParsed: bglResult.taxiwayGraphStats?.points ?? 0, + taxiwayParkingsParsed: bglResult.taxiwayGraphStats?.parkings ?? 0, + taxiwayPathsParsed: bglResult.taxiwayGraphStats?.paths ?? 0, + taxiwayNamesParsed: bglResult.taxiwayGraphStats?.names ?? 0, + lightedTaxiwayPathsParsed: bglResult.taxiwayGraphStats?.lightedTaxiPaths ?? 0, + decodedHoldShortPoints: bglResult.taxiwayGraphStats?.decodedHoldShortPoints ?? 0, + eligibleHoldShortPoints: bglResult.taxiwayGraphStats?.eligibleHoldShortPoints ?? 0, + suppressedHoldShortPoints: bglResult.taxiwayGraphStats?.suppressedHoldShortPoints ?? 0, + inferredPlacementRows: bglResult.taxiwayGraphStats?.inferredPlacementRows ?? 0, + inferredPlacementAssignments: bglResult.taxiwayGraphStats?.inferredPlacementAssignments ?? 0, + excludedPlacementOutliers: bglResult.taxiwayGraphStats?.excludedPlacementOutliers ?? 0, + placementInferenceMilliseconds: bglResult.taxiwayGraphStats?.placementInferenceMilliseconds ?? 0, + placementInference: bglResult.taxiwayGraphStats?.placementInference, + runwayRecordsParsed: bglResult.runways?.length ?? 0, + runwayLightZonesFound: bglResult.runwayLightZones?.length ?? 0, + mustKeepZonesGenerated: mustKeepZones.length, + protectionConflicts: protectionConflicts.length, + performance: { + bglExtractionMilliseconds: roundNumber(bglExtractionMilliseconds), + mustKeepBuildMilliseconds: roundNumber(mustKeepBuildMilliseconds), + removalGeometryMilliseconds: roundNumber(removalGeometryMilliseconds), + removalGroupingMilliseconds: roundNumber(removalPerformance.groupingMilliseconds), + removalPolygonBuildMilliseconds: roundNumber(removalPerformance.polygonBuildMilliseconds), + removalInstanceBuildMilliseconds: roundNumber(removalPerformance.instanceBuildMilliseconds), + removalConflictFilterMilliseconds: roundNumber(removalPerformance.conflictFilterMilliseconds), + extractionMilliseconds: roundNumber(performance.now() - extractionStartedAt) + }, + bglFileStats: bglResult.fileStats ?? [], + unsupportedSceneryRecordTypes: bglResult.unsupportedSceneryRecordTypes ?? {}, + unsupportedFiles: options.unsupportedFiles ?? [], + warnings + }, + instances, + lightRows, + topologyRows: bglResult.topologyRows ?? [], + runways: bglResult.runways ?? [], + mustKeepZones, + protectionConflicts, + removalPolygons, + exclusionCandidates, + existingExclusionRectangles + }; +} + +export function extractFromParsedXml(root, sourceFile) { + const instances = []; + const lightRows = []; + const existingExclusionRectangles = []; + const warnings = []; + const consumedChildPaths = new Set(); + let recognizedTags = 0; + + walkNodes(root, (node, ancestors) => { + const tag = localName(node.name); + + if (tag === "sceneryobject") { + recognizedTags += 1; + const placement = extractPlacement(node); + const objectChildren = node.children.filter((child) => + INSTANCE_CHILD_TAGS.has(localName(child.name)) + ); + + if (objectChildren.length === 0 && isValidPlacement(placement)) { + instances.push(buildInstance({ + sourceFile, + node, + objectNode: node, + placement, + sourceType: "scenery-object" + })); + } + + for (const child of objectChildren) { + consumedChildPaths.add(child.path); + instances.push(buildInstance({ + sourceFile, + node, + objectNode: child, + placement: mergePlacement(placement, extractPlacement(child)), + sourceType: sourceTypeFromTag(child.name) + })); + } + } + + if (INSTANCE_CHILD_TAGS.has(tag) && !consumedChildPaths.has(node.path)) { + recognizedTags += 1; + const nearestSceneryObject = [...ancestors].reverse().find( + (ancestor) => localName(ancestor.name) === "sceneryobject" + ); + const placement = mergePlacement( + nearestSceneryObject ? extractPlacement(nearestSceneryObject) : {}, + extractPlacement(node) + ); + + if (isValidPlacement(placement)) { + instances.push(buildInstance({ + sourceFile, + node: nearestSceneryObject ?? node, + objectNode: node, + placement, + sourceType: sourceTypeFromTag(node.name) + })); + } else { + warnings.push(`${sourceFile}: ${node.path} has no usable lat/lon placement`); + } + } + + if (LIGHT_ROW_TAGS.has(tag)) { + recognizedTags += 1; + const row = buildLightRow(node, sourceFile); + if (row.vertices.length > 0) { + lightRows.push(row); + } else { + warnings.push(`${sourceFile}: ${node.path} has no vertices`); + } + } + + if (EXISTING_EXCLUSION_TAGS.has(tag)) { + recognizedTags += 1; + existingExclusionRectangles.push(buildExistingExclusion(node, sourceFile)); + } + }); + + if (recognizedTags === 0) { + warnings.push(`${sourceFile}: XML parsed but no supported MSFS scenery tags were found`); + } + + return { + instances, + lightRows, + existingExclusionRectangles, + warnings + }; +} + +function buildInstance({ sourceFile, node, objectNode, placement, sourceType }) { + const explicitGuid = firstAttribute(objectNode, [ + "guid", + "Guid", + "GUID", + "modelLibGuid", + "ModelLibGuid" + ]); + const rawName = firstAttribute(objectNode, ["name", "Name"]); + const guid = explicitGuid ?? (isGuidLike(rawName) ? rawName : undefined); + const name = firstAttribute(objectNode, [ + "displayName", + "DisplayName", + "title", + "Title", + "effectName", + "EffectName", + "containerTitle", + "ContainerTitle" + ]) ?? (isGuidLike(rawName) ? undefined : rawName); + const scale = firstNumber(objectNode, ["scale", "Scale"]) ?? placement.scale; + const classification = classifyObject({ + sourceType, + rawTag: objectNode.name, + guid, + name, + extraText: `${node.text ?? ""} ${objectNode.text ?? ""}` + }); + + return { + id: stableId(sourceFile, objectNode.path, placement.lat, placement.lon, guid ?? name ?? sourceType), + sourceFile, + sourceType, + lat: placement.lat, + lon: placement.lon, + ...(typeof placement.alt === "number" ? { alt: placement.alt } : {}), + ...(typeof placement.heading === "number" ? { heading: placement.heading } : {}), + ...(typeof placement.pitch === "number" ? { pitch: placement.pitch } : {}), + ...(typeof placement.bank === "number" ? { bank: placement.bank } : {}), + ...(typeof scale === "number" ? { scale } : {}), + ...(guid ? { guid } : {}), + ...(name ? { name } : {}), + rawTag: objectNode.name, + classification: classification.classification, + confidence: classification.confidence, + classificationReasons: classification.reasons + }; +} + +function buildLightRow(node, sourceFile) { + const vertices = findDescendants(node, (candidate) => { + const tag = localName(candidate.name); + return VERTEX_TAGS.has(tag) && isValidPlacement(extractPlacement(candidate)); + }).map((vertex) => { + const placement = extractPlacement(vertex); + return { + lat: placement.lat, + lon: placement.lon, + ...(typeof placement.alt === "number" ? { alt: placement.alt } : {}) + }; + }); + + const lightPresetNode = findDescendants(node, (candidate) => + localName(candidate.name) === "lightpreset" + )[0]; + const preset = + firstAttribute(node, ["preset", "lightPreset", "LightPreset", "lightPresetName", "name", "type"]) ?? + (lightPresetNode + ? firstAttribute(lightPresetNode, ["name", "type", "preset", "lightPreset"]) + : undefined); + const spacing = + firstNumber(node, ["spacing", "lightSpacing", "LightSpacing", "interval", "spacingMeters"]) ?? + (lightPresetNode ? firstNumber(lightPresetNode, ["spacing", "lightSpacing"]) : undefined); + const snapToVertices = firstBoolean(node, [ + "snapToVertices", + "SnapToVertices", + "snapLightsToVertices" + ]); + const classification = classifyObject({ + sourceType: localName(node.name), + rawTag: node.name, + name: preset, + extraText: `${node.text ?? ""} ${JSON.stringify(node.attributes)}` + }); + + return { + id: stableId(sourceFile, node.path, preset ?? "", vertices.map((v) => `${v.lat},${v.lon}`).join("|")), + sourceFile, + sourceType: localName(node.name), + rawTag: node.name, + ...(preset ? { preset } : {}), + ...(typeof spacing === "number" ? { spacing } : {}), + ...(typeof snapToVertices === "boolean" ? { snapToVertices } : {}), + vertices, + classification: classification.classification, + confidence: classification.confidence, + classificationReasons: classification.reasons + }; +} + +function buildExistingExclusion(node, sourceFile) { + const placement = extractPlacement(node); + return { + id: stableId(sourceFile, node.path, JSON.stringify(node.attributes)), + sourceFile, + rawTag: node.name, + ...(isValidPlacement(placement) ? { lat: placement.lat, lon: placement.lon } : {}), + ...(typeof placement.alt === "number" ? { alt: placement.alt } : {}), + attributes: node.attributes + }; +} + +function buildMustKeepZones(instances, lightRows, runwayLightZones) { + const zones = [...runwayLightZones]; + + for (const row of lightRows) { + if ( + isTargetLightClassification(row.classification) || + !isSourceBackedLightRow(row) || + !Array.isArray(row.vertices) || + row.vertices.length === 0 + ) { + continue; + } + const geometries = mustKeepGeometriesForRow(row); + for (const [geometryIndex, geometry] of geometries.entries()) { + zones.push({ + id: geometries.length === 1 + ? stableId("must-keep-row", row.id) + : stableId("must-keep-row", row.id, "continuous-run", geometryIndex), + sourceId: row.id, + sourceFile: row.sourceFile, + sourceType: row.sourceType, + sourceRecordOffset: row.sourceRecordOffset, + preset: row.preset, + classification: row.classification, + lightType: "source-light-row", + geometryType: geometry.geometryType, + ...(geometry.geometryType === "Point" + ? { point: geometry.point } + : { vertices: geometry.vertices }), + clearanceMeters: MUST_KEEP_ROW_CLEARANCE_METERS, + sourceBasis: "decoded-light-row", + geometryMode: geometries.length === 1 + ? "decoded-source-geometry" + : "decoded-source-continuous-run", + sourceVertexStartIndex: geometry.sourceVertexStartIndex, + sourceVertexEndIndex: geometry.sourceVertexEndIndex, + reason: geometries.length === 1 + ? `non-target ${row.classification} light row must be retained` + : `non-target ${row.classification} light row retained without decoded discontinuity links` + }); + } + } + + for (const instance of instances) { + if ( + isTargetLightClassification(instance.classification) || + !isLikelyLightClassification(instance.classification) || + !Number.isFinite(instance.lat) || + !Number.isFinite(instance.lon) + ) { + continue; + } + zones.push({ + id: stableId("must-keep-instance", instance.id), + sourceId: instance.id, + sourceFile: instance.sourceFile, + sourceType: instance.sourceType, + sourceRecordOffset: instance.sourceRecordOffset, + classification: instance.classification, + lightType: "source-light-instance", + geometryType: "Point", + point: { lat: instance.lat, lon: instance.lon }, + clearanceMeters: MUST_KEEP_INSTANCE_CLEARANCE_METERS, + sourceBasis: "decoded-light-instance", + geometryMode: "decoded-source-position", + reason: `non-target ${instance.classification} light instance must be retained` + }); + } + + return zones; +} + +export function mustKeepGeometriesForRow(row) { + const vertices = row?.vertices ?? []; + if (vertices.length === 0) { + return []; + } + if (vertices.length === 1 || row.sourceType !== "bgl-airport-light-row") { + return [geometryForVertexRun(vertices, 0)]; + } + + const linkDistances = []; + for (let index = 1; index < vertices.length; index += 1) { + linkDistances.push(haversineDistanceMeters(vertices[index - 1], vertices[index])); + } + const sortedDistances = [...linkDistances].sort((left, right) => left - right); + const medianDistance = sortedDistances[Math.floor(sortedDistances.length / 2)] ?? 0; + const discontinuityDistance = Math.max( + MUST_KEEP_DISCONTINUITY_MIN_METERS, + medianDistance * MUST_KEEP_DISCONTINUITY_SPACING_MULTIPLIER + ); + + const runs = []; + let startIndex = 0; + for (let index = 1; index < vertices.length; index += 1) { + if (linkDistances[index - 1] <= discontinuityDistance) { + continue; + } + runs.push(geometryForVertexRun(vertices.slice(startIndex, index), startIndex)); + startIndex = index; + } + runs.push(geometryForVertexRun(vertices.slice(startIndex), startIndex)); + return runs; +} + +function geometryForVertexRun(vertices, sourceVertexStartIndex) { + return vertices.length === 1 + ? { + geometryType: "Point", + point: vertices[0], + sourceVertexStartIndex, + sourceVertexEndIndex: sourceVertexStartIndex + } + : { + geometryType: "LineString", + vertices, + sourceVertexStartIndex, + sourceVertexEndIndex: sourceVertexStartIndex + vertices.length - 1 + }; +} + +export function generateRemovalGeometry(instances, lightRows, sizes, mustKeepZones) { + const groupingStartedAt = performance.now(); + const removalPolygons = []; + const protectionConflicts = []; + const mustKeepBounds = new Map(mustKeepZones.map((zone) => [ + zone.id, + pointBounds(mustKeepGeometryPoints(zone)) + ])); + const mustKeepSpatialIndex = createMustKeepSpatialIndex(mustKeepZones, mustKeepBounds); + const targetRows = lightRows.filter((row) => + isTargetLightClassification(row.classification) && + isRemovalLightRow(row) && + Array.isArray(row.vertices) && + row.vertices.length > 0 + ); + const targetRowSourceInstanceIds = new Set( + targetRows.flatMap((row) => row.sourceInstanceIds ?? []) + ); + + const removalGroups = buildRemovalGroups(targetRows, sizes.lightrow); + const groupingMilliseconds = performance.now() - groupingStartedAt; + const polygonBuildStartedAt = performance.now(); + for (const group of removalGroups) { + const protectedGeometry = buildProtectedRemovalPolygons( + group, + mustKeepZones, + mustKeepBounds, + mustKeepSpatialIndex + ); + protectionConflicts.push(...protectedGeometry.conflicts); + for (const [index, protectedPolygon] of protectedGeometry.polygons.entries()) { + removalPolygons.push({ + id: protectedGeometry.polygons.length === 1 + ? group.id + : stableId(group.id, "protected-segment", index), + sourceRowId: group.sourceRowId, + sourceRowIds: group.sourceRowIds, + sourceFile: group.sourceFile, + sourceType: group.sourceType, + sourceRecordOffset: group.sourceRecordOffset, + preset: group.preset, + classification: group.classification, + confidence: group.confidence, + widthMeters: protectedPolygon.widthMeters, + reason: protectedPolygon.reason ?? group.reason, + coordinates: protectedPolygon.coordinates, + geometrySignature: geometrySignatureForVertices(group.vertices), + protectionApplied: protectedPolygon.protectionApplied, + mustKeepZoneIds: protectedPolygon.mustKeepZoneIds, + protectionMode: protectedPolygon.protectionMode, + targetLightPointCount: protectedPolygon.targetLightPointCount, + sourceClassifications: group.sourceClassifications ?? [group.classification], + exclusionFlags: { + excludeLibraryObjects: true, + excludeVFX: true, + excludeSimPropContainers: true + }, + ...(group.inferred ? { inferred: true } : {}), + ...(group.inference ? { inference: group.inference } : {}), + ...(group.sourceInstanceIds ? { sourceInstanceIds: group.sourceInstanceIds } : {}), + ...(group.partialOverlapCombined ? { partialOverlapCombined: true } : {}) + }); + } + } + const polygonBuildMilliseconds = performance.now() - polygonBuildStartedAt; + + const exclusionCandidates = []; + const instanceBuildStartedAt = performance.now(); + + for (const instance of instances) { + if (!isTargetLightClassification(instance.classification)) { + continue; + } + + if (isCoveredByTargetRow( + instance, + targetRows, + sizes.lightrow, + targetRowSourceInstanceIds + )) { + continue; + } + + const config = exclusionConfigForInstance(instance.sourceType, sizes); + if (!config) { + continue; + } + + const protectedSquare = buildSafePointRemoval( + instance, + config.size, + mustKeepZones, + stableId("instance-removal", instance.id) + ); + if (!protectedSquare.polygon) { + protectionConflicts.push(protectedSquare.conflict); + continue; + } + + exclusionCandidates.push({ + id: stableId("exclusion", instance.id), + sourceId: instance.id, + lat: instance.lat, + lon: instance.lon, + widthMeters: protectedSquare.polygon.widthMeters, + heightMeters: protectedSquare.polygon.widthMeters, + coordinates: protectedSquare.polygon.coordinates, + protectionApplied: protectedSquare.polygon.protectionApplied, + mustKeepZoneIds: protectedSquare.polygon.mustKeepZoneIds, + reason: `${instance.sourceType} classified as ${instance.classification}`, + exclusionFlags: config.flags, + confidence: instance.confidence + }); + } + const instanceBuildMilliseconds = performance.now() - instanceBuildStartedAt; + + const conflictFilterStartedAt = performance.now(); + const removalRings = removalPolygons.map((polygon) => { + const ring = coordinateRingPoints(polygon.coordinates); + return { ring, bounds: pointBounds(ring) }; + }); + const unresolvedProtectionConflicts = protectionConflicts.filter((conflict) => + !Number.isFinite(conflict.lat) || + !Number.isFinite(conflict.lon) || + !removalRings.some(({ ring, bounds }) => + pointWithinBounds(conflict, bounds) && pointInPolygon(conflict, ring) + ) + ); + const conflictFilterMilliseconds = performance.now() - conflictFilterStartedAt; + return { + exclusionCandidates, + removalPolygons, + protectionConflicts: unresolvedProtectionConflicts, + performance: { + groupingMilliseconds, + polygonBuildMilliseconds, + instanceBuildMilliseconds, + conflictFilterMilliseconds + } + }; +} + +function buildProtectedRemovalPolygons( + group, + mustKeepZones, + mustKeepBounds, + mustKeepSpatialIndex +) { + const defaultRing = polylineCorridorPolygon(group.vertices, group.widthMeters); + if (defaultRing.length === 0) { + return { polygons: [], conflicts: [] }; + } + + const groupBounds = group._bounds ?? pointBounds(group.vertices); + const nearbyZones = mustKeepZones.filter((zone) => { + const maximumDistance = group.widthMeters / 2 + + (zone.clearanceMeters ?? 0) + PROTECTION_GEOMETRY_TOLERANCE_METERS; + return boundsOverlapWithPaddingMeters( + groupBounds, + mustKeepBounds.get(zone.id), + maximumDistance + ) && distanceFromZoneToPolyline(zone, group.vertices) <= maximumDistance; + }); + if (nearbyZones.length === 0) { + const polygons = [{ + coordinates: defaultRing, + widthMeters: group.widthMeters, + protectionApplied: false, + protectionMode: "standard-corridor", + mustKeepZoneIds: [] + }]; + const initiallyUncovered = group.targetPoints.filter((point) => + !pointInCoordinateRing(point, defaultRing) + ); + for (const row of group.sourceRows ?? []) { + const rowTargetPoints = targetLightPointsForRow(row); + if (!rowTargetPoints.some((point) => initiallyUncovered.some((candidate) => + normalizedVertexSignature(candidate) === normalizedVertexSignature(point) + ))) { + continue; + } + const coordinates = polylineCorridorPolygon(row.vertices, group.widthMeters); + if (coordinates.length > 0) { + polygons.push({ + coordinates, + widthMeters: group.widthMeters, + protectionApplied: false, + protectionMode: "target-row-corridor-supplement", + mustKeepZoneIds: [], + reason: `${group.reason}; continuous source-row corridor retained at a grouped join` + }); + } + } + const uncoveredPoints = assignTargetPointCounts(polygons, group.targetPoints); + return { + polygons, + conflicts: uncoveredPoints.map((point) => targetCoverageConflict(group, point, [])) + }; + } + + const adaptive = buildAdaptiveCorridorSegments(group, nearbyZones, mustKeepSpatialIndex); + const polygons = adaptive.segments.map((segment) => { + const protectionApplied = segment.mustKeepZoneIds.length > 0; + return { + coordinates: segment.coordinates, + widthMeters: group.widthMeters, + protectionApplied, + protectionMode: protectionApplied + ? (adaptive.segments.length === 1 + ? "asymmetric-bent-corridor" + : "protected-continuous-corridor-segment") + : "continuous-corridor-segment", + mustKeepZoneIds: segment.mustKeepZoneIds, + reason: protectionApplied + ? `${group.reason}; continuous corridor bent around ${segment.mustKeepZoneIds.length} must-keep light zones` + : `${group.reason}; continuous corridor split only at a must-keep light zone` + }; + }); + const uncoveredPoints = assignTargetPointCounts(polygons, group.targetPoints); + const conflicts = uncoveredPoints.map((point) => { + const blockingZoneIds = nearbyZones + .filter((zone) => { + const proximity = nearestZonePoint(point, zone); + return proximity.inside || proximity.distanceMeters <= + (zone.clearanceMeters ?? 0) + MIN_SAFE_REMOVAL_HALF_WIDTH_METERS + + PROTECTION_BEND_MARGIN_METERS; + }) + .map((zone) => zone.id); + return targetCoverageConflict(group, point, blockingZoneIds); + }); + + return { polygons, conflicts }; +} + +function buildAdaptiveCorridorSegments(group, zones, mustKeepSpatialIndex) { + const points = densifyVertices(group.vertices, PROTECTION_SAMPLE_SPACING_METERS); + if (points.length < 2) { + return { segments: [] }; + } + + const halfWidth = group.widthMeters / 2; + const samples = []; + const targetPointSignatures = new Set( + group.targetPoints.map((point) => normalizedVertexSignature(point)) + ); + const nearbyZoneIds = new Set(zones.map((zone) => zone.id)); + const maximumZoneDistance = halfWidth + mustKeepSpatialIndex.maximumClearanceMeters + + PROTECTION_BEND_MARGIN_METERS; + + for (let index = 0; index < points.length; index += 1) { + const point = points[index]; + const previous = points[Math.max(0, index - 1)]; + const next = points[Math.min(points.length - 1, index + 1)]; + const tangent = localVector(previous, next, point); + const tangentLength = vectorLength(tangent); + if (tangentLength <= 0.001) { + continue; + } + const unit = { x: tangent.x / tangentLength, y: tangent.y / tangentLength }; + const leftNormal = { x: -unit.y, y: unit.x }; + let leftWidth = halfWidth; + let rightWidth = halfWidth; + let blocked = false; + const appliedZoneIds = new Set(); + + for (const zone of mustKeepSpatialIndex.queryPoint(point, maximumZoneDistance)) { + if (!nearbyZoneIds.has(zone.id)) { + continue; + } + const proximity = nearestZonePoint(point, zone); + const clearance = zone.clearanceMeters ?? 0; + if ( + !proximity.inside && + proximity.distanceMeters >= halfWidth + clearance + PROTECTION_BEND_MARGIN_METERS + ) { + continue; + } + appliedZoneIds.add(zone.id); + const allowedWidth = + proximity.distanceMeters - clearance - PROTECTION_BEND_MARGIN_METERS; + if (proximity.inside || allowedWidth < MIN_SAFE_REMOVAL_HALF_WIDTH_METERS) { + blocked = true; + continue; + } + + const towardZone = localVector(point, proximity.point, point); + const side = tangent.x * towardZone.y - tangent.y * towardZone.x; + if (Math.abs(side) <= 0.001) { + leftWidth = Math.min(leftWidth, allowedWidth); + rightWidth = Math.min(rightWidth, allowedWidth); + } else if (side > 0) { + leftWidth = Math.min(leftWidth, allowedWidth); + } else { + rightWidth = Math.min(rightWidth, allowedWidth); + } + } + + samples.push({ + point, + leftNormal, + leftWidth, + rightWidth, + blocked, + targetLightPoint: targetPointSignatures.has(normalizedVertexSignature(point)), + mustKeepZoneIds: [...appliedZoneIds] + }); + } + + const runs = []; + let currentRun = []; + for (const sample of samples) { + if (sample.blocked) { + if (currentRun.length >= 2) { + runs.push(currentRun); + } + currentRun = []; + continue; + } + currentRun.push(sample); + } + if (currentRun.length >= 2) { + runs.push(currentRun); + } + + const segments = []; + for (const run of runs) { + const safeSegments = splitAdaptiveRunIntoSafeSegments(run, zones); + segments.push(...safeSegments.map((segment) => + simplifyAdaptiveSegment(segment, zones) + )); + } + return { segments }; +} + +function splitAdaptiveRunIntoSafeSegments(samples, zones) { + const candidate = adaptiveSegmentForSamples(samples); + if (adaptiveSegmentIsSafe(candidate, samples, zones)) { + return [candidate]; + } + if (samples.length <= 2) { + return []; + } + + const midpoint = Math.floor(samples.length / 2); + const split = [ + ...splitAdaptiveRunIntoSafeSegments(samples.slice(0, midpoint + 1), zones), + ...splitAdaptiveRunIntoSafeSegments(samples.slice(midpoint), zones) + ]; + if (split.length <= 1) { + return split; + } + + const merged = []; + let current = split[0]; + for (const next of split.slice(1)) { + const combinedSamples = [ + ...current.samples, + ...next.samples.slice(1) + ]; + const combined = adaptiveSegmentForSamples(combinedSamples); + if (adaptiveSegmentIsSafe(combined, combinedSamples, zones)) { + current = combined; + } else { + merged.push(current); + current = next; + } + } + merged.push(current); + return merged; +} + +function adaptiveSegmentForSamples(samples) { + const left = []; + const right = []; + const mustKeepZoneIds = new Set(); + for (const sample of samples) { + const leftPoint = offsetPointMeters( + sample.point, + sample.leftNormal.x * sample.leftWidth, + sample.leftNormal.y * sample.leftWidth + ); + const rightPoint = offsetPointMeters( + sample.point, + -sample.leftNormal.x * sample.rightWidth, + -sample.leftNormal.y * sample.rightWidth + ); + left.push([leftPoint.lon, leftPoint.lat]); + right.push([rightPoint.lon, rightPoint.lat]); + for (const zoneId of sample.mustKeepZoneIds) { + mustKeepZoneIds.add(zoneId); + } + } + return { + coordinates: left.length >= 2 ? [...left, ...[...right].reverse(), left[0]] : [], + leftCoordinates: left, + rightCoordinates: right, + mustKeepZoneIds: [...mustKeepZoneIds], + samples + }; +} + +function simplifyAdaptiveSegment(segment, zones) { + if ( + segment.leftCoordinates.length <= 2 || + segment.rightCoordinates.length <= 2 + ) { + return segment; + } + + for (const toleranceMeters of REMOVAL_SIMPLIFICATION_TOLERANCES_METERS) { + const leftCoordinates = simplifyCoordinateLine( + segment.leftCoordinates, + toleranceMeters + ); + const rightCoordinates = simplifyCoordinateLine( + segment.rightCoordinates, + toleranceMeters + ); + const coordinates = [ + ...leftCoordinates, + ...[...rightCoordinates].reverse(), + leftCoordinates[0] + ]; + const candidate = { + ...segment, + coordinates, + leftCoordinates, + rightCoordinates + }; + if ( + coordinates.length < segment.coordinates.length && + adaptiveSegmentIsSafe(candidate, segment.samples, zones) + ) { + return candidate; + } + } + return segment; +} + +function simplifyCoordinateLine(coordinates, toleranceMeters) { + if (coordinates.length <= 2) { + return [...coordinates]; + } + const points = coordinates.map(([lon, lat]) => ({ lat, lon })); + const kept = new Set([0, points.length - 1]); + const ranges = [[0, points.length - 1]]; + while (ranges.length > 0) { + const [startIndex, endIndex] = ranges.pop(); + if (endIndex <= startIndex + 1) { + continue; + } + const start = points[startIndex]; + const end = points[endIndex]; + let furthestIndex; + let furthestDistance = -1; + for (let index = startIndex + 1; index < endIndex; index += 1) { + const distance = pointToLocalSegmentDistanceMeters(points[index], start, end); + if (distance > furthestDistance) { + furthestDistance = distance; + furthestIndex = index; + } + } + if (furthestDistance <= toleranceMeters || furthestIndex === undefined) { + continue; + } + kept.add(furthestIndex); + ranges.push([startIndex, furthestIndex], [furthestIndex, endIndex]); + } + return [...kept] + .sort((left, right) => left - right) + .map((index) => coordinates[index]); +} + +function pointToLocalSegmentDistanceMeters(point, start, end) { + const localPoint = localVector(start, point, start); + const localEnd = localVector(start, end, start); + const lengthSquared = localEnd.x * localEnd.x + localEnd.y * localEnd.y; + if (lengthSquared <= 0.000001) { + return vectorLength(localPoint); + } + const ratio = Math.max( + 0, + Math.min( + 1, + (localPoint.x * localEnd.x + localPoint.y * localEnd.y) / lengthSquared + ) + ); + return vectorLength({ + x: localPoint.x - localEnd.x * ratio, + y: localPoint.y - localEnd.y * ratio + }); +} + +function adaptiveSegmentIsSafe(segment, samples, zones) { + if (segment.coordinates.length === 0) { + return false; + } + const ring = coordinateRingPoints(segment.coordinates); + const probeStride = Math.max(1, Math.ceil(samples.length / 128)); + for (let index = 0; index < samples.length; index += probeStride) { + if (!pointInPolygon(samples[index].point, ring)) { + return false; + } + } + if (!pointInPolygon(samples.at(-1).point, ring)) { + return false; + } + for (const sample of samples) { + if ( + sample.targetLightPoint && + !pointInPolygon(sample.point, ring) + ) { + return false; + } + } + + const appliedZoneIds = new Set(segment.mustKeepZoneIds); + const relevantZones = zones.filter((zone) => appliedZoneIds.has(zone.id)); + return isRemovalPointRingSafe(ring, relevantZones); +} + +function assignTargetPointCounts(polygons, targetPoints) { + const assigned = new Set(); + const polygonRings = polygons.map((polygon) => coordinateRingPoints(polygon.coordinates)); + for (const [polygonIndex, polygon] of polygons.entries()) { + const ring = polygonRings[polygonIndex]; + let targetLightPointCount = 0; + for (const point of targetPoints) { + const signature = normalizedVertexSignature(point); + if (!assigned.has(signature) && pointInPolygon(point, ring)) { + assigned.add(signature); + targetLightPointCount += 1; + } + } + polygon.targetLightPointCount = targetLightPointCount; + } + return targetPoints.filter((point) => !assigned.has(normalizedVertexSignature(point))); +} + +function targetCoverageConflict(group, point, mustKeepZoneIds) { + return { + id: stableId("must-keep-conflict", group.id, point.lat, point.lon, "row-corridor"), + ownerId: group.id, + sourceRowId: group.sourceRowId, + sourceRowIds: group.sourceRowIds, + classification: group.classification, + lat: point.lat, + lon: point.lon, + mustKeepZoneIds, + reason: mustKeepZoneIds.length > 0 + ? "target light point is too close to a must-keep light for a safe continuous row corridor" + : "target light point could not be covered by a valid continuous row corridor" + }; +} + +function buildSafePointRemoval(point, requestedWidthMeters, zones, ownerId) { + let halfWidth = requestedWidthMeters / 2; + const appliedZoneIds = []; + for (const zone of zones) { + const proximity = nearestZonePoint(point, zone); + const clearance = zone.clearanceMeters ?? 0; + if (proximity.distanceMeters >= halfWidth * Math.SQRT2 + clearance) { + continue; + } + appliedZoneIds.push(zone.id); + const safeHalfWidth = + (proximity.distanceMeters - clearance - PROTECTION_GEOMETRY_TOLERANCE_METERS) / + Math.SQRT2; + halfWidth = Math.min(halfWidth, safeHalfWidth); + } + + if (!Number.isFinite(halfWidth) || halfWidth < MIN_SAFE_REMOVAL_HALF_WIDTH_METERS) { + return { + conflict: { + id: stableId("must-keep-conflict", ownerId, point.lat, point.lon), + ownerId, + lat: point.lat, + lon: point.lon, + mustKeepZoneIds: appliedZoneIds, + reason: "target light point is too close to a must-keep light for a safe origin-covering remover" + } + }; + } + + const widthMeters = halfWidth * 2; + const coordinates = rectanglePolygonAround(point, widthMeters, widthMeters); + const relevantZones = zones.filter((zone) => appliedZoneIds.includes(zone.id)); + if (!isRemovalRingSafe(coordinates, relevantZones)) { + return { + conflict: { + id: stableId("must-keep-conflict", ownerId, point.lat, point.lon, "validation"), + ownerId, + lat: point.lat, + lon: point.lon, + mustKeepZoneIds: appliedZoneIds, + reason: "candidate point remover did not pass the final must-keep geometry validation" + } + }; + } + + return { + polygon: { + coordinates, + widthMeters, + protectionApplied: appliedZoneIds.length > 0, + mustKeepZoneIds: appliedZoneIds + } + }; +} + +function nearestZonePoint(point, zone) { + if (zone.geometryType === "Point") { + return { + point: zone.point, + distanceMeters: haversineDistanceMeters(point, zone.point), + inside: false + }; + } + if (zone.geometryType === "Polygon") { + return nearestPointOnPolygon(point, zone.vertices ?? []); + } + return { + ...nearestPointOnPolyline(point, zone.vertices ?? []), + inside: false + }; +} + +function distanceFromZoneToPolyline(zone, vertices) { + if (zone.geometryType === "Point") { + return pointToPolylineDistanceMeters(zone.point, vertices); + } + if (zone.geometryType === "Polygon") { + if (vertices.some((point) => pointInPolygon(point, zone.vertices ?? []))) { + return 0; + } + return polylineToPolylineDistanceMeters(vertices, closePointRing(zone.vertices ?? [])); + } + return polylineToPolylineDistanceMeters(vertices, zone.vertices ?? []); +} + +function isRemovalRingSafe(coordinates, zones) { + const ring = coordinateRingPoints(coordinates); + return isRemovalPointRingSafe(ring, zones); +} + +function isRemovalPointRingSafe(ring, zones) { + for (const zone of zones) { + const clearance = zone.clearanceMeters ?? 0; + if (zone.geometryType === "Point") { + if ( + pointInPolygon(zone.point, ring) || + pointToPolylineDistanceMeters(zone.point, ring) < + clearance - PROTECTION_GEOMETRY_TOLERANCE_METERS + ) { + return false; + } + continue; + } + + if (zone.geometryType === "Polygon") { + const zoneRing = closePointRing(zone.vertices ?? []); + if ( + ring.some((point) => pointInPolygon(point, zoneRing)) || + zoneRing.some((point) => pointInPolygon(point, ring)) || + polylineToPolylineDistanceMeters(ring, zoneRing) < + clearance - PROTECTION_GEOMETRY_TOLERANCE_METERS + ) { + return false; + } + continue; + } + + const zoneVertices = zone.vertices ?? []; + if ( + zoneVertices.some((point) => pointInPolygon(point, ring)) || + polylineToPolylineDistanceMeters(ring, zoneVertices) < + clearance - PROTECTION_GEOMETRY_TOLERANCE_METERS + ) { + return false; + } + } + return true; +} + +function pointInCoordinateRing(point, coordinates) { + return pointInPolygon(point, coordinateRingPoints(coordinates)); +} + +function coordinateRingPoints(coordinates) { + return coordinates.map(([lon, lat]) => ({ lat, lon })); +} + +function closePointRing(vertices) { + if (vertices.length === 0) { + return []; + } + return haversineDistanceMeters(vertices[0], vertices.at(-1)) <= 0.05 + ? [...vertices] + : [...vertices, vertices[0]]; +} + +function densifyVertices(vertices, spacingMeters) { + if (vertices.length <= 1) { + return [...vertices]; + } + const points = []; + for (let index = 1; index < vertices.length; index += 1) { + const start = vertices[index - 1]; + const end = vertices[index]; + const length = haversineDistanceMeters(start, end); + const steps = Math.max(1, Math.ceil(length / spacingMeters)); + for (let step = 0; step < steps; step += 1) { + points.push(interpolatePoint(start, end, step / steps)); + } + } + points.push(vertices.at(-1)); + return points; +} + +function buildRemovalGroups(targetRows, widthMeters) { + const groups = []; + const duplicateGroups = new Map(); + const groupTokens = new WeakMap(); + const rejectedPairs = new Set(); + let nextGroupToken = 0; + + for (const row of targetRows) { + const group = groupFromRow(row, widthMeters); + const duplicateKey = `${group.groupKey}|${group.geometrySignature}`; + const duplicate = duplicateGroups.get(duplicateKey); + if (duplicate) { + mergeGroupMetadata(duplicate, group); + } else { + groups.push(group); + duplicateGroups.set(duplicateKey, group); + } + } + + const spatialIndex = createRemovalGroupSpatialIndex(groups); + const indexesByGroup = new Map(groups.map((group, index) => [group, index])); + const maximumWidthMeters = Math.max(0, ...groups.map((group) => group.widthMeters)); + + const candidateIndexesFor = (group, predicate) => spatialIndex + .query(group, Math.max(group.widthMeters, maximumWidthMeters) + ROW_ENDPOINT_MERGE_TOLERANCE_METERS) + .map((candidate) => indexesByGroup.get(candidate)) + .filter((index) => Number.isInteger(index) && predicate(index)) + .sort((a, b) => a - b); + + const tryPair = (left, right) => { + const pairKey = removalGroupPairKey(left, right, groupTokens, () => nextGroupToken++); + // A failed comparison cannot change until one of its immutable group objects is replaced by a merge. + if (rejectedPairs.has(pairKey)) return undefined; + if (!removalGroupsMayInteract(left, right)) { + rejectedPairs.add(pairKey); + return undefined; + } + const merged = tryAbsorbCoveredGroup(left, right) ?? tryMergeRemovalGroups(left, right); + if (!merged) rejectedPairs.add(pairKey); + return merged; + }; + + const replacePair = (leftIndex, rightIndex, merged) => { + const left = groups[leftIndex]; + const right = groups[rightIndex]; + merged._bounds = pointBounds(merged.vertices); + spatialIndex.remove(left); + spatialIndex.remove(right); + groups[leftIndex] = merged; + groups.splice(rightIndex, 1); + spatialIndex.add(merged); + indexesByGroup.delete(left); + indexesByGroup.delete(right); + for (let index = leftIndex; index < groups.length; index += 1) { + indexesByGroup.set(groups[index], index); + } + }; + + let leftIndex = 0; + while (leftIndex < groups.length) { + const left = groups[leftIndex]; + const rightIndex = candidateIndexesFor(left, (index) => index > leftIndex) + .find((index) => { + const merged = tryPair(left, groups[index]); + if (!merged) return false; + replacePair(leftIndex, index, merged); + return true; + }); + if (rightIndex === undefined) { + leftIndex += 1; + continue; + } + + // Only the new merged object can invalidate decisions made earlier in the scan. + // Check it against earlier groups in their original order instead of restarting + // the complete pair scan after every merge. + let mergedEarlier = true; + while (mergedEarlier) { + mergedEarlier = false; + const current = groups[leftIndex]; + for (const earlierIndex of candidateIndexesFor(current, (index) => index < leftIndex)) { + const merged = tryPair(groups[earlierIndex], current); + if (!merged) continue; + replacePair(earlierIndex, leftIndex, merged); + leftIndex = earlierIndex; + mergedEarlier = true; + break; + } + } + } + + return groups; +} + +function createRemovalGroupSpatialIndex(groups, cellSizeMeters = 25) { + const bounds = groups + .map((group) => group._bounds ?? pointBounds(group.vertices)) + .filter((value) => [value.minLat, value.maxLat, value.minLon, value.maxLon].every(Number.isFinite)); + const maximumAbsoluteLatitude = bounds.reduce((maximum, value) => Math.max( + maximum, + Math.abs(value.minLat), + Math.abs(value.maxLat) + ), 0); + // Using the smallest metres-per-degree value across the indexed latitude range + // makes longitude padding conservative, so the index cannot hide an interacting pair. + const metersPerDegreeLon = METERS_PER_DEGREE_LAT * + Math.max(Math.cos((maximumAbsoluteLatitude * Math.PI) / 180), 0.000001); + const cells = new Map(); + const keysByGroup = new Map(); + + const cellKeys = (group, paddingMeters = 0) => { + const value = group._bounds ?? pointBounds(group.vertices); + const minX = Math.floor((value.minLon * metersPerDegreeLon - paddingMeters) / cellSizeMeters); + const maxX = Math.floor((value.maxLon * metersPerDegreeLon + paddingMeters) / cellSizeMeters); + const minY = Math.floor((value.minLat * METERS_PER_DEGREE_LAT - paddingMeters) / cellSizeMeters); + const maxY = Math.floor((value.maxLat * METERS_PER_DEGREE_LAT + paddingMeters) / cellSizeMeters); + const keys = []; + for (let y = minY; y <= maxY; y += 1) { + for (let x = minX; x <= maxX; x += 1) keys.push(`${x},${y}`); + } + return keys; + }; + + const add = (group) => { + const keys = cellKeys(group); + keysByGroup.set(group, keys); + for (const key of keys) { + const entries = cells.get(key); + if (entries) entries.add(group); + else cells.set(key, new Set([group])); + } + }; + + const remove = (group) => { + for (const key of keysByGroup.get(group) ?? []) { + const entries = cells.get(key); + entries?.delete(group); + if (entries?.size === 0) cells.delete(key); + } + keysByGroup.delete(group); + }; + + for (const group of groups) add(group); + return { + add, + remove, + query(group, paddingMeters) { + const result = new Set(); + for (const key of cellKeys(group, paddingMeters)) { + for (const candidate of cells.get(key) ?? []) result.add(candidate); + } + return [...result]; + } + }; +} + +function removalGroupPairKey(left, right, tokens, createToken) { + if (!tokens.has(left)) tokens.set(left, createToken()); + if (!tokens.has(right)) tokens.set(right, createToken()); + const leftToken = tokens.get(left); + const rightToken = tokens.get(right); + return leftToken < rightToken + ? `${leftToken}|${rightToken}` + : `${rightToken}|${leftToken}`; +} + +function groupFromRow(row, widthMeters) { + const sourceRowIds = row.sourceRowIds?.length ? row.sourceRowIds : [row.id]; + return { + id: stableId("row-removal", row.id), + sourceRowId: sourceRowIds[0], + sourceRowIds, + sourceFile: row.sourceFile, + sourceType: row.sourceType, + sourceRecordOffset: row.sourceRecordOffset, + preset: row.preset, + classification: row.classification, + confidence: row.confidence, + widthMeters, + reason: `light row classified as ${row.classification}`, + vertices: row.vertices, + targetPoints: targetLightPointsForRow(row), + sourceRows: [row], + _bounds: pointBounds(row.vertices), + geometrySignature: geometrySignatureForVertices(row.vertices), + groupKey: [ + row.sourceFile, + row.sourceType, + row.classification, + widthMeters + ].join("|"), + ...(row.inferred ? { inferred: true } : {}), + ...(row.inference ? { inference: row.inference } : {}), + ...(row.sourceInstanceIds ? { sourceInstanceIds: row.sourceInstanceIds } : {}) + }; +} + +function removalGroupsMayInteract(left, right) { + return boundsOverlapWithPaddingMeters( + left._bounds ?? pointBounds(left.vertices), + right._bounds ?? pointBounds(right.vertices), + Math.max(left.widthMeters, right.widthMeters) + ROW_ENDPOINT_MERGE_TOLERANCE_METERS + ); +} + +function tryMergeRemovalGroups(left, right) { + const partialOverlap = mergePartiallyOverlappingRows(left, right); + if (partialOverlap) { + return partialOverlap; + } + if (left.groupKey !== right.groupKey) { + return undefined; + } + + return mergeOverlappingSegments(left, right) ?? mergeContiguousRows(left, right); +} + +function mergePartiallyOverlappingRows(left, right) { + const maximumJoinDistanceMeters = Math.min( + PARTIAL_OVERLAP_JOIN_MAX_METERS, + left.widthMeters * 0.75 + ); + if ( + left.sourceFile !== right.sourceFile || + left.sourceType !== right.sourceType || + left.widthMeters !== right.widthMeters || + left.vertices.length < 2 || + right.vertices.length < 2 || + !paddedBoundsOverlap(left.vertices, right.vertices, left.widthMeters) || + !left.vertices.some((leftVertex) => right.vertices.some((rightVertex) => + haversineDistanceMeters(leftVertex, rightVertex) <= maximumJoinDistanceMeters + )) || + polylineToPolylineDistanceMeters(left.vertices, right.vertices) > left.widthMeters + ) { + return undefined; + } + + const originalRings = [ + polylineCorridorPolygon(left.vertices, left.widthMeters), + polylineCorridorPolygon(right.vertices, right.widthMeters) + ].map(coordinateRingPoints); + if (originalRings.some((ring) => ring.length < 3)) { + return undefined; + } + + const targetPoints = uniquePoints([ + ...(left.targetPoints ?? []), + ...(right.targetPoints ?? []) + ]); + const candidates = []; + for (const reverseLeft of [false, true]) { + const leftVertices = reverseLeft ? [...left.vertices].reverse() : left.vertices; + for (const reverseRight of [false, true]) { + const rightVertices = reverseRight ? [...right.vertices].reverse() : right.vertices; + for (let leftIndex = 0; leftIndex < leftVertices.length; leftIndex += 1) { + for (let rightIndex = 0; rightIndex < rightVertices.length; rightIndex += 1) { + const joinDistanceMeters = haversineDistanceMeters( + leftVertices[leftIndex], + rightVertices[rightIndex] + ); + if (joinDistanceMeters > maximumJoinDistanceMeters) { + continue; + } + + const vertices = uniqueConsecutivePoints([ + ...leftVertices.slice(0, leftIndex + 1), + ...rightVertices.slice(rightIndex + 1) + ]); + if (vertices.length < 2) { + continue; + } + const coordinates = polylineCorridorPolygon(vertices, left.widthMeters); + if ( + coordinates.length < 4 || + !targetPoints.every((point) => pointInCoordinateRing(point, coordinates)) + ) { + continue; + } + candidates.push({ vertices, coordinates, joinDistanceMeters }); + } + } + } + } + candidates.sort((leftCandidate, rightCandidate) => + leftCandidate.vertices.length - rightCandidate.vertices.length || + leftCandidate.joinDistanceMeters - rightCandidate.joinDistanceMeters + ); + const best = candidates.find((candidate) => + removalBoundaryWithinExistingUnion(candidate.coordinates, originalRings) + ); + if (!best) { + return undefined; + } + + const merged = mergedGroup(left, right, best.vertices); + merged.partialOverlapCombined = true; + merged.reason = + `source-backed target light rows combined from ${merged.sourceRowIds.length} partially overlapping corridors without expanding removal area`; + return merged; +} + +function paddedBoundsOverlap(leftVertices, rightVertices, paddingMeters) { + const left = pointBounds(leftVertices); + const right = pointBounds(rightVertices); + const paddingLat = paddingMeters / METERS_PER_DEGREE_LAT; + const meanLat = (left.minLat + left.maxLat + right.minLat + right.maxLat) / 4; + const paddingLon = paddingMeters / + (METERS_PER_DEGREE_LAT * Math.max(Math.cos((meanLat * Math.PI) / 180), 0.000001)); + return left.minLat <= right.maxLat + paddingLat && + left.maxLat >= right.minLat - paddingLat && + left.minLon <= right.maxLon + paddingLon && + left.maxLon >= right.minLon - paddingLon; +} + +function pointBounds(points) { + return points.reduce((bounds, point) => ({ + minLat: Math.min(bounds.minLat, point.lat), + maxLat: Math.max(bounds.maxLat, point.lat), + minLon: Math.min(bounds.minLon, point.lon), + maxLon: Math.max(bounds.maxLon, point.lon) + }), { + minLat: Number.POSITIVE_INFINITY, + maxLat: Number.NEGATIVE_INFINITY, + minLon: Number.POSITIVE_INFINITY, + maxLon: Number.NEGATIVE_INFINITY + }); +} + +function boundsOverlapWithPaddingMeters(left, right, paddingMeters) { + if (!left || !right) { + return false; + } + const paddingLat = paddingMeters / METERS_PER_DEGREE_LAT; + const meanLat = (left.minLat + left.maxLat + right.minLat + right.maxLat) / 4; + const paddingLon = paddingMeters / + (METERS_PER_DEGREE_LAT * Math.max(Math.cos((meanLat * Math.PI) / 180), 0.000001)); + return left.minLat <= right.maxLat + paddingLat && + left.maxLat >= right.minLat - paddingLat && + left.minLon <= right.maxLon + paddingLon && + left.maxLon >= right.minLon - paddingLon; +} + +function mustKeepGeometryPoints(zone) { + if (zone.geometryType === "Point" && zone.point) { + return [zone.point]; + } + return zone.vertices ?? []; +} + +function createMustKeepSpatialIndex(zones, boundsById, cellSizeMeters = 20) { + const finiteBounds = [...boundsById.values()].filter((bounds) => + bounds && [bounds.minLat, bounds.maxLat, bounds.minLon, bounds.maxLon].every(Number.isFinite) + ); + const referenceLatitude = finiteBounds.length > 0 + ? finiteBounds.reduce((sum, bounds) => sum + bounds.minLat + bounds.maxLat, 0) / + (finiteBounds.length * 2) + : 0; + const metersPerDegreeLon = METERS_PER_DEGREE_LAT * + Math.max(Math.cos((referenceLatitude * Math.PI) / 180), 0.000001); + const cells = new Map(); + + const cellRange = (bounds, paddingMeters = 0) => ({ + minX: Math.floor((bounds.minLon * metersPerDegreeLon - paddingMeters) / cellSizeMeters), + maxX: Math.floor((bounds.maxLon * metersPerDegreeLon + paddingMeters) / cellSizeMeters), + minY: Math.floor((bounds.minLat * METERS_PER_DEGREE_LAT - paddingMeters) / cellSizeMeters), + maxY: Math.floor((bounds.maxLat * METERS_PER_DEGREE_LAT + paddingMeters) / cellSizeMeters) + }); + + for (const [index, zone] of zones.entries()) { + const bounds = boundsById.get(zone.id); + if (!bounds || ![bounds.minLat, bounds.maxLat, bounds.minLon, bounds.maxLon].every(Number.isFinite)) { + continue; + } + const range = cellRange(bounds); + for (let y = range.minY; y <= range.maxY; y += 1) { + for (let x = range.minX; x <= range.maxX; x += 1) { + const key = `${x},${y}`; + const indexes = cells.get(key); + if (indexes) indexes.push(index); + else cells.set(key, [index]); + } + } + } + + return { + maximumClearanceMeters: Math.max( + 0, + ...zones.map((zone) => zone.clearanceMeters ?? 0).filter(Number.isFinite) + ), + queryPoint(point, paddingMeters) { + const range = cellRange({ + minLat: point.lat, + maxLat: point.lat, + minLon: point.lon, + maxLon: point.lon + }, paddingMeters); + const indexes = new Set(); + for (let y = range.minY; y <= range.maxY; y += 1) { + for (let x = range.minX; x <= range.maxX; x += 1) { + for (const index of cells.get(`${x},${y}`) ?? []) indexes.add(index); + } + } + return [...indexes].sort((left, right) => left - right).map((index) => zones[index]); + } + }; +} + +function pointWithinBounds(point, bounds) { + return point.lat >= bounds.minLat && point.lat <= bounds.maxLat && + point.lon >= bounds.minLon && point.lon <= bounds.maxLon; +} + +function removalBoundaryWithinExistingUnion(coordinates, originalRings) { + const boundary = coordinateRingPoints(coordinates); + const samples = densifyVertices(boundary, PARTIAL_OVERLAP_BOUNDARY_SAMPLE_METERS); + const indexedRings = originalRings.map((ring) => ({ ring, bounds: pointBounds(ring) })); + return samples.every((point) => + indexedRings.some(({ ring, bounds }) => + pointWithinPaddedBounds(point, bounds, 0.002) && pointInPolygon(point, ring) + ) + ); +} + +function pointWithinPaddedBounds(point, bounds, paddingMeters) { + const paddingLat = paddingMeters / METERS_PER_DEGREE_LAT; + const maximumAbsoluteLatitude = Math.max( + Math.abs(point.lat), + Math.abs(bounds.minLat), + Math.abs(bounds.maxLat) + ); + const paddingLon = paddingMeters / ( + METERS_PER_DEGREE_LAT * + Math.max(Math.cos((maximumAbsoluteLatitude * Math.PI) / 180), 0.000001) + ); + return point.lat >= bounds.minLat - paddingLat && + point.lat <= bounds.maxLat + paddingLat && + point.lon >= bounds.minLon - paddingLon && + point.lon <= bounds.maxLon + paddingLon; +} + +function uniqueConsecutivePoints(points) { + const result = []; + for (const point of points) { + if ( + result.length === 0 || + haversineDistanceMeters(result.at(-1), point) > PROTECTION_GEOMETRY_TOLERANCE_METERS + ) { + result.push(point); + } + } + return result; +} + +function tryAbsorbCoveredGroup(left, right) { + if (isPolylineCoveredByGroup(right.vertices, left)) { + return mergeCoveredGroup(left, right); + } + + if (isPolylineCoveredByGroup(left.vertices, right)) { + return mergeCoveredGroup(right, left); + } + + return undefined; +} + +function mergeCoveredGroup(covering, covered) { + const merged = { ...covering }; + mergeGroupMetadata(merged, covered); + merged.id = stableId("row-removal", merged.sourceRowIds.join("|")); + merged.reason = + `source-backed ${merged.classification} light rows combined from ${merged.sourceRowIds.length} covered or overlapping row segments`; + return merged; +} + +function isPolylineCoveredByGroup(vertices, group) { + if (vertices.length === 0 || group.vertices.length === 0) { + return false; + } + + const maxDistanceMeters = Math.max( + MIN_SAFE_REMOVAL_HALF_WIDTH_METERS, + group.widthMeters / 2 - PROTECTION_BEND_MARGIN_METERS + ); + return polylineProbePoints(vertices).every((point) => + pointToPolylineDistanceMeters(point, group.vertices) <= maxDistanceMeters + ); +} + +function polylineProbePoints(vertices) { + const points = [...vertices]; + for (let index = 1; index < vertices.length; index += 1) { + points.push(interpolatePoint(vertices[index - 1], vertices[index], 0.25)); + points.push(interpolatePoint(vertices[index - 1], vertices[index], 0.5)); + points.push(interpolatePoint(vertices[index - 1], vertices[index], 0.75)); + } + return points; +} + +function mergeOverlappingSegments(left, right) { + if (left.vertices.length !== 2 || right.vertices.length !== 2) { + return undefined; + } + + const leftStart = left.vertices[0]; + const leftEnd = left.vertices[1]; + const rightStart = right.vertices[0]; + const rightEnd = right.vertices[1]; + const axis = localVector(leftStart, leftEnd, leftStart); + const axisLength = vectorLength(axis); + if (axisLength <= ROW_ENDPOINT_MERGE_TOLERANCE_METERS) { + return undefined; + } + + const rightAxis = localVector(rightStart, rightEnd, leftStart); + if (undirectedAngleDegrees(axis, rightAxis) > ROW_MERGE_ANGLE_TOLERANCE_DEGREES) { + return undefined; + } + + if ( + pointToInfiniteLineDistanceMeters(rightStart, leftStart, leftEnd) > ROW_LINE_MERGE_TOLERANCE_METERS || + pointToInfiniteLineDistanceMeters(rightEnd, leftStart, leftEnd) > ROW_LINE_MERGE_TOLERANCE_METERS + ) { + return undefined; + } + + const axisUnit = { x: axis.x / axisLength, y: axis.y / axisLength }; + const endpoints = [leftStart, leftEnd, rightStart, rightEnd].map((point) => ({ + point, + projection: projectPointMeters(point, leftStart, axisUnit) + })); + const leftInterval = intervalFor(endpoints[0].projection, endpoints[1].projection); + const rightInterval = intervalFor(endpoints[2].projection, endpoints[3].projection); + if (intervalGapMeters(leftInterval, rightInterval) > ROW_ENDPOINT_MERGE_TOLERANCE_METERS) { + return undefined; + } + + endpoints.sort((a, b) => a.projection - b.projection); + return mergedGroup(left, right, [endpoints[0].point, endpoints[endpoints.length - 1].point]); +} + +function mergeContiguousRows(left, right) { + const candidates = [ + [left.vertices, right.vertices], + [right.vertices, left.vertices], + [left.vertices, [...right.vertices].reverse()], + [[...right.vertices].reverse(), left.vertices] + ]; + + for (const [first, second] of candidates) { + const endpointGapMeters = haversineDistanceMeters(first.at(-1), second[0]); + if ( + endpointGapMeters <= ROW_ENDPOINT_MERGE_TOLERANCE_METERS && + isStraightContinuation(first, second) + ) { + return mergedGroup( + left, + right, + endpointGapMeters <= ROW_LINE_MERGE_TOLERANCE_METERS + ? [...first, ...second.slice(1)] + : [...first, ...second] + ); + } + } + + return undefined; +} + +function isStraightContinuation(first, second) { + if (first.length < 2 || second.length < 2) { + return false; + } + + const join = first.at(-1); + const incoming = localVector(first.at(-2), join, join); + const outgoing = localVector(join, second[1], join); + return angleDegrees(incoming, outgoing) <= ROW_MERGE_ANGLE_TOLERANCE_DEGREES; +} + +function mergedGroup(left, right, vertices) { + const merged = { + ...left, + vertices, + geometrySignature: geometrySignatureForVertices(vertices) + }; + mergeGroupMetadata(merged, right); + merged.id = stableId("row-removal", merged.sourceRowIds.join("|")); + merged.reason = + merged.sourceRowIds.length > 1 + ? `source-backed ${merged.classification} light rows combined from ${merged.sourceRowIds.length} overlapping or contiguous row segments` + : left.reason; + return merged; +} + +function mergeGroupMetadata(target, source) { + target.sourceRowIds = [...new Set([...target.sourceRowIds, ...source.sourceRowIds])]; + target.targetPoints = uniquePoints([...(target.targetPoints ?? []), ...(source.targetPoints ?? [])]); + target.sourceRows = [...(target.sourceRows ?? []), ...(source.sourceRows ?? [])] + .filter((row, index, rows) => rows.findIndex((candidate) => candidate.id === row.id) === index); + target.sourceClassifications = [...new Set([ + ...(target.sourceClassifications ?? [target.classification]), + ...(source.sourceClassifications ?? [source.classification]) + ])]; + target.sourceRecordOffset = + target.sourceRecordOffset === source.sourceRecordOffset ? target.sourceRecordOffset : undefined; + target.preset = target.preset === source.preset ? target.preset : undefined; + if (source.sourceInstanceIds) { + target.sourceInstanceIds = [...new Set([...(target.sourceInstanceIds ?? []), ...source.sourceInstanceIds])]; + } +} + +function targetLightPointsForRow(row) { + if ( + row.snapToVertices === false && + Number.isFinite(row.spacing) && + row.spacing > 0 + ) { + return interpolatePolyline(row.vertices, row.spacing); + } + return uniquePoints(row.vertices); +} + +function uniquePoints(points) { + const seen = new Set(); + const unique = []; + for (const point of points) { + const signature = normalizedVertexSignature(point); + if (!seen.has(signature)) { + seen.add(signature); + unique.push(point); + } + } + return unique; +} + +function geometrySignatureForVertices(vertices) { + const forward = vertices.map(normalizedVertexSignature).join("|"); + const reverse = vertices.map(normalizedVertexSignature).reverse().join("|"); + return forward <= reverse ? forward : reverse; +} + +function localVector(start, end, origin) { + const metersPerDegreeLon = + METERS_PER_DEGREE_LAT * Math.max(Math.cos((origin.lat * Math.PI) / 180), 0.000001); + return { + x: (end.lon - start.lon) * metersPerDegreeLon, + y: (end.lat - start.lat) * METERS_PER_DEGREE_LAT + }; +} + +function vectorLength(vector) { + return Math.sqrt(vector.x * vector.x + vector.y * vector.y); +} + +function angleDegrees(left, right) { + const leftLength = vectorLength(left); + const rightLength = vectorLength(right); + if (leftLength <= 0 || rightLength <= 0) { + return 180; + } + + const cosine = Math.max( + -1, + Math.min(1, (left.x * right.x + left.y * right.y) / (leftLength * rightLength)) + ); + return (Math.acos(cosine) * 180) / Math.PI; +} + +function undirectedAngleDegrees(left, right) { + const angle = angleDegrees(left, right); + return Math.min(angle, 180 - angle); +} + +function pointToInfiniteLineDistanceMeters(point, lineStart, lineEnd) { + const line = localVector(lineStart, lineEnd, lineStart); + const pointVector = localVector(lineStart, point, lineStart); + const length = vectorLength(line); + if (length <= 0) { + return haversineDistanceMeters(point, lineStart); + } + + return Math.abs(line.x * pointVector.y - line.y * pointVector.x) / length; +} + +function projectPointMeters(point, origin, axisUnit) { + const vector = localVector(origin, point, origin); + return vector.x * axisUnit.x + vector.y * axisUnit.y; +} + +function intervalFor(left, right) { + return { + min: Math.min(left, right), + max: Math.max(left, right) + }; +} + +function intervalGapMeters(left, right) { + return Math.max(0, Math.max(left.min, right.min) - Math.min(left.max, right.max)); +} + +function isSourceBackedLightRow(row) { + return !row.inferred && SOURCE_BACKED_LIGHT_ROW_TYPES.has(row.sourceType); +} + +function isRemovalLightRow(row) { + return isSourceBackedLightRow(row) || + row.inferred === true && INFERRED_REMOVAL_LIGHT_ROW_TYPES.has(row.sourceType); +} + +function normalizedVertexSignature(vertex) { + return `${vertex.lat.toFixed(7)},${vertex.lon.toFixed(7)}`; +} + +function isCoveredByTargetRow(instance, targetRows, widthMeters, sourceInstanceIds) { + if (sourceInstanceIds.has(instance.id)) { + return true; + } + return targetRows.some((row) => { + const rowWidth = Math.max(widthMeters, 1); + return pointToPolylineDistanceMeters(instance, row.vertices) <= rowWidth / 2; + }); +} + +function exclusionConfigForInstance(sourceType, sizes) { + if (sourceType === "library-object") { + return { + size: sizes.library, + flags: { excludeLibraryObjects: true } + }; + } + + if (sourceType === "visual-effect") { + return { + size: sizes.vfx, + flags: { excludeVFX: true } + }; + } + + if (sourceType === "simprop-container") { + return { + size: sizes.simprop, + flags: { excludeSimPropContainers: true } + }; + } + + return null; +} + +function isGuidLike(value) { + return typeof value === "string" && /^\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?$/i.test(value.trim()); +} + +function extractPlacement(node) { + return { + ...numberProperty(node, "lat", ["lat", "latitude", "Latitude"]), + ...numberProperty(node, "lon", ["lon", "lng", "longitude", "Longitude"]), + ...numberProperty(node, "alt", ["alt", "altitude", "Altitude"]), + ...numberProperty(node, "heading", ["heading", "Heading", "hdg"]), + ...numberProperty(node, "pitch", ["pitch", "Pitch"]), + ...numberProperty(node, "bank", ["bank", "Bank"]), + ...numberProperty(node, "scale", ["scale", "Scale"]) + }; +} + +function mergePlacement(primary, secondary) { + return { + ...primary, + ...Object.fromEntries( + Object.entries(secondary).filter(([, value]) => typeof value === "number") + ) + }; +} + +function numberProperty(node, property, names) { + const number = firstNumber(node, names); + return typeof number === "number" ? { [property]: number } : {}; +} + +function firstAttribute(node, names) { + for (const name of names) { + if (node.attributes && Object.prototype.hasOwnProperty.call(node.attributes, name)) { + return node.attributes[name]; + } + } + + const lowerNameMap = new Map( + Object.entries(node.attributes ?? {}).map(([key, value]) => [key.toLowerCase(), value]) + ); + for (const name of names) { + const value = lowerNameMap.get(name.toLowerCase()); + if (value !== undefined) { + return value; + } + } + + return undefined; +} + +function firstNumber(node, names) { + const value = firstAttribute(node, names); + return parseNumeric(value); +} + +function firstBoolean(node, names) { + const value = firstAttribute(node, names); + if (value === undefined) { + return undefined; + } + + if (/^(true|1|yes)$/i.test(value)) { + return true; + } + if (/^(false|0|no)$/i.test(value)) { + return false; + } + return undefined; +} + +function parseNumeric(value) { + if (value === undefined || value === null || value === "") { + return undefined; + } + + const stringValue = String(value).trim(); + const match = stringValue.match(/[-+]?\d+(?:\.\d+)?/); + if (!match) { + return undefined; + } + + let number = Number.parseFloat(match[0]); + if (!Number.isFinite(number)) { + return undefined; + } + + if (/[SW]\s*$/i.test(stringValue) && number > 0) { + number *= -1; + } + + return number; +} + +function isValidPlacement(placement) { + return Number.isFinite(placement.lat) && Number.isFinite(placement.lon); +} + +function sourceTypeFromTag(tagName) { + const tag = localName(tagName); + if (tag === "libraryobject") { + return "library-object"; + } + if (tag === "visualeffectobject") { + return "visual-effect"; + } + if (tag === "simpropcontainer") { + return "simprop-container"; + } + if (tag === "sceneryobject") { + return "scenery-object"; + } + return "unknown"; +} + +export function summarizeExtraction(data) { + const likelyLights = data.instances.filter((instance) => + isLikelyLightClassification(instance.classification) + ).length; + const targetLights = data.instances.filter((instance) => + isTargetLightClassification(instance.classification) + ).length; + const targetLightRows = data.lightRows.filter((row) => + isTargetLightClassification(row.classification) + ).length; + const sourceLightRows = data.lightRows.filter(isSourceBackedLightRow).length; + const sourceTargetLightRows = data.lightRows.filter((row) => + isSourceBackedLightRow(row) && isTargetLightClassification(row.classification) + ).length; + const inferredLightRows = data.lightRows.filter((row) => row.inferred).length; + + return { + filesScanned: data.meta.filesScanned, + xmlFilesParsed: data.meta.xmlFilesParsed, + bglFilesParsed: data.meta.bglFilesParsed ?? 0, + modelLibraryEntries: data.meta.modelLibraryEntries ?? 0, + taxiwayGraphsParsed: data.meta.taxiwayGraphsParsed ?? 0, + taxiwayPointsParsed: data.meta.taxiwayPointsParsed ?? 0, + taxiwayParkingsParsed: data.meta.taxiwayParkingsParsed ?? 0, + taxiwayPathsParsed: data.meta.taxiwayPathsParsed ?? 0, + taxiwayNamesParsed: data.meta.taxiwayNamesParsed ?? 0, + lightedTaxiwayPathsParsed: data.meta.lightedTaxiwayPathsParsed ?? 0, + inferredPlacementRows: data.meta.inferredPlacementRows ?? 0, + inferredPlacementAssignments: data.meta.inferredPlacementAssignments ?? 0, + excludedPlacementOutliers: data.meta.excludedPlacementOutliers ?? 0, + placementInferenceMilliseconds: data.meta.placementInferenceMilliseconds ?? 0, + runwayRecordsParsed: data.meta.runwayRecordsParsed ?? 0, + runwayLightZonesFound: data.meta.runwayLightZonesFound ?? 0, + objectsExtracted: data.instances.length, + likelyLightsFound: likelyLights, + targetLightsFound: targetLights, + lightRowsFound: data.lightRows.length, + targetLightRowsFound: targetLightRows, + sourceLightRowsFound: sourceLightRows, + sourceTargetLightRowsFound: sourceTargetLightRows, + inferredLightRowsFound: inferredLightRows, + removalPolygonsGenerated: data.removalPolygons.length, + exclusionCandidatesGenerated: data.exclusionCandidates.length, + mustKeepZonesGenerated: data.mustKeepZones?.length ?? 0, + protectionConflicts: data.protectionConflicts?.length ?? 0, + warnings: data.meta.warnings.length + }; +} + +export function normalizeOutputPath(outPath) { + return path.resolve(outPath); +} diff --git a/src/features/draft-generator/extractor/geo.js b/src/features/draft-generator/extractor/geo.js new file mode 100644 index 0000000..d79b581 --- /dev/null +++ b/src/features/draft-generator/extractor/geo.js @@ -0,0 +1,495 @@ +const EARTH_RADIUS_METERS = 6371008.8; +const METERS_PER_DEGREE_LAT = 111320; +const CLEANED_VERTICES_CACHE = new WeakMap(); + +export function degreesToRadians(value) { + return (value * Math.PI) / 180; +} + +export function haversineDistanceMeters(a, b) { + const lat1 = degreesToRadians(a.lat); + const lat2 = degreesToRadians(b.lat); + const deltaLat = degreesToRadians(b.lat - a.lat); + const deltaLon = degreesToRadians(b.lon - a.lon); + + const sinLat = Math.sin(deltaLat / 2); + const sinLon = Math.sin(deltaLon / 2); + const h = + sinLat * sinLat + + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon; + + return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(h)); +} + +export function interpolatePoint(a, b, ratio) { + const alt = + typeof a.alt === "number" && typeof b.alt === "number" + ? a.alt + (b.alt - a.alt) * ratio + : a.alt ?? b.alt; + + return { + lat: a.lat + (b.lat - a.lat) * ratio, + lon: a.lon + (b.lon - a.lon) * ratio, + ...(typeof alt === "number" ? { alt } : {}) + }; +} + +export function interpolatePolyline(vertices, spacingMeters) { + if (vertices.length === 0) { + return []; + } + + if (vertices.length === 1 || !Number.isFinite(spacingMeters) || spacingMeters <= 0) { + return [...vertices]; + } + + const segments = []; + let totalLength = 0; + for (let index = 0; index < vertices.length - 1; index += 1) { + const start = vertices[index]; + const end = vertices[index + 1]; + const length = haversineDistanceMeters(start, end); + segments.push({ start, end, length, startDistance: totalLength }); + totalLength += length; + } + + if (totalLength <= 0) { + return [vertices[0]]; + } + + const points = []; + for (let distance = 0; distance <= totalLength; distance += spacingMeters) { + points.push(pointAtDistance(segments, distance)); + } + + const lastPoint = points[points.length - 1]; + const lastVertex = vertices[vertices.length - 1]; + if (!lastPoint || haversineDistanceMeters(lastPoint, lastVertex) > 0.05) { + points.push(lastVertex); + } + + return points; +} + +function pointAtDistance(segments, distance) { + const segment = + segments.find((candidate) => distance <= candidate.startDistance + candidate.length) ?? + segments[segments.length - 1]; + + if (!segment || segment.length <= 0) { + return segment?.start ?? { lat: 0, lon: 0 }; + } + + const localDistance = Math.max(0, distance - segment.startDistance); + const ratio = Math.min(1, localDistance / segment.length); + return interpolatePoint(segment.start, segment.end, ratio); +} + +export function rectanglePolygonAround(point, widthMeters, heightMeters) { + const halfHeightDegrees = (heightMeters / 2) / METERS_PER_DEGREE_LAT; + const lonScale = Math.max(Math.cos(degreesToRadians(point.lat)), 0.000001); + const halfWidthDegrees = (widthMeters / 2) / (METERS_PER_DEGREE_LAT * lonScale); + + const north = point.lat + halfHeightDegrees; + const south = point.lat - halfHeightDegrees; + const east = point.lon + halfWidthDegrees; + const west = point.lon - halfWidthDegrees; + + return [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south] + ]; +} + +export function polylineCorridorPolygon(vertices, widthMeters) { + const cleanedVertices = removeDuplicateVertices(vertices); + if (cleanedVertices.length === 0) { + return []; + } + + if (cleanedVertices.length === 1) { + return rectanglePolygonAround(cleanedVertices[0], widthMeters, widthMeters); + } + + const origin = cleanedVertices[0]; + const localVertices = cleanedVertices.map((vertex) => toLocalMeters(vertex, origin)); + const segments = []; + for (let index = 1; index < localVertices.length; index += 1) { + const start = localVertices[index - 1]; + const end = localVertices[index]; + const dx = end.x - start.x; + const dy = end.y - start.y; + const length = Math.sqrt(dx * dx + dy * dy); + if (length <= 0.05) { + continue; + } + + const ux = dx / length; + const uy = dy / length; + segments.push({ + start, + end, + leftNormal: { x: -uy, y: ux }, + rightNormal: { x: uy, y: -ux } + }); + } + + if (segments.length === 0) { + return rectanglePolygonAround(cleanedVertices[0], widthMeters, widthMeters); + } + + const halfWidth = widthMeters / 2; + const leftSide = []; + const rightSide = []; + + for (let index = 0; index < localVertices.length; index += 1) { + const point = localVertices[index]; + const previousSegment = segments[Math.max(0, index - 1)]; + const nextSegment = segments[Math.min(index, segments.length - 1)]; + + leftSide.push(offsetJoinPoint(point, previousSegment, nextSegment, halfWidth, "left")); + rightSide.push(offsetJoinPoint(point, previousSegment, nextSegment, halfWidth, "right")); + } + + const ring = [ + ...leftSide, + ...rightSide.reverse(), + leftSide[0] + ]; + + return ring.map((point) => fromLocalMeters(point, origin)); +} + +export function pointToPolylineDistanceMeters(point, vertices) { + const cleanedVertices = removeDuplicateVertices(vertices); + if (cleanedVertices.length === 0) { + return Number.POSITIVE_INFINITY; + } + + if (cleanedVertices.length === 1) { + return haversineDistanceMeters(point, cleanedVertices[0]); + } + + let nearest = Number.POSITIVE_INFINITY; + for (let index = 1; index < cleanedVertices.length; index += 1) { + nearest = Math.min( + nearest, + pointToSegmentDistanceMeters(point, cleanedVertices[index - 1], cleanedVertices[index]) + ); + } + + return nearest; +} + +export function nearestPointOnPolyline(point, vertices) { + const cleanedVertices = removeDuplicateVertices(vertices); + if (cleanedVertices.length === 0) { + return { point: undefined, distanceMeters: Number.POSITIVE_INFINITY }; + } + if (cleanedVertices.length === 1) { + return { + point: cleanedVertices[0], + distanceMeters: haversineDistanceMeters(point, cleanedVertices[0]) + }; + } + + let nearest = { point: undefined, distanceMeters: Number.POSITIVE_INFINITY }; + for (let index = 1; index < cleanedVertices.length; index += 1) { + const candidate = nearestPointOnSegment(point, cleanedVertices[index - 1], cleanedVertices[index]); + if (candidate.distanceMeters < nearest.distanceMeters) { + nearest = candidate; + } + } + return nearest; +} + +export function pointInPolygon(point, vertices) { + const cleanedVertices = removeDuplicateVertices(vertices); + if (cleanedVertices.length < 3) { + return false; + } + + const metersPerDegreeLon = + METERS_PER_DEGREE_LAT * Math.max(Math.cos(degreesToRadians(point.lat)), 0.000001); + let inside = false; + const last = cleanedVertices.at(-1); + let rightX = (last.lon - point.lon) * metersPerDegreeLon; + let rightY = (last.lat - point.lat) * METERS_PER_DEGREE_LAT; + for (const left of cleanedVertices) { + const leftX = (left.lon - point.lon) * metersPerDegreeLon; + const leftY = (left.lat - point.lat) * METERS_PER_DEGREE_LAT; + const dx = rightX - leftX; + const dy = rightY - leftY; + const lengthSquared = dx * dx + dy * dy; + const ratio = lengthSquared <= 0 + ? 0 + : Math.max(0, Math.min(1, (-leftX * dx - leftY * dy) / lengthSquared)); + const nearestX = leftX + dx * ratio; + const nearestY = leftY + dy * ratio; + if (nearestX * nearestX + nearestY * nearestY <= 0.001 * 0.001) { + return true; + } + const crosses = + (leftY > 0) !== (rightY > 0) && + 0 < (dx * -leftY) / dy + leftX; + if (crosses) { + inside = !inside; + } + rightX = leftX; + rightY = leftY; + } + return inside; +} + +export function nearestPointOnPolygon(point, vertices) { + if (pointInPolygon(point, vertices)) { + return { point, distanceMeters: 0, inside: true }; + } + const ring = closeVertices(vertices); + const nearest = nearestPointOnPolyline(point, ring); + return { ...nearest, inside: false }; +} + +export function polylineToPolylineDistanceMeters(leftVertices, rightVertices) { + const left = removeDuplicateVertices(leftVertices); + const right = removeDuplicateVertices(rightVertices); + if (left.length === 0 || right.length === 0) { + return Number.POSITIVE_INFINITY; + } + if (left.length === 1) { + return pointToPolylineDistanceMeters(left[0], right); + } + if (right.length === 1) { + return pointToPolylineDistanceMeters(right[0], left); + } + + let nearest = Number.POSITIVE_INFINITY; + for (let leftIndex = 1; leftIndex < left.length; leftIndex += 1) { + for (let rightIndex = 1; rightIndex < right.length; rightIndex += 1) { + nearest = Math.min( + nearest, + segmentToSegmentDistanceMeters( + left[leftIndex - 1], + left[leftIndex], + right[rightIndex - 1], + right[rightIndex] + ) + ); + if (nearest <= 0.001) { + return 0; + } + } + } + return nearest; +} + +export function offsetPointMeters(point, eastMeters, northMeters) { + const metersPerDegreeLon = + METERS_PER_DEGREE_LAT * Math.max(Math.cos(degreesToRadians(point.lat)), 0.000001); + return { + lat: point.lat + northMeters / METERS_PER_DEGREE_LAT, + lon: point.lon + eastMeters / metersPerDegreeLon + }; +} + +function removeDuplicateVertices(vertices) { + const cached = CLEANED_VERTICES_CACHE.get(vertices); + if (cached) { + return cached; + } + const cleaned = []; + for (const vertex of vertices) { + if (!Number.isFinite(vertex.lat) || !Number.isFinite(vertex.lon)) { + continue; + } + + const previous = cleaned[cleaned.length - 1]; + if (!previous || haversineDistanceMeters(previous, vertex) > 0.05) { + cleaned.push(vertex); + } + } + + CLEANED_VERTICES_CACHE.set(vertices, cleaned); + return cleaned; +} + +function offsetJoinPoint(point, previousSegment, nextSegment, halfWidth, side) { + const previousNormal = previousSegment[`${side}Normal`]; + const nextNormal = nextSegment[`${side}Normal`]; + const previousOffset = offsetPoint(point, previousNormal, halfWidth); + const nextOffset = offsetPoint(point, nextNormal, halfWidth); + const joined = lineIntersection( + offsetPoint(previousSegment.start, previousNormal, halfWidth), + previousOffset, + nextOffset, + offsetPoint(nextSegment.end, nextNormal, halfWidth) + ); + + if (joined && distanceLocalMeters(point, joined) <= halfWidth * 4) { + return joined; + } + + const averageNormal = normalizeVector({ + x: previousNormal.x + nextNormal.x, + y: previousNormal.y + nextNormal.y + }) ?? nextNormal; + return offsetPoint(point, averageNormal, halfWidth); +} + +function offsetPoint(point, normal, distanceMeters) { + return { + x: point.x + normal.x * distanceMeters, + y: point.y + normal.y * distanceMeters + }; +} + +function lineIntersection(a, b, c, d) { + const denominator = (a.x - b.x) * (c.y - d.y) - (a.y - b.y) * (c.x - d.x); + if (Math.abs(denominator) < 0.000001) { + return undefined; + } + + const aCross = a.x * b.y - a.y * b.x; + const cCross = c.x * d.y - c.y * d.x; + return { + x: (aCross * (c.x - d.x) - (a.x - b.x) * cCross) / denominator, + y: (aCross * (c.y - d.y) - (a.y - b.y) * cCross) / denominator + }; +} + +function normalizeVector(vector) { + const length = Math.sqrt(vector.x * vector.x + vector.y * vector.y); + if (length <= 0.000001) { + return undefined; + } + + return { + x: vector.x / length, + y: vector.y / length + }; +} + +function pointToSegmentDistanceMeters(point, segmentStart, segmentEnd) { + return nearestPointOnSegment(point, segmentStart, segmentEnd).distanceMeters; +} + +function nearestPointOnSegment(point, segmentStart, segmentEnd) { + const localPoint = toLocalMeters(point, segmentStart); + const localEnd = toLocalMeters(segmentEnd, segmentStart); + const lengthSquared = localEnd.x * localEnd.x + localEnd.y * localEnd.y; + if (lengthSquared <= 0) { + return { + point: segmentStart, + distanceMeters: haversineDistanceMeters(point, segmentStart) + }; + } + + const ratio = Math.max( + 0, + Math.min(1, (localPoint.x * localEnd.x + localPoint.y * localEnd.y) / lengthSquared) + ); + const closest = { + x: localEnd.x * ratio, + y: localEnd.y * ratio + }; + + return { + point: fromLocalMetersObject(closest, segmentStart), + distanceMeters: distanceLocalMeters(localPoint, closest) + }; +} + +function segmentToSegmentDistanceMeters(a, b, c, d) { + const localB = toLocalMeters(b, a); + const localC = toLocalMeters(c, a); + const localD = toLocalMeters(d, a); + if (segmentsIntersectLocal({ x: 0, y: 0 }, localB, localC, localD)) { + return 0; + } + return Math.min( + pointToLocalSegmentDistance({ x: 0, y: 0 }, localC, localD), + pointToLocalSegmentDistance(localB, localC, localD), + pointToLocalSegmentDistance(localC, { x: 0, y: 0 }, localB), + pointToLocalSegmentDistance(localD, { x: 0, y: 0 }, localB) + ); +} + +function pointToLocalSegmentDistance(point, start, end) { + const dx = end.x - start.x; + const dy = end.y - start.y; + const lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= 0) { + return distanceLocalMeters(point, start); + } + const ratio = Math.max( + 0, + Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared) + ); + return distanceLocalMeters(point, { + x: start.x + dx * ratio, + y: start.y + dy * ratio + }); +} + +function segmentsIntersectLocal(a, b, c, d) { + const orientation = (p, q, r) => + Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x)); + const o1 = orientation(a, b, c); + const o2 = orientation(a, b, d); + const o3 = orientation(c, d, a); + const o4 = orientation(c, d, b); + if (o1 !== o2 && o3 !== o4) { + return true; + } + return ( + pointToLocalSegmentDistance(a, c, d) <= 0.001 || + pointToLocalSegmentDistance(b, c, d) <= 0.001 || + pointToLocalSegmentDistance(c, a, b) <= 0.001 || + pointToLocalSegmentDistance(d, a, b) <= 0.001 + ); +} + +function closeVertices(vertices) { + if (vertices.length === 0) { + return []; + } + const first = vertices[0]; + const last = vertices.at(-1); + return haversineDistanceMeters(first, last) <= 0.05 + ? [...vertices] + : [...vertices, first]; +} + +function distanceLocalMeters(left, right) { + const dx = left.x - right.x; + const dy = left.y - right.y; + return Math.sqrt(dx * dx + dy * dy); +} + +function toLocalMeters(point, origin) { + const metersPerDegreeLon = + METERS_PER_DEGREE_LAT * Math.max(Math.cos(degreesToRadians(origin.lat)), 0.000001); + + return { + x: (point.lon - origin.lon) * metersPerDegreeLon, + y: (point.lat - origin.lat) * METERS_PER_DEGREE_LAT + }; +} + +function fromLocalMeters(point, origin) { + const metersPerDegreeLon = + METERS_PER_DEGREE_LAT * Math.max(Math.cos(degreesToRadians(origin.lat)), 0.000001); + + return [ + origin.lon + point.x / metersPerDegreeLon, + origin.lat + point.y / METERS_PER_DEGREE_LAT + ]; +} + +function fromLocalMetersObject(point, origin) { + const [lon, lat] = fromLocalMeters(point, origin); + return { lat, lon }; +} diff --git a/src/features/draft-generator/extractor/placement-rows.js b/src/features/draft-generator/extractor/placement-rows.js new file mode 100644 index 0000000..3b9a179 --- /dev/null +++ b/src/features/draft-generator/extractor/placement-rows.js @@ -0,0 +1,892 @@ +import { isTargetLightClassification, stableId } from "./classify.js"; + +const EARTH_RADIUS_METERS = 6371008.8; +const GRID_CELL_METERS = 40; +const SEARCH_RADIUS_METERS = 100; +const MAX_NEIGHBORS_PER_POINT = 64; +const MAX_CANDIDATES_PER_SIDE = 8; +const HEADING_ALIGNMENT_TOLERANCE_DEGREES = 28; +const HEADING_ERROR_WEIGHT_METERS = 0.75; +const CONTINUATION_SUPPORT_BONUS_METERS = 12; +const MAX_TURN_DEFLECTION_DEGREES = 48; +const ADAPTIVE_LINK_DISTANCE_MULTIPLIER = 6; +const MIN_ADAPTIVE_LINK_DISTANCE_METERS = 30; +const CROSSING_GRID_CELL_METERS = 50; +const MIN_DISTINCT_DISTANCE_METERS = 0.05; +const INSERTION_DISTANCE_METERS = 4; +const INSERTION_LENGTH_RATIO = 1.08; +const MAX_FRAGMENT_JOIN_DISTANCE_METERS = 45; + +export const INFERRED_PLACEMENT_ROW_SOURCE_TYPE = "inferred-bgl-placement-row"; + +export function inferBglPlacementLightRows(instances) { + const startedAt = performance.now(); + const sourceInstances = (instances ?? []).filter(isPlacementTarget); + const fileGroups = Map.groupBy(sourceInstances, (instance) => instance.sourceFile); + const rows = []; + const totals = emptyStats(); + + for (const group of fileGroups.values()) { + const result = inferRowsForSourceFile(group); + rows.push(...result.rows); + addStats(totals, result.stats); + } + + totals.rows = rows.length; + totals.elapsedMilliseconds = roundNumber(performance.now() - startedAt); + totals.validation = validateInferredPlacementRows(sourceInstances, rows); + return { rows, stats: totals }; +} + +export function validateInferredPlacementRows(instances, rows) { + const sourceById = new Map((instances ?? []).map((instance) => [instance.id, instance])); + const assignmentCounts = new Map(); + const edgeCounts = new Map(); + let verticesWithoutExactSource = 0; + let rowsWithFewerThanTwoVertices = 0; + let excessiveTurnRows = 0; + for (const row of rows ?? []) { + const sourceIds = row.sourceInstanceIds ?? []; + if ((row.vertices?.length ?? 0) < 2 || sourceIds.length < 2) { + rowsWithFewerThanTwoVertices += 1; + } + if ((row.maximumObservedTurnDeflectionDegrees ?? 0) > MAX_TURN_DEFLECTION_DEGREES) { + excessiveTurnRows += 1; + } + for (let index = 0; index < sourceIds.length; index += 1) { + const id = sourceIds[index]; + assignmentCounts.set(id, (assignmentCounts.get(id) ?? 0) + 1); + const source = sourceById.get(id); + const vertex = row.vertices?.[index]; + if (!source || !vertex || source.lat !== vertex.lat || source.lon !== vertex.lon) { + verticesWithoutExactSource += 1; + } + if (index > 0) { + const edge = [sourceIds[index - 1], id].sort().join("|"); + edgeCounts.set(edge, (edgeCounts.get(edge) ?? 0) + 1); + } + } + } + const eligible = (instances ?? []).filter((instance) => !instance.rowInferenceExcluded); + const unassignedEligiblePlacements = eligible.filter((instance) => + !assignmentCounts.has(instance.id) + ).length; + const duplicatePlacementAssignments = [...assignmentCounts.values()].filter((count) => count > 1).length; + const duplicateEdges = [...edgeCounts.values()].filter((count) => count > 1).length; + return { + eligiblePlacements: eligible.length, + unassignedEligiblePlacements, + duplicatePlacementAssignments, + duplicateEdges, + verticesWithoutExactSource, + rowsWithFewerThanTwoVertices, + excessiveTurnRows, + valid: unassignedEligiblePlacements === 0 && + duplicatePlacementAssignments === 0 && + duplicateEdges === 0 && + verticesWithoutExactSource === 0 && + rowsWithFewerThanTwoVertices === 0 && + excessiveTurnRows === 0 + }; +} + +function inferRowsForSourceFile(instances) { + const points = localPoints(instances); + const nearby = buildNearbyLists(points); + const candidateResult = buildCandidateEdges(points, nearby); + const graph = buildPathGraph(points, candidateResult.initialEdges); + insertUnassignedPoints(points, graph, candidateResult.edges); + attachUnassignedEndpoints(points, graph, candidateResult.edges); + attachBySplittingInternalEdges(points, graph, candidateResult.byPoint); + joinCompatiblePathEndpoints(points, graph, candidateResult.edges); + + const paths = orderedGraphPaths(points, graph); + const rows = paths.map((path, index) => buildRow(points, graph, path, index)); + const assigned = new Set(paths.flat()); + const excluded = []; + for (const point of points) { + if (assigned.has(point.index)) { + continue; + } + point.instance.originalClassification = point.instance.classification; + point.instance.classification = "unknown-light"; + point.instance.rowInferenceExcluded = true; + point.instance.rowInferenceExclusionReason = + candidateResult.byPoint[point.index].length === 0 + ? `no heading-compatible target light within ${SEARCH_RADIUS_METERS} m` + : "no non-crossing degree-limited row connection remained"; + point.instance.classificationReasons = [ + ...(point.instance.classificationReasons ?? []), + point.instance.rowInferenceExclusionReason + ]; + excluded.push(point.index); + } + + return { + rows, + stats: { + inputPlacements: points.length, + assignedPlacements: assigned.size, + excludedOutliers: excluded.length, + candidatePairsChecked: candidateResult.pairsChecked, + headingCompatibleCandidates: candidateResult.headingCompatible, + retainedCandidates: candidateResult.edges.length, + adaptiveInitialCandidates: candidateResult.initialEdges.length, + selectedEdges: graph.edges.size, + crossingCandidatesRejected: graph.crossingRejections, + turnCandidatesRejected: graph.turnRejections, + insertedPlacements: graph.insertedPlacements, + joinedPathFragments: graph.joinedPathFragments, + rows: rows.length, + maximumObservedLinkDistanceMeters: roundNumber(maximumSelectedEdgeValue(graph, "distanceMeters")), + maximumObservedHeadingErrorDegrees: roundNumber(maximumSelectedEdgeValue(graph, "maximumHeadingErrorDegrees")), + maximumObservedTurnDeflectionDegrees: roundNumber(maximumPathTurnDeflection(points, paths)) + } + }; +} + +function isPlacementTarget(instance) { + return instance?.sourceType === "library-object" && + isTargetLightClassification(instance.classification) && + Number.isFinite(instance.lat) && + Number.isFinite(instance.lon) && + Number.isFinite(instance.heading) && + typeof instance.guid === "string"; +} + +function localPoints(instances) { + const latitude = instances.reduce((sum, instance) => sum + instance.lat, 0) / + Math.max(instances.length, 1); + const longitude = instances.reduce((sum, instance) => sum + instance.lon, 0) / + Math.max(instances.length, 1); + const radians = Math.PI / 180; + const metersPerDegreeLatitude = EARTH_RADIUS_METERS * radians; + const metersPerDegreeLongitude = metersPerDegreeLatitude * Math.cos(latitude * radians); + return instances.map((instance, index) => ({ + index, + instance, + x: (instance.lon - longitude) * metersPerDegreeLongitude, + y: (instance.lat - latitude) * metersPerDegreeLatitude + })); +} + +function buildNearbyLists(points) { + const grid = new Map(); + for (const point of points) { + const key = gridKey(point.x, point.y, GRID_CELL_METERS); + if (!grid.has(key)) { + grid.set(key, []); + } + grid.get(key).push(point); + } + + const cellRadius = Math.ceil(SEARCH_RADIUS_METERS / GRID_CELL_METERS); + return points.map((point) => { + const cellX = Math.floor(point.x / GRID_CELL_METERS); + const cellY = Math.floor(point.y / GRID_CELL_METERS); + const neighbors = []; + for (let x = cellX - cellRadius; x <= cellX + cellRadius; x += 1) { + for (let y = cellY - cellRadius; y <= cellY + cellRadius; y += 1) { + for (const candidate of grid.get(`${x},${y}`) ?? []) { + if (candidate.index === point.index) { + continue; + } + const dx = candidate.x - point.x; + const dy = candidate.y - point.y; + const distanceMeters = Math.hypot(dx, dy); + if ( + distanceMeters < MIN_DISTINCT_DISTANCE_METERS || + distanceMeters > SEARCH_RADIUS_METERS + ) { + continue; + } + neighbors.push({ + index: candidate.index, + dx, + dy, + distanceMeters, + bearingDegrees: modulo180((Math.atan2(dx, dy) * 180) / Math.PI) + }); + } + } + } + neighbors.sort((left, right) => left.distanceMeters - right.distanceMeters); + return neighbors.slice(0, MAX_NEIGHBORS_PER_POINT); + }); +} + +function buildCandidateEdges(points, nearby) { + const slotKeys = ["0:negative", "0:positive", "90:negative", "90:positive"]; + const byPointAndSide = points.map(() => Object.fromEntries(slotKeys.map((key) => [key, []]))); + const nearestCompatibleDistance = points.map(() => Number.POSITIVE_INFINITY); + const candidateEdges = []; + let pairsChecked = 0; + let headingCompatible = 0; + for (const point of points) { + for (const neighbor of nearby[point.index]) { + if (neighbor.index <= point.index) { + continue; + } + pairsChecked += 1; + const other = points[neighbor.index]; + if (!classificationsCanShareRow(point.instance.classification, other.instance.classification)) { + continue; + } + const leftAlignment = bestHeadingAlignment(point, neighbor.bearingDegrees, neighbor.dx, neighbor.dy); + const rightAlignment = bestHeadingAlignment(other, neighbor.bearingDegrees, -neighbor.dx, -neighbor.dy); + const leftError = leftAlignment.errorDegrees; + const rightError = rightAlignment.errorDegrees; + const maximumHeadingErrorDegrees = Math.max(leftError, rightError); + if (maximumHeadingErrorDegrees > HEADING_ALIGNMENT_TOLERANCE_DEGREES) { + continue; + } + headingCompatible += 1; + const leftSide = leftAlignment.side; + const rightSide = rightAlignment.side; + const crossModelPenalty = point.instance.guid === other.instance.guid ? 1 : 1.08; + // Distance alone incorrectly stitches adjacent parallel rows through their + // closest cross-row pair. Treat the decoded placement headings as a real + // geometric signal: a slightly longer continuation with a near-zero + // heading error must beat a short, barely-tolerated perpendicular link. + const score = neighbor.distanceMeters * crossModelPenalty + + (leftError + rightError) * HEADING_ERROR_WEIGHT_METERS; + const edge = { + key: edgeKey(point.index, other.index), + left: point.index, + right: other.index, + leftSide, + rightSide, + leftOffsetDegrees: leftAlignment.offsetDegrees, + rightOffsetDegrees: rightAlignment.offsetDegrees, + distanceMeters: neighbor.distanceMeters, + maximumHeadingErrorDegrees, + score + }; + candidateEdges.push(edge); + nearestCompatibleDistance[point.index] = Math.min( + nearestCompatibleDistance[point.index], + neighbor.distanceMeters + ); + nearestCompatibleDistance[other.index] = Math.min( + nearestCompatibleDistance[other.index], + neighbor.distanceMeters + ); + byPointAndSide[point.index][`${leftAlignment.offsetDegrees}:${leftSide}`].push(edge); + byPointAndSide[other.index][`${rightAlignment.offsetDegrees}:${rightSide}`].push(edge); + } + } + + for (const edge of candidateEdges) { + edge.continuationSupport = Number(hasContinuationCandidate( + byPointAndSide[edge.left], + edge.leftOffsetDegrees, + oppositeSide(edge.leftSide), + edge + )) + Number(hasContinuationCandidate( + byPointAndSide[edge.right], + edge.rightOffsetDegrees, + oppositeSide(edge.rightSide), + edge + )); + edge.score -= edge.continuationSupport * CONTINUATION_SUPPORT_BONUS_METERS; + } + + const retained = new Map(); + for (const sides of byPointAndSide) { + for (const slotKey of slotKeys) { + sides[slotKey].sort(compareCandidateEdges); + for (const edge of sides[slotKey].slice(0, MAX_CANDIDATES_PER_SIDE)) { + retained.set(edge.key, edge); + } + } + } + const edges = [...retained.values()].sort(compareCandidateEdges); + const initialEdges = edges.filter((edge) => edge.distanceMeters <= Math.max( + MIN_ADAPTIVE_LINK_DISTANCE_METERS, + Math.min( + nearestCompatibleDistance[edge.left], + nearestCompatibleDistance[edge.right] + ) * ADAPTIVE_LINK_DISTANCE_MULTIPLIER + )); + const byPoint = points.map(() => []); + for (const edge of edges) { + byPoint[edge.left].push(edge); + byPoint[edge.right].push(edge); + } + for (const list of byPoint) { + list.sort(compareCandidateEdges); + } + return { edges, initialEdges, byPoint, pairsChecked, headingCompatible }; +} + +function hasContinuationCandidate(sides, offsetDegrees, side, currentEdge) { + return sides[`${offsetDegrees}:${side}`].some((edge) => + edge !== currentEdge && edge.distanceMeters <= MAX_FRAGMENT_JOIN_DISTANCE_METERS + ); +} + +function oppositeSide(side) { + return side === "positive" ? "negative" : "positive"; +} + +function classificationsCanShareRow(left, right) { + return (left === "stopbar") === (right === "stopbar"); +} + +function bestHeadingAlignment(point, bearingDegrees, dx, dy) { + const parallelError = undirectedAngleDifferenceDegrees(bearingDegrees, point.instance.heading); + const perpendicularError = undirectedAngleDifferenceDegrees(bearingDegrees, point.instance.heading + 90); + const offsetDegrees = perpendicularError < parallelError ? 90 : 0; + const tangentDegrees = modulo180(point.instance.heading + offsetDegrees); + return { + offsetDegrees, + errorDegrees: Math.min(parallelError, perpendicularError), + side: sideForVector(tangentDegrees, dx, dy) + }; +} + +function compareCandidateEdges(left, right) { + return left.score - right.score || + left.distanceMeters - right.distanceMeters || + left.left - right.left || + left.right - right.right; +} + +function sideForVector(tangentDegrees, dx, dy) { + const tangentRadians = (tangentDegrees * Math.PI) / 180; + const projection = dx * Math.sin(tangentRadians) + dy * Math.cos(tangentRadians); + return projection >= 0 ? "positive" : "negative"; +} + +function buildPathGraph(points, candidates) { + const graph = { + adjacency: points.map(() => new Set()), + sideEdges: points.map(() => ({ negative: undefined, positive: undefined })), + tangentOffsets: points.map(() => undefined), + edges: new Map(), + edgeGrid: new Map(), + parents: points.map((_, index) => index), + crossingRejections: 0, + turnRejections: 0, + insertedPlacements: 0, + joinedPathFragments: 0 + }; + for (const candidate of candidates) { + tryAddEdge(points, graph, candidate, true); + } + return graph; +} + +function tryAddEdge(points, graph, edge, preventCycles) { + if ( + graph.tangentOffsets[edge.left] !== undefined && + graph.tangentOffsets[edge.left] !== edge.leftOffsetDegrees || + graph.tangentOffsets[edge.right] !== undefined && + graph.tangentOffsets[edge.right] !== edge.rightOffsetDegrees || + graph.sideEdges[edge.left][edge.leftSide] || + graph.sideEdges[edge.right][edge.rightSide] + ) { + return false; + } + if (preventCycles && findRoot(graph.parents, edge.left) === findRoot(graph.parents, edge.right)) { + return false; + } + if (!turnIsValid(points, graph, edge.left, edge.right) || + !turnIsValid(points, graph, edge.right, edge.left)) { + graph.turnRejections += 1; + return false; + } + if (edgeCrossesGraph(points, graph, edge)) { + graph.crossingRejections += 1; + return false; + } + addEdge(points, graph, edge); + unionRoots(graph.parents, edge.left, edge.right); + return true; +} + +function addEdge(points, graph, edge) { + graph.edges.set(edge.key, edge); + graph.adjacency[edge.left].add(edge.right); + graph.adjacency[edge.right].add(edge.left); + graph.sideEdges[edge.left][edge.leftSide] = edge.key; + graph.sideEdges[edge.right][edge.rightSide] = edge.key; + graph.tangentOffsets[edge.left] ??= edge.leftOffsetDegrees; + graph.tangentOffsets[edge.right] ??= edge.rightOffsetDegrees; + indexEdge(points, graph, edge); +} + +function removeEdge(graph, edge) { + graph.edges.delete(edge.key); + graph.adjacency[edge.left].delete(edge.right); + graph.adjacency[edge.right].delete(edge.left); + if (graph.sideEdges[edge.left][edge.leftSide] === edge.key) { + graph.sideEdges[edge.left][edge.leftSide] = undefined; + } + if (graph.sideEdges[edge.right][edge.rightSide] === edge.key) { + graph.sideEdges[edge.right][edge.rightSide] = undefined; + } +} + +function turnIsValid(points, graph, pointIndex, candidateIndex) { + const neighbors = [...graph.adjacency[pointIndex]]; + if (neighbors.length === 0) { + return true; + } + const point = points[pointIndex]; + const existing = points[neighbors[0]]; + const candidate = points[candidateIndex]; + const angle = directedAngleDegrees( + existing.x - point.x, + existing.y - point.y, + candidate.x - point.x, + candidate.y - point.y + ); + return 180 - angle <= MAX_TURN_DEFLECTION_DEGREES; +} + +function edgeCrossesGraph(points, graph, edge) { + const cells = edgeGridCells(points[edge.left], points[edge.right], CROSSING_GRID_CELL_METERS); + const possible = new Set(cells.flatMap((cell) => graph.edgeGrid.get(cell) ?? [])); + for (const key of possible) { + const existing = graph.edges.get(key); + if (!existing || sharesEndpoint(edge, existing)) { + continue; + } + if (segmentsProperlyIntersect( + points[edge.left], + points[edge.right], + points[existing.left], + points[existing.right] + )) { + const crossingAngle = undirectedAngleDifferenceDegrees( + segmentBearingDegrees(points[edge.left], points[edge.right]), + segmentBearingDegrees(points[existing.left], points[existing.right]) + ); + if (crossingAngle < 35) { + return true; + } + } + } + return false; +} + +function segmentBearingDegrees(left, right) { + return modulo180((Math.atan2(right.x - left.x, right.y - left.y) * 180) / Math.PI); +} + +function indexEdge(points, graph, edge) { + for (const cell of edgeGridCells(points[edge.left], points[edge.right], CROSSING_GRID_CELL_METERS)) { + if (!graph.edgeGrid.has(cell)) { + graph.edgeGrid.set(cell, []); + } + graph.edgeGrid.get(cell).push(edge.key); + } +} + +function edgeGridCells(left, right, cellSize) { + const minX = Math.floor(Math.min(left.x, right.x) / cellSize); + const maxX = Math.floor(Math.max(left.x, right.x) / cellSize); + const minY = Math.floor(Math.min(left.y, right.y) / cellSize); + const maxY = Math.floor(Math.max(left.y, right.y) / cellSize); + const cells = []; + for (let x = minX; x <= maxX; x += 1) { + for (let y = minY; y <= maxY; y += 1) { + cells.push(`${x},${y}`); + } + } + return cells; +} + +function insertUnassignedPoints(points, graph, candidates) { + const candidatesByPoint = points.map(() => []); + for (const edge of candidates) { + candidatesByPoint[edge.left].push(edge); + candidatesByPoint[edge.right].push(edge); + } + for (const point of points) { + if (graph.adjacency[point.index].size > 0) { + continue; + } + let best; + for (const existing of graph.edges.values()) { + const projection = pointToSegment(point, points[existing.left], points[existing.right]); + if ( + projection.distanceMeters > INSERTION_DISTANCE_METERS || + projection.ratio <= 0.02 || + projection.ratio >= 0.98 + ) { + continue; + } + const leftDistance = Math.hypot(point.x - points[existing.left].x, point.y - points[existing.left].y); + const rightDistance = Math.hypot(point.x - points[existing.right].x, point.y - points[existing.right].y); + if ((leftDistance + rightDistance) / existing.distanceMeters > INSERTION_LENGTH_RATIO) { + continue; + } + const leftCandidate = candidatesByPoint[point.index].find((edge) => + otherIndex(edge, point.index) === existing.left + ); + const rightCandidate = candidatesByPoint[point.index].find((edge) => + otherIndex(edge, point.index) === existing.right + ); + if (!leftCandidate || !rightCandidate) { + continue; + } + const score = projection.distanceMeters + leftCandidate.score + rightCandidate.score; + if (!best || score < best.score) { + best = { existing, leftCandidate, rightCandidate, score }; + } + } + if (!best) { + continue; + } + removeEdge(graph, best.existing); + addEdge(points, graph, best.leftCandidate); + addEdge(points, graph, best.rightCandidate); + graph.insertedPlacements += 1; + } +} + +function attachUnassignedEndpoints(points, graph, candidates) { + for (const edge of candidates) { + const leftAssigned = graph.adjacency[edge.left].size > 0; + const rightAssigned = graph.adjacency[edge.right].size > 0; + if (leftAssigned && rightAssigned) { + continue; + } + tryAddEdge(points, graph, edge, true); + } +} + +function attachBySplittingInternalEdges(points, graph, candidatesByPoint) { + for (const point of points) { + if (graph.adjacency[point.index].size > 0) { + continue; + } + for (const candidate of candidatesByPoint[point.index]) { + const neighborIndex = otherIndex(candidate, point.index); + const neighborSide = candidate.left === neighborIndex + ? candidate.leftSide + : candidate.rightSide; + const occupiedKey = graph.sideEdges[neighborIndex][neighborSide]; + const occupied = graph.edges.get(occupiedKey); + if (!occupied) { + continue; + } + const displacedIndex = otherIndex(occupied, neighborIndex); + if (graph.adjacency[displacedIndex].size !== 2) { + continue; + } + removeEdge(graph, occupied); + if (tryAddEdge(points, graph, candidate, false)) { + break; + } + addEdge(points, graph, occupied); + } + } +} + +function joinCompatiblePathEndpoints(points, graph, candidates) { + rebuildGraphParents(graph); + for (const candidate of candidates) { + if (candidate.distanceMeters > MAX_FRAGMENT_JOIN_DISTANCE_METERS) { + continue; + } + if ( + graph.adjacency[candidate.left].size !== 1 || + graph.adjacency[candidate.right].size !== 1 || + findRoot(graph.parents, candidate.left) === findRoot(graph.parents, candidate.right) + ) { + continue; + } + if (tryAddEdge(points, graph, candidate, true)) { + graph.joinedPathFragments += 1; + } + } +} + +function rebuildGraphParents(graph) { + graph.parents = graph.adjacency.map((_, index) => index); + for (const edge of graph.edges.values()) { + unionRoots(graph.parents, edge.left, edge.right); + } +} + +function orderedGraphPaths(points, graph) { + const visited = new Set(); + const paths = []; + const starts = points.filter((point) => graph.adjacency[point.index].size === 1); + for (const start of starts) { + if (visited.has(start.index)) { + continue; + } + paths.push(walkComponent(start.index, graph.adjacency, visited)); + } + for (const point of points) { + if (graph.adjacency[point.index].size > 0 && !visited.has(point.index)) { + paths.push(walkComponent(point.index, graph.adjacency, visited)); + } + } + return paths.filter((path) => path.length >= 2); +} + +function walkComponent(start, adjacency, visited) { + const path = []; + let previous = -1; + let current = start; + while (current !== undefined && !visited.has(current)) { + visited.add(current); + path.push(current); + const next = [...adjacency[current]].find((candidate) => candidate !== previous && !visited.has(candidate)); + previous = current; + current = next; + } + return path; +} + +function buildRow(points, graph, path, pathIndex) { + const pathPoints = path.map((index) => points[index]); + const instances = pathPoints.map((point) => point.instance); + const sourceInstanceIds = instances.map((instance) => instance.id); + const sourceOffsets = instances + .map((instance) => instance.sourceRecordOffset) + .filter(Number.isInteger) + .sort((left, right) => left - right); + const classifications = [...new Set(instances.map((instance) => instance.classification))]; + const presets = [...new Set(instances.map((instance) => instance.name).filter(Boolean))]; + const modelGuids = [...new Set(instances.map((instance) => instance.guid))]; + const pathEdges = []; + for (let index = 1; index < path.length; index += 1) { + const edge = graph.edges.get(edgeKey(path[index - 1], path[index])); + if (edge) { + pathEdges.push(edge); + } + } + const classification = rowClassification(classifications); + const id = stableId( + INFERRED_PLACEMENT_ROW_SOURCE_TYPE, + instances[0].sourceFile, + sourceInstanceIds.join(","), + pathIndex + ); + for (const instance of instances) { + instance.inferredPlacementRowId = id; + } + return { + id, + sourceFile: instances[0].sourceFile, + sourceType: INFERRED_PLACEMENT_ROW_SOURCE_TYPE, + sourceRecordOffset: sourceOffsets[0], + sourceRecordEndOffset: sourceOffsets.at(-1), + rawTag: "Inferred BGL placement light row", + ...(presets.length === 1 ? { preset: presets[0] } : {}), + sourcePresets: presets, + sourceModelGuids: modelGuids, + sourceClassifications: classifications, + classification, + confidence: 0.78, + vertices: instances.map((instance) => ({ lat: instance.lat, lon: instance.lon })), + snapToVertices: true, + inferred: true, + inference: "grid-indexed exact-placement heading graph", + reconstructMode: "exact-bgl-placement-control-points", + sourceInstanceIds, + sourcePlacementCount: instances.length, + tangentOffsetUsageByModelGuid: Object.fromEntries(modelGuids.map((guid) => { + const matching = pathPoints.filter((point) => point.instance.guid === guid); + return [guid, { + parallelPlacements: matching.filter((point) => graph.tangentOffsets[point.index] === 0).length, + perpendicularPlacements: matching.filter((point) => graph.tangentOffsets[point.index] === 90).length + }]; + })), + headingAlignmentToleranceDegrees: HEADING_ALIGNMENT_TOLERANCE_DEGREES, + maximumLinkDistanceMeters: SEARCH_RADIUS_METERS, + maximumObservedLinkDistanceMeters: roundNumber( + Math.max(0, ...pathEdges.map((edge) => edge.distanceMeters)) + ), + maximumObservedHeadingErrorDegrees: roundNumber( + Math.max(0, ...pathEdges.map((edge) => edge.maximumHeadingErrorDegrees)) + ), + maximumObservedTurnDeflectionDegrees: roundNumber(pathTurnDeflection(points, path)), + sourceBasis: "exact decoded BGL 0x0B placement coordinates and headings", + relationshipBasis: "grid-indexed degree-limited non-crossing heading-compatible graph" + }; +} + +function rowClassification(classifications) { + if (classifications.length === 1) { + return classifications[0]; + } + if (classifications.includes("taxi-centerline")) { + return "taxi-centerline"; + } + if (classifications.includes("lead-on")) { + return "lead-on"; + } + return "stopbar"; +} + +function maximumPathTurnDeflection(points, paths) { + return Math.max(0, ...paths.map((path) => pathTurnDeflection(points, path))); +} + +function pathTurnDeflection(points, path) { + let maximum = 0; + for (let index = 1; index < path.length - 1; index += 1) { + const previous = points[path[index - 1]]; + const current = points[path[index]]; + const next = points[path[index + 1]]; + const angle = directedAngleDegrees( + previous.x - current.x, + previous.y - current.y, + next.x - current.x, + next.y - current.y + ); + maximum = Math.max(maximum, 180 - angle); + } + return maximum; +} + +function maximumSelectedEdgeValue(graph, property) { + return Math.max(0, ...[...graph.edges.values()].map((edge) => edge[property] ?? 0)); +} + +function pointToSegment(point, start, end) { + const dx = end.x - start.x; + const dy = end.y - start.y; + const lengthSquared = dx * dx + dy * dy; + if (lengthSquared === 0) { + return { ratio: 0, distanceMeters: Math.hypot(point.x - start.x, point.y - start.y) }; + } + const ratio = Math.max(0, Math.min(1, + ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared + )); + return { + ratio, + distanceMeters: Math.hypot( + point.x - (start.x + dx * ratio), + point.y - (start.y + dy * ratio) + ) + }; +} + +function segmentsProperlyIntersect(a, b, c, d) { + const abC = orientation(a, b, c); + const abD = orientation(a, b, d); + const cdA = orientation(c, d, a); + const cdB = orientation(c, d, b); + return abC * abD < 0 && cdA * cdB < 0; +} + +function orientation(a, b, c) { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +function sharesEndpoint(left, right) { + return left.left === right.left || left.left === right.right || + left.right === right.left || left.right === right.right; +} + +function directedAngleDegrees(ax, ay, bx, by) { + const leftLength = Math.hypot(ax, ay); + const rightLength = Math.hypot(bx, by); + if (leftLength === 0 || rightLength === 0) { + return 0; + } + const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (leftLength * rightLength))); + return (Math.acos(cosine) * 180) / Math.PI; +} + +function undirectedAngleDifferenceDegrees(left, right) { + const difference = Math.abs(modulo180(left) - modulo180(right)); + return Math.min(difference, 180 - difference); +} + +function otherIndex(edge, index) { + return edge.left === index ? edge.right : edge.left; +} + +function edgeKey(left, right) { + return `${Math.min(left, right)}:${Math.max(left, right)}`; +} + +function gridKey(x, y, cellSize) { + return `${Math.floor(x / cellSize)},${Math.floor(y / cellSize)}`; +} + +function findRoot(parents, index) { + let root = index; + while (parents[root] !== root) { + root = parents[root]; + } + while (parents[index] !== index) { + const next = parents[index]; + parents[index] = root; + index = next; + } + return root; +} + +function unionRoots(parents, left, right) { + const leftRoot = findRoot(parents, left); + const rightRoot = findRoot(parents, right); + if (leftRoot !== rightRoot) { + parents[rightRoot] = leftRoot; + } +} + +function modulo180(value) { + return ((value % 180) + 180) % 180; +} + +function roundNumber(value) { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function emptyStats() { + return { + inputPlacements: 0, + assignedPlacements: 0, + excludedOutliers: 0, + candidatePairsChecked: 0, + headingCompatibleCandidates: 0, + retainedCandidates: 0, + adaptiveInitialCandidates: 0, + selectedEdges: 0, + crossingCandidatesRejected: 0, + turnCandidatesRejected: 0, + insertedPlacements: 0, + joinedPathFragments: 0, + rows: 0, + maximumObservedLinkDistanceMeters: 0, + maximumObservedHeadingErrorDegrees: 0, + maximumObservedTurnDeflectionDegrees: 0, + elapsedMilliseconds: 0 + }; +} + +function addStats(target, source) { + for (const key of [ + "inputPlacements", + "assignedPlacements", + "excludedOutliers", + "candidatePairsChecked", + "headingCompatibleCandidates", + "retainedCandidates", + "adaptiveInitialCandidates", + "selectedEdges", + "crossingCandidatesRejected", + "turnCandidatesRejected", + "insertedPlacements", + "joinedPathFragments" + ]) { + target[key] += source[key] ?? 0; + } + for (const key of [ + "maximumObservedLinkDistanceMeters", + "maximumObservedHeadingErrorDegrees", + "maximumObservedTurnDeflectionDegrees" + ]) { + target[key] = Math.max(target[key], source[key] ?? 0); + } +} diff --git a/src/features/draft-generator/extractor/scanner.js b/src/features/draft-generator/extractor/scanner.js new file mode 100644 index 0000000..d4aa71c --- /dev/null +++ b/src/features/draft-generator/extractor/scanner.js @@ -0,0 +1,52 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const UNSUPPORTED_EXTENSIONS = new Set([".cgl", ".spb", ".wasm"]); + +export async function scanInput(inputPath) { + const resolvedInput = path.resolve(inputPath); + const stats = await fs.stat(resolvedInput); + const allFiles = []; + const unsupportedFiles = []; + + if (stats.isFile()) { + allFiles.push(resolvedInput); + } else if (stats.isDirectory()) { + await collectFiles(resolvedInput, allFiles); + } else { + throw new Error(`Input path is neither a file nor a folder: ${inputPath}`); + } + + const xmlFiles = []; + const bglFiles = []; + for (const file of allFiles) { + const extension = path.extname(file).toLowerCase(); + if (extension === ".xml") { + xmlFiles.push(file); + } else if (extension === ".bgl") { + bglFiles.push(file); + } else if (UNSUPPORTED_EXTENSIONS.has(extension)) { + unsupportedFiles.push(file); + } + } + + return { + input: resolvedInput, + filesScanned: allFiles.length, + xmlFiles, + bglFiles, + unsupportedFiles + }; +} + +async function collectFiles(folder, files) { + const entries = await fs.readdir(folder, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(folder, entry.name); + if (entry.isDirectory()) { + await collectFiles(fullPath, files); + } else if (entry.isFile()) { + files.push(fullPath); + } + } +} diff --git a/src/features/draft-generator/extractor/xml.js b/src/features/draft-generator/extractor/xml.js new file mode 100644 index 0000000..b7c8242 --- /dev/null +++ b/src/features/draft-generator/extractor/xml.js @@ -0,0 +1,155 @@ +export function parseXml(xmlText, sourceFile = "unknown") { + const warnings = []; + const root = { + name: "#document", + attributes: {}, + children: [], + text: "", + path: "#document" + }; + const stack = [root]; + const tagCounts = new Map(); + const tokenPattern = /||<\?[\s\S]*?\?>|]*>|<\/?[^>]+>|[^<]+/g; + + let match; + while ((match = tokenPattern.exec(xmlText)) !== null) { + const token = match[0]; + + if (token.startsWith("