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 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {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 (
+
+
+ {label}
+
+ );
+}
+
+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("