381 lines
13 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|