/* ============================================================================= * E03_StabSection_UI_View3D.ts * E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만). * 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) · * 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀. * 설계 지형 = 응답 `terrain`(E 복사본 DTM · 노면 자리 구멍 · 요구 390) → E04 `terrainMesh` · 한 색 · 켬끔. * ========================================================================== */ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { terrainMesh } from "../E04_Stab3D/logic/E04_Stab3D_Mesh"; import type { Grid } from "../E04_Stab3D/logic/E04_Stab3D_Raster"; import { check, legend, muted } from "./E03_StabSection_UI_Parts"; import { bandIndex, RATIO_BREAKS, type Condition, type StationRow, } from "./logic/E03_StabSection_Logic"; interface Corridor3d { origin: [number, number, number]; ribbons: { kind: string; side: string; colCount: number; chainages: number[]; positions: number[]; }[]; /** 설계 지형 — z 행 우선(iy·nx+ix) · null = 노면 구멍 · 띠 밖 · 복사본에 격자 없으면 null */ terrain?: { grid: Grid; z: (number | null)[] } | null; } /** 측점 하나 그 조건 최소 비(안전율 ÷ 기준)의 띠 */ function stationBand(row: StationRow, condition: Condition): number | null { let best: number | null = null; for (const c of Object.values(row.cases[condition] ?? {})) { const band = bandIndex(c.fs, c.required); if (band !== null && (best === null || band < best)) best = band; } return best; } function cssColor(name: string): THREE.Color { const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return new THREE.Color(value || "#888888"); } export async function openView3d( data: Corridor3d, rows: StationRow[], condition: Condition, conditionLabel: string, ): Promise { const edges = [null, ...RATIO_BREAKS, null]; const items = [...Array(RATIO_BREAKS.length + 1).keys()].map((i) => ({ label: edges[i] === null ? `< ${edges[i + 1]}` : edges[i + 1] === null ? `≥ ${edges[i]}` : `${edges[i]}–${edges[i + 1]}`, color: `var(--e03-band-${i})`, })); items.push({ label: t("E03_Verdict_none"), color: "var(--e03-none)" }); let ground: THREE.Mesh | null = null; const legendRow = el("div", { className: "e03-legend", children: [ muted(`${conditionLabel} · ${t("E03_View3d_Legend")}`), legend(items), ...(data.terrain ? [check(t("E03_View3d_Terrain"), true, (v) => ground && (ground.visible = v))] : []), ], }); const host = el("div", { className: "e03-view3d__canvas" }); let teardown = (): void => undefined; openModal({ title: t("E03_View3d"), closeLabel: t("Common_Btn_Close"), wide: true, dialogClass: "e03-view3d", mount: (body) => { body.closest(".ui-modal")!.dataset.role = "view3d"; body.append(legendRow, host); }, beforeClose: () => (teardown(), true), }); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); host.append(renderer.domElement); const scene = new THREE.Scene(); scene.background = cssColor("--e03-plot"); scene.add(new THREE.AmbientLight(0xffffff, 0.75)); const sun = new THREE.DirectionalLight(0xffffff, 0.9); sun.position.set(200, 400, 300); scene.add(sun); // 측점 누가 → 색 const sorted = rows.slice().sort((a, b) => a.chainage_m - b.chainage_m); const bandColors = [...Array(RATIO_BREAKS.length + 1).keys()].map((i) => cssColor(`--e03-band-${i}`), ); const noneColor = cssColor("--e03-none"); const colorAt = (chainage: number): THREE.Color => { let lo = 0; let hi = sorted.length - 1; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (sorted[mid].chainage_m <= chainage) lo = mid; else hi = mid; } const near = sorted.length && Math.abs(sorted[hi].chainage_m - chainage) < Math.abs(sorted[lo].chainage_m - chainage) ? sorted[hi] : sorted[lo]; const band = near ? stationBand(near, condition) : null; return band === null ? noneColor : bandColors[band]; }; const group = new THREE.Group(); for (const ribbon of data.ribbons) { const rowsN = ribbon.chainages.length; const cols = ribbon.colCount; if (rowsN < 2 || cols < 2) continue; const pos = new Float32Array(rowsN * cols * 3); const col = new Float32Array(rowsN * cols * 3); for (let r = 0; r < rowsN; r += 1) { const c3 = colorAt(ribbon.chainages[r]); for (let c = 0; c < cols; c += 1) { const i = (r * cols + c) * 3; const [x, y, z] = ribbon.positions.slice(i, i + 3); pos.set([x, z, -y], i); col.set([c3.r, c3.g, c3.b], i); } } const index: number[] = []; for (let r = 0; r + 1 < rowsN; r += 1) { for (let c = 0; c + 1 < cols; c += 1) { const a = r * cols + c; index.push(a, a + cols, a + 1, a + 1, a + cols, a + cols + 1); } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(col, 3)); geometry.setIndex(index); geometry.computeVertexNormals(); const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }); group.add(new THREE.Mesh(geometry, material)); } scene.add(group); // 설계 지형 — 모델 [x, y, z] → three [x, z, −y](리본과 같음) · 노면과 겹치는 곳은 뒤로 밀어 둠 if (data.terrain) { const mesh = terrainMesh(data.terrain.grid, data.terrain.z); const pos = new Float32Array(mesh.positions.length); for (let i = 0; i < pos.length; i += 3) { pos.set([mesh.positions[i], mesh.positions[i + 2], -mesh.positions[i + 1]], i); } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3)); geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1)); geometry.computeVertexNormals(); ground = new THREE.Mesh( geometry, new THREE.MeshLambertMaterial({ color: cssColor("--e03-terrain"), side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1, }), ); scene.add(ground); } const box = new THREE.Box3().setFromObject(group); const sphere = box.getBoundingSphere(new THREE.Sphere()); const camera = new THREE.PerspectiveCamera(45, 1, 0.5, Math.max(sphere.radius * 20, 1000)); camera.position .copy(sphere.center) .add(new THREE.Vector3(0, sphere.radius * 1.9, sphere.radius * 1.6)); const controls = new OrbitControls(camera, renderer.domElement); controls.target.copy(sphere.center); controls.update(); let frame = 0; const resize = (): void => { const w = Math.max(host.clientWidth, 1); const h = Math.max(host.clientHeight, 1); renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); }; const loop = (): void => { frame = requestAnimationFrame(loop); controls.update(); renderer.render(scene, camera); }; const observer = new ResizeObserver(resize); observer.observe(host); resize(); loop(); (window as unknown as { __e03View3d?: unknown }).__e03View3d = { meshes: group.children.length, vertices: group.children.reduce( (n, m) => n + ((m as THREE.Mesh).geometry.getAttribute("position").count ?? 0), 0, ), terrain: ground?.geometry.getAttribute("position").count ?? 0, }; const dispose = (): void => { cancelAnimationFrame(frame); observer.disconnect(); controls.dispose(); ground?.geometry.dispose(); (ground?.material as THREE.Material | undefined)?.dispose(); group.traverse((o) => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose(); } }); renderer.dispose(); }; teardown = dispose; }