- 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>
236 lines
8.4 KiB
TypeScript
236 lines
8.4 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|