- 일꾼 stderr 를 파이프로 두면 자식 경고가 쌓여 8/57 에서 멈춤 → 임시 파일로 - 횡단 처음 보기 = 탐색 범위 · 임계면 · 격자 중심을 판 가운데(두 번 누름 = 이 보기 · 줌 아웃 = 모형 전체) - 본문 높이 = 화면 높이(커지며 넘치던 것) · 고른 줄 표시 밝음/어두움 같이 · 3D 보기 창 맨 위 - 자유물체도 N · U · S = 바닥으로 들어오는 화살 · 글 = 화살 바깥 끝 - 목록 측점 0+000 꼴 · 좌우 깎/쌓 · 해석 없음 = - Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
7.2 KiB
TypeScript
202 lines
7.2 KiB
TypeScript
/* =============================================================================
|
||
* 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<void> {
|
||
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);
|
||
}
|