/* ============================================================================= * 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 { createButton, el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; 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 shell = el("div", { className: "e03-view3d", attrs: { "data-role": "view3d" } }); const head = el("div", { className: "e03-view3d__head" }); const legend = el("div", { className: "e03-legend" }); legend.append( el("span", { className: "e03-muted", text: `${conditionLabel} · ${t("E03_View3d_Legend")}` }), ); const edges = [null, ...RATIO_BREAKS, null]; for (let i = 0; i <= RATIO_BREAKS.length; i += 1) { const item = el("span", { className: "e03-legend__item" }); const text = edges[i] === null ? `< ${edges[i + 1]}` : edges[i + 1] === null ? `≥ ${edges[i]}` : `${edges[i]}–${edges[i + 1]}`; item.append(el("span", { className: `e03-legend__swatch e03-band-${i}` }), text); legend.append(item); } const noneItem = el("span", { className: "e03-legend__item" }); noneItem.append( el("span", { className: "e03-legend__swatch e03-swatch-none" }), t("E03_Verdict_none"), ); legend.append(noneItem); const close = createButton({ label: t("Common_Btn_Close"), variant: "ghost", size: "sm" }); head.append(el("h3", { className: "e03-card__title", text: t("E03_View3d") }), legend, close); const host = el("div", { className: "e03-view3d__canvas" }); shell.append(head, host); document.body.append(shell); 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(); shell.remove(); document.removeEventListener("keydown", onKey); }; const onKey = (ev: KeyboardEvent): void => { if (ev.key === "Escape") dispose(); }; document.addEventListener("keydown", onKey); close.addEventListener("click", dispose); }