/* ============================================================================= * E03_StabSection_UI_View3D.ts * E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만). * 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) · * 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀. * ========================================================================== */ 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 { 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[]; }[]; } /** 측점 하나 그 조건 최소 비(안전율 ÷ 기준)의 띠 */ 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)" }); const legendRow = el("div", { className: "e03-legend", children: [muted(`${conditionLabel} · ${t("E03_View3d_Legend")}`), legend(items)], }); 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); 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, ), }; const dispose = (): void => { cancelAnimationFrame(frame); observer.disconnect(); controls.dispose(); group.traverse((o) => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose(); } }); renderer.dispose(); }; teardown = dispose; }