Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts
T
2026-07-19 13:08:34 +09:00

381 lines
13 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 {
createRouteMarkers,
sceneToModel,
type ModelBounds,
type RouteMarkers,
type RoutePointKind,
type SectionStationMarker,
} from "./B05_wf2_Route_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;
setContoursVisible: (visible: boolean) => void;
setAxesVisible: (visible: boolean) => void;
setStationLinesVisible: (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);
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 response = await fetch(
`${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`,
{ credentials: "include", cache: "no-store" },
);
if (!response.ok) throw new Error("등고선 조회에 실패했습니다.");
const data = (await response.json()) as {
contours: Array<{ level: number; coordinates: [number, number, number][] }>;
};
clearContours();
data.contours.forEach((contour) => {
const points = contour.coordinates.map(([x, y, z]) => {
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;
return new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy));
});
if (points.length > 1) {
contours.add(
new THREE.Line(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({
color: contour.level % (interval * 5) === 0 ? 0xd97706 : 0xf59e0b,
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();
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}`;
terrain = await new Promise<THREE.Object3D>((resolve, reject) => {
if (method === "meshfree") {
new PLYLoader().load(
url,
(geometry) =>
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))),
undefined,
reject,
);
} else {
new GLTFLoader().load(url, (gltf) => resolve(gltf.scene), undefined, reject);
}
});
terrain.traverse((child) => {
if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide;
});
scene.add(terrain);
fit("top");
markers.renderMarkers();
await reloadContours(interval);
status.textContent = "지형을 클릭하거나 팔레트 포인트를 드래그해 배치하세요.";
},
reloadContours,
setSurfaceVisible(visible) {
if (terrain) terrain.visible = visible;
},
setContoursVisible(visible) {
contours.visible = visible;
},
setAxesVisible(visible) {
axes.visible = visible;
},
setStationLinesVisible: markers.setStationLinesVisible,
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);
markers.dispose();
clearContours();
disposeObject(terrain);
controls.dispose();
renderer.dispose();
},
};
}