Files
GhiVideo_v4/client/src/utils/demKml.ts
T
b23042andClaude Fable 5 7d51ba9945 표고 체계 정비·설정 파일 단순화·선형 클리핑·시연 준비
- 타원체고 기준 자동 변환: 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>
2026-07-13 20:37:46 +09:00

116 lines
5.2 KiB
TypeScript

/**
* 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;
}