Files
Aislo/E04_Stab3D/E04_Stab3D_UI_View3D.ts
T

243 lines
9.1 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.
/* =============================================================================
* E04_Stab3D_UI_View3D.ts
* E04 [3D 보기](PLAN 533-3) — 합성 지표(원지형 + 노선면 · 셀 색 = 최소 안전율 · 임계 체적) +
* 노선 중심선 + 임계 활동면(구 · 타원체를 지표 아래만 남긴 잘린 면 · 그 위 활동체는 걷어냄) · 표시만.
* E 복사본에는 B04 지표면 모델 행 · 미리보기 GLB 가 없어 B05 `createRouteViewer.loadSurface` 대신
* 해석 격자(z)를 그대로 그림 — 좌표 변환은 B05 `modelToScene` 과 같은 꼴(x · z · −y).
* 메쉬 · 색 = logic/E04_Stab3D_Mesh · logic/E04_Stab3D_Raster.
* ========================================================================== */
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createTerrainCompass } from "@ui/ui_template_compass";
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import {
cutCells,
heightAt,
slipMesh,
terrainMesh,
terrainWithout,
type MeshArrays,
} from "./logic/E04_Stab3D_Mesh";
import {
FS_STEPS,
VOLUME_STEPS,
hexToRgb,
stepColor,
type Result3d,
} from "./logic/E04_Stab3D_Raster";
import type { MapLayer } from "./E04_Stab3D_UI_Map";
export interface View3D {
root: HTMLElement;
show: (result: Result3d | null, line: number[][], layer: MapLayer, aim?: boolean) => void;
setActive: (active: boolean) => void;
dispose: () => void;
}
const LIGHT_BG = 0xf5f7fa;
const DARK_BG = 0x251f38;
const SLIP_COLOR = 0x8b1a2b;
const ROUTE_COLOR = 0x2563eb;
export function createView3D(): View3D {
const root = el("div", { className: "e04-view3d" });
const canvas = el("canvas", { className: "e04-view3d__canvas" });
const compass = createTerrainCompass({ sizePx: 88, className: "e04-view3d__compass" });
// [임계 활동면] 켬 = 활동체를 걷어낸 지표 + 잘린 활동면(Scoops3D 꼴) · 끔 = 원래 지표
const slipToggle = el("input", { attrs: { type: "checkbox" } });
slipToggle.checked = true;
const tools = el("div", { className: "e04-view3d__tools" });
const label = el("label", { className: "e04-view3d__toggle" });
label.append(slipToggle, el("span", { text: t("E04_SlipSurface") }));
tools.append(label);
root.append(canvas, compass.root, tools);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio || 1);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.5, 20000);
camera.up.set(0, 1, 0);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2));
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.position.set(-1, 2, 1);
scene.add(sun);
const content = new THREE.Group();
scene.add(content);
const background = (): void => {
const theme = document.documentElement.getAttribute("data-theme");
const dark =
theme === "dark" ||
(theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
scene.background = new THREE.Color(dark ? DARK_BG : LIGHT_BG);
};
background();
const themeWatch = new MutationObserver(background);
themeWatch.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
let origin = { x: 0, y: 0, z: 0 };
let terrain: THREE.Mesh | null = null;
let slip: THREE.Mesh | null = null;
let counts = { terrain: 0, slip: 0, slipTriangles: 0 };
const toScene = (x: number, y: number, z: number): [number, number, number] => [
x - origin.x,
z - origin.z,
-(y - origin.y),
];
function geometry(mesh: MeshArrays, colors?: Float32Array): THREE.BufferGeometry {
const out = new Float32Array(mesh.positions.length);
for (let i = 0; i < mesh.positions.length; i += 3) {
out.set(toScene(mesh.positions[i], mesh.positions[i + 1], mesh.positions[i + 2]), i);
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(out, 3));
if (colors) g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
g.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
g.computeVertexNormals();
return g;
}
function clear(): void {
for (const child of [...content.children]) {
content.remove(child);
child.traverse((node) => {
const mesh = node as THREE.Mesh;
mesh.geometry?.dispose();
const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
(Array.isArray(material) ? material : material ? [material] : []).forEach((m) =>
m.dispose(),
);
});
}
terrain = slip = null;
}
let last: { result: Result3d | null; line: number[][]; layer: MapLayer } | null = null;
slipToggle.addEventListener("change", () => {
if (last) show(last.result, last.line, last.layer, false);
});
function show(result: Result3d | null, line: number[][], layer: MapLayer, aim = true): void {
last = { result, line, layer };
clear();
counts = { terrain: 0, slip: 0, slipTriangles: 0 };
if (!result) return;
const { grid, z, best } = result;
const zs = z.filter((v): v is number => v !== null);
origin = {
x: grid.x0 + ((grid.nx - 1) * grid.dx) / 2,
y: grid.y0 + ((grid.ny - 1) * grid.dx) / 2,
z: zs.reduce((a, b) => a + b, 0) / Math.max(zs.length, 1),
};
// 지표 — 셀 색 = 지금 층(안전율 · 체적)
const cut = cutCells(grid, z, best);
const ground = slipToggle.checked ? terrainWithout(grid, z, cut) : terrainMesh(grid, z);
const values = layer === "fs" ? result.f : result.v;
const steps = layer === "fs" ? FS_STEPS : VOLUME_STEPS;
const colors = new Float32Array(ground.cells.length * 3);
ground.cells.forEach((cell, i) => colors.set(hexToRgb(stepColor(steps, values[cell])), i * 3));
terrain = new THREE.Mesh(
geometry(ground, colors),
new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }),
);
content.add(terrain);
counts.terrain = ground.cells.length;
// 임계 활동면 — 잘린 구 · 타원체 + 테두리(지표 높이) · 양면 · 옅은 격자선
const surface = slipMesh(grid, z, best);
counts.slip = cut.size;
counts.slipTriangles = surface.indices.length / 3;
if (surface.indices.length && slipToggle.checked) {
const g = geometry(surface);
slip = new THREE.Mesh(
g,
new THREE.MeshPhongMaterial({ color: SLIP_COLOR, side: THREE.DoubleSide, shininess: 30 }),
);
slip.add(
new THREE.LineSegments(
new THREE.WireframeGeometry(g),
new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.25 }),
),
);
content.add(slip);
}
// 노선 중심선 — 지표 위 0.4 m 관(굵기 0.35 m)
const points = line
.map((p) => ({ p, h: heightAt(grid, z, p[0], p[1]) }))
.filter((q): q is { p: number[]; h: number } => q.h !== null)
.map(({ p, h }) => new THREE.Vector3(...toScene(p[0], p[1], h + 0.4)));
if (points.length >= 2) {
const path = new THREE.CatmullRomCurve3(points, false, "centripetal");
content.add(
new THREE.Mesh(
new THREE.TubeGeometry(path, points.length * 4, 0.35, 6, false),
new THREE.MeshLambertMaterial({ color: ROUTE_COLOR }),
),
);
}
if (!aim) return;
// 사시도 — 남동쪽 위에서 격자 전체가 들어오게
const span = Math.max(grid.nx, grid.ny) * grid.dx;
const focus = new THREE.Vector3(...toScene(best.x, best.y, best.z - best.r));
controls.target.copy(focus);
camera.position.set(focus.x + span * 0.55, focus.y + span * 0.6, focus.z + span * 0.75);
controls.update();
}
let frame = 0;
const loop = (): void => {
frame = requestAnimationFrame(loop);
const w = root.clientWidth;
const h = root.clientHeight;
if (
w &&
h &&
(canvas.width !== Math.round(w * renderer.getPixelRatio()) || camera.aspect !== w / h)
) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
controls.update();
compass.update(
camera.position.x - controls.target.x,
camera.position.y - controls.target.y,
camera.position.z - controls.target.z,
);
renderer.render(scene, camera);
};
const probe = { counts: () => ({ ...counts }), scene, camera };
(window as unknown as { __e04View?: typeof probe }).__e04View = probe;
return {
root,
show,
setActive(active) {
if (active && !frame) loop();
if (!active && frame) {
cancelAnimationFrame(frame);
frame = 0;
}
},
dispose() {
cancelAnimationFrame(frame);
frame = 0;
clear();
themeWatch.disconnect();
controls.dispose();
renderer.dispose();
renderer.forceContextLoss();
if ((window as unknown as { __e04View?: unknown }).__e04View === probe)
delete (window as unknown as { __e04View?: unknown }).__e04View;
},
};
}