표고 체계 정비·설정 파일 단순화·선형 클리핑·시연 준비
- 타원체고 기준 자동 변환: route.json ellipsoidal_height 선언 시 드론 고도(CSV 정표고)를 영상 메타(djmd 6.6 타원체고)와의 차(지오이드고 자동 산출)만큼 보정, 세그먼트 전환에도 유지 - KML 표고 파이프라인: 좌표 고도(z) 사용(POI/측점), *_byDem.kml 자동 생성(DEM 조회·높이 속성 포함) + 로더의 _byDem 우선 규칙, 선형중심선(LineString) 로딩·경로표고 앵커 - 설정 파일: camera.json/display.json/route.json 단순 이름 인식, 화면표시 옵션 PC 저장/자동 적용, 초기화 버튼을 환경설정 파일 기준 복원으로 변경, 저장 파일명 단순화 - 선형 표시: 표출된 마지막 측점까지 클립(경계 세그먼트 절단 + 관통 현 처리 — 구간 실종 수정) - 스테이션바: 시점(0k000) 마크(실통과 위치), 노선밖 프레임 통과 오인 방지, 커서 배지 1m 단위, 배너 연장/소요 실측값(측점 체이니지·영상 길이 합) 우선 - 세그먼트 전환 라벨 깜빡임 구조 수정(캐시 초기화·최근접 키 조회·이중 재계산 제거) + 사전계산 프레임 불변값 호이스팅(재생 지연 해소) - UI: 슬라이더/값 입력칸 휠 조절, 패널 버튼 위치 정렬, 저장 버튼 중복 제거, dev_mode(route.json)로 영상제어 UI 표시 제어, 지오이드 최대 200, 이격거리 측점 동반 적용 - 시연: start-demo.bat + docs/DEMO.md - docs/history: 작업 이력 30건 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,11 @@ export async function detectCameraInfo(file: File): Promise<DetectedCameraInfo |
|
||||
if (w && w > 100) info.width = w;
|
||||
if (h && h > 100) info.height = h;
|
||||
if (typeof fps === 'number' && fps > 1 && fps < 1000) info.fps = Math.round(fps * 100) / 100;
|
||||
// 6.6 = 드론 타원체고(mm) — 영상 시작 시점. CSV 정표고와의 차 = 지역 지오이드고(datum 변환용).
|
||||
const altMm = out.get('6.6');
|
||||
if (typeof altMm === 'number' && altMm > 0 && altMm < 10_000_000) {
|
||||
info.ellipsoidalAltM = Math.round(altMm) / 1000;
|
||||
}
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* KML 표고(DEM) 파생 파일 생성 — `<이름>_byDem.kml`
|
||||
*
|
||||
* 폴더 로드 시 측점/지장물 KML 의 좌표 고도 성분이 없거나 0 이면, DEM(서버 /api/elevation
|
||||
* 프록시 → SRTM 30m, 폴백 90m)으로 지면 표고를 조회해 채운 `<이름>_byDem.kml` 을 만들어
|
||||
* PC 로 다운로드한다. 사용자가 받은 파일을 영상 폴더에 넣으면, 로더(parseKmz)가
|
||||
* 원본 대신 _byDem 버전을 읽는다(중복 파싱 방지 규칙).
|
||||
*
|
||||
* - 이미 표고가 있는 좌표(>0)는 그대로 두고 없는 것만 채운다
|
||||
* - 폴더에 `<이름>_byDem.kml` 이 이미 있으면 해당 원본은 건너뜀
|
||||
* - DEM 조회는 100좌표/청크, 업스트림 rate limit(1req/s) 고려 1.2s 간격
|
||||
*/
|
||||
|
||||
/** File 의 폴더 내 파일명 (webkitRelativePath 마지막 세그먼트). */
|
||||
function baseNameOf(f: File): string {
|
||||
const p = f.webkitRelativePath || f.name;
|
||||
return p.split('/').pop() ?? f.name;
|
||||
}
|
||||
|
||||
function isInBuilding(f: File): boolean {
|
||||
const parts = (f.webkitRelativePath || f.name).split('/');
|
||||
return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building';
|
||||
}
|
||||
|
||||
/** 표고가 필요한(없거나 0) 좌표 목록 수집. */
|
||||
function collectMissing(text: string): { key: string; lat: number; lon: number }[] {
|
||||
const need: { key: string; lat: number; lon: number }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const block of text.matchAll(/<coordinates>([\s\S]*?)<\/coordinates>/g)) {
|
||||
for (const tok of block[1].trim().split(/\s+/)) {
|
||||
const p = tok.split(',');
|
||||
if (p.length < 2) continue;
|
||||
const lon = parseFloat(p[0]), lat = parseFloat(p[1]);
|
||||
if (!isFinite(lon) || !isFinite(lat)) continue;
|
||||
const alt = p.length >= 3 ? parseFloat(p[2]) : NaN;
|
||||
if (isFinite(alt) && alt > 0) continue; // 이미 표고 보유
|
||||
const key = `${p[0]},${p[1]}`;
|
||||
if (!seen.has(key)) { seen.add(key); need.push({ key, lat, lon }); }
|
||||
}
|
||||
}
|
||||
return need;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더 파일들에서 표고 없는 KML 을 찾아 `<이름>_byDem.kml` 을 생성·다운로드한다.
|
||||
* 생성한 파일명 목록을 반환(생성할 것이 없으면 빈 배열).
|
||||
*/
|
||||
export async function generateByDemKmls(input: FileList | File[]): Promise<string[]> {
|
||||
const files = Array.from(input);
|
||||
const kmls = files.filter((f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(f)));
|
||||
const lowerNames = new Set(kmls.map((f) => baseNameOf(f).toLowerCase()));
|
||||
const made: string[] = [];
|
||||
|
||||
for (const f of kmls) {
|
||||
const base = baseNameOf(f).replace(/\.kml$/i, '');
|
||||
if (/_bydem$/i.test(base)) continue; // 자신이 DEM 버전
|
||||
if (lowerNames.has(`${base.toLowerCase()}_bydem.kml`)) continue; // DEM 버전이 이미 폴더에 있음
|
||||
|
||||
const text = await f.text();
|
||||
const need = collectMissing(text);
|
||||
if (!need.length) continue; // 전 좌표 표고 보유 → 불필요
|
||||
|
||||
// DEM 조회 (서버 프록시)
|
||||
const elev = new Map<string, number>();
|
||||
for (let i = 0; i < need.length; i += 100) {
|
||||
const chunk = need.slice(i, i + 100);
|
||||
const r = await fetch(
|
||||
`/api/elevation?lat=${chunk.map((c) => c.lat).join(',')}&lon=${chunk.map((c) => c.lon).join(',')}`,
|
||||
);
|
||||
if (!r.ok) throw new Error(`DEM 조회 실패 (HTTP ${r.status})`);
|
||||
const j: { elevation?: (number | null)[] } = await r.json();
|
||||
chunk.forEach((c, k) => {
|
||||
const e = j.elevation?.[k];
|
||||
if (typeof e === 'number' && isFinite(e)) elev.set(c.key, e);
|
||||
});
|
||||
if (i + 100 < need.length) await new Promise((res) => setTimeout(res, 1200));
|
||||
}
|
||||
if (!elev.size) continue;
|
||||
|
||||
// 고도 성분 치환(없거나 0 인 것만) — 그 외 텍스트/구조는 원본 그대로 보존
|
||||
let next = text.replace(/<coordinates>([\s\S]*?)<\/coordinates>/g, (_m, body: string) =>
|
||||
`<coordinates>${body.replace(/(\S+)/g, (tok: string) => {
|
||||
const p = tok.split(',');
|
||||
if (p.length < 2) return tok;
|
||||
const alt = p.length >= 3 ? parseFloat(p[2]) : NaN;
|
||||
if (isFinite(alt) && alt > 0) return tok;
|
||||
const e = elev.get(`${p[0]},${p[1]}`);
|
||||
return e === undefined ? tok : `${p[0]},${p[1]},${e.toFixed(1)}`;
|
||||
})}</coordinates>`);
|
||||
|
||||
// '높이' 속성(지장물 스키마)도 0/빈값이면 같은 DEM 표고로 채운다 — Placemark 단위로
|
||||
// 자기 좌표의 표고를 대응. 실제 값이 있는 높이는 보존.
|
||||
next = next.replace(/<Placemark[^>]*>[\s\S]*?<\/Placemark>/g, (pm) => {
|
||||
const cm = pm.match(/<coordinates>\s*([-\d.]+),([-\d.]+)/);
|
||||
if (!cm) return pm;
|
||||
const e = elev.get(`${cm[1]},${cm[2]}`);
|
||||
if (e === undefined) return pm;
|
||||
return pm.replace(
|
||||
/(<SimpleData name="높이">)\s*(?:0(?:\.0+)?)?\s*(<\/SimpleData>)/,
|
||||
`$1${e.toFixed(1)}$2`,
|
||||
);
|
||||
});
|
||||
|
||||
const outName = `${base}_byDem.kml`;
|
||||
const url = URL.createObjectURL(new Blob([next], { type: 'application/vnd.google-earth.kml+xml' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = outName;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
made.push(outName);
|
||||
console.log(`[DEM] ${outName} 생성 — 좌표 ${need.length}개 중 ${elev.size}개 표고 기록`);
|
||||
}
|
||||
return made;
|
||||
}
|
||||
+143
-22
@@ -423,11 +423,25 @@ function inlineIconColor(pm: Element): string | undefined {
|
||||
* 등 측점 파이프라인(스테이션바·노선패널·검색)과 그대로 호환된다. */
|
||||
export async function parseKmz(
|
||||
files: File[],
|
||||
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[]; stations: GeoPoint[] } | null> {
|
||||
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[]; stations: GeoPoint[]; centerline: CenterlinePoint[] } | null> {
|
||||
// 폴더 내 모든 KML/KMZ 를 파싱해 병합한다 (예: 제주 — 측점 KML + 지장물 KML 별도 파일).
|
||||
const kmlFiles = files.filter(
|
||||
// 단, `<이름>_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))),
|
||||
);
|
||||
@@ -458,6 +472,8 @@ export async function parseKmz(
|
||||
const pois: GeoPoint[] = [];
|
||||
const structures: RouteStructure[] = [];
|
||||
const stations: GeoPoint[] = [];
|
||||
// 선형중심선 (LineString) — 도로 중심선 폴리라인. 여러 개면 가장 긴 것 사용.
|
||||
let centerline: CenterlinePoint[] = [];
|
||||
let nStruct = 0;
|
||||
|
||||
const handlePlacemark = (pm: Element, folder: string): void => {
|
||||
@@ -472,12 +488,38 @@ export async function parseKmz(
|
||||
}
|
||||
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) centerline = pts;
|
||||
return;
|
||||
}
|
||||
|
||||
// 좌표: <coordinates> 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 >= 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') ?? '');
|
||||
@@ -495,10 +537,11 @@ export async function parseKmz(
|
||||
const lenM = parseFloat(pget('연장') ?? '');
|
||||
// 라벨 색: 텍스트박스_색상(한국어명) 우선, 없으면 인라인 IconStyle 색.
|
||||
const labelColor = koreanColorToCss(pget('텍스트박스_색상')) ?? inlineIconColor(pm);
|
||||
// z: '높이'는 표고가 아니라 구조물 높이라 사용하지 않음(팝업 props 로만 노출).
|
||||
// 지면표고 미상(0) → 오버레이 기본(드론고도−이격) 모드 및 중심선 z 폴백으로 표시.
|
||||
// z: 좌표 고도(DEM 기록 지면 표고) 있으면 사용 — 지형 기복을 따라 정확히 배치.
|
||||
// ('높이' 속성은 표고가 아니라 구조물 높이라 사용하지 않음 — 팝업 props 로만 노출.)
|
||||
// 미상(0)이면 오버레이가 지면고도 모드에서 경로표고 앵커로 폴백.
|
||||
pois.push({
|
||||
title, category, lat, lon, z: 0, type: 'poi',
|
||||
title, category, lat, lon, z: coordAlt ?? 0, type: 'poi',
|
||||
...(labelColor ? { labelColor } : {}),
|
||||
props,
|
||||
});
|
||||
@@ -533,8 +576,8 @@ export async function parseKmz(
|
||||
: staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN;
|
||||
if (!isFinite(meters)) return;
|
||||
const title = `${Math.floor(meters / 1000)}k${String(meters % 1000).padStart(3, '0')}`;
|
||||
// z 미상(KML 고도 0) — 소비측(loadFolderGeoData)에서 드론 고도 기반으로 보정.
|
||||
stations.push({ title, category: '측점', lat, lon, z: 0, type: 'station', props });
|
||||
// z: 좌표 고도(DEM 기록값) 있으면 사용 — 없으면 0(미상, 오버레이가 경로표고 앵커).
|
||||
stations.push({ title, category: '측점', lat, lon, z: coordAlt ?? 0, type: 'station', props });
|
||||
return;
|
||||
}
|
||||
// 02)지장물 등 = 지오코딩 POI. 모든 POI 는 영상에 표출(철도역 포함).
|
||||
@@ -572,30 +615,37 @@ export async function parseKmz(
|
||||
if (doc.documentElement) walk(doc.documentElement, '');
|
||||
}
|
||||
|
||||
return { pois, structures, stations };
|
||||
return { pois, structures, stations, centerline };
|
||||
}
|
||||
|
||||
// ── 카메라 파라미터 파일 (<base>.camera.json) ─────────────────────────
|
||||
|
||||
/**
|
||||
* 폴더에서 카메라 파라미터 파일을 읽는다. 서버 '카메라값 저장'(PUT /api/camera/:videoId)이
|
||||
* 영상 옆에 만든 `<영상 base>.camera.json`(또는 `<영상 base>.json`) 형식:
|
||||
* 폴더에서 카메라 파라미터 파일을 읽는다. 'PC에 저장' 버튼이 만든 형식:
|
||||
* { "camera": { focalLen, sensorW, sensorH, yawOffset, pitch, roll, offX, offY, offZ, geoidOffset, cx0, cy0 }, ... }
|
||||
* 폴더 내 어떤 영상의 base 와도 매칭 허용(분할본 공용). 숫자 필드만 통과시킨다.
|
||||
* 폴더 공용 설정 — 파일명 규칙(우선순위):
|
||||
* ① `camera.json` (단순 이름 — 권장)
|
||||
* ② `<영상 base>.camera.json` / `<영상 base>.json` (어느 분할본 base 든)
|
||||
* ③ 임의 이름의 `*.camera.json` (예: "제주 중산간도로 경로 1-1.camera.json")
|
||||
* 같은 폴더의 모든 영상(분할본 전체)에 동일하게 적용된다. 숫자 필드만 통과시킨다.
|
||||
*/
|
||||
export async function parseCameraJson(
|
||||
files: File[],
|
||||
videoFiles: File[],
|
||||
): Promise<Record<string, number> | null> {
|
||||
const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase());
|
||||
const file = files.find((f) => {
|
||||
const rootJson = files.filter((f) => {
|
||||
if (isInBuilding(f)) return false;
|
||||
const name = baseNameOf(relPath(f)).toLowerCase();
|
||||
const m = name.match(/^(.+?)(\.camera)?\.json$/);
|
||||
if (!m) return false;
|
||||
if (/\.route\.json$|_poi_overrides\.json$/.test(name)) return false;
|
||||
return bases.includes(m[1]);
|
||||
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());
|
||||
@@ -611,6 +661,40 @@ export async function parseCameraJson(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 화면표시 옵션 파일 (<base>.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<Record<string, unknown> | 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<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── route.json 보정 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -777,7 +861,7 @@ export async function loadFolderGeoData(
|
||||
);
|
||||
}
|
||||
|
||||
const [frames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson] = await Promise.all([
|
||||
const [frames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson, displayJson] = await Promise.all([
|
||||
parseDroneFrames(files, baseName, segmentWindow),
|
||||
parseStations(files),
|
||||
parseRouteMeta(files, baseName),
|
||||
@@ -785,12 +869,37 @@ export async function loadFolderGeoData(
|
||||
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;
|
||||
@@ -815,7 +924,7 @@ export async function loadFolderGeoData(
|
||||
} 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} 로드(원본)`);
|
||||
console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} · 측점 ${kmz!.stations.length} · 중심선 ${kmz!.centerline.length}점 로드(원본)`);
|
||||
}
|
||||
const pois = kmz?.pois ?? [];
|
||||
const baseStructures = kmz?.structures ?? [];
|
||||
@@ -827,7 +936,11 @@ export async function loadFolderGeoData(
|
||||
let rawStations = stationResult.stations;
|
||||
if (!rawStations.length && kmz?.stations.length) {
|
||||
rawStations = kmz.stations;
|
||||
console.log(`[KMZ] 체이니지 측점 ${rawStations.length}개 로드 (표고 미상 → 오버레이 '경로 표고' 평면 앵커)`);
|
||||
const withZ = rawStations.filter((s) => s.z > 0).length;
|
||||
console.log(
|
||||
`[KMZ] 체이니지 측점 ${rawStations.length}개 로드` +
|
||||
(withZ ? ` (DEM 표고 보유 ${withZ}개)` : ' (표고 미상 → 오버레이 경로표고 앵커)'),
|
||||
);
|
||||
}
|
||||
|
||||
const stations = rawStations.sort(
|
||||
@@ -839,8 +952,14 @@ export async function loadFolderGeoData(
|
||||
// 철도역(역사)은 KMZ의 KAKAO_RAIL placemark에서 구조물로 생성됨(스테이션바 표출).
|
||||
const structures = mergeStructures(baseStructures, routeMeta);
|
||||
|
||||
// v2.0엔 center.csv 가 없다 → 측점 폴리라인으로 중심선 생성.
|
||||
const centerline = buildCenterlineFromStations(stations);
|
||||
// 중심선 소스 우선순위: 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) {
|
||||
@@ -867,5 +986,7 @@ export async function loadFolderGeoData(
|
||||
folderFiles: files,
|
||||
cameraInfo,
|
||||
cameraJson,
|
||||
displayJson,
|
||||
altitudeOffsetM,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user