오버레이 — 선형 입체형(반투명 원통+측점 분할 점)·자세 필터·설정 파일명

선형 입체형(기본형/입체형 토글, display.json clFx):
- 진한 중심선 + 반투명 원통 셸(지름/거리 원근 폭, 3층 음영) — 오프스크린에
  불투명으로 그려 일괄 알파 합성(세그먼트 이음매 중첩·단차 제거)
- 선형 점: 측점(100m) 사이 N등분 위치(기본 10등분=10m, 옵션 clDotDiv 1~20,
  중간점 크게) — 궤적 측정점과 동일한 월드 고정 방식(근거리 역행 착시 없음)
- '선형 지름'(clTubeD) 옵션, 유동 대시는 진행방향 정렬 후 월드 고정 점으로 대체
- 커튼(면·수직살)은 제거

자세/포즈:
- 프레임 동기 데이터 포즈 평활 완전 제거(행 단위 ±0.5s 오해 교정) + One Euro
  속도적응 필터(직진 미세떨림 억제, 팬 즉시 추종 — route.json att_min_cut/att_beta)
- 측점·POI 라벨 화면 EMA 우회(즉시 투영 — 선형과 픽셀 고정)

기타:
- 카메라/디스플레이 PC 저장 파일명 = 환경설정 이름(CSV명 → 노선명 → 영상 base)
- 측점 팝업 표고(타원체고) 행, 구조물-POI 이중 라벨 제거, sen H 최대 100mm

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 16:27:53 +09:00
co-authored by Claude Fable 5
parent 0a7044d79b
commit 67cf646d70
+323 -17
View File
@@ -38,9 +38,11 @@ const POI_MERGE_Y = 0.035;
type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number };
// djmd 프레임 동기 데이터일 때 포즈 평활 상한(±프레임). 위치·자세가 이미 매끄러워
// 긴 평활(기본 60 ≈ ±1s)은 회전 시 포즈 지연 → 오버레이 전체가 드론에 '딸려오는' 원인.
const POSE_SMOOTH_SYNCED_MAX = 5;
// djmd 프레임 동기 데이터일 때 포즈 평활 상한.
// 주의: smoothFrame 의 halfWin 단위는 '프레임 배열 행'(CSV 10Hz 행)이라 ±5 = ±0.5초 —
// 팬(회전) 시작·종료 때 라벨이 늦게 출발하고 늦게 정렬되는 원인이었음.
// 프레임 동기 데이터는 위치(djmd)·자세(10Hz 선형보간) 모두 이미 매끄러워 평활 불필요 → 0(끔).
const POSE_SMOOTH_SYNCED_MAX = 0;
// 속도 적응형 평활(One Euro 방식) 상수.
// 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정).
@@ -126,6 +128,10 @@ const DISPLAY_DEFAULTS = {
showDronePath: true,
dronePathZ: 58,
dronePathAlpha: 0.75,
clFx: true, // 선형 표시 형태 — true=입체형(반투명 원통+유동 대시), false=기본형(단순 선)
clTubeD: 1.2, // 선형 튜브 지름(m) — 입체형에서 원통의 실제 지름
clDotDiv: 10, // 측점 사이 점 분할 수(N등분 — 10이면 10m 단위). 1=점 없음
smoothHalf: 60,
emaAlpha: 1.0,
smoothMinAlpha: 0.12, // 정지/떨림 시 최소 추종(작을수록 강한 평활)
@@ -285,6 +291,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
// 데이터 ref
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
const allCenterlinePointsRef = useRef<CenterlinePoint[]>([]);
const clCumRef = useRef<number[]>([]); // 중심선 정점 누적거리(m) — 선형 점 배치용
const clStaAnchorsRef = useRef<{ cum: number; km: number }[]>([]); // 측점 체이니지 ↔ 누적거리 앵커
const tubeCanvasRef = useRef<HTMLCanvasElement | null>(null); // 원통 셸 오프스크린(불투명→일괄 알파 합성)
const allGeoStationsRef = useRef<GeoPoint[]>([]);
const allPoisRef = useRef<GeoPoint[]>([]);
const allStructuresRef = useRef<GeoPoint[]>([]); // 교량/터널/구교 → POI처럼 표시
@@ -352,10 +361,19 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const showDronePath = useSettingsStore(s => s.showDronePath);
const setShowDronePath = useSettingsStore(s => s.setShowDronePath);
const [dronePathZ, setDronePathZ] = useState(DISPLAY_DEFAULTS.dronePathZ); // 기본 경로 표고
const [clFx, setClFx] = useState(DISPLAY_DEFAULTS.clFx); // 선형 입체 효과 on/off
const clFxRef = useRef(DISPLAY_DEFAULTS.clFx);
const [clTubeD, setClTubeD] = useState(DISPLAY_DEFAULTS.clTubeD); // 선형 튜브 지름(m)
const clTubeDRef = useRef(DISPLAY_DEFAULTS.clTubeD);
const [clDotDiv, setClDotDiv] = useState(DISPLAY_DEFAULTS.clDotDiv); // 측점 분할 수
const clDotDivRef = useRef(DISPLAY_DEFAULTS.clDotDiv);
const showDronePathRef = useRef(true);
const dronePathZRef = useRef(78);
useEffect(() => { showDronePathRef.current = showDronePath; }, [showDronePath]);
useEffect(() => { dronePathZRef.current = dronePathZ; }, [dronePathZ]);
useEffect(() => { clFxRef.current = clFx; }, [clFx]);
useEffect(() => { clTubeDRef.current = clTubeD; }, [clTubeD]);
useEffect(() => { clDotDivRef.current = clDotDiv; }, [clDotDiv]);
// 선형(중심선) 독립 토글 + 드론경로 투명도(0~1) — 토글은 설정 스토어 공유(바 버튼 제어).
const showCenterline = useSettingsStore(s => s.showCenterline);
const setShowCenterline = useSettingsStore(s => s.setShowCenterline);
@@ -661,6 +679,17 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
// Yaw±(자동추정/드래그 역산 결과) 포함 전체 파라미터가 담긴다.
// 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 최우선으로 자동 적용된다.
const [camSaveState, setCamSaveState] = useState<'idle' | 'ok'>('idle');
// 환경설정 파일 이름 — 폴더의 CSV(비행로그) 이름 → route.json 노선명 → 영상 base 순.
// 예: "제주 중산간도로 경로 1-1.camera.json" (로더의 *.camera.json 규칙으로 자동 인식)
const settingsBaseName = useCallback((): string => {
const geo = useGeoStore.getState();
const csv = geo.folderFiles.find((f) => /\.csv$/i.test(f.name));
if (csv) return csv.name.replace(/\.csv$/i, '');
const rn = geo.routeMeta?.routeInfo?.name;
if (rn && rn.trim()) return rn.trim();
return geo.baseName ?? 'settings';
}, []);
const saveCameraToPc = useCallback(() => {
const geo = useGeoStore.getState();
const payload = {
@@ -672,12 +701,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'camera.json';
a.download = `${settingsBaseName()}.camera.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
setCamSaveState('ok');
setTimeout(() => setCamSaveState('idle'), 2500);
}, []);
}, [settingsBaseName]);
// 폴더의 <영상 base>.display.json 화면표시 옵션 자동 적용 — 'PC에 저장' 버튼과 왕복.
// localStorage 복원(calib effect)보다 뒤에 선언되어 파일 값이 우선 반영된다.
@@ -692,6 +721,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const bool = (k: string): boolean | undefined =>
typeof d[k] === 'boolean' ? (d[k] as boolean) : undefined;
if (num('dronePathZ') !== undefined) setDronePathZ(num('dronePathZ')!);
if (bool('clFx') !== undefined) setClFx(bool('clFx')!);
if (num('clTubeD') !== undefined) setClTubeD(num('clTubeD')!);
if (num('clDotDiv') !== undefined) setClDotDiv(num('clDotDiv')!);
if (num('dronePathAlpha') !== undefined) setDronePathAlpha(num('dronePathAlpha')!);
if (num('smoothHalf') !== undefined) setSmoothHalf(num('smoothHalf')!);
if (num('emaAlpha') !== undefined) setEmaAlpha(num('emaAlpha')!);
@@ -723,7 +755,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const ss = useSettingsStore.getState();
const payload = {
display: {
dronePathZ, dronePathAlpha, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
dronePathZ, dronePathAlpha, clFx, clTubeD, clDotDiv, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
maxPoiRange, poiDroneHeight, droneHeightDrop,
showCenterline: ss.showCenterline,
showDronePath: ss.showDronePath,
@@ -739,12 +771,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'display.json';
a.download = `${settingsBaseName()}.display.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
setDispSaveState('ok');
setTimeout(() => setDispSaveState('idle'), 2500);
}, [dronePathZ, dronePathAlpha, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef, maxPoiRange, poiDroneHeight, droneHeightDrop]);
}, [dronePathZ, dronePathAlpha, clFx, clTubeD, clDotDiv, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef, maxPoiRange, poiDroneHeight, droneHeightDrop, settingsBaseName]);
// Yaw 자동 추정 — 전진 비행 구간에서 GPS 진행 방위(정확) vs 짐벌 헤딩(나침반 오차 포함)의
// 차이 중앙값을 Yaw± 로 설정. 카메라가 진행방향을 보며 촬영하는 도로 추종 비행 가정(근사치).
@@ -828,11 +860,40 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
}));
}, [storeStructures, storePois]);
// 중심선
// 중심선 (+ 누적거리, 측점 체이니지 앵커 — 10m 단위 선형 점 배치용)
useEffect(() => {
allCenterlinePointsRef.current = storeCenterline;
const cum: number[] = [0];
if (storeCenterline.length > 1) {
const k = Math.cos((storeCenterline[0].lat * Math.PI) / 180) * 111000;
for (let i = 1; i < storeCenterline.length; i++) {
const a = storeCenterline[i - 1], b = storeCenterline[i];
cum.push(cum[i - 1] + Math.hypot((b.lon - a.lon) * k, (b.lat - a.lat) * 111000));
}
}
clCumRef.current = cum;
// 측점(체이니지 값) ↔ 폴리라인 누적거리 앵커 — 측점 정점이 선형에 삽입되어 있어
// 최근접 정점(5m 이내)이 곧 그 측점 위치. 점을 '측점 기준 10m 단위'로 정렬하는 근거.
const anchors: { cum: number; km: number }[] = [];
if (storeCenterline.length > 1 && storeStations.length) {
const k2 = Math.cos((storeCenterline[0].lat * Math.PI) / 180) * 111000;
for (const st of storeStations) {
const m = st.title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
if (!m) continue;
const km = parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
let bi = -1, bd = Infinity;
for (let i = 0; i < storeCenterline.length; i++) {
const p = storeCenterline[i];
const d = Math.hypot((p.lon - st.lon) * k2, (p.lat - st.lat) * 111000);
if (d < bd) { bd = d; bi = i; }
}
if (bi >= 0 && bd < 5) anchors.push({ cum: cum[bi], km });
}
anchors.sort((a, b) => a.cum - b.cum);
}
clStaAnchorsRef.current = anchors;
setClDataLoaded(storeCenterline.length > 0);
}, [storeCenterline]);
}, [storeCenterline, storeStations]);
// 드론 프레임
useEffect(() => {
@@ -919,6 +980,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
};
const centerlineSegs: [number, number, number, number][] = [];
// 튜브 레일(입체형) — 세그먼트를 ~15m 분할, 분할점 사이 [x1,y1,x2,y2,평균거리m].
// 폭은 그리기에서 지름/거리 원근 환산.
const railTubes: number[][] = [];
// 유동 펄스 — 체이니지(월드)에 고정된 점 [x,y,거리m]. 화면 대시는 근거리에서
// 지면 대비 역행처럼 보이므로, 월드 좌표로 실속도 이동시켜 항상 '앞으로' 흐르게.
const railPulses: number[][] = [];
if (showCenterlineRef.current && allCL.length) {
// 표고 미상(z≤0) 중심선(예: 제주 KML 선형 — 고도 전부 0)은 '경로 표고' 평면에 앵커 —
// 측점/POI 라벨과 동일 기준. z=0 그대로 투영하면 해수면 높이로 그려져 크게 어긋난다.
@@ -959,6 +1026,19 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
: null;
const projCL = (lat: number, lon: number, z: number): CameraCoords =>
toCameraCoords(drone, lat, lon, planeZ ?? (z > 0 ? z : CL_Z), params, worldOrigin);
// 진행방향 벡터(동/북) — 유동 대시가 '가는 방향'으로 흐르도록 세그먼트 정렬 기준.
// 실제 이동(±3행 GPS 변위) 우선, 이동이 미미(호버)하면 카메라 yaw 폴백.
let travelE = Math.sin((drone.yaw * Math.PI) / 180);
let travelN = Math.cos((drone.yaw * Math.PI) / 180);
{
const fr = allDroneFramesRef.current;
const ci = currentFrameIdxRef.current;
if (fr.length > 6) {
const A = fr[Math.max(0, ci - 3)], B = fr[Math.min(fr.length - 1, ci + 3)];
const de = (B.lon - A.lon) * cosLat, dn = B.lat - A.lat;
if (Math.hypot(de, dn) * 111000 > 1) { travelE = de; travelN = dn; }
}
}
for (let i = 1; i < allCL.length; i++) {
const p0 = allCL[i - 1], p1 = allCL[i];
const e0 = enOf(p0), e1 = enOf(p1);
@@ -984,8 +1064,85 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
if (in0) a1 = cut; else a0 = cut;
}
}
const s = segFrom(projCL(a0.lat, a0.lon, a0.z), projCL(a1.lat, a1.lon, a1.z));
if (s) centerlineSegs.push(s);
// 세그먼트를 진행방향(뒤→앞)으로 정렬 — 대시 흐름이 항상 '가는 방향'이 되게.
if ((e1[0] - e0[0]) * travelE + (e1[1] - e0[1]) * travelN < 0) {
const tmp = a0; a0 = a1; a1 = tmp;
}
const cT0 = projCL(a0.lat, a0.lon, a0.z);
const cT1 = projCL(a1.lat, a1.lon, a1.z);
const s = segFrom(cT0, cT1);
if (s) {
centerlineSegs.push(s);
// 커튼: 세그먼트를 ~15m 단위로 분할, 각 경계에서 윗변(z)·아랫변(z−H)을 투영.
// 분할 경계마다 수직살을 세워 원근 수렴(멀수록 촘촘·짧아짐)으로 입체감을 만든다.
if (clFxRef.current) {
const e0c = enOf(a0), e1c = enOf(a1);
const segLen = Math.hypot(e1c[0] - e0c[0], e1c[1] - e0c[1]);
const N = Math.max(1, Math.min(40, Math.ceil(segLen / 15)));
const zt0 = planeZ ?? (a0.z > 0 ? a0.z : CL_Z);
const zt1 = planeZ ?? (a1.z > 0 ? a1.z : CL_Z);
let prev: { xt: number; yt: number; d: number } | null = null;
for (let k = 0; k <= N; k++) {
const t = k / N;
const wla = a0.lat + (a1.lat - a0.lat) * t;
const wlo = a0.lon + (a1.lon - a0.lon) * t;
const wzt = zt0 + (zt1 - zt0) * t;
const cT = toCameraCoords(drone, wla, wlo, wzt, params, worldOrigin);
if (cT.Zc < CLIP_Z) { prev = null; continue; }
const pT = pixelFromCamera(cT, params);
const cur = { xt: pT.pxRaw, yt: pT.pyRaw, d: cT.distH ?? 0 };
if (prev && !(oc(prev.xt, prev.yt) & oc(cur.xt, cur.yt))) {
railTubes.push([prev.xt, prev.yt, cur.xt, cur.yt, (prev.d + cur.d) / 2]);
}
prev = cur;
}
}
}
}
// 선형 점 — 측점(100m) 사이를 십등분한 '10m 단위' 체이니지 고정점(궤적 측정점 방식).
// 사용자가 10m 간격으로 검토 가능. 50m 지점은 약간 크게(중간 확인). 100m 지점은
// 측점 눈금이 이미 있어 생략. 측점 앵커 없으면 20m 등간격 폴백.
if (clFxRef.current) {
const cum = clCumRef.current;
if (cum.length === allCL.length && cum.length > 1) {
const anchors = clStaAnchorsRef.current;
const targets: { s: number; big: boolean }[] = [];
const div = Math.max(1, Math.round(clDotDivRef.current));
if (anchors.length >= 2 && div > 1) {
// 인접 측점 구간을 div 등분 — 등분점(측점 자체 제외)에 점, 중간(½)은 크게.
for (let ai = 0; ai < anchors.length - 1; ai++) {
const A = anchors[ai], B = anchors[ai + 1];
if (A.km === B.km) continue;
for (let j = 1; j < div; j++) {
const tt = j / div;
targets.push({ s: A.cum + tt * (B.cum - A.cum), big: div % 2 === 0 && j === div / 2 });
}
}
} else if (anchors.length >= 2) {
// div=1 — 점 없음
} else {
const total = cum[cum.length - 1];
for (let sb = 0; sb <= total; sb += 20) targets.push({ s: sb, big: false });
}
const total = cum[cum.length - 1];
for (const tg of targets) {
const sPos = tg.s;
if (sPos < 0 || sPos > total) continue;
let lo = 0, hi = cum.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (cum[m] <= sPos) lo = m; else hi = m; }
const tt = cum[hi] > cum[lo] ? (sPos - cum[lo]) / (cum[hi] - cum[lo]) : 0;
const la = allCL[lo].lat + (allCL[hi].lat - allCL[lo].lat) * tt;
const ln = allCL[lo].lon + (allCL[hi].lon - allCL[lo].lon) * tt;
const zz = allCL[lo].z + (allCL[hi].z - allCL[lo].z) * tt;
const e = enOf({ lat: la, lon: ln });
if (Math.hypot(e[0], e[1]) > clipR) continue;
const cc = toCameraCoords(drone, la, ln, planeZ ?? (zz > 0 ? zz : CL_Z), params, worldOrigin);
if (cc.Zc < CLIP_Z) continue;
const p = pixelFromCamera(cc, params);
if (oc(p.pxRaw, p.pyRaw)) continue;
railPulses.push([p.pxRaw, p.pyRaw, cc.distH ?? 0, tg.big ? 1 : 0]);
}
}
}
}
@@ -1009,7 +1166,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
dronePathPts.push([p.pxRaw, p.pyRaw]);
}
}
return { centerlineSegs, dronePathPts };
return { centerlineSegs, railTubes, railPulses, dronePathPts };
}, []);
// 보간 포즈 — estFrame(연속 프레임번호)에서 앞뒤 smoothFrame 선형보간(yaw는 최단각). RAF에서 호출.
@@ -1042,6 +1199,55 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
};
}, [smoothFrame, poseHalf]);
// ── 자세 속도적응 필터(One Euro) — frameSynced 전용, RAF 에서 dronePose 에 적용 ──
// 평활 제거(POSE_SMOOTH_SYNCED_MAX=0) 후 남는 자세 미세 잡음(짐벌 피치 정수 양자화 등)이
// 직진 시 원거리 라벨 떨림으로 보임 → 변화 느릴 때만 강하게 평활(MIN_CUT), 팬처럼 빠르면
// 컷오프가 속도에 비례해 올라가 즉시 추종(지연 없음). 위치(djmd)는 잡음 없어 미적용.
// 필터 강도 — route.json routeInfo 로 폴더별 조정:
// att_min_cut(Hz, 기본 0.8): 저속 변화 평활 강도 — 작을수록 떨림 억제 강함(0=필터 끔 아님, 0.1~3 권장)
// att_beta(기본 0.5): 각속도(°/s)당 컷오프 증가 — 클수록 회전 추종 민감
const attMinCutRef = useRef(0.8);
const attBetaRef = useRef(0.5);
useEffect(() => {
const ri = routeMeta?.routeInfo as Record<string, unknown> | undefined;
const num = (v: unknown): number | null =>
typeof v === 'number' && isFinite(v) ? v
: typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) ? Number(v) : null;
attMinCutRef.current = num(ri?.att_min_cut) ?? 0.8;
attBetaRef.current = num(ri?.att_beta) ?? 0.5;
if (ri?.att_min_cut !== undefined || ri?.att_beta !== undefined) {
console.log(`[오버레이] 자세 필터: min_cut ${attMinCutRef.current}Hz, beta ${attBetaRef.current} (route.json)`);
}
}, [routeMeta]);
const attFilterRef = useRef<{ yaw: number; pitch: number; roll: number; dyaw: number; dpitch: number; droll: number; ts: number } | null>(null);
const filterAttitude = useCallback((p: DroneFrameBasic): DroneFrameBasic => {
if (!frameSyncedRef.current) { attFilterRef.current = null; return p; }
const now = performance.now() / 1000;
const st = attFilterRef.current;
const angDiff = (a: number, b: number): number => ((a - b + 540) % 360) - 180;
// 초기화/시크·세그먼트 전환(큰 점프)/장시간 정지 후에는 필터 리셋
if (!st || now - st.ts > 0.5 || Math.abs(angDiff(p.yaw, st.yaw)) > 30) {
attFilterRef.current = { yaw: p.yaw, pitch: p.pitch, roll: p.roll, dyaw: 0, dpitch: 0, droll: 0, ts: now };
return p;
}
const dt = Math.max(1e-3, Math.min(0.1, now - st.ts));
const alphaOf = (fc: number): number => { const r = 2 * Math.PI * fc * dt; return r / (r + 1); };
const MIN_CUT = attMinCutRef.current; // Hz — 저속 변화 평활 강도(작을수록 강한 평활)
const BETA = attBetaRef.current; // 속도(°/s)당 컷오프 증가 — 팬 시 즉시 추종
const D_CUT = 1.0; // 속도 추정 평활
const one = (val: number, prev: number, dprev: number, ang: boolean): [number, number] => {
const diff = ang ? angDiff(val, prev) : val - prev;
const d = dprev + alphaOf(D_CUT) * (diff / dt - dprev);
const a = alphaOf(MIN_CUT + BETA * Math.abs(d));
return [prev + a * diff, d];
};
const [yaw, dyaw] = one(p.yaw, st.yaw, st.dyaw, true);
const [pitch, dpitch] = one(p.pitch, st.pitch, st.dpitch, false);
const [roll, droll] = one(p.roll, st.roll, st.droll, false);
attFilterRef.current = { yaw, pitch, roll, dyaw, dpitch, droll, ts: now };
return { ...p, yaw, pitch, roll };
}, []);
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
const id = ++precomputeIdRef.current;
@@ -1366,9 +1572,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const estFrame = estTime * fpsRef.current;
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
const dronePose = filterAttitude(poseAt(estFrame) ?? (allDroneFramesRef.current.length
? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, poseHalf())
: currentDroneFrameRef.current!);
: currentDroneFrameRef.current!));
const lines = buildLines(dronePose);
// 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
@@ -1400,8 +1606,11 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
}
}
// 선로 중심선 (선형) — 독립 토글
if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
// 선로 중심선 (선형) — 독립 토글. '홀로그램 펜스' 입체 표현:
// ① 그라데이션 커튼(위 진함→아래 소멸) ② 수직살(원근 수렴 + 원거리 페이드)
// ③ 상단 레일 글로우 ④ 진행방향 유동 대시(흐르는 빛)
if (showCenterlineRef.current && lines.centerlineSegs.length > 0 && !clFxRef.current) {
// 입체 효과 꺼짐 — 종전처럼 단순 선만
ctx.strokeStyle = 'rgba(255,50,50,0.85)';
ctx.lineWidth = 3;
ctx.setLineDash([]);
@@ -1411,6 +1620,90 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
ctx.lineTo(vx(px2), vy(py2));
}
ctx.stroke();
} else if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
ctx.setLineDash([]);
// 상단 레일 — 튜브(지름 있는 원형 단면): 각크기(지름/거리)를 픽셀 폭으로 환산해
// 가까울수록 굵고 멀수록 가늘게 + 3중 스트로크(외곽 어둡게/본체/하이라이트)로 원통 음영.
const railPath = (): void => {
ctx.beginPath();
for (const [px1, py1, px2, py2] of lines.centerlineSegs) {
ctx.moveTo(vx(px1), vy(py1));
ctx.lineTo(vx(px2), vy(py2));
}
};
// 반투명 원통 셸 — 중심의 진한 실선을 감싸는 유리관 느낌(다층 저알파 스트로크).
// 폭은 지름/거리 원근 환산, 안쪽으로 갈수록 은은히 밝아져 원통 볼륨감.
if (lines.railTubes.length > 0) {
const pp = paramsRef.current;
const hfov = 2 * Math.atan((pp.sensorW ?? 36) / (2 * pp.focalLen));
const pxPerRad = (coverRef.current?.dispW || canvasSizeRef.current.w || 1920) / hfov;
const D = clTubeDRef.current;
// 이음매 중첩 제거: 반투명 조각을 직접 겹치면 이음/폭 단차마다 알파가 중첩돼
// 잘려 보인다 → 오프스크린에 '불투명'으로 그린 뒤 전체를 한 번에 알파 합성.
const cv = ctx.canvas;
let oc2 = tubeCanvasRef.current;
if (!oc2 || oc2.width !== cv.width || oc2.height !== cv.height) {
oc2 = document.createElement('canvas');
oc2.width = cv.width;
oc2.height = cv.height;
tubeCanvasRef.current = oc2;
}
const otx = oc2.getContext('2d');
if (otx) {
otx.clearRect(0, 0, oc2.width, oc2.height);
otx.lineCap = 'round';
const passes: Array<[number, string]> = [
[1.0, 'rgb(150,30,30)'],
[0.66, 'rgb(225,60,58)'],
[0.34, 'rgb(255,140,125)'],
];
// 폭 버킷(0.5px)별로 묶어 스트로크 횟수 절감(불투명이라 겹침 무해)
for (const [fw, col] of passes) {
otx.strokeStyle = col;
const buckets = new Map<number, number[][]>();
for (const tb of lines.railTubes) {
const w = Math.min(44, Math.max(2, (D / Math.max(5, tb[4])) * pxPerRad)) * fw;
const key = Math.round(w * 2);
let arr = buckets.get(key);
if (!arr) { arr = []; buckets.set(key, arr); }
arr.push(tb);
}
for (const [key, arr] of buckets) {
otx.lineWidth = key / 2;
otx.beginPath();
for (const tb of arr) {
otx.moveTo(vx(tb[0]), vy(tb[1]));
otx.lineTo(vx(tb[2]), vy(tb[3]));
}
otx.stroke();
}
}
ctx.save();
ctx.globalAlpha = 0.32;
ctx.drawImage(oc2, 0, 0);
ctx.restore();
}
} else {
ctx.strokeStyle = 'rgba(255,50,50,0.20)';
ctx.lineWidth = 8;
railPath();
ctx.stroke();
}
// 중심선(코어) — 항상 진한 실선. 반투명 원통이 이 선을 감싼다.
ctx.strokeStyle = 'rgba(205,25,25,0.95)';
ctx.lineWidth = 2.5;
railPath();
ctx.stroke();
// 선형 점 — 측점 체이니지 10m 단위 고정점(궤적 측정점과 동일 질감).
// 50m 지점은 약간 크게 — 측점 사이 중간 위치를 한눈에 확인.
if (lines.railPulses.length > 0) {
ctx.fillStyle = 'rgba(255,230,90,0.9)';
for (const q of lines.railPulses) {
ctx.beginPath();
ctx.arc(vx(q[0]), vy(q[1]), q[3] ? 2.8 : 1.8, 0, Math.PI * 2);
ctx.fill();
}
}
}
// 드론 경로 — 하나의 연속 폴리라인(둥근 연결)으로 부드럽게 + 작은 점. 독립 토글 + 투명도
@@ -1924,6 +2217,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
setShowCenterline(bool('showCenterline', DISPLAY_DEFAULTS.showCenterline));
setShowDronePath(bool('showDronePath', DISPLAY_DEFAULTS.showDronePath));
setDronePathZ(num('dronePathZ', DISPLAY_DEFAULTS.dronePathZ));
setClFx(bool('clFx', DISPLAY_DEFAULTS.clFx));
setClTubeD(num('clTubeD', DISPLAY_DEFAULTS.clTubeD));
setClDotDiv(num('clDotDiv', DISPLAY_DEFAULTS.clDotDiv));
setDronePathAlpha(num('dronePathAlpha', DISPLAY_DEFAULTS.dronePathAlpha));
setSmoothHalf(num('smoothHalf', DISPLAY_DEFAULTS.smoothHalf));
setEmaAlpha(num('emaAlpha', DISPLAY_DEFAULTS.emaAlpha));
@@ -2091,6 +2387,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
{/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */}
<ParamRow label="경로 표고" tip={`GPS 측정점(노란 점)을 표고 ${dronePathZ}m 에 투영·연결`} value={dronePathZ} min={-10} max={pathZMax} step={1} unit="m" decimals={0} onChange={v => setDronePathZ(Math.round(v))} />
<button onClick={() => setClFx(v => !v)}
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${clFx ? 'bg-red-500/70 border-red-300 text-white font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
{clFx ? '■ 선형 입체형 (원통 + 유동 대시)' : '▶ 선형 기본형 (단순 선)'}
</button>
{clFx && (
<>
<ParamRow label="선형 지름" tip="입체형 원통의 실제 지름(m) — 가까울수록 굵고 멀수록 가늘게 원근 표현. display.json(clTubeD) 저장" value={clTubeD} min={0.2} max={5} step={0.1} unit="m" decimals={1} onChange={v => setClTubeD(v)} />
<ParamRow label="측점 분할" tip="측점(100m) 사이를 N등분한 위치에 노란 점 표시 — 10이면 10m 단위. 1 = 점 없음. 중간(½) 지점은 크게. display.json(clDotDiv) 저장" value={clDotDiv} min={1} max={20} step={1} unit="등분" decimals={0} onChange={v => setClDotDiv(Math.round(v))} />
</>
)}
<ParamRow label="투명도" tip={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2"> <span className="text-gray-600">( 500ms )</span></div>