/* ============================================================================= * E04_Stab3D_UI_View3D.ts * E04 [3D 보기](PLAN 533-3) — 합성 지표(원지형 + 노선면 · 셀 색 = 최소 안전율 · 임계 체적) + * 노선 중심선 + 임계 활동면(구 · 타원체를 지표 아래만 남긴 잘린 면 · 그 위 활동체는 걷어냄) · 표시만. * E 복사본에는 B04 지표면 모델 행 · 미리보기 GLB 가 없어 B05 `createRouteViewer.loadSurface` 대신 * 해석 격자(z)를 그대로 그림 — 좌표 변환은 B05 `modelToScene` 과 같은 꼴(x · z · −y). * 메쉬 · 색 = logic/E04_Stab3D_Mesh · logic/E04_Stab3D_Raster. * ========================================================================== */ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { createTerrainCompass } from "@ui/ui_template_compass"; import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; import { cutCells, heightAt, slipMesh, terrainMesh, terrainWithout, type MeshArrays, } from "./logic/E04_Stab3D_Mesh"; import { FS_STEPS, VOLUME_STEPS, hexToRgb, stepColor, type Result3d, } from "./logic/E04_Stab3D_Raster"; import type { MapLayer } from "./E04_Stab3D_UI_Map"; export interface View3D { root: HTMLElement; show: (result: Result3d | null, line: number[][], layer: MapLayer, aim?: boolean) => void; setActive: (active: boolean) => void; dispose: () => void; } const LIGHT_BG = 0xf5f7fa; const DARK_BG = 0x251f38; const SLIP_COLOR = 0x8b1a2b; const ROUTE_COLOR = 0x2563eb; export function createView3D(): View3D { const root = el("div", { className: "e04-view3d" }); const canvas = el("canvas", { className: "e04-view3d__canvas" }); const compass = createTerrainCompass({ sizePx: 88, className: "e04-view3d__compass" }); // [임계 활동면] 켬 = 활동체를 걷어낸 지표 + 잘린 활동면(Scoops3D 꼴) · 끔 = 원래 지표 const slipToggle = el("input", { attrs: { type: "checkbox" } }); slipToggle.checked = true; const tools = el("div", { className: "e04-view3d__tools" }); const label = el("label", { className: "e04-view3d__toggle" }); label.append(slipToggle, el("span", { text: t("E04_SlipSurface") })); tools.append(label); root.append(canvas, compass.root, tools); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(window.devicePixelRatio || 1); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.5, 20000); camera.up.set(0, 1, 0); const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2)); const sun = new THREE.DirectionalLight(0xffffff, 1.4); sun.position.set(-1, 2, 1); scene.add(sun); const content = new THREE.Group(); scene.add(content); const background = (): void => { const theme = document.documentElement.getAttribute("data-theme"); const dark = theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches); scene.background = new THREE.Color(dark ? DARK_BG : LIGHT_BG); }; background(); const themeWatch = new MutationObserver(background); themeWatch.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); let origin = { x: 0, y: 0, z: 0 }; let terrain: THREE.Mesh | null = null; let slip: THREE.Mesh | null = null; let counts = { terrain: 0, slip: 0, slipTriangles: 0 }; const toScene = (x: number, y: number, z: number): [number, number, number] => [ x - origin.x, z - origin.z, -(y - origin.y), ]; function geometry(mesh: MeshArrays, colors?: Float32Array): THREE.BufferGeometry { const out = new Float32Array(mesh.positions.length); for (let i = 0; i < mesh.positions.length; i += 3) { out.set(toScene(mesh.positions[i], mesh.positions[i + 1], mesh.positions[i + 2]), i); } const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.BufferAttribute(out, 3)); if (colors) g.setAttribute("color", new THREE.BufferAttribute(colors, 3)); g.setIndex(new THREE.BufferAttribute(mesh.indices, 1)); g.computeVertexNormals(); return g; } function clear(): void { for (const child of [...content.children]) { content.remove(child); child.traverse((node) => { const mesh = node as THREE.Mesh; mesh.geometry?.dispose(); const material = mesh.material as THREE.Material | THREE.Material[] | undefined; (Array.isArray(material) ? material : material ? [material] : []).forEach((m) => m.dispose(), ); }); } terrain = slip = null; } let last: { result: Result3d | null; line: number[][]; layer: MapLayer } | null = null; slipToggle.addEventListener("change", () => { if (last) show(last.result, last.line, last.layer, false); }); function show(result: Result3d | null, line: number[][], layer: MapLayer, aim = true): void { last = { result, line, layer }; clear(); counts = { terrain: 0, slip: 0, slipTriangles: 0 }; if (!result) return; const { grid, z, best } = result; const zs = z.filter((v): v is number => v !== null); origin = { x: grid.x0 + ((grid.nx - 1) * grid.dx) / 2, y: grid.y0 + ((grid.ny - 1) * grid.dx) / 2, z: zs.reduce((a, b) => a + b, 0) / Math.max(zs.length, 1), }; // 지표 — 셀 색 = 지금 층(안전율 · 체적) const cut = cutCells(grid, z, best); const ground = slipToggle.checked ? terrainWithout(grid, z, cut) : terrainMesh(grid, z); const values = layer === "fs" ? result.f : result.v; const steps = layer === "fs" ? FS_STEPS : VOLUME_STEPS; const colors = new Float32Array(ground.cells.length * 3); ground.cells.forEach((cell, i) => colors.set(hexToRgb(stepColor(steps, values[cell])), i * 3)); terrain = new THREE.Mesh( geometry(ground, colors), new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }), ); content.add(terrain); counts.terrain = ground.cells.length; // 임계 활동면 — 잘린 구 · 타원체 + 테두리(지표 높이) · 양면 · 옅은 격자선 const surface = slipMesh(grid, z, best); counts.slip = cut.size; counts.slipTriangles = surface.indices.length / 3; if (surface.indices.length && slipToggle.checked) { const g = geometry(surface); slip = new THREE.Mesh( g, new THREE.MeshPhongMaterial({ color: SLIP_COLOR, side: THREE.DoubleSide, shininess: 30 }), ); slip.add( new THREE.LineSegments( new THREE.WireframeGeometry(g), new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.25 }), ), ); content.add(slip); } // 노선 중심선 — 지표 위 0.4 m 관(굵기 0.35 m) const points = line .map((p) => ({ p, h: heightAt(grid, z, p[0], p[1]) })) .filter((q): q is { p: number[]; h: number } => q.h !== null) .map(({ p, h }) => new THREE.Vector3(...toScene(p[0], p[1], h + 0.4))); if (points.length >= 2) { const path = new THREE.CatmullRomCurve3(points, false, "centripetal"); content.add( new THREE.Mesh( new THREE.TubeGeometry(path, points.length * 4, 0.35, 6, false), new THREE.MeshLambertMaterial({ color: ROUTE_COLOR }), ), ); } if (!aim) return; // 사시도 — 남동쪽 위에서 격자 전체가 들어오게 const span = Math.max(grid.nx, grid.ny) * grid.dx; const focus = new THREE.Vector3(...toScene(best.x, best.y, best.z - best.r)); controls.target.copy(focus); camera.position.set(focus.x + span * 0.55, focus.y + span * 0.6, focus.z + span * 0.75); controls.update(); } let frame = 0; const loop = (): void => { frame = requestAnimationFrame(loop); const w = root.clientWidth; const h = root.clientHeight; if ( w && h && (canvas.width !== Math.round(w * renderer.getPixelRatio()) || camera.aspect !== w / h) ) { renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } controls.update(); compass.update( camera.position.x - controls.target.x, camera.position.y - controls.target.y, camera.position.z - controls.target.z, ); renderer.render(scene, camera); }; const probe = { counts: () => ({ ...counts }), scene, camera }; (window as unknown as { __e04View?: typeof probe }).__e04View = probe; return { root, show, setActive(active) { if (active && !frame) loop(); if (!active && frame) { cancelAnimationFrame(frame); frame = 0; } }, dispose() { cancelAnimationFrame(frame); frame = 0; clear(); themeWatch.disconnect(); controls.dispose(); renderer.dispose(); renderer.forceContextLoss(); if ((window as unknown as { __e04View?: unknown }).__e04View === probe) delete (window as unknown as { __e04View?: unknown }).__e04View; }, }; }