feat(B05): 진입 로딩 3D 후순위 · 도넛 회전 분리 · 직교/원근 전환 단추

사용자 지시(2026-09-04) — 「3D 는 보조라 후순위. 조용히 뒤에서 뜨고 다 되면
팝업으로 알릴 것. 로딩 도넛은 진행률과 무관하게 계속 돌고 숫자만 갱신」,
그리고 「직교/원근 전환 단추는 넣음」.

진입 로딩
- 단계 5 → 4. ④ 종·횡단 자료까지 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 함.
- 3D 지형은 finally 뒤 배경 작업으로 돌리고 끝나면 토스트 「3D 지형 준비 완료」.
  실패해도 나머지 화면은 그대로 씀. [초기화]는 재진입이라 같은 규칙이 걸림.
- 「확정 지표면 없음」 판정을 ③ 뒤로 앞당김.

진행 서클(공용)
- 회전 껍데기(`__spin`)를 한 겹 두고 CSS 회전을 거기에만 검. 껍데기는 늘 돌고
  안쪽 svg 는 12시 고정이라 호·숫자가 제자리에서 갱신됨. `is-indeterminate` 제거.

직교/원근 전환
- 카메라 두 벌을 `B05_Profile_UI_Viewer_Camera.ts` 가 쥐고 갈아 끼움. 위치·시선·
  근평면·먼평면과 보이는 크기를 옮겨 화면이 튀지 않음.
- 마커 입력·구조물 클릭·커서 피벗 유틸이 카메라를 함수로 받게 바꿈(공용 유틸은
  값도 그대로 받아 B04 호출부는 무변경).

자체검증(공용 브라우저 5173, 용화_LAS) — 진행률 25→50→75%, 회전 애니메이션
running 유지(변환행렬 표본 전부 다름), 로딩 걷힌 순간 그래프선 2·좌측단추 61·
3D 메쉬 2, 1.8초 뒤 메쉬 489 + 토스트. 초기화 재진입도 같음. 전환 단추는
Ortho ↔ Perspective 왕복 후 값 완전 일치, B04 페이지 오류 0건.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:13:46 +09:00
co-authored by Claude Opus 5
parent 84042fe010
commit 5203191751
9 changed files with 193 additions and 64 deletions
+13 -3
View File
@@ -120,7 +120,11 @@ function pivotReticleTexture(): THREE.CanvasTexture {
}
export interface CursorPivotOptions {
camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
/** 카메라. **바뀔 수 있으면 함수로** 준다 — B05는 직교/원근을 갈아 끼운다(2026-09-04). */
camera:
| THREE.PerspectiveCamera
| THREE.OrthographicCamera
| (() => THREE.PerspectiveCamera | THREE.OrthographicCamera);
controls: OrbitControls;
/** 포인터 이벤트를 받는 캔버스. */
element: HTMLElement;
@@ -134,7 +138,9 @@ export interface CursorPivotOptions {
/** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */
export function bindCursorPivotControls(options: CursorPivotOptions): () => void {
const { camera, controls, element } = options;
const { controls, element } = options;
const getCamera = (): THREE.PerspectiveCamera | THREE.OrthographicCamera =>
typeof options.camera === "function" ? options.camera() : options.camera;
// 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다).
controls.enableRotate = false;
controls.enableZoom = false;
@@ -165,6 +171,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
/** 조준점을 현재 축 위치·크기로 맞춘다. 스프라이트 scale = 쿼드의 월드 폭. */
function syncPivotMarker(): void {
const camera = getCamera();
if (!pivotMarker || !pivotMarker.visible) return;
pivotMarker.position.copy(pivot);
const distance = camera.position.distanceTo(pivot);
@@ -190,6 +197,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
* 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을
* 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */
function pickPivot(event: { clientX: number; clientY: number }): void {
const camera = getCamera();
pivot.copy(controls.target);
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
@@ -219,7 +227,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
pickPivot(event);
panAnchor.copy(pivot);
camera.getWorldDirection(viewDirection);
getCamera().getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
panPointerId = event.pointerId;
element.setPointerCapture?.(event.pointerId);
@@ -243,6 +251,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
* 휠을 위로 올리면 멀어진다(사용자 지시). */
function onWheel(event: WheelEvent): void {
const camera = getCamera();
if (!controls.enabled || options.blocked?.()) return;
event.preventDefault();
pickPivot(event);
@@ -265,6 +274,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
}
function onPointerMove(event: PointerEvent): void {
const camera = getCamera();
if (panPointerId === event.pointerId) {
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다.
const rect = element.getBoundingClientRect();
+34 -28
View File
@@ -257,6 +257,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
onProjection: viewer.setProjection,
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
onCorridorRefresh: async () => {
if (!currentSectionDetail || !latest?.route?.id) return;
@@ -581,12 +582,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
};
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
* 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고
* 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안
* 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */
const LOAD_STEP_COUNT = 5;
// 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다
// (2026-08-01 사용자 지시).
* 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시).
* 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — 네 단계가
* 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 하고, 3D는 뒤에서 올린 뒤 알린다.
* 서클은 3D 뷰포트 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */
const LOAD_STEP_COUNT = 4;
const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true });
viewer.root.append(progress.root);
let loadedSteps = 0;
@@ -654,33 +654,15 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// ③ 확정 지표면 모델 목록.
const models = await listSurfaceModels(activeProjectId);
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
advanceLoading("종단면 자료를 불러오는 중…");
// ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
if (latestResponse.route) await restoreSections(latestResponse.route.id);
advanceLoading("3D 지형을 불러오는 중…");
// ⑤ 3D 지형 — 가장 무거우므로 맨 마지막.
if (!confirmedSurface) {
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
leaveForDashboard();
return;
} else {
// 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
await viewer.loadSurface(
activeProjectId,
confirmedSurface.id,
latestResponse.surface_params.method,
latestResponse.surface_params.smooth,
latestResponse.surface_params.contour_interval_m,
toBounds(confirmed.bounds),
);
// 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
renderLatest(latestResponse);
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
advanceLoading("종단면 자료를 불러오는 중…");
// ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
if (latestResponse.route) await restoreSections(latestResponse.route.id);
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
await bridge.load();
advanceLoading("");
@@ -691,4 +673,28 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
restoring = false;
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
}
// ⑤ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다.
void (async () => {
const [surface, current] = [confirmedSurface, latest];
if (!surface || !current) return;
try {
// 가장자리만 받는다 — 포인트클라우드 전체(수십 MB)는 안 받는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
await viewer.loadSurface(
activeProjectId,
surface.id,
current.surface_params.method,
current.surface_params.smooth,
current.surface_params.contour_interval_m,
toBounds(confirmed.bounds),
);
renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
if (currentSectionDetail) renderStationLines(currentSectionDetail);
showToast("3D 지형 준비 완료", "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
}
})();
}
+15
View File
@@ -104,6 +104,8 @@ interface PanelCallbacks {
/** 지표면 흑백 표시 토글(기본 꺼짐 — 무지개 고도색). */
onSurfaceGrayscale: (grayscale: boolean) => void;
onView: (view: "iso" | "top" | "front" | "side") => void;
/** 직교/원근 전환 — 탑뷰에서 크기를 정밀 대조할 때만 직교로 본다(2026-09-04 사용자 지시). */
onProjection: (kind: "perspective" | "ortho") => void;
onResetView: () => void;
/** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자
* 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */
@@ -220,6 +222,19 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
(["iso", "top", "front", "side"] as const).forEach((preset) =>
viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")),
);
// 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을
// 가리킨다(누르면 그쪽으로 간다).
let projection: "perspective" | "ortho" = "perspective";
const projectionButton = button(
"직교로",
() => {
projection = projection === "perspective" ? "ortho" : "perspective";
projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로";
callbacks.onProjection(projection);
},
"glass",
);
viewButtons.append(projectionButton);
const visibilityButtons = document.createElement("div");
visibilityButtons.className = "b05-route__view-group";
visibilityButtons.append(
+23 -17
View File
@@ -14,6 +14,7 @@ import {
type RouteMarkers,
type SectionStationMarker,
} from "./B05_Profile_UI_Markers";
import { createCameraRig, type ProjectionKind } from "./B05_Profile_UI_Viewer_Camera";
import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import {
@@ -117,6 +118,8 @@ export interface RouteViewer {
setCorridorVisible: (visible: boolean) => void;
renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void;
setView: (view: "iso" | "top" | "front" | "side") => void;
/** 직교/원근 전환(2026-09-04) — 보이는 크기를 유지한 채 카메라만 갈아 끼운다. */
setProjection: (kind: ProjectionKind) => void;
beginMoveSelected: () => void;
/** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */
modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null;
@@ -164,13 +167,12 @@ export function createRouteViewer(): RouteViewer {
});
systemDarkTheme.addEventListener("change", updateSceneBackground);
updateSceneBackground();
// 원근 카메라(시야각 45°) — 2026-09-04 사용자 지시로 직교에서 되돌렸다. 직교가
// 필요했던 탑뷰 구조물 투영 윤곽선은 2026-09-02에 숨김 처리되어 화면에 없다.
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000);
camera.position.set(100, 120, 100);
// 카메라는 원근(기본)·직교 두 벌을 두고 갈아 끼운다 — 갈아 끼우면 **객체가 바뀌므로**
// 붙잡아 두지 말고 `cameraRig.camera()`로 그때그때 읽는다(2026-09-04 사용자 지시).
const cameraRig = createCameraRig();
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const controls = new OrbitControls(camera, canvas);
const controls = new OrbitControls(cameraRig.camera(), canvas);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2));
const directional = new THREE.DirectionalLight(0xffffff, 2.2);
@@ -216,7 +218,9 @@ export function createRouteViewer(): RouteViewer {
THREE,
// 카메라도 함께 낸다(2026-09-04) — 화면 좌표에서 레이캐스트로 무엇이 앞에 있는지
// 확인해야 3D 클릭 검증을 수치로 할 수 있다.
camera,
get camera() {
return cameraRig.camera();
},
toScene: (x: number, y: number, z: number) =>
bounds ? modelToScene({ x, y, z }, bounds) : null,
topZ: () => (bounds ? bounds.z[1] + 100 : null),
@@ -231,7 +235,7 @@ export function createRouteViewer(): RouteViewer {
// 눌러 검증할 때 쓴다. 캔버스가 아래 패널에 가려 중앙이 안 보이므로 자리를 직접 잰다.
project: (x: number, y: number, z: number) => {
if (!bounds) return null;
const point = modelToScene({ x, y, z }, bounds).project(camera);
const point = modelToScene({ x, y, z }, bounds).project(cameraRig.camera());
const rect = canvas.getBoundingClientRect();
return {
x: rect.left + ((point.x + 1) / 2) * rect.width,
@@ -243,7 +247,7 @@ export function createRouteViewer(): RouteViewer {
// 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한).
const markerInput = bindMarkerPointerControls({
canvas,
camera,
camera: cameraRig.camera,
controls,
markers,
getTerrain: () => terrain,
@@ -252,14 +256,14 @@ export function createRouteViewer(): RouteViewer {
// 코리도 구조물 클릭 선택(2026-09-04) — 마커보다 뒤 순위다.
const structurePick = bindStructurePick({
canvas,
camera,
camera: cameraRig.camera,
group: () => corridorGroup,
blocked: () => markerInput.blocked(),
});
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
const releaseCursorPivot = bindCursorPivotControls({
camera,
camera: cameraRig.camera,
controls,
element: canvas,
pickables: () => (terrain ? [terrain] : []),
@@ -276,8 +280,7 @@ export function createRouteViewer(): RouteViewer {
const width = Math.max(1, root.clientWidth);
const height = Math.max(1, root.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
cameraRig.setAspect(width / height);
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(root);
@@ -292,6 +295,7 @@ export function createRouteViewer(): RouteViewer {
side: [distance, distance * 0.25, 0],
} as const;
const [x, y, z] = positions[view];
const camera = cameraRig.camera();
camera.position.set(target.x + x, target.y + y, target.z + z);
// 근평면 상한 0.4m — 휠 확대는 커서 아래 지점 0.5m 앞에서 멈춘다(커서 피벗 유틸).
// 거리에만 비례시키면 긴 노선(맞춤 거리 1km 이상)에서 근평면이 그 0.5m를 넘어
@@ -299,6 +303,7 @@ export function createRouteViewer(): RouteViewer {
camera.near = Math.max(0.1, Math.min(0.4, distance / 1000));
camera.far = distance * 10;
camera.updateProjectionMatrix();
cameraRig.setFit(distance);
controls.update();
}
@@ -360,16 +365,16 @@ export function createRouteViewer(): RouteViewer {
if (terrain) {
compass.setVisible(true);
compass.update(
camera.position.x - controls.target.x,
camera.position.y - controls.target.y,
camera.position.z - controls.target.z,
cameraRig.camera().position.x - controls.target.x,
cameraRig.camera().position.y - controls.target.y,
cameraRig.camera().position.z - controls.target.z,
);
} else {
compass.setVisible(false);
}
// 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다).
markers.updateLabelDetail(camera.position.distanceTo(controls.target));
renderer.render(scene, camera);
markers.updateLabelDetail(cameraRig.camera().position.distanceTo(controls.target));
renderer.render(scene, cameraRig.camera());
}
animate();
@@ -665,6 +670,7 @@ export function createRouteViewer(): RouteViewer {
setStationLabelsVisible: markers.setStationLabelsVisible,
renderStationLines: markers.renderStationLines,
setView: fit,
setProjection: (kind) => cameraRig.setKind(kind, controls),
beginMoveSelected() {
markerInput.beginMoveSelected();
status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요.";
@@ -0,0 +1,81 @@
/* =============================================================================
* B05_Profile_UI_Viewer_Camera.ts
* B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기.
*
* 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정).
* 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면·
* 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다.
*
* 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것.
* ========================================================================== */
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
export type ProjectionKind = "perspective" | "ortho";
const FOV = 45;
/** 원근 45°의 반각 tan — 직교 반높이를 같은 크기감으로 맞출 때 쓴다. */
const HALF_TAN = Math.tan((FOV * Math.PI) / 360);
export function createCameraRig() {
const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000);
perspective.position.set(100, 120, 100);
const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000);
let kind: ProjectionKind = "perspective";
let aspect = 1;
let halfHeight = 100;
const active = (): THREE.PerspectiveCamera | THREE.OrthographicCamera =>
kind === "perspective" ? perspective : ortho;
function apply(): void {
perspective.aspect = aspect;
perspective.updateProjectionMatrix();
ortho.left = -halfHeight * aspect;
ortho.right = halfHeight * aspect;
ortho.top = halfHeight;
ortho.bottom = -halfHeight;
ortho.updateProjectionMatrix();
}
return {
camera: active,
kind: () => kind,
/** 뷰포트 종횡비(리사이즈 시). */
setAspect(value: number): void {
aspect = value;
apply();
},
/** 화면맞춤 — 시점까지 거리로 직교 반높이를 잡는다(원근과 같은 크기감). */
setFit(distance: number): void {
halfHeight = distance * HALF_TAN;
ortho.zoom = 1;
apply();
},
/** 투영 전환. 보이는 크기를 유지하며 OrbitControls의 대상 카메라도 갈아 끼운다. */
setKind(next: ProjectionKind, controls: OrbitControls): void {
if (next === kind) return;
const from = active();
kind = next;
const to = active();
to.quaternion.copy(from.quaternion);
to.near = from.near;
to.far = from.far;
const offset = from.position.clone().sub(controls.target);
if (next === "ortho") {
halfHeight = offset.length() * HALF_TAN;
ortho.zoom = 1;
to.position.copy(from.position);
} else {
// 직교는 배율(zoom)로도 커지므로, 같은 크기로 보이는 거리까지 카메라를 물린다.
to.position.copy(controls.target).add(offset.setLength(halfHeight / ortho.zoom / HALF_TAN));
}
apply();
controls.object = to;
controls.update();
},
};
}
export type CameraRig = ReturnType<typeof createCameraRig>;
@@ -30,7 +30,8 @@ export interface MarkerPointerControls {
export function bindMarkerPointerControls(options: {
canvas: HTMLCanvasElement;
camera: THREE.Camera;
/** 카메라 조회 — 뷰어가 원근/직교를 갈아 끼우므로 값이 아니라 함수로 받는다. */
camera: () => THREE.Camera;
controls: OrbitControls;
markers: RouteMarkers;
getTerrain: () => THREE.Object3D | null;
@@ -60,7 +61,7 @@ export function bindMarkerPointerControls(options: {
const bounds = getBounds();
if (!terrain || !bounds) return null;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointerOf(clientX, clientY), camera);
raycaster.setFromCamera(pointerOf(clientX, clientY), camera());
const hit = raycaster.intersectObject(terrain, true)[0];
return hit ? sceneToModel(hit.point, bounds) : null;
}
@@ -84,7 +85,7 @@ export function bindMarkerPointerControls(options: {
function markerHit(event: PointerEvent): THREE.Object3D | undefined {
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera);
raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera());
return raycaster.intersectObject(markers.group, true)[0]?.object;
}
@@ -39,7 +39,8 @@ const CLICK_SLOP_PX = 3;
export function bindStructurePick(options: {
canvas: HTMLCanvasElement;
camera: THREE.Camera;
/** 카메라 조회 — 뷰어가 원근/직교를 갈아 끼우므로 값이 아니라 함수로 받는다. */
camera: () => THREE.Camera;
/** 코리도 그룹 — 없거나 꺼져 있으면 고르지 않는다. */
group: () => THREE.Object3D | null;
/** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */
@@ -102,7 +103,7 @@ export function bindStructurePick(options: {
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
),
camera,
camera(),
);
// 모서리 선(LineSegments)은 뺀다 — 라인 레이캐스트 허용반경이 1m라 클릭을 가로챈다.
const hit = raycaster
+11 -7
View File
@@ -25,6 +25,15 @@
height: var(--ui-progress-size);
}
/* 회전 껍데기 진행률을 알든 모르든 **항상** 돈다(2026-09-04 사용자 지시).
무거운 단계에서 호가 늘어도 도넛이 멈춰 보이지 않는다. 안쪽 svg는 12시 고정이라
·숫자는 제자리에서 갱신된다. */
.ui-progress-circle__spin {
width: 100%;
height: 100%;
animation: ui-progress-spin 1s linear infinite;
}
.ui-progress-circle__svg {
width: 100%;
height: 100%;
@@ -46,17 +55,12 @@
transition: stroke-dashoffset var(--transition-base);
}
/* 진행률을 모르는 구간 — 호 하나를 계속 돌린다. */
.ui-progress-circle.is-indeterminate .ui-progress-circle__svg {
animation: ui-progress-spin 1s linear infinite;
}
@keyframes ui-progress-spin {
from {
transform: rotate(-90deg);
transform: rotate(0deg);
}
to {
transform: rotate(270deg);
transform: rotate(360deg);
}
}
+9 -4
View File
@@ -61,22 +61,27 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr
label.className = "ui-progress-circle__label";
label.textContent = options.label ?? "";
// 회전은 진행률과 **따로 논다**(2026-09-04 사용자 지시) — 무거운 단계에서 호가
// 안 늘어도 도넛은 계속 돌아야 "멈춘 것"으로 안 보인다. 바깥 껍데기만 CSS로 돌리고
// 안쪽 svg는 12시 고정이라, 호·숫자는 제자리에서 갱신된다.
const spin = document.createElement("div");
spin.className = "ui-progress-circle__spin";
spin.append(svg);
const dial = document.createElement("div");
dial.className = "ui-progress-circle__dial";
dial.append(svg, percent);
dial.append(spin, percent);
root.append(dial, label);
function set(ratio: number | null, nextLabel?: string): void {
if (nextLabel !== undefined) label.textContent = nextLabel;
if (ratio === null) {
// 진행률 미상 — 4분의 1 호를 돌려 "돌아가는 중"만 알린다.
root.classList.add("is-indeterminate");
// 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다).
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75));
percent.textContent = "";
return;
}
const clamped = Math.min(1, Math.max(0, ratio));
root.classList.remove("is-indeterminate");
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped)));
percent.textContent = `${Math.round(clamped * 100)}%`;
}