From 16a24ace3d1a424c3b14f1f3b1fc7c3397892205 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 10:02:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04):=20=EC=A7=80=ED=91=9C=EB=A9=B4=203D?= =?UTF-8?q?=20=EB=B7=B0=EC=96=B4=EC=97=90=20=EB=B0=A9=EC=9C=84=20=EC=BD=A4?= =?UTF-8?q?=ED=8C=8C=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 뷰어 좌표 규약이 `x, 높이, -y` 라 세계 북쪽은 -z 다. 카메라 오프셋(카메라 − 타깃)의 수평 성분으로 도북이 화면에서 몇 도 돌아갔는지 계산해(atan2(offset.x, offset.z)) 바늘을 돌린다. 뷰어 우하단 — 축척 막대(좌하단)의 반대편에 둔다. 뷰어 파일이 이미 946줄이라 콤파스는 별도 모듈로 분리(700줄 제한). 애니메이션 루프에서 0.5° 이상 움직였을 때만 transform 을 다시 쓴다. 검증: typecheck 통과. 공용 브라우저 실측 — [상단] 시점 0°(바늘이 북쪽=화면 위), [사시도] 41.6°, 캔버스 220px 드래그 시 218.6° 로 카메라와 함께 회전. Co-Authored-By: Claude Opus 5 (1M context) --- B04_PreProcess/B04_PreProcess_UI_Compass.ts | 52 +++++++++++++++++++ B04_PreProcess/B04_PreProcess_UI_Style.css | 38 ++++++++++++++ .../B04_PreProcess_UI_TerrainViewer.ts | 8 +++ 3 files changed, 98 insertions(+) create mode 100644 B04_PreProcess/B04_PreProcess_UI_Compass.ts diff --git a/B04_PreProcess/B04_PreProcess_UI_Compass.ts b/B04_PreProcess/B04_PreProcess_UI_Compass.ts new file mode 100644 index 00000000..92c98b5d --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_Compass.ts @@ -0,0 +1,52 @@ +/* B04 지표면 3D 뷰어 방위 콤파스 — 바늘이 늘 도북(N)을 가리킨다. + * + * 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y. 그래서 세계에서 북쪽은 + * **-z** 다. 화면 위쪽은 카메라가 보는 수평 방향이므로, 카메라 오프셋(카메라 − 타깃)만 + * 알면 북쪽이 화면에서 몇 도 돌아가 있는지 나온다 — `Math.atan2(offset.x, offset.z)`. + * (기본 시점 offset=(0, d, d·tilt)면 0° = 북쪽이 화면 위, 카메라가 동쪽으로 가면 +90°.) + * + * 뷰어 파일이 이미 900줄을 넘어 여기로 뺐다(CLAUDE.md 4장 700줄 제한). + */ + +export interface TerrainCompass { + root: HTMLElement; + /** 카메라 오프셋(카메라 위치 − 타깃)으로 바늘 각도를 맞춘다. */ + update(offsetX: number, offsetZ: number): void; + setVisible(visible: boolean): void; +} + +const NEEDLE_SVG = ` +`; + +export function createTerrainCompass(): TerrainCompass { + const root = document.createElement("div"); + root.className = "b04-surface__compass"; + root.hidden = true; + root.title = "도북(N)"; + + const dial = document.createElement("div"); + dial.className = "b04-surface__compass-dial"; + dial.innerHTML = NEEDLE_SVG; + root.append(dial); + + // 프레임마다 style을 다시 쓰지 않도록 직전 각도를 들고 있는다. + let lastHeading = Number.NaN; + + 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)`; + }, + 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 0ee38188..0659e84c 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -557,6 +557,44 @@ text-shadow: 0 1px 2px var(--color-surface-raised); } +/* 방위 콤파스 — 3D 뷰어 우하단. 축척 막대(좌하단)와 짝. */ +.b04-surface__compass { + position: absolute; + right: var(--spacing-16); + bottom: var(--spacing-16); + z-index: 2; + pointer-events: none; + 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; + stroke: var(--color-text-secondary); + stroke-width: 1; +} + +.b04-surface__compass-north { + fill: var(--color-danger, #d64545); +} + +.b04-surface__compass-south { + fill: var(--color-text-secondary); +} + +.b04-surface__compass-letter { + fill: var(--color-text-body); + font-family: var(--font-body); + font-size: 9px; + font-weight: var(--font-weight-semibold); + text-anchor: middle; +} + /* --- 하단 2D 지도 --- */ .b04-map { --b04-map-vector: var(--color-accent); diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 75b7c565..01d7e64a 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -7,6 +7,7 @@ import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; // 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다. +import { createTerrainCompass } from "./B04_PreProcess_UI_Compass"; import { routeLineColor } from "./B04_PreProcess_UI_MapRender"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch"; import { @@ -166,6 +167,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { scaleBar.append(scaleLabel); viewerArea.append(scaleBar); + // 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다. + const compass = createTerrainCompass(); + viewerArea.append(compass.root); + // Elevation bounds legend bar overlay (I-403) const legendBar = document.createElement("div"); legendBar.style.position = "absolute"; @@ -794,6 +799,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Render scale bar dynamically if (terrainMesh && terrainMesh.visible) { scaleBar.hidden = false; + compass.setVisible(true); + compass.update(camera.position.x - controls.target.x, camera.position.z - controls.target.z); const dist = camera.position.distanceTo(controls.target); const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight); const roughMeters = 100 * metersPerPixel; @@ -803,6 +810,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; } else { scaleBar.hidden = true; + compass.setVisible(false); } // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).