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>
This commit is contained in:
@@ -1,49 +1,96 @@
|
||||
/* B04 지표면 3D 뷰어 방위 콤파스 — 바늘이 늘 도북(N)을 가리킨다.
|
||||
/* B04 지표면 3D 뷰어 방위 표시 — 카메라 자세를 그대로 따르는 **3축 트라이어드**.
|
||||
*
|
||||
* 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y. 그래서 세계에서 북쪽은
|
||||
* **-z** 다. 화면 위쪽은 카메라가 보는 수평 방향이므로, 카메라 오프셋(카메라 − 타깃)만
|
||||
* 알면 북쪽이 화면에서 몇 도 돌아가 있는지 나온다 — `Math.atan2(offset.x, offset.z)`.
|
||||
* (기본 시점 offset=(0, d, d·tilt)면 0° = 북쪽이 화면 위, 카메라가 동쪽으로 가면 +90°.)
|
||||
* 종전에는 방위각만으로 도는 평면 콤파스였다. 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, offsetZ: number): void;
|
||||
/** 카메라 오프셋(카메라 위치 − 타깃)으로 축 방향을 맞춘다. */
|
||||
update(offsetX: number, offsetY: number, offsetZ: number): void;
|
||||
setVisible(visible: boolean): void;
|
||||
}
|
||||
|
||||
const NEEDLE_SVG = `
|
||||
<svg viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
|
||||
<circle cx="22" cy="22" r="20" class="b04-surface__compass-ring" />
|
||||
<polygon points="22,5 27,22 22,19 17,22" class="b04-surface__compass-north" />
|
||||
<polygon points="22,39 17,22 22,25 27,22" class="b04-surface__compass-south" />
|
||||
<text x="22" y="14" class="b04-surface__compass-letter">N</text>
|
||||
/** 세계 축 — [이름, 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)";
|
||||
root.title = "도북(N) · 동(E) · 표고(Z)";
|
||||
root.innerHTML = GIZMO_SVG;
|
||||
|
||||
const dial = document.createElement("div");
|
||||
dial.className = "b04-surface__compass-dial";
|
||||
dial.innerHTML = NEEDLE_SVG;
|
||||
root.append(dial);
|
||||
|
||||
// 프레임마다 style을 다시 쓰지 않도록 직전 각도를 들고 있는다.
|
||||
let lastHeading = Number.NaN;
|
||||
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, offsetZ: number): void {
|
||||
const heading = (Math.atan2(offsetX, offsetZ) * 180) / Math.PI;
|
||||
if (Number.isFinite(lastHeading) && Math.abs(heading - lastHeading) < 0.5) return;
|
||||
lastHeading = heading;
|
||||
dial.style.transform = `rotate(${heading.toFixed(1)}deg)`;
|
||||
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;
|
||||
|
||||
@@ -567,11 +567,6 @@
|
||||
filter: drop-shadow(0 1px 2px var(--color-surface-raised));
|
||||
}
|
||||
|
||||
.b04-surface__compass-dial {
|
||||
transform-origin: 50% 50%;
|
||||
transition: transform 80ms linear;
|
||||
}
|
||||
|
||||
.b04-surface__compass-ring {
|
||||
fill: var(--color-surface-raised);
|
||||
fill-opacity: 0.75;
|
||||
@@ -579,15 +574,14 @@
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b04-surface__compass-north {
|
||||
fill: var(--color-danger, #d64545);
|
||||
/* 축 하나 = 선 + 글자. 좌표는 카메라 자세대로 매 프레임 다시 쓰인다. */
|
||||
.b04-surface__compass-axis line {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.b04-surface__compass-south {
|
||||
fill: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b04-surface__compass-letter {
|
||||
.b04-surface__compass-axis text {
|
||||
fill: var(--color-text-body);
|
||||
font-family: var(--font-body);
|
||||
font-size: 9px;
|
||||
@@ -595,6 +589,15 @@
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
/* 도북은 눈에 먼저 들어와야 한다 — 선·글자 모두 강조색. */
|
||||
.b04-surface__compass-axis--n line {
|
||||
stroke: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
.b04-surface__compass-axis--n text {
|
||||
fill: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
/* --- 하단 2D 지도 --- */
|
||||
.b04-map {
|
||||
--b04-map-vector: var(--color-accent);
|
||||
|
||||
@@ -800,7 +800,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
if (terrainMesh && terrainMesh.visible) {
|
||||
scaleBar.hidden = false;
|
||||
compass.setVisible(true);
|
||||
compass.update(camera.position.x - controls.target.x, camera.position.z - controls.target.z);
|
||||
compass.update(
|
||||
camera.position.x - controls.target.x,
|
||||
camera.position.y - controls.target.y,
|
||||
camera.position.z - controls.target.z,
|
||||
);
|
||||
const dist = camera.position.distanceTo(controls.target);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
|
||||
const roughMeters = 100 * metersPerPixel;
|
||||
|
||||
Reference in New Issue
Block a user