Files
Aislo/B04_PreProcess/B04_PreProcess_UI_Compass.ts
T
eomsangdonandClaude Opus 5 b5de29679f feat(B04): 방위 표시를 지면에 누운 나침반 링으로
시안 3종 비교 뒤 사용자 선택(2026-09-03) — TerriaJS·cesium-navigation 계열 표기.
링·눈금·도북 화살을 지면 평면 도형으로 두고 카메라에 투영하므로, 링이 눌린 정도가
곧 시점 기울기이고 도북은 늘 화면의 실제 북쪽을 가리킴.

- 15° 눈금(90°마다 긴 눈금), N·E·S·W 라벨(뒤로 넘어가면 흐림).
- 표고축 침은 내려다볼수록 짧아지고, 점으로 눌리면 Z 라벨을 감춤.
- 위젯 56 → 84px — 라벨이 읽히는 최소 크기.

검증: typecheck·prettier 통과. 리로드 실측 — 위젯 84×84, 링 경로 `M0.00 −33.98…`,
도북 화살 `M0.00 −33.30 L−4.28 −17.14 L4.28 −17.14Z` 로 기본 시점 계산값과 일치.
회전 중 거동은 사용자 화면 검증 대기.

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

205 lines
8.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 뷰어 방위 표시 — **지면에 누운 나침반 링**(2026-09-03 사용자 선택).
*
* 종전에는 방위각만으로 도는 평면 콤파스였다. 3D 화면은 기울어 있어 남북이 화면에서
* 눌리는데(사시도 앙각 27.7° → 0.47배) 바늘은 안 눌린 각도로 돌아, 실제 화면 북쪽과
* 최대 20.8° 어긋났다(2026-09-03 사용자 지적·실측). 링·눈금·도북 화살을 **지면 평면
* 위의 도형으로 두고 카메라에 투영**하면 그 어긋남이 원리적으로 사라지고, 링이 눌린
* 정도가 곧 시점의 기울기가 된다(TerriaJS·cesium-navigation 계열 표기).
*
* 뷰어 좌표 규약은 백엔드 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;
}
const SIZE_PX = 84;
const VIEW_BOX = 104;
const CENTER = 0;
const RING_RADIUS = 34;
/** 눈금 간격(도)과 90°마다 주는 긴 눈금. */
const TICK_STEP_DEG = 15;
const TICK_INNER = 0.93;
const TICK_INNER_MAJOR = 0.86;
const LABEL_RADIUS = RING_RADIUS * 1.3;
/** 링을 그리는 다각형 분할(도) — 6°면 84px에서 원으로 보인다. */
const RING_STEP_DEG = 6;
/** 이보다 뒤로 넘어간 글자는 흐리게 — 0 근처에서 깜빡이지 않게 여유를 둔다. */
const BEHIND_DEPTH = 0.3;
const DEG = Math.PI / 180;
/** 방위(도, 북=0, 시계 방향) 위치의 지면 벡터. */
function bearingVector(bearingDeg: number, radius: number): [number, number, number] {
const radians = bearingDeg * DEG;
return [Math.sin(radians) * radius, 0, -Math.cos(radians) * radius];
}
const CARDINALS: Array<[string, number]> = [
["N", 0],
["E", 90],
["S", 180],
["W", 270],
];
function svg(tag: string, attributes: Record<string, string>): SVGElement {
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
return element;
}
export function createTerrainCompass(): TerrainCompass {
const root = document.createElement("div");
root.className = "b04-surface__compass";
root.hidden = true;
root.title = "도북(N) — 링이 지면에 누워 시점 기울기를 함께 보여 준다";
const half = VIEW_BOX / 2;
const canvas = svg("svg", {
viewBox: `${-half} ${-half} ${VIEW_BOX} ${VIEW_BOX}`,
width: String(SIZE_PX),
height: String(SIZE_PX),
"aria-hidden": "true",
});
const face = svg("path", { class: "b04-surface__compass-face", d: "" });
const ring = svg("path", { class: "b04-surface__compass-ring", d: "" });
const ticks: SVGElement[] = [];
for (let bearing = 0; bearing < 360; bearing += TICK_STEP_DEG) {
const tick = svg("line", {
class: `b04-surface__compass-tick${bearing % 90 === 0 ? " b04-surface__compass-tick--major" : ""}`,
x1: "0",
y1: "0",
x2: "0",
y2: "0",
});
ticks.push(tick);
}
const arrow = svg("path", { class: "b04-surface__compass-arrow", d: "" });
const pole = svg("line", {
class: "b04-surface__compass-pole",
x1: "0",
y1: "0",
x2: "0",
y2: "0",
});
const labels = CARDINALS.map(([name]) =>
svg("text", {
class: `b04-surface__compass-label${name === "N" ? " b04-surface__compass-label--north" : ""}`,
x: "0",
y: "0",
}),
);
labels.forEach((label, index) => {
label.textContent = CARDINALS[index][0];
});
const poleLabel = svg("text", { class: "b04-surface__compass-label", x: "0", y: "0" });
poleLabel.textContent = "Z";
canvas.append(face, ring, ...ticks, arrow, pole, ...labels, poleLabel);
root.append(canvas);
// 프레임마다 다시 그리지 않도록 직전 시선 방향(단위 벡터)을 들고 있는다.
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);
/** 세계 벡터 → 화면 좌표(SVG는 y가 아래로 자라 위 성분을 뒤집는다)와 깊이. */
const project = (
vector: [number, number, number],
): { x: number; y: number; depth: number } => {
const [vx, vy, vz] = vector;
const alongCamera = vx * offsetX + vz * offsetZ;
// 깊이는 방향만 보므로 벡터 길이로 정규화한다(1 = 시선 정방향, −1 = 화면 앞).
const size = Math.max(Math.hypot(vx, vy, vz), 1e-6);
return {
x: CENTER + (vx * offsetZ - vz * offsetX) / horizontal,
y:
CENTER - (vy * horizontal * horizontal - alongCamera * offsetY) / (horizontal * length),
depth: -(vx * unit.x + vy * unit.y + vz * unit.z) / size,
};
};
/** 지면 원(반지름 r)을 다각형으로 — 시점이 누우면 그대로 눌린 타원이 된다. */
const groundCircle = (radius: number): string => {
let path = "";
for (let bearing = 0; bearing <= 360; bearing += RING_STEP_DEG) {
const point = project(bearingVector(bearing, radius));
path += `${path ? "L" : "M"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
}
return `${path}Z`;
};
ring.setAttribute("d", groundCircle(RING_RADIUS));
face.setAttribute("d", groundCircle(RING_RADIUS * 0.97));
ticks.forEach((tick, index) => {
const bearing = index * TICK_STEP_DEG;
const inner = project(
bearingVector(
bearing,
RING_RADIUS * (bearing % 90 === 0 ? TICK_INNER_MAJOR : TICK_INNER),
),
);
const outer = project(bearingVector(bearing, RING_RADIUS));
tick.setAttribute("x1", inner.x.toFixed(2));
tick.setAttribute("y1", inner.y.toFixed(2));
tick.setAttribute("x2", outer.x.toFixed(2));
tick.setAttribute("y2", outer.y.toFixed(2));
});
// 도북 화살도 지면에 누운 삼각형이라 시점이 눕는 만큼 함께 눌린다.
const tip = project(bearingVector(0, RING_RADIUS * 0.98));
const left = project(bearingVector(-14, RING_RADIUS * 0.52));
const right = project(bearingVector(14, RING_RADIUS * 0.52));
arrow.setAttribute(
"d",
`M${tip.x.toFixed(2)} ${tip.y.toFixed(2)}L${left.x.toFixed(2)} ${left.y.toFixed(2)}` +
`L${right.x.toFixed(2)} ${right.y.toFixed(2)}Z`,
);
// 표고축 — 링 가운데에서 곧게 선 침. 내려다볼수록 짧아져 시점을 한 번 더 알린다.
const up = project([0, RING_RADIUS * 0.9, 0]);
pole.setAttribute("x1", String(CENTER));
pole.setAttribute("y1", String(CENTER));
pole.setAttribute("x2", up.x.toFixed(2));
pole.setAttribute("y2", up.y.toFixed(2));
labels.forEach((label, index) => {
const point = project(bearingVector(CARDINALS[index][1], LABEL_RADIUS));
label.setAttribute("x", point.x.toFixed(2));
// 글자는 baseline이 아래라 시각 중심을 맞추려면 조금 내린다.
label.setAttribute("y", (point.y + 3.5).toFixed(2));
label.setAttribute("opacity", point.depth > BEHIND_DEPTH ? "0.5" : "1");
});
const poleTip = project([0, LABEL_RADIUS * 0.78, 0]);
poleLabel.setAttribute("x", poleTip.x.toFixed(2));
poleLabel.setAttribute("y", (poleTip.y + 3.5).toFixed(2));
// 위에서 내려다보면 표고축이 점으로 눌려 링 가운데 글자만 남는다 — 그때는 감춘다.
poleLabel.setAttribute("opacity", Math.abs(poleTip.y - CENTER) < 8 ? "0" : "0.8");
},
setVisible(visible: boolean): void {
root.hidden = !visible;
},
};
}