refactor(B04): B04_wf1_Surface -> B04_PreProcess 전면 개명
- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존) - 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess), 라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석 - 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
import { RENDER_OPTIONS } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
bindCursorPivotControls,
|
||||
bindSurfaceViewerTheme,
|
||||
getReferenceCenter,
|
||||
getTopFitDistance,
|
||||
niceScaleDistance,
|
||||
SURFACE_CAMERA_FOV,
|
||||
targetPlaneMetersPerPixel,
|
||||
type SurfaceCameraState,
|
||||
} from "./B04_PreProcess_UI_Camera";
|
||||
|
||||
export type { SurfaceCameraState } from "./B04_PreProcess_UI_Camera";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
export interface SurfacePointCloudViewer {
|
||||
root: HTMLElement;
|
||||
controlsGroup: HTMLElement;
|
||||
/** 점 크기·밀도 슬라이더 묶음. 페이지가 "모델 표시 옵션" 컨테이너 맨 아래에 붙인다. */
|
||||
optionsContent: HTMLElement;
|
||||
statusSpan: HTMLElement;
|
||||
/** 로딩 서클 표시. 문구를 주면 켜고, null이면 끈다. `render()` 시 자동으로 꺼진다. */
|
||||
setLoading: (label: string | null) => void;
|
||||
render: (data: SurfacePointCloudSampleResponse | null) => void;
|
||||
setAxesVisible: (visible: boolean) => void;
|
||||
applyCameraState: (state: SurfaceCameraState) => void;
|
||||
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
|
||||
resetOptions: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
type CameraView = "iso" | "top" | "front" | "side";
|
||||
|
||||
function makeButton(label: string): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
const root = document.createElement("div");
|
||||
root.className = "point-viewer";
|
||||
|
||||
const statusSpan = document.createElement("span");
|
||||
statusSpan.className = "b04-surface__status-info";
|
||||
statusSpan.textContent = "포인트 데이터 로딩 중...";
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "viewer-controls";
|
||||
const viewButtons: Array<[CameraView, HTMLButtonElement]> = [
|
||||
["iso", makeButton("사시도")],
|
||||
["top", makeButton("상단")],
|
||||
["front", makeButton("정면")],
|
||||
["side", makeButton("측면")],
|
||||
];
|
||||
controls.append(...viewButtons.map(([, button]) => button));
|
||||
|
||||
const controlsGroup = document.createElement("section");
|
||||
controlsGroup.className = "b04-surface__group";
|
||||
const controlsTitle = document.createElement("h3");
|
||||
controlsTitle.className = "b04-surface__panel-title";
|
||||
controlsTitle.textContent = L("B04_Surface_Group_ViewControls");
|
||||
controlsGroup.append(controlsTitle, controls);
|
||||
|
||||
/** 슬라이더 한 줄 — `이름 [현재값] [게이지]`. 값이 게이지 앞에 온다(2026-08-01 사용자 지시). */
|
||||
function buildSlider(
|
||||
label: string,
|
||||
attributes: { min: string; max: string; step: string; value: string },
|
||||
): { root: HTMLLabelElement; input: HTMLInputElement; value: HTMLSpanElement } {
|
||||
const root = document.createElement("label");
|
||||
root.className = "viewer-option-row";
|
||||
const name = document.createElement("span");
|
||||
name.className = "viewer-option-name";
|
||||
name.textContent = label;
|
||||
const value = document.createElement("span");
|
||||
value.className = "viewer-option-val";
|
||||
const input = document.createElement("input");
|
||||
input.type = "range";
|
||||
input.min = attributes.min;
|
||||
input.max = attributes.max;
|
||||
input.step = attributes.step;
|
||||
input.value = attributes.value;
|
||||
root.append(name, value, input);
|
||||
return { root, input, value };
|
||||
}
|
||||
|
||||
const options = document.createElement("div");
|
||||
options.className = "viewer-options";
|
||||
const size = buildSlider(L("B04_Surface_Opt_PointSize"), {
|
||||
min: "0.1",
|
||||
max: "2.5",
|
||||
step: "0.01",
|
||||
value: "0.42",
|
||||
});
|
||||
const density = buildSlider(L("B04_Surface_Opt_Density"), {
|
||||
min: "1",
|
||||
max: "10",
|
||||
step: "1",
|
||||
value: "10",
|
||||
});
|
||||
const sizeInput = size.input;
|
||||
const densityInput = density.input;
|
||||
options.append(size.root, density.root);
|
||||
|
||||
/** 게이지 앞 숫자를 현재값으로 맞춘다. */
|
||||
function syncOptionValues(): void {
|
||||
size.value.textContent = Number(sizeInput.value).toFixed(2);
|
||||
density.value.textContent = `${Number(densityInput.value) * 10}%`;
|
||||
}
|
||||
syncOptionValues();
|
||||
|
||||
const viewerArea = document.createElement("div");
|
||||
viewerArea.className = "three-viewer";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "b04-surface-viewer__canvas";
|
||||
const scaleBar = document.createElement("div");
|
||||
scaleBar.className = "b04-surface__scale";
|
||||
const scaleText = document.createElement("span");
|
||||
scaleBar.append(scaleText);
|
||||
viewerArea.append(canvas, scaleBar, statusSpan);
|
||||
root.append(viewerArea);
|
||||
// 뷰포트 정중앙 로딩 서클 — 지도·그래프·다른 3D 뷰어와 같은 공통 컴포넌트.
|
||||
const progress = createProgressCircle({ overlay: true });
|
||||
progress.root.hidden = true;
|
||||
viewerArea.append(progress.root);
|
||||
|
||||
function setLoading(label: string | null): void {
|
||||
progress.root.hidden = label === null;
|
||||
if (label !== null) progress.set(null, label);
|
||||
}
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: RENDER_OPTIONS.antialias,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
|
||||
const scene = new THREE.Scene();
|
||||
const releaseTheme = bindSurfaceViewerTheme((color) => {
|
||||
scene.background = new THREE.Color(color);
|
||||
});
|
||||
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.1, 22000);
|
||||
const orbit = new OrbitControls(camera, canvas);
|
||||
orbit.enableDamping = true;
|
||||
orbit.dampingFactor = 0.08;
|
||||
orbit.screenSpacePanning = true;
|
||||
const axes = new THREE.AxesHelper(45);
|
||||
axes.visible = false;
|
||||
scene.add(axes);
|
||||
|
||||
let pointsObject: THREE.Points | null = null;
|
||||
// 회전·줌 중심을 커서 아래 지점으로 (지형 뷰어·B05와 공용 유틸).
|
||||
const releaseCursorPivot = bindCursorPivotControls({
|
||||
camera,
|
||||
controls: orbit,
|
||||
element: canvas,
|
||||
pickables: () => (pointsObject ? [pointsObject] : []),
|
||||
scene,
|
||||
});
|
||||
let currentData: SurfacePointCloudSampleResponse | null = null;
|
||||
let animationFrame = 0;
|
||||
let hasConnected = false;
|
||||
let disposed = false;
|
||||
let cameraListener: ((state: SurfaceCameraState) => void) | null = null;
|
||||
let suppressCameraEvent = false;
|
||||
let referenceBounds: SurfaceBounds | null = null;
|
||||
|
||||
function resize(): void {
|
||||
const rect = viewerArea.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.floor(rect.width));
|
||||
const height = Math.max(1, Math.floor(rect.height));
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function clearPoints(): void {
|
||||
if (!pointsObject) return;
|
||||
pointsObject.geometry.dispose();
|
||||
const material = pointsObject.material;
|
||||
if (Array.isArray(material)) material.forEach((item) => item.dispose());
|
||||
else material.dispose();
|
||||
scene.remove(pointsObject);
|
||||
pointsObject = null;
|
||||
}
|
||||
|
||||
function setCameraView(view: CameraView): void {
|
||||
const directions: Record<CameraView, [number, number, number]> = {
|
||||
iso: [120, 95, 135],
|
||||
top: [0, 1, 0.00001],
|
||||
front: [0, 60, 240],
|
||||
side: [240, 60, 0],
|
||||
};
|
||||
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
||||
const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : 190;
|
||||
orbit.target.set(0, 0, 0);
|
||||
camera.position
|
||||
.set(...directions[view])
|
||||
.normalize()
|
||||
.multiplyScalar(distance);
|
||||
camera.near = Math.max(distance / 10000, 0.01);
|
||||
camera.far = distance * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
camera.lookAt(orbit.target);
|
||||
orbit.update();
|
||||
}
|
||||
|
||||
function emitCameraState(): void {
|
||||
if (suppressCameraEvent || !cameraListener) return;
|
||||
const offset = camera.position.clone().sub(orbit.target);
|
||||
const distance = Math.max(offset.length(), 0.001);
|
||||
offset.normalize();
|
||||
cameraListener({
|
||||
direction: [offset.x, offset.y, offset.z],
|
||||
distanceMeters: distance,
|
||||
targetMeters: [orbit.target.x, orbit.target.y, orbit.target.z],
|
||||
});
|
||||
}
|
||||
|
||||
function applyCameraState(state: SurfaceCameraState): void {
|
||||
suppressCameraEvent = true;
|
||||
orbit.target.set(...state.targetMeters);
|
||||
camera.position
|
||||
.set(...state.direction)
|
||||
.multiplyScalar(Math.max(state.distanceMeters, 0.001))
|
||||
.add(orbit.target);
|
||||
camera.lookAt(orbit.target);
|
||||
orbit.update();
|
||||
suppressCameraEvent = false;
|
||||
}
|
||||
|
||||
function renderPointCloud(data: SurfacePointCloudSampleResponse): void {
|
||||
clearPoints();
|
||||
const bounds = data.bounds;
|
||||
const zMin = bounds.z_min ?? 0;
|
||||
const zMax = bounds.z_max ?? 0;
|
||||
const [xMid, yMid, zMid] = getReferenceCenter(bounds);
|
||||
const zSpan = Math.max(zMax - zMin, 1e-9);
|
||||
referenceBounds = bounds;
|
||||
const density = Number(densityInput.value);
|
||||
const step = Math.max(1, Math.ceil(10 / density));
|
||||
const count = Math.ceil(data.points.length / step);
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colors = new Float32Array(count * 3);
|
||||
const hasRgb = Boolean(data.rgb && data.rgb.length === data.points.length);
|
||||
let outputIndex = 0;
|
||||
for (let index = 0; index < data.points.length; index += step) {
|
||||
const [x, y, z] = data.points[index];
|
||||
positions[outputIndex * 3] = x - xMid;
|
||||
positions[outputIndex * 3 + 1] = z - zMid;
|
||||
positions[outputIndex * 3 + 2] = -(y - yMid);
|
||||
const rgb = hasRgb ? data.rgb?.[index] : undefined;
|
||||
if (rgb) {
|
||||
const divisor = Math.max(...rgb) > 255 ? 65535 : 255;
|
||||
colors[outputIndex * 3] = rgb[0] / divisor;
|
||||
colors[outputIndex * 3 + 1] = rgb[1] / divisor;
|
||||
colors[outputIndex * 3 + 2] = rgb[2] / divisor;
|
||||
} else {
|
||||
const ratio = Math.max(0, Math.min(1, (z - zMin) / zSpan));
|
||||
colors[outputIndex * 3] = (36 + ratio * 190) / 255;
|
||||
colors[outputIndex * 3 + 1] = (86 + Math.sin(ratio * Math.PI) * 95) / 255;
|
||||
colors[outputIndex * 3 + 2] = (128 - ratio * 80) / 255;
|
||||
}
|
||||
outputIndex += 1;
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
geometry.computeBoundingSphere();
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: Number(sizeInput.value),
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
pointsObject = new THREE.Points(geometry, material);
|
||||
scene.add(pointsObject);
|
||||
statusSpan.textContent = `${count.toLocaleString()}개 점 표시 중`;
|
||||
}
|
||||
|
||||
function updateScaleBar(): void {
|
||||
if (!currentData) {
|
||||
scaleBar.hidden = true;
|
||||
return;
|
||||
}
|
||||
const distance = camera.position.distanceTo(orbit.target);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(distance, viewerArea.clientHeight);
|
||||
const meters = niceScaleDistance(100 * metersPerPixel);
|
||||
scaleBar.hidden = false;
|
||||
scaleBar.style.width = `${meters / metersPerPixel}px`;
|
||||
scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
|
||||
}
|
||||
|
||||
function animate(): void {
|
||||
if (!root.isConnected) {
|
||||
if (!hasConnected) animationFrame = requestAnimationFrame(animate);
|
||||
else disposeViewer();
|
||||
return;
|
||||
}
|
||||
hasConnected = true;
|
||||
resize();
|
||||
orbit.update();
|
||||
updateScaleBar();
|
||||
renderer.render(scene, camera);
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function disposeViewer(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelAnimationFrame(animationFrame);
|
||||
releaseTheme();
|
||||
releaseCursorPivot();
|
||||
clearPoints();
|
||||
orbit.dispose();
|
||||
renderer.dispose();
|
||||
}
|
||||
|
||||
viewButtons.forEach(([view, button]) => {
|
||||
button.addEventListener("click", () => setCameraView(view));
|
||||
});
|
||||
sizeInput.addEventListener("input", () => {
|
||||
syncOptionValues();
|
||||
if (pointsObject)
|
||||
(pointsObject.material as THREE.PointsMaterial).size = Number(sizeInput.value);
|
||||
});
|
||||
densityInput.addEventListener("input", () => {
|
||||
syncOptionValues();
|
||||
if (currentData) renderPointCloud(currentData);
|
||||
});
|
||||
orbit.addEventListener("change", emitCameraState);
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
|
||||
return {
|
||||
root,
|
||||
controlsGroup,
|
||||
optionsContent: options,
|
||||
statusSpan,
|
||||
setLoading,
|
||||
render(data) {
|
||||
setLoading(null);
|
||||
currentData = data;
|
||||
clearPoints();
|
||||
if (!data) {
|
||||
statusSpan.textContent = "데이터가 존재하지 않습니다.";
|
||||
return;
|
||||
}
|
||||
renderPointCloud(data);
|
||||
setCameraView("top");
|
||||
},
|
||||
applyCameraState,
|
||||
onCameraChange(listener) {
|
||||
cameraListener = listener;
|
||||
},
|
||||
setAxesVisible(visible) {
|
||||
axes.visible = visible;
|
||||
},
|
||||
resetOptions() {
|
||||
axes.visible = false;
|
||||
sizeInput.value = "0.42";
|
||||
densityInput.value = "10";
|
||||
syncOptionValues();
|
||||
if (currentData) renderPointCloud(currentData);
|
||||
setCameraView("iso");
|
||||
},
|
||||
dispose: disposeViewer,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user