Files
Aislo/B04_PreProcess/B04_PreProcess_UI_Compass.ts
T
eomsangdonandClaude Opus 5 dcabce147e feat(B04): 지표면 3D 방위 표시를 3축 트라이어드로 교체
옛 콤파스는 방위각(`atan2(ox, oz)`)만 써서 경사에 따른 남북 압축이 빠졌음 —
사시도 프리셋(오프셋 120·95·135, 앙각 27.7°)에서 바늘 41.6°, 화면 실제 북쪽 62.4°로
20.8° 어긋났음(2026-09-03 사용자 지적).

세계 축 N(−z)·E(+x)·Z(+y)를 카메라 화면 기저(오른쪽 r, 위 u)에 투영해 선과 글자를
그림. 기저는 카메라 오프셋만으로 구함(up = +y 고정). 뒤로 누운 축(깊이 > 0.25)과
정면으로 와 짧아진 축은 흐리게 그려 겹침을 줄임.

검증: typecheck 통과. 기본 시점에서 DOM 좌표가 해석값과 일치 —
N (28.0, 11.0) · E (45.0, 28.0) · Z (28.0, 27.4). 회전 중 거동은 사용자 화면 검증 대기.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 19:54:13 +09:00

100 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = `
<svg viewBox="0 0 56 56" width="56" height="56" aria-hidden="true">
<circle cx="28" cy="28" r="26" class="b04-surface__compass-ring" />
${AXES.map(
([name]) => `
<g class="b04-surface__compass-axis b04-surface__compass-axis--${name.toLowerCase()}" data-axis="${name}">
<line x1="28" y1="28" x2="28" y2="28" />
<text x="28" y="28">${name}</text>
</g>`,
).join("")}
</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<SVGGElement>(`[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;
},
};
}