/** * 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱 (v2.0 데이터 형상 전용) * * `` 로 선택된 File[] 에서 v2.0 폴더 구조를 * 식별해 영상 / 드론 CSV / 측점·POI·구조물 CSV 를 파싱한다. * * v2.0 폴더 구조 (영상 base = 영상파일명에서 확장자 제거): * .MP4 영상 * .csv 드론 프레임 (frame_cnt,latitude,...) * _POI.csv POI (source,query_label,title,category_clean,...,lat,lon,...) * building/01)측점.csv 측점 (측점,X좌표,...,비고,lat,lon,...) * building/02)지장물.csv POI (명칭,...,lat,lon) * building/03)교량.csv 구조물 bridge (구분,...,연장(m),...,lat,lon,...) * building/04)터널.csv 구조물 tunnel (구분,...,연장(m),...,lat,lon,...) * building/05)출입문번호.csv 출입문 (기본 표시 제외 — 아래 참고) * building/06)구교.csv 구조물 bridge (역구간,시설물명,...,연장(m),lat,lon,...) * * v2.0엔 center.csv 가 없다 → 측점(01)측점)을 측점값 순으로 이어 중심선을 생성한다. * * 인코딩(혼재 — 자동 감지): ArrayBuffer 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8, * 아니면 EUC-KR. 디코딩 후 잔여 BOM(U+FEFF)을 제거한다. 파일별 하드코딩 없음. */ import type { DroneFrame, GeoPoint, CenterlinePoint, GeoOrigin, FolderGeoData, RouteMeta, RouteStructure, DirectionChange, PoiOverrideMap, } from '../types/geo'; import { stationOrder, getWorldOrigin } from './geoSearch'; import { detectCameraInfo } from './cameraMeta'; import { unzipSync, strFromU8 } from 'fflate'; // ── CSV 파싱 헬퍼 ───────────────────────────────────────────────────── export function parseCsvLine(line: string): string[] { const result: string[] = []; let current = ''; let inQuotes = false; for (const ch of line) { if (ch === '"') { inQuotes = !inQuotes; } else if (ch === ',' && !inQuotes) { result.push(current.trim()); current = ''; } else { current += ch; } } result.push(current.trim()); return result; } /** * ArrayBuffer → 문자열. 인코딩 자동 감지: * 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8, 아니면 EUC-KR. * 디코딩 후 남을 수 있는 BOM(U+FEFF)을 제거한다. */ export function decodeBytes(buf: ArrayBuffer): string { const head = new Uint8Array(buf, 0, Math.min(3, buf.byteLength)); const isUtf8Bom = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf; const encoding = isUtf8Bom ? 'utf-8' : 'euc-kr'; const text = new TextDecoder(encoding).decode(buf); // 디코딩 후 잔여 BOM(U+FEFF) 제거 return text.replace(/^/, ''); } /** File → 파싱된 행 배열. 인코딩은 BOM 기반 자동 감지. */ export async function readCsv(file: File): Promise { const buf = await file.arrayBuffer(); const text = decodeBytes(buf); return text .split(/\r?\n/) .filter(Boolean) .map(parseCsvLine); } /** 헤더 행 → (헤더명|위치인덱스) → 값 추출기. 헤더가 깨지면 위치 인덱스로 폴백. */ function makeFieldIndexer(header: string[]): (name: string, fallback?: number) => number { const cleaned = header.map((h) => h.trim().replace(/^/, '')); return (name: string, fallback?: number): number => { const i = cleaned.indexOf(name); if (i >= 0) return i; return fallback ?? -1; }; } /** 안전한 셀 접근 (인덱스 음수/범위 밖이면 ''). */ function cell(row: string[], i: number): string { return i >= 0 && i < row.length ? row[i] : ''; } // ── 파일 식별 헬퍼 ──────────────────────────────────────────────────── /** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */ function relPath(f: File): string { return f.webkitRelativePath || f.name; } /** 경로의 마지막 세그먼트(파일명). */ function baseNameOf(p: string): string { const parts = p.split('/'); return parts[parts.length - 1]; } /** building/ 하위 파일 여부 (마지막 디렉토리 세그먼트가 building). */ function isInBuilding(f: File): boolean { const parts = relPath(f).split('/'); return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building'; } /** building/ 하위에서 파일명이 키워드를 포함하는 첫 파일. (예 '01)측점', '03)교량') */ function findBuildingFile(files: File[], keyword: string): File | null { return files.find((f) => isInBuilding(f) && baseNameOf(relPath(f)).includes(keyword)) ?? null; } const VIDEO_EXT = /\.(mp4|webm)$/i; /** 폴더 내 모든 영상 파일 (이름순 정렬 — DJI 분할본 _0002, _0003… 순서 보장). */ export function findVideoFiles(files: File[]): File[] { return files .filter((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) .sort((a, b) => baseNameOf(relPath(a)).localeCompare(baseNameOf(relPath(b)))); } /** 영상 파일 찾기 (mp4/webm, building 제외). 여러 개면 이름순 첫 번째. */ export function findVideoFile(files: File[]): File | null { return findVideoFiles(files)[0] ?? null; } /** 영상 File 의 재생 길이(초) — 메타데이터만 읽는다(전체 로드 없음). 실패 시 null. */ export function probeVideoDuration(file: File): Promise { return new Promise((resolve) => { const url = URL.createObjectURL(file); const v = document.createElement('video'); v.preload = 'metadata'; const done = (d: number | null): void => { URL.revokeObjectURL(url); v.removeAttribute('src'); resolve(d); }; v.onloadedmetadata = () => done(isFinite(v.duration) ? v.duration : null); v.onerror = () => done(null); v.src = url; }); } /** 영상 파일명에서 base(확장자 제거) 추출. */ export function deriveBaseName(videoFile: File | null): string | null { if (!videoFile) return null; return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, ''); } // ── 파서: 드론 프레임 ───────────────────────────────────────────────── /** * DJI 비행로그(시간 기반) → 프레임 환산용 가상 fps. * frame = 영상 상대시간(초) × 이 값. 소비측(effectiveFps = maxFrame ÷ 영상길이)이 * 같은 값을 역산해 시간으로 되돌리므로 상수 자체는 임의여도 정합이 유지된다. * 59.94 는 이 데이터셋(DJI 4K60) 실제 fps 와 일치시켜 HUD 표기도 자연스럽게 한 것. */ const DJI_VIRTUAL_FPS = 59.94; /** 영상 세그먼트 창 — DJI 시간 기반 로그를 특정 영상 구간에 정렬할 때 사용. */ export interface SegmentWindow { /** 전체 녹화 기준 이 영상의 시작 오프셋(초). 첫 세그먼트는 0. */ offsetSec: number; /** 이 영상의 길이(초). 미상(null)이면 클리핑 없이 전체 로그를 쓴다. */ durationSec: number | null; } /** * 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스). 두 형식 지원: * * ① 프레임 기반(회덕 v2.0): frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len * ② 시간 기반(DJI 비행로그, 제주): time(millisecond),latitude,longitude,…, * altitude_above_seaLevel/compass_heading/gimbal_heading(degrees) 등. * - 첫 행 time = 녹화 시작으로 보고(로그 폭 ≈ 분할영상 총길이 검증됨), * frame = ((time−t0)/1000 − offsetSec) × DJI_VIRTUAL_FPS 로 환산. * - 자세는 카메라 방향인 gimbal_* 우선, 없으면 기체 compass/pitch/roll 폴백. * - focal_len 이 없어 35mm 환산 24mm(DJI 광각 기본)로 둔다 → 카메라 파라미터 UI로 보정. * - segmentWindow.durationSec 가 있으면 해당 영상 구간 밖 행을 버린다 * (분할 영상에서 effectiveFps 자기보정이 성립하려면 필수). * * 루트(building 제외)의 .csv 를 식별한다. base 가 없으면 루트 .csv 중 * POI 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백). */ export async function parseDroneFrames( files: File[], baseName: string | null, segmentWindow: SegmentWindow | null = null, ): Promise { const rootCsv = files.filter((f) => { if (isInBuilding(f)) return false; const name = baseNameOf(relPath(f)); if (!/\.csv$/i.test(name)) return false; if (/POI/i.test(name)) return false; return true; }); if (!rootCsv.length) return []; // base 일치 우선, 없으면 첫 루트 csv const droneFile = (baseName && rootCsv.find((f) => baseNameOf(relPath(f)) === `${baseName}.csv`)) || rootCsv[0]; const rows = await readCsv(droneFile); if (rows.length < 2) return []; const fi = makeFieldIndexer(rows[0]); // ② 시간 기반(DJI 비행로그) — time(millisecond) 헤더로 감지. const iTime = fi('time(millisecond)'); if (iTime >= 0) { const iLat = fi('latitude'); const iLon = fi('longitude'); const iAlt = fi('altitude_above_seaLevel(meter)'); const iYaw = fi('gimbal_heading(degrees)') >= 0 ? fi('gimbal_heading(degrees)') : fi('compass_heading(degrees)'); const iPitch = fi('gimbal_pitch(degrees)') >= 0 ? fi('gimbal_pitch(degrees)') : fi('pitch(degrees)'); const iRoll = fi('gimbal_roll(degrees)') >= 0 ? fi('gimbal_roll(degrees)') : fi('roll(degrees)'); const raw = rows .slice(1) .map((r) => ({ timeMs: parseFloat(cell(r, iTime)), lat: parseFloat(cell(r, iLat)), lon: parseFloat(cell(r, iLon)), altitude: parseFloat(cell(r, iAlt)), yaw: parseFloat(cell(r, iYaw)), pitch: parseFloat(cell(r, iPitch)), roll: parseFloat(cell(r, iRoll)) || 0, focalLen: 24, })) .filter((f) => !isNaN(f.lat) && !isNaN(f.timeMs)); if (!raw.length) return []; const t0 = raw[0].timeMs; const offsetSec = segmentWindow?.offsetSec ?? 0; const durationSec = segmentWindow?.durationSec ?? null; const maxFrame = durationSec != null ? durationSec * DJI_VIRTUAL_FPS : Infinity; const frames = raw .map(({ timeMs, ...rest }) => ({ ...rest, frame: Math.round(((timeMs - t0) / 1000 - offsetSec) * DJI_VIRTUAL_FPS), })) .filter((f) => f.frame >= 0 && f.frame <= maxFrame); console.log( `[geo] DJI 시간기반 로그: ${raw.length}행 중 ${frames.length}행 사용 ` + `(구간 ${offsetSec.toFixed(1)}s~${durationSec != null ? (offsetSec + durationSec).toFixed(1) : '끝'}s, 가상fps ${DJI_VIRTUAL_FPS})`, ); return frames; } // ① 프레임 기반(회덕 v2.0) return rows .slice(1) .map((r) => ({ frame: parseInt(cell(r, fi('frame_cnt', 0)), 10), lat: parseFloat(cell(r, fi('latitude', 1))), lon: parseFloat(cell(r, fi('longitude', 2))), altitude: parseFloat(cell(r, fi('altitude', 3))), yaw: parseFloat(cell(r, fi('yaw', 4))), pitch: parseFloat(cell(r, fi('pitch', 5))), roll: parseFloat(cell(r, fi('roll', 6))), focalLen: parseFloat(cell(r, fi('focal_len', 7))), })) .filter((f) => !isNaN(f.lat)); } // ── 파서: 측점 + 방향전환점 ─────────────────────────────────────────── /** "02:05" → 125 (초). 파싱 실패 시 NaN. */ function mmssToSeconds(s: string): number { const m = s.match(/(\d+):(\d+)/); if (!m) return NaN; return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); } /** * 측점 CSV 파싱 (building/01)측점.csv, EUC-KR). * 헤더: 측점,X좌표,Y좌표,Z좌표,비고,lat,lon,... * title ← 측점, lat ← lat, lon ← lon, z ← Z좌표, category='측점', type='station' * 비고(방향전환점) 도 함께 추출한다: `방향전환점(상행->하행, 02:05)`. */ export async function parseStations(files: File[]): Promise<{ stations: GeoPoint[]; directionChanges: DirectionChange[]; }> { // 측점 파일 — building/ 또는 root(영상 옆) 어디든 허용(building 폴더 삭제 대비). const file = findBuildingFile(files, '01)측점') ?? files.find((f) => baseNameOf(relPath(f)).includes('01)측점')) ?? files.find((f) => baseNameOf(relPath(f)).includes('측점')); if (!file) return { stations: [], directionChanges: [] }; const rows = await readCsv(file); if (rows.length < 2) return { stations: [], directionChanges: [] }; const fi = makeFieldIndexer(rows[0]); const iTitle = fi('측점', 0); // Z좌표(col 3)는 로컬좌표(≈0). 실제 표고는 Z좌표_한국(EPSG:5186, 정표고)에 있다. // 없으면 Z좌표로 폴백. const iZKorea = fi('Z좌표_한국'); const iZ = iZKorea >= 0 ? iZKorea : fi('Z좌표', 3); const iNote = fi('비고', 4); const iLat = fi('lat', 5); const iLon = fi('lon', 6); const stations: GeoPoint[] = []; const directionChanges: DirectionChange[] = []; for (const r of rows.slice(1)) { const lat = parseFloat(cell(r, iLat)); const lon = parseFloat(cell(r, iLon)); const title = cell(r, iTitle); if (isNaN(lat) || isNaN(lon)) continue; stations.push({ title, category: '측점', lat, lon, z: parseFloat(cell(r, iZ)) || 0, type: 'station', }); // 비고: 방향전환점(상행->하행, 02:05) const note = cell(r, iNote); const m = note.match(/방향전환점\s*\(\s*([^->]+?)\s*->\s*([^,)]+?)\s*,\s*([\d:]+)\s*\)/); if (m) { const atSeconds = mmssToSeconds(m[3]); directionChanges.push({ station: title, from: m[1].trim(), to: m[2].trim(), atSeconds: isNaN(atSeconds) ? -1 : atSeconds, }); } } return { stations, directionChanges }; } // ── 파서: KMZ (원본) ───────────────────────────────────────────────── // // KMZ(=doc.kml zip)는 building CSV의 원본이다. 폴더가 02)지장물·03)교량·04)터널·05)출입문번호· // 06)구교 로 1:1 대응하고, 각 Placemark 가 좌표 + description HTML표(속성)를 담는다. // → KMZ 에서 직접 POI/구조물을 추출하면 CSV 추출 중복이 사라진다. (측점(01)은 KMZ에 없음 → CSV 유지) /** HTML 태그 제거 + 엔티티 디코드 + 트림. */ function stripHtml(s: string): string { return s .replace(/<[^>]*>/g, ' ') .replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&') .replace(/ /g, ' ').replace(/"/g, '"').replace(/'/g, "'") .replace(/\s+/g, ' ').trim(); } /** * description CDATA(HTML표) → 속성 배열. 행/셀 단위로 견고하게 파싱. * 구글어스 재저장본은 ``, `` 뒤 `
`, `>` 엔티티 등 변형이 있어 * `` 안의 두 `` 셀에서 태그를 벗겨 (키, 값)으로 만든다(옛/새 형식 모두 처리). */ function parseKmlDescProps(descHtml: string): { k: string; v: string }[] { const out: { k: string; v: string }[] = []; const trRe = /]*>([\s\S]*?)<\/tr>/gi; let tr: RegExpExecArray | null; while ((tr = trRe.exec(descHtml))) { const tds = [...tr[1].matchAll(/]*>([\s\S]*?)<\/td>/gi)].map((m) => stripHtml(m[1])); if (tds.length >= 2 && tds[0]) out.push({ k: tds[0], v: tds[1] }); } return out; } /** 폴더 내 직계 자식 중 localName 일치 첫 요소. */ function childLocal(el: Element, local: string): Element | null { for (const c of Array.from(el.children)) if (c.localName === local) return c; return null; } /** 한국어 색상명 → CSS 색 (KML 텍스트박스_색상 속성). 어두운 색은 렌더 시 밝은 외곽선 처리. */ const KOR_LABEL_COLORS: Record = { 검정: '#1f2937', 흰색: '#ffffff', 회색: '#9ca3af', 빨강: '#ef4444', 주황: '#fb923c', 노랑: '#facc15', 연두: '#a3e635', 초록: '#22c55e', 하늘: '#38bdf8', 파랑: '#3b82f6', 남색: '#3730a3', 보라: '#a855f7', 분홍: '#f472b6', }; function koreanColorToCss(v?: string): string | undefined { if (!v) return undefined; const key = v.trim(); if (KOR_LABEL_COLORS[key]) return KOR_LABEL_COLORS[key]; for (const k in KOR_LABEL_COLORS) if (key.includes(k)) return KOR_LABEL_COLORS[k]; return undefined; } /** Placemark 인라인 IconStyle 색 — KML aabbggrr → CSS #rrggbb. */ function inlineIconColor(pm: Element): string | undefined { const icon = pm.getElementsByTagName('IconStyle')[0]; const c = icon?.getElementsByTagName('color')[0]?.textContent?.trim(); if (!c || !/^[0-9a-fA-F]{8}$/.test(c)) return undefined; const bb = c.slice(2, 4), gg = c.slice(4, 6), rr = c.slice(6, 8); return `#${rr}${gg}${bb}`.toLowerCase(); } /** KMZ/KML 파싱 — 있으면 { pois, structures, stations }, 없거나 실패 시 null. * bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제. * * 속성 소스 2종을 모두 props 로 수집한다: * - description CDATA HTML 표 (구글어스/지오코딩 산출물 — 회덕 v2.0) * - ExtendedData/SchemaData/SimpleData (CAD·GIS 내보내기 — 제주 도로 KML) * * 측점(STA): 이름이 도로 체이니지(`6+998`) 형식이거나 STA 속성이 있는 Point 는 * POI 가 아니라 측점(type='station')으로 수집한다. km 속성(단위 km) 우선, 없으면 * 이름에서 미터값을 파싱해 기존 철도식 제목(`6k998`)으로 변환 — stationOrder/stationKm * 등 측점 파이프라인(스테이션바·노선패널·검색)과 그대로 호환된다. */ export async function parseKmz( files: File[], ): Promise<{ pois: GeoPoint[]; structures: RouteStructure[]; stations: GeoPoint[]; centerline: CenterlinePoint[] } | null> { // 폴더 내 모든 KML/KMZ 를 파싱해 병합한다 (예: 제주 — 측점 KML + 지장물 KML 별도 파일). // 단, `<이름>_byDem.kml`(DEM 표고 채운 파생본)이 있으면 그 원본 `<이름>.kml` 은 제외 — // 같은 지물이 두 번 파싱되는 것 방지 + 표고 보유 버전 우선. let kmlFiles = files.filter( (f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))), ); const byDemBases = new Set( kmlFiles .map((f) => baseNameOf(relPath(f)).toLowerCase().match(/^(.+)_bydem\.kml$/)?.[1]) .filter((b): b is string => !!b), ); if (byDemBases.size) { kmlFiles = kmlFiles.filter((f) => { const name = baseNameOf(relPath(f)).toLowerCase(); const m = name.match(/^(.+)\.kml$/); return !(m && !m[1].endsWith('_bydem') && byDemBases.has(m[1])); }); } const kmzFiles = files.filter( (f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))), ); if (!kmlFiles.length && !kmzFiles.length) return null; const kmlTexts: string[] = []; for (const kf of kmlFiles) { try { kmlTexts.push(await kf.text()); // 구글 KML 은 UTF-8 } catch (e) { console.warn(`[KML] 읽기 실패: ${baseNameOf(relPath(kf))}`, e); } } for (const kf of kmzFiles) { try { const buf = new Uint8Array(await kf.arrayBuffer()); const entries = unzipSync(buf); const kmlName = Object.keys(entries).find((n) => /(^|\/)doc\.kml$/i.test(n)) ?? Object.keys(entries).find((n) => /\.kml$/i.test(n)); if (kmlName && entries[kmlName]) kmlTexts.push(strFromU8(entries[kmlName])); } catch (e) { console.warn(`[KMZ] 읽기 실패: ${baseNameOf(relPath(kf))}`, e); } } if (!kmlTexts.length) return null; const pois: GeoPoint[] = []; const structures: RouteStructure[] = []; const stations: GeoPoint[] = []; // 선형중심선 (LineString) — 도로 중심선 폴리라인. 여러 개면 가장 긴 것 사용. let centerline: CenterlinePoint[] = []; let nStruct = 0; const handlePlacemark = (pm: Element, folder: string): void => { const name = childLocal(pm, 'name')?.textContent?.trim() ?? ''; const desc = childLocal(pm, 'description')?.textContent ?? ''; const props = parseKmlDescProps(desc); // ExtendedData/SchemaData/SimpleData 속성 (CAD·GIS 내보내기) — HTML 표 props 와 통합. for (const sd of Array.from(pm.getElementsByTagName('SimpleData'))) { const k = sd.getAttribute('name') ?? ''; const v = sd.textContent?.trim() ?? ''; if (k) props.push({ k, v }); } const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v; // 선형중심선 (LineString) — 도로 중심선 폴리라인으로 수집(POI 아님). // CAD 내보내기 KML(예: 제주_중산간도로.kml Folder 선형중심선)의 실제 도로 곡선. const lineEl = pm.getElementsByTagName('LineString')[0]; if (lineEl) { const coordText = lineEl.getElementsByTagName('coordinates')[0]?.textContent ?? ''; const pts: CenterlinePoint[] = []; let prevKey = ''; for (const tok of coordText.trim().split(/\s+/)) { const p = tok.split(',').map(Number); if (p.length < 2 || !isFinite(p[0]) || !isFinite(p[1])) continue; if (p[1] < 33 || p[1] > 39 || p[0] < 124 || p[0] > 132) continue; const key = `${p[0].toFixed(8)},${p[1].toFixed(8)}`; if (key === prevKey) continue; // 연속 중복 정점 제거 prevKey = key; pts.push({ lat: p[1], lon: p[0], z: p.length >= 3 && isFinite(p[2]) ? p[2] : 0 }); } if (pts.length >= 2 && pts.length > centerline.length) { // z 톱니 노이즈 평활 — 원본(지형고도 샘플링)의 ±1m 양자화 잔차가 인접 정점에서 // 부호 반전 톱니를 만들고, 영상의 낮은 시야각 투영에서 선이 지그재그로 꺾여 보임. // 도로 종단 경사는 완만하므로 ±3점 이동평균으로 톱니만 제거(경사 보존). const zs = pts.map((p) => p.z); for (let i = 0; i < pts.length; i++) { if (zs[i] <= 0) continue; let sum = 0, cnt = 0; for (let j = Math.max(0, i - 3); j <= Math.min(pts.length - 1, i + 3); j++) { if (zs[j] > 0) { sum += zs[j]; cnt++; } } if (cnt) pts[i].z = sum / cnt; } centerline = pts; } return; } // 좌표: lon,lat[,alt] 우선, 없으면 속성 lat/lon. // alt(3번째 성분)는 DEM 일괄 기록(docs/history 2026-07-13 참조) 등으로 채워진 지면 표고 — // 있으면(>0) POI/측점의 z 로 사용해 지형 기복을 정확히 따른다. let lat = NaN, lon = NaN; let coordAlt: number | undefined; const coordEl = pm.getElementsByTagName('coordinates')[0]; if (coordEl?.textContent) { const p = coordEl.textContent.trim().split(/[\s,]+/).map(Number); if (p.length >= 2 && isFinite(p[0]) && isFinite(p[1])) { lon = p[0]; lat = p[1]; if (p.length >= 3 && isFinite(p[2]) && p[2] > 0) coordAlt = p[2]; } } if (isNaN(lat) || isNaN(lon)) { lat = parseFloat(pget('lat') ?? pget('latitude') ?? ''); lon = parseFloat(pget('lon') ?? pget('longitude') ?? ''); } if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) return; // 새 포맷: 구글어스 CSV 변환 지장물 KML — Schema(타입/타입상세/이름/연장/텍스트박스_색상/높이), // 폴더 구분 없이 Placemark 직속. '타입' 키 존재로 감지한다(회덕 desc표/STA 스키마엔 없음). // 좌표는 SimpleData X/Y(저정밀)가 아니라 Point coordinates(고정밀)를 쓴다(위에서 이미 추출). if (pget('타입') !== undefined) { const title = pget('이름') || name; if (!title) return; const category = pget('타입') || '지장물'; const lenM = parseFloat(pget('연장') ?? ''); // 라벨 색: 텍스트박스_색상(한국어명) 우선, 없으면 인라인 IconStyle 색. const labelColor = koreanColorToCss(pget('텍스트박스_색상')) ?? inlineIconColor(pm); // z: 좌표 고도(DEM 기록 지면 표고) 있으면 사용 — 지형 기복을 따라 정확히 배치. // ('높이' 속성은 표고가 아니라 구조물 높이라 사용하지 않음 — 팝업 props 로만 노출.) // 미상(0)이면 오버레이가 지면고도 모드에서 경로표고 앵커로 폴백. pois.push({ title, category, lat, lon, z: coordAlt ?? 0, type: 'poi', ...(labelColor ? { labelColor } : {}), props, }); // 교량/터널은 스테이션바·노선패널 마크용 구조물로도 등록. if (category.includes('교량')) { structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props }); } else if (category.includes('터널')) { structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props }); } return; } const lenNum = parseFloat(pget('연장(m)') ?? ''); if (folder.includes('교량')) { structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props }); } else if (folder.includes('터널')) { structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props }); } else if (folder.includes('구교')) { structures.push({ id: `구교-${nStruct++}`, type: 'bridge', category: '구교', name: pget('시설물명') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), props }); } else if (folder.includes('출입문')) { const title = pget('출입문번호') || name; if (title) pois.push({ title, category: '출입문', lat, lon, z: parseFloat(pget('z') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props }); } else { // 도로 체이니지 측점 (예: "0+100" 또는 STA 속성) → type='station' 으로 수집. // km 속성(단위 km) 우선, 없으면 이름의 "k+m" 파싱. 제목은 철도식 NkNNN 으로 변환. const staName = pget('STA') || name; const staMatch = staName.match(/^(\d+)\+(\d+)$/); if (staMatch || pget('STA') !== undefined) { const kmProp = parseFloat(pget('km') ?? ''); const meters = isFinite(kmProp) ? Math.round(kmProp * 1000) : staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN; if (!isFinite(meters)) return; const title = `${Math.floor(meters / 1000)}+${String(meters % 1000).padStart(3, '0')}`; // z: 좌표 고도(DEM 기록값) 있으면 사용 — 없으면 0(미상, 오버레이가 경로표고 앵커). stations.push({ title, category: '측점', lat, lon, z: coordAlt ?? 0, type: 'station', props }); return; } // 02)지장물 등 = 지오코딩 POI. 모든 POI 는 영상에 표출(철도역 포함). if (!name) return; const src = pget('source'); const cat = pget('category_clean') || '지장물'; // 라벨: KML '구분' → 'title' → (없으면) placemark name 순. const label = pget('구분') || pget('title') || name; pois.push({ title: label, category: cat, lat, lon, z: parseFloat(pget('z') ?? pget('Z좌표') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props }); // 철도역(KAKAO_RAIL)은 영상 POI 에 더해 '역사'로 하단 스테이션바에도 표출(lat/lon 으로 배치). if (src === 'KAKAO_RAIL' || cat === '철도역' || cat === '역사') { structures.push({ id: `역사-${nStruct++}`, type: 'station', category: '역사', name: label, lat, lon, props }); } } }; // 폴더 트리 재귀 — Placemark 가 직접 속한 (가장 안쪽) 폴더명을 folder 로 전달. const walk = (el: Element, folder: string): void => { for (const c of Array.from(el.children)) { if (c.localName === 'Folder') { walk(c, childLocal(c, 'name')?.textContent?.trim() ?? folder); } else if (c.localName === 'Document') { walk(c, folder); } else if (c.localName === 'Placemark') { handlePlacemark(c, folder); } } }; for (const text of kmlTexts) { const doc = new DOMParser().parseFromString(text, 'application/xml'); if (doc.getElementsByTagName('parsererror').length) { console.warn('[KML] XML 파싱 실패 — 해당 파일 건너뜀'); continue; } if (doc.documentElement) walk(doc.documentElement, ''); } // 선형이 측점 POI 를 정확히 통과하도록 정비 — 측점 기준. // ① 측점 위치를 선형 폴리라인에 '정점으로 삽입' (긴 직선 현 중간의 측점도 통과 보장 — // 라벨과 선이 같은 점(lat/lon/z)을 투영하므로 어떤 카메라 자세에서도 화면에서 일치) // ② 나머지 원본 정점 z 는 측점 z 의 체이니지 보간으로 교체 (톱니 평활 대체, 종단 = 측점) const staZ = stations.filter((s) => isFinite(s.z) && s.z > 0); if (centerline.length >= 2 && staZ.length >= 2) { const cosLat = Math.cos((centerline[0].lat * Math.PI) / 180); const ex = (p: { lat: number; lon: number }): number => (p.lon - centerline[0].lon) * 111320 * cosLat; const ey = (p: { lat: number; lon: number }): number => (p.lat - centerline[0].lat) * 111320; const cum: number[] = [0]; for (let i = 1; i < centerline.length; i++) { cum.push(cum[i - 1] + Math.hypot(ex(centerline[i]) - ex(centerline[i - 1]), ey(centerline[i]) - ey(centerline[i - 1]))); } // 측점 → 선형 세그먼트 투영 (선형에서 50m 이상 떨어진 측점은 제외) const anchors: { s: number; z: number; seg: number; t: number; lat: number; lon: number }[] = []; for (const st of staZ) { const px = ex(st), py = ey(st); let bestS = 0, bestD = Infinity, bestSeg = 0, bestT = 0; for (let i = 0; i < centerline.length - 1; i++) { const ax = ex(centerline[i]), ay = ey(centerline[i]); const dx = ex(centerline[i + 1]) - ax, dy = ey(centerline[i + 1]) - ay; const len2 = dx * dx + dy * dy; const t = len2 > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0; const d = Math.hypot(px - (ax + dx * t), py - (ay + dy * t)); if (d < bestD) { bestD = d; bestS = cum[i] + Math.sqrt(len2) * t; bestSeg = i; bestT = t; } } if (bestD < 50) anchors.push({ s: bestS, z: st.z, seg: bestSeg, t: bestT, lat: st.lat, lon: st.lon }); } anchors.sort((a, b) => a.s - b.s); if (anchors.length >= 2) { // ② 원본 정점 z ← 측점 체이니지 보간 for (let i = 0; i < centerline.length; i++) { const s = cum[i]; if (s <= anchors[0].s) { centerline[i].z = anchors[0].z; continue; } if (s >= anchors[anchors.length - 1].s) { centerline[i].z = anchors[anchors.length - 1].z; continue; } let lo = 0, hi = anchors.length - 1; while (hi - lo > 1) { const m = (lo + hi) >> 1; if (anchors[m].s <= s) lo = m; else hi = m; } const a = anchors[lo], b = anchors[hi]; const r = b.s > a.s ? (s - a.s) / (b.s - a.s) : 0; centerline[i].z = a.z + (b.z - a.z) * r; } // ① 측점 정점 삽입 — 세그먼트별로 t 순 삽입. 기존 정점과 1m 이내면 생략(중복 방지). const rebuilt: CenterlinePoint[] = []; let inserted = 0; for (let i = 0; i < centerline.length; i++) { rebuilt.push(centerline[i]); if (i === centerline.length - 1) break; const onSeg = anchors.filter((a) => a.seg === i).sort((a, b) => a.t - b.t); for (const a of onSeg) { const near = (p: CenterlinePoint): boolean => Math.hypot(ex(a) - ex(p), ey(a) - ey(p)) < 1; if (near(centerline[i]) || near(centerline[i + 1])) continue; rebuilt.push({ lat: a.lat, lon: a.lon, z: a.z }); inserted++; } } centerline = rebuilt; console.log(`[KML] 선형-측점 정합: 측점 정점 ${inserted}개 삽입 + 정점 z 를 측점 ${anchors.length}점 기준 재정렬`); } } return { pois, structures, stations, centerline }; } // ── 카메라 파라미터 파일 (.camera.json) ───────────────────────── /** * 폴더에서 카메라 파라미터 파일을 읽는다. 'PC에 저장' 버튼이 만든 형식: * { "camera": { focalLen, sensorW, sensorH, yawOffset, pitch, roll, offX, offY, offZ, geoidOffset, cx0, cy0 }, ... } * 폴더 공용 설정 — 파일명 규칙(우선순위): * ① `camera.json` (단순 이름 — 권장) * ② `<영상 base>.camera.json` / `<영상 base>.json` (어느 분할본 base 든) * ③ 임의 이름의 `*.camera.json` (예: "제주 중산간도로 경로 1-1.camera.json") * 같은 폴더의 모든 영상(분할본 전체)에 동일하게 적용된다. 숫자 필드만 통과시킨다. */ export async function parseCameraJson( files: File[], videoFiles: File[], ): Promise | null> { const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase()); const rootJson = files.filter((f) => { if (isInBuilding(f)) return false; const name = baseNameOf(relPath(f)).toLowerCase(); return /\.json$/.test(name) && !/^route\.json$|\.route\.json$|^display\.json$|\.display\.json$|_poi_overrides\.json$/.test(name); }); const file = rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'camera.json') ?? rootJson.find((f) => { const m = baseNameOf(relPath(f)).toLowerCase().match(/^(.+?)(\.camera)?\.json$/); return !!m && bases.includes(m[1]); }) ?? rootJson.find((f) => /\.camera\.json$/i.test(baseNameOf(relPath(f)))); if (!file) return null; try { const obj = JSON.parse(await file.text()); const cam = obj && typeof obj === 'object' ? (obj.camera ?? obj) : null; if (!cam || typeof cam !== 'object') return null; const out: Record = {}; for (const [k, v] of Object.entries(cam)) { if (typeof v === 'number' && isFinite(v)) out[k] = v; } return Object.keys(out).length ? out : null; } catch { return null; } } // ── 화면표시 옵션 파일 (.display.json) ────────────────────────── /** * 폴더에서 화면표시 옵션 파일을 읽는다. '화면표시 옵션 PC 저장' 버튼이 만든 * `{ display: {...}, savedAt }` 형식. 폴더 공용 설정 — 파일명 규칙(우선순위): * ① `display.json` (단순 이름 — 권장) * ② `<영상 base>.display.json` * ③ 임의 이름의 `*.display.json` (예: "제주 중산간도로 경로 1-1.display.json") * 폴더 내 모든 영상에 동일 적용. */ export async function parseDisplayJson( files: File[], videoFiles: File[], ): Promise | null> { const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase()); const rootDisplay = files.filter( (f) => !isInBuilding(f) && /(^|\.)display\.json$/i.test(baseNameOf(relPath(f)).toLowerCase()), ); const file = rootDisplay.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'display.json') ?? rootDisplay.find((f) => { const m = baseNameOf(relPath(f)).toLowerCase().match(/^(.+?)\.display\.json$/); return !!m && bases.includes(m[1]); }) ?? rootDisplay[0]; if (!file) return null; try { const obj = JSON.parse(await file.text()); const d = obj && typeof obj === 'object' ? (obj.display ?? obj) : null; return d && typeof d === 'object' ? (d as Record) : null; } catch { return null; } } // ── route.json 보정 ─────────────────────────────────────────────────── /** * 폴더 보조 파일 route.json 파싱 (UTF-8, JSON). * `.route.json` 우선(case-insensitive), 없으면 `route.json`. * building/ 제외, 루트 파일만. 파싱 실패 시 null. */ export async function parseRouteMeta( files: File[], baseName: string | null, ): Promise { const rootJson = files.filter( (f) => !isInBuilding(f) && /\.json$/i.test(baseNameOf(relPath(f))), ); if (!rootJson.length) return null; const wantBase = baseName ? `${baseName}.route.json`.toLowerCase() : null; const file = (wantBase && rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) || rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json') || // 임의 접두 `<이름>.route.json` 허용 (예: 제주 중산간도로 경로 1-1.route.json) rootJson.find((f) => /\.route\.json$/i.test(baseNameOf(relPath(f)))); if (!file) return null; try { const text = await file.text(); return JSON.parse(text) as RouteMeta; } catch { return null; } } /** 이름 정규화(괄호 이하 제거) — route.json ↔ CSV 구조물 매칭용. */ function structBaseName(name: string): string { return name.replace(/\s*[((].*$/, '').trim(); } /** * CSV 유래 구조물에 route.json structures 의 offset/station/이정 등을 보정(augment)한다. * name 이 일치(괄호 이하 제거 후 부분일치)하는 항목의 값으로 override 하고, * CSV 에 없는 구조물(route.json 전용)은 그대로 추가한다. */ export function mergeStructures( csvStructures: RouteStructure[], meta: RouteMeta | null, ): RouteStructure[] { const metaList = meta?.structures; if (!metaList || !metaList.length) return csvStructures; const result = csvStructures.map((s) => ({ ...s })); const used = new Set(); for (const m of metaList) { const mBase = structBaseName(m.name); const idx = result.findIndex((s) => { const sBase = structBaseName(s.name); return sBase === mBase || sBase.includes(mBase) || mBase.includes(sBase); }); if (idx >= 0) { // 보정: route.json 에 명시된 필드만 override (CSV 좌표/연장은 유지). const tgt = result[idx]; if (m.station != null) tgt.station = m.station; if (m.offset != null) tgt.offset = m.offset; if (m.startMileage != null) tgt.startMileage = m.startMileage; if (m.endMileage != null) tgt.endMileage = m.endMileage; if (m.lat != null) tgt.lat = m.lat; if (m.lon != null) tgt.lon = m.lon; used.add(idx); } else { // CSV 에 없는 route.json 전용 구조물 → 그대로 추가. result.push({ ...m }); } } return result; } // ── POI 위치 보정 (마우스 드래그 저장값) ────────────────────────────── /** * 폴더에서 POI 보정 파일을 읽는다. `_poi_overrides.json` 우선, * 없으면 `poi_overrides.json`. building/ 제외, 루트만. 파싱 실패 시 {}. * 형식: { baseName?, overrides: { "": {lat,lon,z}, ... } } (또는 평면 맵). */ export async function parsePoiOverrides( files: File[], baseName: string | null, ): Promise<PoiOverrideMap> { const rootJson = files.filter( (f) => !isInBuilding(f) && /_poi_overrides\.json$/i.test(baseNameOf(relPath(f))), ); if (!rootJson.length) return {}; const wantBase = baseName ? `${baseName}_poi_overrides.json`.toLowerCase() : null; const file = (wantBase && rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) || rootJson[0]; try { const obj = JSON.parse(await file.text()); const map = (obj && typeof obj === 'object' && obj.overrides) ? obj.overrides : obj; const out: PoiOverrideMap = {}; for (const [title, v] of Object.entries(map as Record<string, unknown>)) { const o = v as { lat?: number; lon?: number; z?: number }; if (o && isFinite(o.lat as number) && isFinite(o.lon as number)) { out[title] = { lat: o.lat as number, lon: o.lon as number, z: Number(o.z) || 0 }; } } return out; } catch { return {}; } } /** 보정맵을 POI 배열에 적용 (title 일치 시 lat/lon/z 교체). 원본 불변. */ export function applyPoiOverrides(pois: GeoPoint[], overrides: PoiOverrideMap): GeoPoint[] { if (!overrides || !Object.keys(overrides).length) return pois; return pois.map((p) => { const o = overrides[p.title]; return o ? { ...p, lat: o.lat, lon: o.lon, z: o.z } : p; }); } // ── 중심선 생성 (v2.0엔 center.csv 없음) ────────────────────────────── /** * 측점을 측점값(mileage) 순으로 정렬한 폴리라인을 중심선으로 생성한다. * v2.0엔 center.csv 가 없으므로 측점(01)측점)이 중심선의 소스다. * 이 중심선이 체이니지 투영/RoutePanel 에 쓰인다. */ export function buildCenterlineFromStations(stations: GeoPoint[]): CenterlinePoint[] { return [...stations] .filter((s) => !isNaN(s.lat) && !isNaN(s.lon)) .sort((a, b) => stationOrder(a.title) - stationOrder(b.title)) .map((s) => ({ lat: s.lat, lon: s.lon, z: s.z })); } // ── 통합 로더 ───────────────────────────────────────────────────────── /** * 폴더 내 파일에서 영상 + 지리정보(v2.0 형상)를 모두 파싱한다. * 반환값을 geoStore.loadFromFolder 가 스토어에 적재한다. */ export async function loadFolderGeoData( input: FileList | File[], ): Promise<FolderGeoData> { const files = Array.from(input); const videoFiles = findVideoFiles(files); const videoFile = videoFiles[0] ?? null; const baseName = deriveBaseName(videoFile); // 각 영상 길이를 메타데이터로 측정 — DJI 시간 기반 로그의 세그먼트 정렬(누적 오프셋)과 // ended 연속재생(geoStore.loadSegment)에 쓴다. 분할본이 아니면 1개만 측정. const videoDurations: (number | null)[] = []; for (const vf of videoFiles) videoDurations.push(await probeVideoDuration(vf)); // 첫 세그먼트 창 — 로그는 전체 녹화(분할본 합)를 커버하므로 첫 영상 구간만 잘라 쓴다. const segmentWindow: SegmentWindow | null = videoFile ? { offsetSec: 0, durationSec: videoDurations[0] } : null; if (videoFiles.length > 1) { console.log( `[geo] 분할 영상 ${videoFiles.length}개 감지 (이름 오름차순 연속재생): ` + videoFiles.map((vf, i) => `${baseNameOf(relPath(vf))}(${videoDurations[i]?.toFixed(1) ?? '?'}s)`).join(' → '), ); } const [frames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson, displayJson] = await Promise.all([ parseDroneFrames(files, baseName, segmentWindow), parseStations(files), parseRouteMeta(files, baseName), parsePoiOverrides(files, baseName), parseKmz(files), videoFile ? detectCameraInfo(videoFile) : Promise.resolve(null), parseCameraJson(files, videoFiles), parseDisplayJson(files, videoFiles), ]); if (displayJson) { console.log('[display] <base>.display.json 발견 — 저장된 화면표시 옵션 적용 예정'); } if (cameraJson) { console.log('[camera] <base>.camera.json 발견 — 저장된 카메라 파라미터 적용:', cameraJson); } // 타원체고 기준 데이터셋 자동 변환 — route.json 에 ellipsoidal_height 가 선언된 폴더는 // 지물(측점/지장물) z 가 타원체고이므로, 드론 고도(CSV 정표고)를 영상 메타(djmd)의 // 타원체고와의 차이(= 그 지점 지오이드고)만큼 올려 기준을 통일한다. 지오이드/offZ 수동 보정 불필요. let altitudeOffsetM = 0; if ( routeMeta?.routeInfo?.ellipsoidal_height != null && cameraInfo?.ellipsoidalAltM != null && frames.length ) { const geoidN = cameraInfo.ellipsoidalAltM - frames[0].altitude; if (isFinite(geoidN) && Math.abs(geoidN) < 60) { altitudeOffsetM = Math.round(geoidN * 100) / 100; for (const f of frames) f.altitude += altitudeOffsetM; console.log( `[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+${altitudeOffsetM.toFixed(2)}m = ` + `djmd ${cameraInfo.ellipsoidalAltM.toFixed(1)} − CSV ${(frames[0].altitude - altitudeOffsetM).toFixed(1)})`, ); } } // 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성). if (cameraInfo?.focalLen35) { for (const f of frames) f.focalLen = cameraInfo.focalLen35; console.log( `[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` + (cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''), ); } else if (cameraInfo) { console.log(`[camera] ${cameraInfo.model} 감지 (초점거리 정보 없음)`); } // POI/구조물 출처: KMZ(원본)가 유일한 소스다(지장물·출입문·교량/터널/구교·철도역). // KMZ 파일 자체가 없을 때만 "데이터 누락"(kmzMissing=alert 대상)으로 본다. // 파일은 있는데 POI/구조물이 0건인 경우(예: 제주 — 노선 KML만 있고 POI 없음)는 // 정상 데이터셋으로 취급하고 콘솔 경고만 남긴다. const kmzMissing = !kmz; if (kmzMissing) { console.warn( '[KMZ] 누락 — POI·구조물이 표시되지 않습니다. ' + 'KMZ(원본)를 포함해 데이터를 재구축·전달하세요. (측점·드론 정보는 정상 로드)', ); } else if (kmz!.pois.length === 0 && kmz!.structures.length === 0 && kmz!.stations.length === 0) { console.warn('[KMZ] POI·구조물·측점 0건 — 이 데이터셋엔 표출할 지물이 없습니다(드론 궤적은 무관).'); } else { console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} · 측점 ${kmz!.stations.length} · 중심선 ${kmz!.centerline.length}점 로드(원본)`); } const pois = kmz?.pois ?? []; const baseStructures = kmz?.structures ?? []; // 측점 소스: CSV(01)측점 — 실측 표고 보유) 우선, 없으면 KML 체이니지 측점(STA) 사용. // KML 측점은 표고 미상 → z=0 그대로 둔다(미상 표식). 드론고도 기반 근사(−24m)는 // 실제 지면과 수십 m 어긋나 라벨이 공중에 떠서 시차 불일치(카메라 전진 시 라벨 밀림)를 // 유발했음 → 오버레이가 z=0 라벨을 '경로 표고' 평면에 앵커하는 방식으로 대체. let rawStations = stationResult.stations; if (!rawStations.length && kmz?.stations.length) { rawStations = kmz.stations; const withZ = rawStations.filter((s) => s.z > 0).length; console.log( `[KMZ] 체이니지 측점 ${rawStations.length}개 로드` + (withZ ? ` (DEM 표고 보유 ${withZ}개)` : ' (표고 미상 → 오버레이 경로표고 앵커)'), ); } const stations = rawStations.sort( (a, b) => stationOrder(a.title) - stationOrder(b.title), ); const directionChanges = stationResult.directionChanges; // route.json 은 선택적 보정 레이어 — 있으면 구조물에 override/augment. // 철도역(역사)은 KMZ의 KAKAO_RAIL placemark에서 구조물로 생성됨(스테이션바 표출). const structures = mergeStructures(baseStructures, routeMeta); // 중심선 소스 우선순위: KML 선형중심선(LineString — 실제 도로 곡선, 예: 제주 146정점) // → 없으면 측점 폴리라인(100m 간격 직선 연결). v2.0엔 center.csv 가 없다. const centerline = (kmz?.centerline.length ?? 0) >= 2 ? kmz!.centerline : buildCenterlineFromStations(stations); if ((kmz?.centerline.length ?? 0) >= 2) { console.log(`[geo] 중심선: KML 선형중심선 ${kmz!.centerline.length}점 사용`); } let origin: GeoOrigin | null = null; if (stations.length || frames.length) { origin = getWorldOrigin(frames, [...stations, ...pois]); } else if (centerline.length) { origin = { lat: centerline[0].lat, lon: centerline[0].lon, alt: centerline[0].z }; } return { videoFile, baseName, frames, pois, stations, centerline, origin, routeMeta, structures, directionChanges, poiOverrides, kmzMissing, videoFiles, videoDurations, folderFiles: files, cameraInfo, cameraJson, displayJson, altitudeOffsetM, }; }