diff --git a/B04_PreProcess/B04_PreProcess_UI_Compass.ts b/B04_PreProcess/B04_PreProcess_UI_Compass.ts index 11f6c740..09113347 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Compass.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Compass.ts @@ -1,9 +1,10 @@ -/* B04 지표면 3D 뷰어 방위 표시 — 카메라 자세를 그대로 따르는 **3축 트라이어드**. +/* B04 지표면 3D 뷰어 방위 표시 — **지면에 누운 나침반 링**(2026-09-03 사용자 선택). * * 종전에는 방위각만으로 도는 평면 콤파스였다. 3D 화면은 기울어 있어 남북이 화면에서 * 눌리는데(사시도 앙각 27.7° → 0.47배) 바늘은 안 눌린 각도로 돌아, 실제 화면 북쪽과 - * 최대 20.8° 어긋났다(2026-09-03 사용자 지적·실측). 축을 카메라 기저에 투영해 그리면 - * 그 어긋남이 원리적으로 사라진다. + * 최대 20.8° 어긋났다(2026-09-03 사용자 지적·실측). 링·눈금·도북 화살을 **지면 평면 + * 위의 도형으로 두고 카메라에 투영**하면 그 어긋남이 원리적으로 사라지고, 링이 눌린 + * 정도가 곧 시점의 기울기가 된다(TerriaJS·cesium-navigation 계열 표기). * * 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x(동), 높이, -y. 그래서 세계에서 * 북쪽은 **-z**, 동쪽은 **+x**, 표고는 **+y** 다. 카메라 오프셋(카메라 − 타깃)만 있으면 @@ -16,45 +17,95 @@ 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 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; -const GIZMO_SVG = ` -`; +/** 방위(도, 북=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): 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) · 동(E) · 표고(Z)"; - root.innerHTML = GIZMO_SVG; + root.title = "도북(N) — 링이 지면에 누워 시점 기울기를 함께 보여 준다"; - const groups = AXES.map( - ([name]) => root.querySelector(`[data-axis="${name}"]`) as SVGGElement, + 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 }; @@ -71,26 +122,80 @@ export function createTerrainCompass(): TerrainCompass { 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)); + + /** 세계 벡터 → 화면 좌표(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; diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 9fea703d..e44dcf7b 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -567,34 +567,57 @@ filter: drop-shadow(0 1px 2px var(--color-surface-raised)); } -.b04-surface__compass-ring { +/* inline SVG의 baseline 여백을 없앤다 — 안 그러면 아래로 5px 떠 모서리 여백이 어긋난다. */ +.b04-surface__compass svg { + display: block; +} + +/* 지면에 누운 나침반 링 — 좌표는 카메라 자세대로 매 프레임 다시 쓰인다. + 링 안쪽 면은 지형이 비치도록 아주 옅게만 깔아 도면 위 방위표처럼 읽힌다. */ +.b04-surface__compass-face { fill: var(--color-surface-raised); - fill-opacity: 0.75; + fill-opacity: 0.5; + stroke: none; +} + +.b04-surface__compass-ring { + fill: none; stroke: var(--color-text-secondary); + stroke-width: 1.4; +} + +.b04-surface__compass-tick { + stroke: var(--color-border); stroke-width: 1; } -/* 축 하나 = 선 + 글자. 좌표는 카메라 자세대로 매 프레임 다시 쓰인다. */ -.b04-surface__compass-axis line { +.b04-surface__compass-tick--major { stroke: var(--color-text-secondary); - stroke-width: 1.5; - stroke-linecap: round; + stroke-width: 1.6; } -.b04-surface__compass-axis text { - fill: var(--color-text-body); +/* 도북 화살 — 이 위젯에서 가장 먼저 읽혀야 하는 하나. */ +.b04-surface__compass-arrow { + fill: var(--color-danger, #d64545); +} + +/* 표고축 — 내려다볼수록 짧아져 시점 기울기를 한 번 더 알린다. */ +.b04-surface__compass-pole { + stroke: var(--color-text-body); + stroke-width: 1.6; + stroke-linecap: round; + opacity: 0.75; +} + +.b04-surface__compass-label { + fill: var(--color-text-secondary); font-family: var(--font-body); - font-size: 9px; + font-size: 11px; font-weight: var(--font-weight-semibold); text-anchor: middle; } -/* 도북은 눈에 먼저 들어와야 한다 — 선·글자 모두 강조색. */ -.b04-surface__compass-axis--n line { - stroke: var(--color-danger, #d64545); -} - -.b04-surface__compass-axis--n text { +.b04-surface__compass-label--north { fill: var(--color-danger, #d64545); }