/* B04 지표면 3D 뷰어 방위 표시 — 카메라 자세를 그대로 따르는 **3축 트라이어드**. * * 종전에는 방위각만으로 도는 평면 콤파스였다. 3D 화면은 기울어 있어 남북이 화면에서 * 눌리는데(사시도 앙각 27.7° → 0.47배) 바늘은 안 눌린 각도로 돌아, 실제 화면 북쪽과 * 최대 20.8° 어긋났다(2026-09-03 사용자 지적·실측). 축을 카메라 기저에 투영해 그리면 * 그 어긋남이 원리적으로 사라진다. * * 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x(동), 높이, -y. 그래서 세계에서 * 북쪽은 **-z**, 동쪽은 **+x**, 표고는 **+y** 다. 카메라 오프셋(카메라 − 타깃)만 있으면 * 화면 기저를 만들 수 있다(up = +y 고정): * · 오른쪽 r = (oz, 0, −ox)/h, 위 u = (−ox·oy, h², −oz·oy)/(h·L) * · 화면 좌표 = (v·r, v·u), 깊이 = v·(−offset)/L — 양수면 시선 방향(뒤)이라 흐리게. * * 뷰어 파일이 이미 900줄을 넘어 여기로 뺐다(CLAUDE.md 4장 700줄 제한). */ export interface TerrainCompass { root: HTMLElement; /** 카메라 오프셋(카메라 위치 − 타깃)으로 축 방향을 맞춘다. */ update(offsetX: number, offsetY: number, offsetZ: number): void; setVisible(visible: boolean): void; } /** 세계 축 — [이름, x, y, z]. 표고는 측량 관행대로 Z로 적는다(평면직각 X=북·Y=동·Z=표고). */ const AXES: Array<[string, number, number, number]> = [ ["N", 0, 0, -1], ["E", 1, 0, 0], ["Z", 0, 1, 0], ]; const CENTER = 28; const AXIS_RADIUS = 17; const LABEL_RADIUS = 23; /** 이보다 뒤로 누운 축은 흐리게 — 0 근처에서 깜빡이지 않게 여유를 둔다. */ const BEHIND_DEPTH = 0.25; const GIZMO_SVG = ` `; export function createTerrainCompass(): TerrainCompass { const root = document.createElement("div"); root.className = "b04-surface__compass"; root.hidden = true; root.title = "도북(N) · 동(E) · 표고(Z)"; root.innerHTML = GIZMO_SVG; const groups = AXES.map( ([name]) => root.querySelector(`[data-axis="${name}"]`) as SVGGElement, ); // 프레임마다 다시 그리지 않도록 직전 시선 방향(단위 벡터)을 들고 있는다. let last = { x: Number.NaN, y: 0, z: 0 }; return { root, update(offsetX: number, offsetY: number, offsetZ: number): void { const length = Math.hypot(offsetX, offsetY, offsetZ); if (!(length > 0)) return; const unit = { x: offsetX / length, y: offsetY / length, z: offsetZ / length }; const moved = !Number.isFinite(last.x) || Math.abs(unit.x - last.x) + Math.abs(unit.y - last.y) + Math.abs(unit.z - last.z) > 0.008; if (!moved) return; last = unit; // 수평 성분. 정확히 수직으로 내려다보면 0이 되므로 하한을 둔다(시점 프리셋도 2° 기울임). const horizontal = Math.max(Math.hypot(offsetX, offsetZ), 1e-6); AXES.forEach(([, vx, vy, vz], index) => { const screenX = (vx * offsetZ - vz * offsetX) / horizontal; const screenY = (vy * horizontal * horizontal - (vx * offsetX + vz * offsetZ) * offsetY) / (horizontal * length); const depth = -(vx * unit.x + vy * unit.y + vz * unit.z); const group = groups[index]; const line = group.firstElementChild as SVGLineElement; const label = group.lastElementChild as SVGTextElement; // SVG는 y가 아래로 자라므로 화면 위 성분을 뒤집는다. line.setAttribute("x2", (CENTER + screenX * AXIS_RADIUS).toFixed(1)); line.setAttribute("y2", (CENTER - screenY * AXIS_RADIUS).toFixed(1)); label.setAttribute("x", (CENTER + screenX * LABEL_RADIUS).toFixed(1)); // 글자는 baseline이 아래라 시각 중심을 맞추려면 조금 내린다. label.setAttribute("y", (CENTER - screenY * LABEL_RADIUS + 3).toFixed(1)); // 정면으로 오는 축은 화면에서 짧아진다 — 그만큼 흐려 겹침을 덜 만든다. const foreshorten = Math.hypot(screenX, screenY); const opacity = Math.max(0.35, foreshorten) * (depth > BEHIND_DEPTH ? 0.5 : 1); group.setAttribute("opacity", opacity.toFixed(2)); }); }, setVisible(visible: boolean): void { root.hidden = !visible; }, }; }