feat(B05): 3D 마커 개선 — 측구 램프 축소·측점 띠 가시성·회전 조준점

사용자 보고 3건(2026-08-06) 수정:
1. 측구 방향 램프 절반 축소(반지름 1.1→0.55, 띄움 0.6→0.3) — 측점 고를 때
   가리는 문제. 클릭 판정은 투명 히트 구(1.1)로 기존 범위 유지
2. 측점선을 Line → 납작 띠 Mesh(BoxGeometry 길이×0.18×0.5, 띄움 0.45→0.8)
   — WebGL Line은 굵기 지정이 안 먹혀 1px라 지형에 묻혀 안 보이고 클릭도
   어려웠음. 띠는 굵게 보이고 레이캐스트도 몸통 전체. selectStation 색
   갱신을 Mesh 기준으로 전환
3. 회전 중심 불투명 구슬 → 링+십자 조준점 스프라이트(캔버스 텍스처, 항상
   카메라 정면, 반투명). 비율 0.02 — 구슬(0.024)보다 지름 작고 속이 비어
   시각적 무게 대폭 감소. 공용 유틸이라 B04 지형 뷰어들도 함께 적용

실측 확인: 띠 클릭 선택 정상(빨간 핀), 램프 축소 확인, 조준점 지형 위
또렷, 콘솔 에러 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 18:02:51 +09:00
co-authored by Claude Fable 5
parent a71727e004
commit 7a4326e339
2 changed files with 83 additions and 24 deletions
+54 -11
View File
@@ -60,9 +60,52 @@ const POLAR_EPSILON = 0.02;
const POINT_PICK_RATIO = 0.01;
/** 휠 한 칸당 배율. 휠을 위로 올리면 이 값의 역수만큼 멀어진다(2026-08-01 사용자 지시). */
const ZOOM_STEP = 0.9;
/** 회전 중심 구슬의 화면상 크기 비율(카메라 거리 대비). 멀어져도 같은 크기로 보인다. */
const PIVOT_MARKER_RATIO = 0.012;
const PIVOT_MARKER_COLOR = 0xf59e0b;
/** 회전 중심 조준점의 화면상 크기 비율(카메라 거리 대비). 멀어져도 같은 크기로 보인다.
* 예전 불투명 구슬(지름 = 거리×0.024)이 크고 투박하다는 보고(2026-08-06)로 얇은 링
* 조준점으로 교체. 0.012(구슬의 절반 폭)는 링이 뭉개져 안 보였다 — 0.02면 화면에서
* 약 15px: 구슬보다 지름은 약간 작고, 속이 빈 링이라 시각적 무게는 훨씬 가볍다. */
const PIVOT_MARKER_RATIO = 0.02;
const PIVOT_MARKER_COLOR = "rgba(245, 158, 11, 0.95)";
/** 링+십자+중심점 조준점 텍스처 — 구슬 대신 축 위치만 가볍게 표시한다(2026-08-06). */
function pivotReticleTexture(): THREE.CanvasTexture {
const size = 96;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d");
if (context) {
const center = size / 2;
context.strokeStyle = PIVOT_MARKER_COLOR;
context.fillStyle = PIVOT_MARKER_COLOR;
context.lineWidth = 6;
context.beginPath();
context.arc(center, center, size * 0.3, 0, Math.PI * 2);
context.stroke();
// 링 안팎으로 걸치는 십자 4획 — 조준점임을 또렷하게.
const inner = size * 0.16;
const outer = size * 0.47;
(
[
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
] as const
).forEach(([dx, dy]) => {
context.beginPath();
context.moveTo(center + dx * inner, center + dy * inner);
context.lineTo(center + dx * outer, center + dy * outer);
context.stroke();
});
context.beginPath();
context.arc(center, center, 5, 0, Math.PI * 2);
context.fill();
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
export interface CursorPivotOptions {
camera: THREE.PerspectiveCamera;
@@ -86,12 +129,12 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
// 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다.
controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN;
// 회전 중심 구슬 — 돌리는 동안에만 보인다. 화면상 크기는 거리와 무관하게 일정하다.
// 회전 중심 조준점 — 돌리는 동안에만 보인다. 항상 카메라를 향하는 스프라이트라
// 어느 각도에서도 또렷하고, 화면상 크기는 거리와 무관하게 일정하다.
const pivotMarker = options.scene
? new THREE.Mesh(
new THREE.SphereGeometry(1, 16, 12),
new THREE.MeshBasicMaterial({
color: PIVOT_MARKER_COLOR,
? new THREE.Sprite(
new THREE.SpriteMaterial({
map: pivotReticleTexture(),
// 지형에 묻혀 안 보이면 축을 확인할 수 없으므로 항상 위에 그린다.
depthTest: false,
transparent: true,
@@ -105,7 +148,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
options.scene.add(pivotMarker);
}
/** 구슬을 현재 축 위치·크기로 맞춘다. */
/** 조준점을 현재 축 위치·크기로 맞춘다. 스프라이트 scale = 쿼드의 월드 폭. */
function syncPivotMarker(): void {
if (!pivotMarker || !pivotMarker.visible) return;
pivotMarker.position.copy(pivot);
@@ -246,8 +289,8 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.removeEventListener("wheel", onWheel);
if (pivotMarker) {
pivotMarker.removeFromParent();
pivotMarker.geometry.dispose();
(pivotMarker.material as THREE.Material).dispose();
pivotMarker.material.map?.dispose();
pivotMarker.material.dispose();
}
};
}
+29 -13
View File
@@ -335,7 +335,8 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
stations.forEach((station) => {
if (station.center_z === null) return;
const [leftX, leftY] = station.frame.left_xy;
const center = { x: station.center_x, y: station.center_y, z: station.center_z + 0.45 };
// 띄움 0.8 — 예전 0.45는 굴곡진 지형에 절반쯤 묻혀 잘 안 보였다(2026-08-06 보고).
const center = { x: station.center_x, y: station.center_y, z: station.center_z + 0.8 };
stationCenters.set(station.station_id, modelToScene(center, bounds));
const points = [
modelToScene(
@@ -348,13 +349,19 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
),
];
const selected = station.station_id === selectedStationId;
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: selected ? 0xef4444 : 0xfacc15 }),
// 측점선은 선(Line)이 아니라 **납작한 띠(Mesh)**로 그린다 — WebGL에서 Line은
// 굵기 지정이 안 먹혀 항상 1px라 안 보이고, 레이캐스트도 얇아 클릭이 어려웠다
// (2026-08-06 보고). 띠는 굵게 보이고 클릭 판정도 몸통 전체다.
const barLength = points[0].distanceTo(points[1]);
const bar = new THREE.Mesh(
new THREE.BoxGeometry(barLength, 0.18, 0.5),
new THREE.MeshBasicMaterial({ color: selected ? 0xef4444 : 0xfacc15 }),
);
line.userData.stationId = station.station_id;
if (selected) line.material.linewidth = 2;
stationGroup.add(line);
bar.position.lerpVectors(points[0], points[1], 0.5);
const direction = points[1].clone().sub(points[0]);
bar.rotation.y = Math.atan2(-direction.z, direction.x);
bar.userData.stationId = station.station_id;
stationGroup.add(bar);
// 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색.
// 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick).
@@ -365,17 +372,27 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
(["left", "right"] as const).forEach((side, endIndex) => {
const active = station.uphill_side === side;
// 램프 절반 축소(1.1→0.55) — 측점 고를 때 가리는 문제(2026-08-06 보고).
const lamp = new THREE.Mesh(
new THREE.SphereGeometry(1.1, 14, 10),
new THREE.SphereGeometry(0.55, 14, 10),
new THREE.MeshBasicMaterial({
color: active ? UPHILL_LAMP_COLOR : UPHILL_LAMP_INACTIVE_COLOR,
}),
);
lamp.position.copy(points[endIndex]);
lamp.position.y += 0.6;
lamp.position.y += 0.3;
lamp.userData.uphillStationId = station.station_id;
lamp.userData.uphillSide = side;
stationGroup.add(lamp);
// 클릭 판정은 예전 크기 그대로 — 투명 히트 구를 겹쳐 "작게 보여도 잘 눌리게".
const lampHit = new THREE.Mesh(
new THREE.SphereGeometry(1.1, 8, 6),
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
);
lampHit.position.copy(lamp.position);
lampHit.userData.uphillStationId = station.station_id;
lampHit.userData.uphillSide = side;
stationGroup.add(lampHit);
});
});
// 재렌더로 좌표가 갱신됐으니 선택 핀도 그 자리로 다시 놓는다.
@@ -413,11 +430,10 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
function selectStation(stationId: string | null): void {
selectedStationId = stationId;
stationGroup.children.forEach((object) => {
if (!(object instanceof THREE.Line)) return;
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다 — 램프(uphill*)는 제외.
if (!(object instanceof THREE.Mesh) || typeof object.userData.stationId !== "string") return;
const selected = object.userData.stationId === selectedStationId;
const material = object.material as THREE.LineBasicMaterial;
material.color.set(selected ? 0xef4444 : 0xfacc15);
material.linewidth = selected ? 3 : 1;
(object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15);
});
syncSelectionPin();
stationSelectionListener?.(selectedStationId);