diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index 7a40a3a5..b6c20751 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -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(); diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 10bee531..e35323b4 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -257,6 +257,7 @@ export async function renderB05Route(root: HTMLElement): Promise { 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 { }; /* ── 진입 로딩 ───────────────────────────────────────────────────────── - * 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고 - * 자료가 끝나는 순서대로 채운다. 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 { // ③ 확정 지표면 모델 목록. 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 { 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"); + } + })(); } diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index a9450ea7..905b0ca2 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -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( diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index c09b1096..09b5674e 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -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 = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts new file mode 100644 index 00000000..32f94f3f --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts index 6ff036bb..afeb0ab9 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -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; } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts index 3e2688e4..4235b2e4 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -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 diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css index 9deb740c..498ad60b 100644 --- a/ui_template/ui_template_progress.css +++ b/ui_template/ui_template_progress.css @@ -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); } } diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts index 00c12cfb..048116cc 100644 --- a/ui_template/ui_template_progress.ts +++ b/ui_template/ui_template_progress.ts @@ -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)}%`; }