Files
Aislo/B05_Profile/B05_Profile_UI_Viewer.ts
T
eomsangdonandClaude Opus 5 3d037d869e feat(B05): 종단 3D 뷰어에 방위 나침반 추가 + 나침반 위젯 공용 이관
B05 3D 뷰어에는 방위 표시가 없어 방향을 읽을 수 없었음(사용자 지적, 메인 창 경유).
B04 가 오늘 새로 만든 「지면에 누운 나침반 링」을 그대로 쓰되, 두 페이지가 함께 쓰도록
공용으로 옮김.

- `B04_PreProcess_UI_Compass.ts` → `ui_template/ui_template_compass.ts` (git mv).
  `createTerrainCompass({ sizePx, className })` 로 크기·자리를 호출부가 정함.
- 스타일도 `ui_template/ui_template_compass.css` 로 옮기고 모듈이 직접 물고 옴.
  **놓을 자리만** 페이지 CSS 몫 — B04 우하단(`b04-surface__compass`),
  B05 는 ISO 버튼 아래(`b05-route__compass`).
- B05 뷰어는 104px 로 쓰고 렌더 루프에서 카메라 오프셋으로 갱신, 지형이 없으면 감춤.
  좌표 규약이 B04 와 같음(북 −z · 동 +x · 표고 +y, `Viewer.ts:295` 정점 변환) — 그대로 맞음.

화면 검증(5174): B05 나침반 104×104, ISO 버튼 아래 8px. 도북 화살이 뷰 전환에 반응 —
TOP 에서 `M0.00 -33.30`(수직), ISO 에서 `M23.56 -13.60`. 후자는 카메라 오프셋 (d,d,d)
에 대한 투영 이론값(x=R/√2=23.56, y=−R/√6=−13.60)과 일치. B04 쪽도 그대로(84×84,
우하단 16px, 링·화살 색 토큰 유지). `tsc --noEmit` 통과, 뷰어 693줄로 제한 안.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 20:18:51 +09:00

694 lines
30 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 { createOrthoCameraRig } 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 { 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;
/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */
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;
beginMoveSelected: () => void;
/** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */
modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null;
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();
const cameraRig = createOrthoCameraRig();
const camera = cameraRig.camera;
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;
// 예상형상(코리도) 상태 — 원본/클리핑 지형 두 벌 유지·스왑(2026-08-23).
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,
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;
},
};
const markers = createRouteMarkers(scene, () => bounds, terrainElevation);
// 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한).
const markerInput = bindMarkerPointerControls({
canvas,
camera,
controls,
markers,
getTerrain: () => terrain,
getBounds: () => bounds,
});
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
const releaseCursorPivot = bindCursorPivotControls({
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];
camera.position.set(target.x + x, target.y + y, target.z + z);
camera.near = Math.max(0.1, distance / 1000);
camera.far = distance * 10;
// 원근 45°(반각 tan ≈ 0.414)와 비슷한 화면 배율 — 뷰 전환 시 크기감이 유지된다.
cameraRig.setHalfHeight(distance * 0.42);
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(
camera.position.x - controls.target.x,
camera.position.y - controls.target.y,
camera.position.z - controls.target.z,
);
} else {
compass.setVisible(false);
}
renderer.render(scene, 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;
// 검증용 요약(2026-08-26) — 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서
// 수치로 확인한다(`__corridorClip`과 같은 용도).
(window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build
? {
ribbons: build.ribbons.map((ribbon) => ({
kind: ribbon.kind,
side: ribbon.side,
rows: ribbon.chainages.length,
cols: ribbon.colCount,
first: ribbon.chainages[0],
last: ribbon.chainages[ribbon.chainages.length - 1],
})),
outlineRows: build.outline.chainages.length,
outline: build.outline,
// 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28).
structures: (build.structures ?? []).map((solid) => ({
kind: solid.kind,
at: solid.chainage_m,
rings: solid.rings?.length ?? 0,
points: solid.polygon?.length ?? 0,
})),
// 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다.
raw: {
ribbons: build.ribbons,
planCurves: build.planCurves ?? [],
cutWalls: build.cutWalls ?? [],
},
planCurves: (build.planCurves ?? []).map((curve) => ({
source: curve.source,
role: curve.role,
side: curve.side,
at: curve.setChainageM,
loops: curve.loops.length,
points: curve.loops.reduce((sum, loop) => sum + loop.length, 0),
z0: curve.loops[0]?.[0]?.[2] ?? null,
planZ: curve.planZ,
})),
}
: null;
if (build && bounds) {
snapCorridorEdges(build);
attachPatchSkirts(build);
corridorGroup = createCorridorGroup(build, bounds);
scene.add(corridorGroup);
}
// 평면 스케치 되켜기 — 최종 결과물에서는 숨기지만 절취·패치 기하를 다시 볼 때 쓴다
// (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,
async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) {
bounds = nextBounds;
current = { projectId, modelId, smooth, interval };
if (terrain) {
scene.remove(terrain);
disposeObject(terrain);
}
heightIndex = null; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다.
bandSplit?.dispose();
bandSplit = null;
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();
// 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단
// 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다.
if (corridorBuild) setCorridor(corridorBuild);
fit("top");
markers.renderMarkers();
await reloadContours(interval);
// 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가
// 없다(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,
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);
corridorBuild = null; // 예약된 클립 콜백 무효화.
disposeCorridorGroup();
disposeClippedTerrain();
bandSplit?.dispose();
controls.dispose();
renderer.dispose();
},
};
}