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:
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user