@@ -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 ) , 1 e - 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 ;