- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수) - B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존) - 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section), 라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로 - 로직 변경 없음. typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
import * as THREE from "three";
|
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
|
|
import { bindCursorPivotControls } from "../B04_PreProcess/B04_PreProcess_UI_Camera";
|
|
import {
|
|
createRouteMarkers,
|
|
sceneToModel,
|
|
type ModelBounds,
|
|
type RouteMarkers,
|
|
type RoutePointKind,
|
|
type SectionStationMarker,
|
|
} from "./B05_Profile_UI_Markers";
|
|
|
|
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
|
|
const DARK_VIEWER_BACKGROUND = 0x251f38;
|
|
|
|
function disposeObject(object: THREE.Object3D | null): void {
|
|
object?.traverse((child) => {
|
|
if (
|
|
child instanceof THREE.Mesh ||
|
|
child instanceof THREE.Points ||
|
|
child instanceof THREE.Line
|
|
) {
|
|
child.geometry.dispose();
|
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
materials.forEach((material) => material.dispose());
|
|
}
|
|
});
|
|
}
|
|
|
|
export interface RouteViewer {
|
|
root: HTMLElement;
|
|
markers: RouteMarkers;
|
|
loadSurface: (
|
|
projectId: string,
|
|
modelId: number,
|
|
method: string,
|
|
smooth: boolean,
|
|
interval: number,
|
|
bounds: ModelBounds,
|
|
) => Promise<void>;
|
|
reloadContours: (interval: number) => Promise<void>;
|
|
setSurfaceVisible: (visible: boolean) => void;
|
|
/** 지표면 흑백 표시 — 무지개 고도색이 헷갈릴 때 명도만 남긴다(정점색 1회 변환, 성능 영향 없음). */
|
|
setSurfaceGrayscale: (grayscale: boolean) => void;
|
|
setContoursVisible: (visible: boolean) => void;
|
|
setAxesVisible: (visible: boolean) => void;
|
|
setStationLinesVisible: (visible: boolean) => void;
|
|
/** 구조물(비정규) 측점의 번호·이름 라벨 표시 토글. */
|
|
setStationLabelsVisible: (visible: boolean) => void;
|
|
renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void;
|
|
setView: (view: "iso" | "top" | "front" | "side") => void;
|
|
beginMoveSelected: () => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
export function createRouteViewer(): RouteViewer {
|
|
const root = document.createElement("div");
|
|
root.className = "b05-route__viewport";
|
|
const status = document.createElement("div");
|
|
status.className = "b05-route__viewer-status";
|
|
status.textContent = "확정 지표면을 불러오는 중입니다.";
|
|
const canvas = document.createElement("canvas");
|
|
root.append(canvas, status);
|
|
|
|
const scene = new THREE.Scene();
|
|
const systemDarkTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
|
|
function updateSceneBackground(): void {
|
|
const theme = document.documentElement.getAttribute("data-theme");
|
|
const dark = theme === "dark" || (theme !== "light" && systemDarkTheme.matches);
|
|
if (!dark) {
|
|
scene.background = new THREE.Color(LIGHT_VIEWER_BACKGROUND);
|
|
return;
|
|
}
|
|
const surfaceRaised = getComputedStyle(document.documentElement)
|
|
.getPropertyValue("--color-surface-raised")
|
|
.trim();
|
|
scene.background = new THREE.Color(
|
|
surfaceRaised && CSS.supports("color", surfaceRaised)
|
|
? surfaceRaised
|
|
: DARK_VIEWER_BACKGROUND,
|
|
);
|
|
}
|
|
|
|
const themeObserver = new MutationObserver(updateSceneBackground);
|
|
themeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ["data-theme"],
|
|
});
|
|
systemDarkTheme.addEventListener("change", updateSceneBackground);
|
|
updateSceneBackground();
|
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000);
|
|
camera.position.set(100, 120, 100);
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
const controls = new OrbitControls(camera, canvas);
|
|
controls.enableDamping = true;
|
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2));
|
|
const directional = new THREE.DirectionalLight(0xffffff, 2.2);
|
|
directional.position.set(100, 200, 100);
|
|
scene.add(directional);
|
|
const axes = new THREE.AxesHelper(30);
|
|
axes.visible = false;
|
|
scene.add(axes);
|
|
|
|
let terrain: THREE.Object3D | null = null;
|
|
const contours = new THREE.Group();
|
|
scene.add(contours);
|
|
let bounds: ModelBounds | null = null;
|
|
let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null =
|
|
null;
|
|
let movingSelected = false;
|
|
let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null;
|
|
let draggingMarker = false;
|
|
let lastDragPoint: { x: number; y: number; z: number } | null = null;
|
|
const markers = createRouteMarkers(scene, () => bounds);
|
|
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
|
|
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
|
|
const releaseCursorPivot = bindCursorPivotControls({
|
|
camera,
|
|
controls,
|
|
element: canvas,
|
|
pickables: () => (terrain ? [terrain] : []),
|
|
blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
|
|
scene,
|
|
});
|
|
|
|
function clearContours(): void {
|
|
disposeObject(contours);
|
|
contours.clear();
|
|
}
|
|
|
|
function resize(): void {
|
|
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();
|
|
}
|
|
const resizeObserver = new ResizeObserver(resize);
|
|
resizeObserver.observe(root);
|
|
|
|
function fit(view: "iso" | "top" | "front" | "side" = "top"): void {
|
|
if (!bounds) return;
|
|
const width = bounds.x[1] - bounds.x[0];
|
|
const depth = bounds.y[1] - bounds.y[0];
|
|
const distance = Math.max(width, depth, 20) * 1.35;
|
|
controls.target.set(0, 0, 0);
|
|
const positions = {
|
|
iso: [distance, distance, distance],
|
|
top: [0, distance, 0.001],
|
|
front: [0, distance * 0.25, distance],
|
|
side: [distance, distance * 0.25, 0],
|
|
} as const;
|
|
const [x, y, z] = positions[view];
|
|
camera.position.set(x, y, z);
|
|
camera.near = Math.max(0.1, distance / 1000);
|
|
camera.far = distance * 10;
|
|
camera.updateProjectionMatrix();
|
|
controls.update();
|
|
}
|
|
|
|
async function reloadContours(interval: number): Promise<void> {
|
|
if (!current || !bounds) return;
|
|
current.interval = interval;
|
|
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
|
|
const data = await fetchCachedJson<{
|
|
contours: Array<{ level: number; coordinates: [number, number, number][] }>;
|
|
}>(
|
|
current.projectId,
|
|
`${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`,
|
|
);
|
|
clearContours();
|
|
// 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다.
|
|
const majorPoints: THREE.Vector3[] = [];
|
|
const minorPoints: THREE.Vector3[] = [];
|
|
const cx = (bounds.x[0] + bounds.x[1]) / 2;
|
|
const cy = (bounds.y[0] + bounds.y[1]) / 2;
|
|
const cz = (bounds.z[0] + bounds.z[1]) / 2;
|
|
data.contours.forEach((contour) => {
|
|
const points = contour.coordinates.map(
|
|
([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)),
|
|
);
|
|
if (points.length < 2) return;
|
|
const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints;
|
|
for (let index = 0; index < points.length - 1; index += 1) {
|
|
bucket.push(points[index], points[index + 1]);
|
|
}
|
|
});
|
|
[
|
|
{ points: minorPoints, color: 0xf59e0b },
|
|
{ points: majorPoints, color: 0xd97706 },
|
|
].forEach(({ points, color }) => {
|
|
if (points.length === 0) return;
|
|
contours.add(
|
|
new THREE.LineSegments(
|
|
new THREE.BufferGeometry().setFromPoints(points),
|
|
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
function terrainPoint(
|
|
event: PointerEvent | DragEvent,
|
|
): { x: number; y: number; z: number } | null {
|
|
if (!terrain || !bounds) return null;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const pointer = new THREE.Vector2(
|
|
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
|
);
|
|
const raycaster = new THREE.Raycaster();
|
|
raycaster.setFromCamera(pointer, camera);
|
|
const hit = raycaster.intersectObject(terrain, true)[0];
|
|
return hit ? sceneToModel(hit.point, bounds) : null;
|
|
}
|
|
|
|
canvas.addEventListener("dragover", (event) => event.preventDefault());
|
|
canvas.addEventListener("drop", (event) => {
|
|
event.preventDefault();
|
|
const kind = event.dataTransfer?.getData("pointType") as RoutePointKind;
|
|
const point = terrainPoint(event);
|
|
if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point);
|
|
});
|
|
function markerHit(event: PointerEvent): THREE.Object3D | undefined {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const pointer = new THREE.Vector2(
|
|
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
|
);
|
|
const raycaster = new THREE.Raycaster();
|
|
raycaster.setFromCamera(pointer, camera);
|
|
return raycaster.intersectObject(markers.group, true)[0]?.object;
|
|
}
|
|
|
|
function finishMarkerInteraction(selectCandidate: boolean): void {
|
|
if (dragCandidate && (draggingMarker || selectCandidate)) {
|
|
markers.selectPoint(dragCandidate.id);
|
|
}
|
|
if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) {
|
|
canvas.releasePointerCapture(dragCandidate.pointerId);
|
|
}
|
|
controls.enabled = true;
|
|
dragCandidate = null;
|
|
draggingMarker = false;
|
|
lastDragPoint = null;
|
|
}
|
|
|
|
function handlePointerDown(event: PointerEvent): void {
|
|
if (event.button !== 0) return;
|
|
const hit = markerHit(event);
|
|
const pointId = markers.pointIdForObject(hit);
|
|
if (pointId) {
|
|
dragCandidate = {
|
|
id: pointId,
|
|
pointerId: event.pointerId,
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
};
|
|
draggingMarker = false;
|
|
lastDragPoint = null;
|
|
canvas.setPointerCapture(event.pointerId);
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
if (hit) {
|
|
markers.selectObject(hit);
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
if (movingSelected) {
|
|
const point = terrainPoint(event);
|
|
if (point) markers.moveSelected(point);
|
|
movingSelected = false;
|
|
} else {
|
|
markers.selectObject(undefined);
|
|
}
|
|
}
|
|
|
|
function handlePointerMove(event: PointerEvent): void {
|
|
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (
|
|
!draggingMarker &&
|
|
Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3
|
|
) {
|
|
draggingMarker = true;
|
|
controls.enabled = false;
|
|
}
|
|
if (!draggingMarker) return;
|
|
const point = terrainPoint(event);
|
|
if (!point) return;
|
|
lastDragPoint = point;
|
|
markers.movePoint(dragCandidate.id, point);
|
|
}
|
|
|
|
function handlePointerUp(event: PointerEvent): void {
|
|
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (draggingMarker) {
|
|
const point = terrainPoint(event) ?? lastDragPoint;
|
|
if (point) markers.movePoint(dragCandidate.id, point);
|
|
}
|
|
finishMarkerInteraction(!draggingMarker);
|
|
}
|
|
|
|
function handlePointerExit(event: PointerEvent): void {
|
|
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
finishMarkerInteraction(false);
|
|
}
|
|
|
|
canvas.addEventListener("pointerdown", handlePointerDown, true);
|
|
canvas.addEventListener("pointermove", handlePointerMove, true);
|
|
canvas.addEventListener("pointerup", handlePointerUp, true);
|
|
canvas.addEventListener("pointerleave", handlePointerExit, true);
|
|
canvas.addEventListener("pointercancel", handlePointerExit, true);
|
|
|
|
let frame = 0;
|
|
function animate(): void {
|
|
frame = requestAnimationFrame(animate);
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
}
|
|
animate();
|
|
|
|
// 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라
|
|
// (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
|
|
let surfaceGrayscale = false;
|
|
function applySurfaceGrayscale(): void {
|
|
terrain?.traverse((child) => {
|
|
const geometry = (child as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
|
|
const color = geometry?.getAttribute("color") as THREE.BufferAttribute | undefined;
|
|
if (!geometry || !color) return;
|
|
const target = color.array as Float32Array;
|
|
if (!child.userData.originalColors) child.userData.originalColors = target.slice();
|
|
const original = child.userData.originalColors as Float32Array;
|
|
const stride = color.itemSize;
|
|
if (surfaceGrayscale) {
|
|
for (let i = 0; i + 2 < original.length; i += stride) {
|
|
const gray = 0.299 * original[i] + 0.587 * original[i + 1] + 0.114 * original[i + 2];
|
|
target[i] = target[i + 1] = target[i + 2] = gray;
|
|
}
|
|
} else {
|
|
target.set(original);
|
|
}
|
|
color.needsUpdate = true;
|
|
});
|
|
}
|
|
|
|
return {
|
|
root,
|
|
markers,
|
|
async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) {
|
|
bounds = nextBounds;
|
|
current = { projectId, modelId, smooth, interval };
|
|
if (terrain) {
|
|
scene.remove(terrain);
|
|
disposeObject(terrain);
|
|
}
|
|
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`;
|
|
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
|
|
const buffer = await fetchCachedBytes(projectId, url);
|
|
terrain = await new Promise<THREE.Object3D>((resolve, reject) => {
|
|
if (method === "meshfree") {
|
|
const geometry = new PLYLoader().parse(buffer);
|
|
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 })));
|
|
} else {
|
|
new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject);
|
|
}
|
|
});
|
|
terrain.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide;
|
|
});
|
|
scene.add(terrain);
|
|
// 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다.
|
|
applySurfaceGrayscale();
|
|
fit("top");
|
|
markers.renderMarkers();
|
|
await reloadContours(interval);
|
|
status.textContent = "지형을 클릭하거나 팔레트 포인트를 드래그해 배치하세요.";
|
|
},
|
|
reloadContours,
|
|
setSurfaceVisible(visible) {
|
|
if (terrain) terrain.visible = visible;
|
|
},
|
|
setSurfaceGrayscale(grayscale) {
|
|
if (surfaceGrayscale === grayscale) return;
|
|
surfaceGrayscale = grayscale;
|
|
applySurfaceGrayscale();
|
|
},
|
|
setContoursVisible(visible) {
|
|
contours.visible = visible;
|
|
},
|
|
setAxesVisible(visible) {
|
|
axes.visible = visible;
|
|
},
|
|
setStationLinesVisible: markers.setStationLinesVisible,
|
|
setStationLabelsVisible: markers.setStationLabelsVisible,
|
|
renderStationLines: markers.renderStationLines,
|
|
setView: fit,
|
|
beginMoveSelected() {
|
|
movingSelected = true;
|
|
status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요.";
|
|
},
|
|
dispose() {
|
|
cancelAnimationFrame(frame);
|
|
resizeObserver.disconnect();
|
|
themeObserver.disconnect();
|
|
systemDarkTheme.removeEventListener("change", updateSceneBackground);
|
|
canvas.removeEventListener("pointerdown", handlePointerDown, true);
|
|
canvas.removeEventListener("pointermove", handlePointerMove, true);
|
|
canvas.removeEventListener("pointerup", handlePointerUp, true);
|
|
canvas.removeEventListener("pointerleave", handlePointerExit, true);
|
|
canvas.removeEventListener("pointercancel", handlePointerExit, true);
|
|
releaseCursorPivot();
|
|
markers.dispose();
|
|
clearContours();
|
|
disposeObject(terrain);
|
|
controls.dispose();
|
|
renderer.dispose();
|
|
},
|
|
};
|
|
}
|