Files
Aislo/E03_StabSection/E03_StabSection_UI_View3D.ts
T
eomsangdonandClaude Opus 5.5 8348e6b922 fix(532-3): E03 일꾼 stderr 파일로(파이프 막힘으로 해석 멈춤) · 처음 보기 = 그 비탈 확대 · 화면 높이 고정 · 자유물체도 반력 화살 · 목록 줄임
- 일꾼 stderr 를 파이프로 두면 자식 경고가 쌓여 8/57 에서 멈춤 → 임시 파일로
- 횡단 처음 보기 = 탐색 범위 · 임계면 · 격자 중심을 판 가운데(두 번 누름 = 이 보기 · 줌 아웃 = 모형 전체)
- 본문 높이 = 화면 높이(커지며 넘치던 것) · 고른 줄 표시 밝음/어두움 같이 · 3D 보기 창 맨 위
- 자유물체도 N · U · S = 바닥으로 들어오는 화살 · 글 = 화살 바깥 끝
- 목록 측점 0+000 꼴 · 좌우 깎/쌓 · 해석 없음 = -

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:01:50 +09:00

202 lines
7.2 KiB
TypeScript
Raw 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 를 바로 씀.
* ========================================================================== */
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);
}