diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx index 6821428..4cd5b20 100644 --- a/client/src/components/overlay/StationOverlay.tsx +++ b/client/src/components/overlay/StationOverlay.tsx @@ -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([]); const allCenterlinePointsRef = useRef([]); + const clCumRef = useRef([]); // 중심선 정점 누적거리(m) — 선형 점 배치용 + const clStaAnchorsRef = useRef<{ cum: number; km: number }[]>([]); // 측점 체이니지 ↔ 누적거리 앵커 + const tubeCanvasRef = useRef(null); // 원통 셸 오프스크린(불투명→일괄 알파 합성) const allGeoStationsRef = useRef([]); const allPoisRef = useRef([]); const allStructuresRef = useRef([]); // 교량/터널/구교 → 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 | 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(); + 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 무관하게 항상 표시. */} setDronePathZ(Math.round(v))} /> + + {clFx && ( + <> + setClTubeD(v)} /> + setClDotDiv(Math.round(v))} /> + + )} setDronePathAlpha(v)} />
스무딩 (재계산 500ms 후)