243 lines
9.1 KiB
TypeScript
243 lines
9.1 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
},
|
||
};
|
||
}
|