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