Files
Aislo/B05_Profile/B05_Profile_UI_Viewer.ts
T
eomsangdonandClaude Opus 5 44ff1dea4d perf(B05): 화면에 들어올 때마다 지표면을 다시 파싱하던 것 제거
B05 는 해시가 바뀔 때마다 renderB05Route 로 통째로 다시 조립되어 뷰어 상태가 비워짐.
그래서 8MB 지표면을 진입할 때마다 다시 읽고 다시 파싱했고, 그것이 진입을 잡는
단일 동기 블록 14.4초였음(공용 브라우저 3왕복 실측: 14,708 / 1,060 / 14,841ms).

- 파싱해 둔 지형을 모듈 단위 cachedTerrain 에 한 벌 보관 — 같은 모델이면 새 장면에
  그대로 붙임. 다른 모델을 부르면 옛것을 버려 GPU 버퍼가 안 쌓임.
- 장면에서 뗄 때 보관본은 disposeObject 하지 않음.
- window.__surfaceTiming 디버그 훅 추가 — 단계별 시간(fetch·parse·fit·contours).

자체검증(공용 브라우저 3왕복) — B05 진입 683 / 474 / 639ms (전 14,708 / 1,060 / 14,841).
surfaceTiming: reuse 0ms · fit+markers 35ms · contours 183ms · total 222ms.
B06 진입은 2.4~2.7초로 변화 없음. 시험 395 통과·17 건너뜀, typecheck 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 21:52:13 +09:00

753 lines
33 KiB
TypeScript

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createTerrainCompass } from "@ui/ui_template_compass";
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, TOP_VIEW_TILT } from "../B04_PreProcess/B04_PreProcess_UI_Camera";
import {
createRouteMarkers,
modelToScene,
sceneToModel,
type ModelBounds,
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 {
createCorridorGroup,
PLAN_CURVE_FLAG,
PLAN_CURVE_GROUP,
} from "./B05_Profile_UI_Corridor_Mesh";
import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip";
import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug";
import {
bindStructurePick,
type StructurePickControls,
} from "./B05_Profile_UI_Viewer_Structure_Pick";
import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt";
import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split";
type ViewKind = "iso" | "top" | "front" | "side";
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
const DARK_VIEWER_BACKGROUND = 0x251f38;
/**
* 파싱해 둔 지표면 한 벌 — **뷰어보다 오래 산다**.
*
* B05 는 해시가 바뀔 때마다 `renderB05Route` 로 통째로 다시 조립되므로 뷰어 안의 상태는
* 매번 비워진다. 그러면 8MB 짜리 지표면을 화면에 들어올 때마다 다시 읽고 다시 파싱하는데,
* 실측에서 그 값이 **단일 동기 블록 14.4초**였다(2026-09-06 공용 브라우저 3왕복).
* 같은 모델이면 이 자리에 둔 것을 새 장면에 그대로 붙인다.
*
* 한 벌만 쥔다 — 다른 모델을 부르면 옛것을 버린다(GPU 버퍼가 쌓이지 않게).
* 장면에서 뗄 때도 이 객체는 `disposeObject` 하지 않는다.
*/
const cachedTerrain: { key: string | null; object: THREE.Object3D | null } = {
key: null,
object: null,
};
declare global {
interface Window {
/** 지표면 적재 단계별 시간(ms) — 화면 밖에서 수치로 확인하는 디버그 훅. */
__surfaceTiming?: Array<{ step: string; ms: number }>;
}
}
/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */
function countTriangles(root: THREE.Object3D | null): number {
let total = 0;
root?.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const index = child.geometry.getIndex();
const position = child.geometry.getAttribute("position");
total += Math.floor((index ? index.count : (position?.count ?? 0)) / 3);
});
return total;
}
/** 서피스 진단 요약 — 색·법선·재질이 원본과 어긋났는지 화면 밖에서 확인한다. */
function describeSurface(root: THREE.Object3D | null): unknown {
const out: unknown[] = [];
root?.traverse((child) => {
if (!(child instanceof THREE.Mesh) || out.length >= 2) return;
const geometry = child.geometry;
const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined;
const material = (
Array.isArray(child.material) ? child.material[0] : child.material
) as THREE.MeshLambertMaterial;
out.push({
type: material?.type,
vertexColors: material?.vertexColors,
matColor: material?.color?.getHexString(),
side: material?.side,
colorSize: color?.itemSize,
color0: color ? [color.getX(0), color.getY(0), color.getZ(0)] : null,
normal0: normal ? [normal.getX(0), normal.getY(0), normal.getZ(0)] : null,
hasNormal: Boolean(normal),
});
});
return out;
}
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;
/**
* 계획노선 코리도(예상형상) 서피스 반영. build의 비탈 최외곽 정점 z는 지형
* 메쉬에 투영(심 보정)되므로 **전달 객체가 제자리 수정**된다 — 저장 직렬화는
* 이 호출 뒤에 할 것. null이면 코리도 제거.
*/
setCorridor: (build: CorridorBuildResult | null) => void;
/** [예상형상] 토글 — ON: 클리핑 지형+코리도(공사 후), OFF: 원본 지형 완전체. */
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;
/** 코리도 구조물 개별 선택(2026-09-04) — 클릭 알림·강조 되살리기 창구. */
structurePick: StructurePickControls;
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");
// 방위 나침반 — ISO 버튼 아래(2026-09-03 사용자 지시). 지면에 누운 링이라 사시도에서도
// 화면 북쪽과 어긋나지 않는다. 위젯은 B04 지표면 뷰어와 **같은 공용 모듈**을 쓴다.
const compass = createTerrainCompass({ sizePx: 104, className: "b05-route__compass" });
root.append(canvas, status, compass.root);
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();
// 카메라는 원근(기본)·직교 두 벌을 두고 갈아 끼운다 — 갈아 끼우면 **객체가 바뀌므로**
// 붙잡아 두지 말고 `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(cameraRig.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;
// 예상형상(코리도) 상태 — 원본/클리핑 지형 두 벌 유지·스왑(2026-08-23).
//
// 지형은 아래 모듈 단위 `cachedTerrain` 이 한 벌 쥐고 있어, B05 를 드나들어도 다시
// 파싱하지 않는다(2026-09-06 실측: 재진입마다 14.4초짜리 단일 동기 블록이 있었다).
let corridorBuild: CorridorBuildResult | null = null;
let corridorGroup: THREE.Group | null = null;
let clippedTerrain: THREE.Object3D | null = null;
/** 지형 높이 격자 색인 — 지형을 새로 불러올 때만 다시 만든다(편집 중 재사용). */
let heightIndex: TerrainHeightIndex | null = null;
/** 지형 near/far 분할본 — 편집마다 노선 주변만 재트림하려고 1회만 만든다. */
let bandSplit: TerrainBandSplit | null = null;
let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값.
let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합).
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;
/**
* 모델 좌표 (x, y) 자리의 지형 표고. 지형 위에서 수직으로 광선을 쏴 맞은 높이를 돌려준다.
* 계획노선 CSV로 만든 점처럼 표고가 없는 자리를 지형에 얹을 때 쓴다.
*/
function terrainElevation(x: number, y: number): number | null {
if (!terrain || !bounds) return null;
const origin = modelToScene({ x, y, z: bounds.z[1] + 100 }, bounds);
const raycaster = new THREE.Raycaster(origin, new THREE.Vector3(0, -1, 0));
const hit = raycaster.intersectObject(terrain, true)[0];
return hit ? sceneToModel(hit.point, bounds).z : null;
}
// 검증용 — 화면에 **진짜 구멍**이 났는지는 리본 좌표만으로는 못 가린다(구조물
// 솔리드가 덮고 있을 수 있다). 씬·THREE·모델→씬 변환을 내보내 위에서 레이캐스트로
// 확인한다(`__corridorBuild`·`__corridorClip`과 같은 용도).
(window as unknown as { __corridorScene?: unknown }).__corridorScene = {
scene,
THREE,
// 카메라도 함께 낸다(2026-09-04) — 화면 좌표에서 레이캐스트로 무엇이 앞에 있는지
// 확인해야 3D 클릭 검증을 수치로 할 수 있다.
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),
// 카메라를 모델 좌표 한 점으로 바로 보낸다 — 측점 확인을 마우스 휠·드래그로 하면
// 한 번에 20~30초가 걸리고 우클릭이 구조물 메뉴를 연다(2026-09-02). 화면 검증 전용.
lookAt: (x: number, y: number, z: number, distance: number, view: ViewKind = "top") => {
if (!bounds) return false;
placeCamera(modelToScene({ x, y, z }, bounds), distance, view);
return true;
},
// 모델 좌표 한 점의 **화면(client) 좌표**(2026-09-04) — 3D 물체를 실제 마우스로
// 눌러 검증할 때 쓴다. 캔버스가 아래 패널에 가려 중앙이 안 보이므로 자리를 직접 잰다.
project: (x: number, y: number, z: number) => {
if (!bounds) return null;
const point = modelToScene({ x, y, z }, bounds).project(cameraRig.camera());
const rect = canvas.getBoundingClientRect();
return {
x: rect.left + ((point.x + 1) / 2) * rect.width,
y: rect.top + ((1 - point.y) / 2) * rect.height,
};
},
};
const markers = createRouteMarkers(scene, () => bounds, terrainElevation);
// 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한).
const markerInput = bindMarkerPointerControls({
canvas,
camera: cameraRig.camera,
controls,
markers,
getTerrain: () => terrain,
getBounds: () => bounds,
});
// 코리도 구조물 클릭 선택(2026-09-04) — 마커보다 뒤 순위다.
const structurePick = bindStructurePick({
canvas,
camera: cameraRig.camera,
group: () => corridorGroup,
blocked: () => markerInput.blocked(),
});
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
const releaseCursorPivot = bindCursorPivotControls({
camera: cameraRig.camera,
controls,
element: canvas,
pickables: () => (terrain ? [terrain] : []),
blocked: () => markerInput.blocked(),
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);
cameraRig.setAspect(width / height);
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(root);
function placeCamera(target: THREE.Vector3, distance: number, view: ViewKind): void {
controls.target.copy(target);
const positions = {
iso: [distance, distance, distance],
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
top: [0, distance, distance * TOP_VIEW_TILT],
front: [0, distance * 0.25, distance],
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를 넘어
// 최대 확대 시 지형이 잘린다(2026-09-04 원근 복귀 실측: 400m 노선 여유 13mm).
camera.near = Math.max(0.1, Math.min(0.4, distance / 1000));
camera.far = distance * 10;
camera.updateProjectionMatrix();
cameraRig.setFit(distance);
controls.update();
}
function fit(view: ViewKind = "top"): void {
if (!bounds) return;
const width = bounds.x[1] - bounds.x[0];
const depth = bounds.y[1] - bounds.y[0];
placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35, view);
}
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 }),
),
);
});
}
/** 화면(client) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */
let frame = 0;
function animate(): void {
frame = requestAnimationFrame(animate);
controls.update();
// 나침반은 시선 방향이 실제로 바뀔 때만 다시 그린다(모듈 안에서 걸러낸다).
if (terrain) {
compass.setVisible(true);
compass.update(
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(cameraRig.camera().position.distanceTo(controls.target));
renderer.render(scene, cameraRig.camera());
}
animate();
// 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라
// (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
// 기본 흑백 지형(2026-08-23 사용자 확정) — 패널 토글 초기값과 맞춘다.
let surfaceGrayscale = true;
function applySurfaceGrayscale(): void {
[terrain, clippedTerrain].forEach((target) => applyGrayscaleTo(target));
}
function applyGrayscaleTo(target: THREE.Object3D | null): void {
target?.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;
});
}
/* ── 예상형상(코리도) — 원본/클리핑 지형 스왑 + 계획 서피스 조합 ───────── */
/** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */
function applyCorridorVisibility(): void {
const swapped = corridorOn && clippedTerrain !== null;
if (terrain) terrain.visible = surfaceOn && !swapped;
if (clippedTerrain) clippedTerrain.visible = surfaceOn && swapped;
if (corridorGroup) corridorGroup.visible = corridorOn;
}
function disposeClippedTerrain(): void {
if (!clippedTerrain) return;
scene.remove(clippedTerrain);
// 분할본이 들고 있는 geometry·material은 여기서 해제하지 않는다 — 다음 재트림에
// 그대로 다시 쓴다. 이 그룹이 새로 만든 것(userData.owned)만 정리한다.
clippedTerrain.traverse((child) => {
if (!(child instanceof THREE.Mesh) && !(child instanceof THREE.Points)) return;
if (child.userData.owned) child.geometry.dispose();
});
clippedTerrain = null;
}
/** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다. */
function corridorBand(build: CorridorBuildResult): SceneBox | null {
if (!bounds) return null;
const ox = (bounds.x[0] + bounds.x[1]) / 2;
const oy = (bounds.y[0] + bounds.y[1]) / 2;
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
[...build.outline.left, ...build.outline.right].forEach(([mx, my]) => {
const x = mx - ox;
const z = -(my - oy);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
});
if (!Number.isFinite(minX)) return null;
return { minX, maxX, minZ, maxZ };
}
/** 밴드 분할본 확보 — 코리도가 밴드를 벗어났을 때만 다시 가른다. */
function ensureBandSplit(build: CorridorBuildResult): TerrainBandSplit | null {
if (!terrain) return null;
const box = corridorBand(build);
if (!box) return null;
if (bandSplit?.covers(box)) return bandSplit;
bandSplit?.dispose();
bandSplit = new TerrainBandSplit(terrain, {
minX: box.minX - BAND_MARGIN_M,
maxX: box.maxX + BAND_MARGIN_M,
minZ: box.minZ - BAND_MARGIN_M,
maxZ: box.maxZ + BAND_MARGIN_M,
});
return bandSplit;
}
function disposeCorridorGroup(): void {
if (!corridorGroup) return;
scene.remove(corridorGroup);
disposeObject(corridorGroup);
corridorGroup = null;
}
/** 지형 높이 색인 확보 — 지형 1회당 한 번만 만든다(빌드 비용 O(삼각형)). */
function ensureHeightIndex(): TerrainHeightIndex | null {
if (heightIndex) return heightIndex;
if (!terrain) return null;
heightIndex = new TerrainHeightIndex(terrain);
return heightIndex.triangleCount > 0 ? heightIndex : null;
}
/**
* 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지.
* 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부
* 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록).
*/
function snapCorridorEdges(build: CorridorBuildResult): void {
if (!bounds) return;
const index = ensureHeightIndex();
if (!index) return;
const ox = (bounds.x[0] + bounds.x[1]) / 2;
const oy = (bounds.y[0] + bounds.y[1]) / 2;
const oz = (bounds.z[0] + bounds.z[1]) / 2;
build.ribbons.forEach((ribbon) => {
// 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시).
// 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다.
if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return;
// 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체
// 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그
// 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m).
if (ribbon.patch) return;
const cols = ribbon.colCount;
const outerCol = ribbon.side === "right" ? 0 : cols - 1;
const innerCol = ribbon.side === "right" ? cols - 1 : 0;
for (let row = 0; row < ribbon.chainages.length; row += 1) {
const outer = (row * cols + outerCol) * 3;
const inner = (row * cols + innerCol) * 3;
// 축퇴 구간(전이부, 폭≈0)은 그대로 둔다 — 노견 끝을 지형에 끌어붙이지 않는다.
const width = Math.hypot(
ribbon.positions[outer] - ribbon.positions[inner],
ribbon.positions[outer + 1] - ribbon.positions[inner + 1],
);
if (width < 1e-4) continue;
const height = index.heightAt(
ribbon.positions[outer] - ox,
-(ribbon.positions[outer + 1] - oy),
);
if (height !== null) ribbon.positions[outer + 2] = height + oz;
}
});
}
/**
* 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을
* 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은
* `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다.
*/
function attachPatchSkirts(build: CorridorBuildResult): void {
if (!bounds) return;
const index = ensureHeightIndex();
if (!index) return;
const ox = (bounds.x[0] + bounds.x[1]) / 2;
const oy = (bounds.y[0] + bounds.y[1]) / 2;
const oz = (bounds.z[0] + bounds.z[1]) / 2;
const { walls, stats } = buildPatchSkirts(build.ribbons, (x, y) => {
const height = index.heightAt(x - ox, -(y - oy));
return height === null ? null : height + oz;
});
const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt);
build.cutWalls = [...kept, ...walls];
(window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats;
}
/** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */
function scheduleTerrainClip(): void {
disposeClippedTerrain();
if (!corridorBuild || !terrain || !bounds) {
applyCorridorVisibility();
return;
}
const buildAtSchedule = corridorBuild;
requestAnimationFrame(() => {
// 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다.
if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return;
const split = ensureBandSplit(buildAtSchedule);
if (!split) return;
const clipped = clipTerrain(split, buildAtSchedule, bounds);
disposeClippedTerrain();
clippedTerrain = clipped;
scene.add(clipped);
// 검증용 요약 — 원지반이 실제로 잘렸는지 화면 밖에서 수치로 확인한다.
(window as unknown as { __corridorClip?: unknown }).__corridorClip = {
source: countTriangles(terrain),
clipped: countTriangles(clipped),
detail: describeSurface(clipped),
sourceDetail: describeSurface(terrain),
};
applyGrayscaleTo(clipped); // 흑백 토글 상태 유지.
applyCorridorVisibility();
});
applyCorridorVisibility();
}
function setCorridor(build: CorridorBuildResult | null): void {
disposeCorridorGroup();
corridorBuild = build;
setCorridorBuildSummary(build);
if (build && bounds) {
snapCorridorEdges(build);
attachPatchSkirts(build);
corridorGroup = createCorridorGroup(build, bounds);
scene.add(corridorGroup);
structurePick.reapply();
}
// 평면 스케치 되켜기 — 최종 결과물에서는 숨기지만 절취·패치 기하를 다시 볼 때 쓴다
// (2026-09-02 사용자: "나중에 디버깅을 위해 재사용 가능성 있음").
// `__corridorPlanCurves(true)` 로 켜고 `(false)` 로 끈다. 선택은 이 브라우저에 남아
// 다음에 열 때도 그대로다. 인자 없이 부르면 지금 상태를 돌려준다.
(
window as unknown as { __corridorPlanCurves?: (on?: boolean) => boolean }
).__corridorPlanCurves = (on?: boolean): boolean => {
const sketch = corridorGroup?.getObjectByName(PLAN_CURVE_GROUP);
if (on !== undefined) {
try {
window.localStorage.setItem(PLAN_CURVE_FLAG, on ? "1" : "0");
} catch {
// 저장소가 막힌 환경 — 이번 화면에만 적용한다.
}
if (sketch) sketch.visible = on;
}
return sketch?.visible ?? false;
};
scheduleTerrainClip();
}
return {
root,
markers,
structurePick,
async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) {
const step = (label: string, from: number): void => {
timing.push({ step: label, ms: Math.round(performance.now() - from) });
};
const timing: Array<{ step: string; ms: number }> = [];
const started = performance.now();
bounds = nextBounds;
current = { projectId, modelId, smooth, interval };
if (terrain) {
scene.remove(terrain);
if (terrain !== cachedTerrain.object) disposeObject(terrain);
}
heightIndex = null; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다.
bandSplit?.dispose();
bandSplit = null;
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`;
const key = `${projectId}:${modelId}:${method}:${smooth}`;
if (cachedTerrain.key === key && cachedTerrain.object) {
// 같은 지표면 모델을 이미 파싱해 뒀다 — 다시 읽지도 파싱하지도 않는다(2026-09-06).
terrain = cachedTerrain.object;
step("reuse", started);
} else {
const fetched = performance.now();
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(projectId, url);
step("fetch", fetched);
const parsed = performance.now();
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;
});
step("parse", parsed);
// 한 벌만 쥔다 — 다른 모델로 바뀌면 옛것을 버린다.
if (cachedTerrain.object && cachedTerrain.object !== terrain) {
disposeObject(cachedTerrain.object);
}
cachedTerrain.key = key;
cachedTerrain.object = terrain;
}
scene.add(terrain);
// 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다.
applySurfaceGrayscale();
// 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단
// 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다.
if (corridorBuild) setCorridor(corridorBuild);
const fitted = performance.now();
fit("top");
markers.renderMarkers();
step("fit+markers", fitted);
const contoured = performance.now();
await reloadContours(interval);
step("contours", contoured);
step("total", started);
window.__surfaceTiming = timing;
// 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가
// 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다.
status.textContent = "";
},
reloadContours,
setSurfaceVisible(visible) {
surfaceOn = visible;
applyCorridorVisibility();
},
setCorridor,
setCorridorVisible(visible) {
corridorOn = visible;
applyCorridorVisibility();
},
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,
setProjection: (kind) => cameraRig.setKind(kind, controls),
beginMoveSelected() {
markerInput.beginMoveSelected();
status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요.";
},
modelPointAt: markerInput.terrainPointAt,
dispose() {
markerInput.dispose();
cancelAnimationFrame(frame);
resizeObserver.disconnect();
themeObserver.disconnect();
systemDarkTheme.removeEventListener("change", updateSceneBackground);
releaseCursorPivot();
markers.dispose();
clearContours();
disposeObject(terrain);
structurePick.dispose();
corridorBuild = null; // 예약된 클립 콜백 무효화.
disposeCorridorGroup();
disposeClippedTerrain();
bandSplit?.dispose();
controls.dispose();
renderer.dispose();
},
};
}