Files
Aislo/E03_StabSection/E03_StabSection_UI_View3D.ts
eomsangdonandClaude Opus 5.5 0ea6b032a4 feat(E03): 3D 보기 설계 지형 + 노면 구멍 (PLAN 566-3)
- corridor3d 응답에 terrain 칸 — E 복사본 DTM 을 노선면 둘레 80 m 띠로 · 2 m 칸(25만 칸 넘으면 키움) · 리본 발자국 안 = None(깎기 지형이 노면을 덮지 않게) · 리본과 같은 원점 · 캐시 열쇠에 DTM 수정시각
- 3D 보기 = E04 terrainMesh 로 지형 메쉬 · 한 색(--e03-terrain 밝음 · 어두움) · [설계 지형] 켬끔 · 노면 쪽으로 겹침 밀어 둠
- 시험 532_3 노선면 설계 지형(꼴 · 구멍 · 표고 = DTM − 원점)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:02:45 +09:00

236 lines
8.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<void> {
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<HTMLElement>(".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;
}