feat(E04): 3D 보기 = 활동체 걷어낸 지표 + 잘린 활동면(테두리 틈 없음) · 노선 관 · 측점 이름 솎기 · 토글 꼴 · 시험 더함 (533-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:35:16 +09:00
co-authored by Claude Opus 5.5
parent cbc4cf1a59
commit d37efddc46
7 changed files with 155 additions and 62 deletions
+13 -5
View File
@@ -2,12 +2,12 @@
* E04_Stab3D_UI_Map.ts
* E04 가운데 평면 지도(PLAN 533-3) — 셀별 최소 안전율 · 임계 체적 색 지도 · 임계 활동면 평면 윤곽 ·
* 최소 위치 · 노선 · 측점 · 고른 구간 · 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤.
* 색 · 범례 · 셀 찾기 = logic/E04_Stab3D_Raster · 윤곽 셀 = logic/E04_Stab3D_Mesh(slipMesh).
* 색 · 범례 · 셀 찾기 = logic/E04_Stab3D_Raster · 윤곽 셀 = logic/E04_Stab3D_Mesh(cutCells).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { slipMesh } from "./logic/E04_Stab3D_Mesh";
import { cutCells } from "./logic/E04_Stab3D_Mesh";
import {
FS_STEPS,
VOLUME_STEPS,
@@ -40,6 +40,8 @@ interface View {
scale: number; // px / m
}
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
const css = (name: string, fallback: string): string =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
@@ -133,8 +135,9 @@ export function createPlanMap(): PlanMap {
ctx.stroke();
}
// 측점 — 화면 간격이 좁으면 다섯 개마다 이름
// 이름 = "STA.0+020.000" → "0+020" · 이름끼리 70 px 이상 떨어지게 1 · 2 · 5 · 10 … 개마다
const gap = stations.length > 1 ? 20 * view.scale : 999;
const every = gap >= 36 ? 1 : gap >= 8 ? 5 : 25;
const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100;
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
stations.forEach((s, i) => {
@@ -144,7 +147,7 @@ export function createPlanMap(): PlanMap {
ctx.beginPath();
ctx.arc(px, py, i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
ctx.fill();
if (i % every === 0 && s.label) ctx.fillText(s.label, px + 6, py - 8);
if (i % every === 0 && s.label) ctx.fillText(shortLabel(s.label), px + 6, py - 8);
});
}
@@ -216,6 +219,11 @@ export function createPlanMap(): PlanMap {
const pow = 10 ** Math.floor(Math.log10(target));
const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target;
const px = meters * view.scale;
ctx.fillStyle = css("--color-surface", "#ffffff");
ctx.globalAlpha = 0.85;
ctx.fillRect(width - 24 - px, height - 42, px + 16, 34);
ctx.globalAlpha = 1;
ctx.fillStyle = text;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(width - 16 - px, height - 16);
@@ -346,7 +354,7 @@ export function createPlanMap(): PlanMap {
},
setResult(next) {
result = next;
outline = new Set(next ? Array.from(slipMesh(next.grid, next.z, next.best).cells) : []);
outline = new Set(next ? cutCells(next.grid, next.z, next.best).keys() : []);
buildImage();
fit();
draw();
+4 -4
View File
@@ -122,11 +122,11 @@ export function renderE04Stab3D(root: HTMLElement): void {
const toggle = (labels: [LocaleKey, string][], onPick: (value: string) => void) => {
const group = el("div", { className: "e04-toggle" });
const buttons = labels.map(([key, value], i) => {
const button = createButton({ label: t(key), variant: i ? "ghost" : "filled", size: "sm" });
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
button.dataset.value = value;
button.classList.toggle("is-on", i === 0);
button.addEventListener("click", () => {
buttons.forEach((b) => b.classList.replace("ui-btn--filled", "ui-btn--ghost"));
button.classList.replace("ui-btn--ghost", "ui-btn--filled");
buttons.forEach((b) => b.classList.toggle("is-on", b === button));
onPick(value);
});
return button;
@@ -161,7 +161,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
(value) => {
layer = value as MapLayer;
map.setLayer(layer);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer, false);
paintLegend();
},
),
+18 -1
View File
@@ -5,7 +5,7 @@
* ========================================================================== */
.e04-page {
height: 100%;
height: 100dvh;
box-sizing: border-box;
}
@@ -34,6 +34,12 @@
gap: var(--spacing-4);
}
.e04-toggle .ui-btn.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-primary-text);
}
.e04-stage {
position: relative;
flex: 1 1 auto;
@@ -193,6 +199,17 @@
font-size: 12px;
}
.e04-range .ui-field,
.e04-advanced .ui-field {
min-width: 0;
}
.e04-range .ui-input,
.e04-advanced .ui-input {
width: 100%;
box-sizing: border-box;
}
.e04-range,
.e04-advanced {
display: grid;
+36 -39
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* 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.
@@ -12,7 +12,14 @@ 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 { heightAt, slipMesh, terrainMesh, type MeshArrays } from "./logic/E04_Stab3D_Mesh";
import {
cutCells,
heightAt,
slipMesh,
terrainMesh,
terrainWithout,
type MeshArrays,
} from "./logic/E04_Stab3D_Mesh";
import {
FS_STEPS,
VOLUME_STEPS,
@@ -24,7 +31,7 @@ import type { MapLayer } from "./E04_Stab3D_UI_Map";
export interface View3D {
root: HTMLElement;
show: (result: Result3d | null, line: number[][], layer: MapLayer) => void;
show: (result: Result3d | null, line: number[][], layer: MapLayer, aim?: boolean) => void;
setActive: (active: boolean) => void;
dispose: () => void;
}
@@ -32,24 +39,19 @@ export interface View3D {
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" });
const translucent = el("input", { attrs: { type: "checkbox" } });
translucent.checked = true;
// [임계 활동면] 켬 = 활동체를 걷어낸 지표 + 잘린 활동면(Scoops3D 꼴) · 끔 = 원래 지표
const slipToggle = el("input", { attrs: { type: "checkbox" } });
slipToggle.checked = true;
const tools = el("div", { className: "e04-view3d__tools" });
for (const [input, key] of [
[translucent, "E04_Translucent"],
[slipToggle, "E04_SlipSurface"],
] as const) {
const label = el("label", { className: "e04-view3d__toggle" });
label.append(input, el("span", { text: t(key) }));
tools.append(label);
}
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 });
@@ -118,20 +120,13 @@ export function createView3D(): View3D {
terrain = slip = null;
}
function applyToggles(): void {
if (terrain) {
const material = terrain.material as THREE.MeshLambertMaterial;
material.transparent = translucent.checked && slipToggle.checked;
material.opacity = material.transparent ? 0.55 : 1;
material.depthWrite = !material.transparent;
material.needsUpdate = true;
}
if (slip) slip.visible = slipToggle.checked;
}
translucent.addEventListener("change", applyToggles);
slipToggle.addEventListener("change", applyToggles);
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): void {
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;
@@ -143,7 +138,8 @@ export function createView3D(): View3D {
z: zs.reduce((a, b) => a + b, 0) / Math.max(zs.length, 1),
};
// 지표 — 셀 색 = 지금 층(안전율 · 체적)
const ground = terrainMesh(grid, z);
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);
@@ -154,12 +150,12 @@ export function createView3D(): View3D {
);
content.add(terrain);
counts.terrain = ground.cells.length;
// 임계 활동면 — 잘린 구 · 타원체(아래에서도 보이게 양면) + 테두리 선
const cut = slipMesh(grid, z, best);
counts.slip = cut.cells.length;
counts.slipTriangles = cut.indices.length / 3;
if (cut.indices.length) {
const g = geometry(cut);
// 임계 활동면 — 잘린 구 · 타원체 + 테두리(지표 높이) · 양면 · 옅은 격자선
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 }),
@@ -172,20 +168,21 @@ export function createView3D(): View3D {
);
content.add(slip);
}
// 노선 중심선 — 지표 위 0.3 m
// 노선 중심선 — 지표 위 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.3)));
.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.Line(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0x1f2937 }),
new THREE.Mesh(
new THREE.TubeGeometry(path, points.length * 4, 0.35, 6, false),
new THREE.MeshLambertMaterial({ color: ROUTE_COLOR }),
),
);
}
applyToggles();
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));
+50 -6
View File
@@ -70,16 +70,60 @@ export function slipHeight(best: SlipBest, x: number, y: number): number | null
return rest < 0 ? null : best.z - Math.sqrt(rest);
}
/** 임계 활동면 = 활동면이 지표보다 낮은 셀만(잘린 면) */
export function slipMesh(grid: Grid, z: readonly (number | null)[], best: SlipBest): MeshArrays {
return gridMesh(grid, (index) => {
const top = z[index];
if (top === null) return null;
/** 잘린 셀 → 활동면 높이(활동면이 지표보다 낮은 셀만) */
export function cutCells(
grid: Grid,
z: readonly (number | null)[],
best: SlipBest,
): Map<number, number> {
const out = new Map<number, number>();
z.forEach((top, index) => {
if (top === null) return;
const x = grid.x0 + (index % grid.nx) * grid.dx;
const y = grid.y0 + Math.floor(index / grid.nx) * grid.dx;
const slip = slipHeight(best, x, y);
return slip === null || slip >= top ? null : slip;
if (slip !== null && slip < top) out.set(index, slip);
});
return out;
}
/** 잘린 셀의 8 이웃 중 잘리지 않은 셀(값 있음) — 활동면 테두리 */
export function rimCells(
grid: Grid,
z: readonly (number | null)[],
cut: ReadonlyMap<number, number>,
): Set<number> {
const rim = new Set<number>();
for (const index of cut.keys()) {
const ix = index % grid.nx;
const iy = Math.floor(index / grid.nx);
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
const jx = ix + dx;
const jy = iy + dy;
if (jx < 0 || jy < 0 || jx >= grid.nx || jy >= grid.ny) continue;
const next = jy * grid.nx + jx;
if (!cut.has(next) && z[next] !== null) rim.add(next);
}
}
}
return rim;
}
/** 임계 활동면 = 잘린 셀(활동면 높이) + 테두리 셀(지표 높이) — 지표 구멍(잘린 셀)을 틈 없이 메움 */
export function slipMesh(grid: Grid, z: readonly (number | null)[], best: SlipBest): MeshArrays {
const cut = cutCells(grid, z, best);
const rim = rimCells(grid, z, cut);
return gridMesh(grid, (index) => cut.get(index) ?? (rim.has(index) ? z[index] : null));
}
/** 지표에서 잘린 셀을 뺀 메쉬(활동체를 걷어낸 모습) */
export function terrainWithout(
grid: Grid,
z: readonly (number | null)[],
cut: ReadonlyMap<number, number>,
): MeshArrays {
return gridMesh(grid, (index) => (cut.has(index) ? null : z[index]));
}
/** 지표 높이 쌍선형 보간(격자 밖 · 빈 셀 = null) */
+34 -6
View File
@@ -59,6 +59,8 @@ writeFileSync(process.argv[3], JSON.stringify({
terrain: mesh(M.terrainMesh(c.grid, c.z)),
sphere: mesh(M.slipMesh(c.grid, c.z, c.sphere)),
ellipsoid: mesh(M.slipMesh(c.grid, c.z, c.ellipsoid)),
cut: Array.from(M.cutCells(c.grid, c.z, c.sphere).keys()),
holed: mesh(M.terrainWithout(c.grid, c.z, M.cutCells(c.grid, c.z, c.sphere))),
hCenter: M.slipHeight(c.sphere, 110, 207),
hEdge: M.slipHeight(c.sphere, 116.5, 207),
hEll: [M.slipHeight(c.ellipsoid, 121, 207), M.slipHeight(c.ellipsoid, 110, 212)],
@@ -146,17 +148,43 @@ def test_terrain_mesh(ts):
assert 4 * nx + 3 not in ts["terrain"]["cells"]
def _py_rim(cut: set[int]) -> set[int]:
nx, ny = GRID["nx"], GRID["ny"]
rim = set()
for index in cut:
ix, iy = index % nx, index // nx
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
jx, jy = ix + dx, iy + dy
nxt = jy * nx + jx
if 0 <= jx < nx and 0 <= jy < ny and nxt not in cut and Z[nxt] is not None:
rim.add(nxt)
return rim
@pytest.mark.parametrize("name", ["sphere", "ellipsoid"])
def test_slip_mesh_vertices(ts, name):
"""활동면 꼭짓점 = 잘린 셀(활동면 높이 · 지표 아래) + 테두리 셀(지표 높이)."""
best = SPHERE if name == "sphere" else ELLIPSOID
want = _py_cut(best)
cut = dict(_py_cut(best))
rim = _py_rim(set(cut))
want = sorted(set(cut) | rim)
got = ts[name]
assert got["n"] == len(want) > 20
assert got["cells"] == [i for i, _ in want]
assert got["zs"] == pytest.approx([z for _, z in want], abs=1e-3) # Float32
assert len(cut) > 20 and rim
assert got["n"] == len(want)
assert got["cells"] == want
assert got["zs"] == pytest.approx([cut.get(i, Z[i]) for i in want], abs=1e-3) # Float32
assert got["tri"] > 0
# 활동면 꼭짓점은 모두 지표 아래
assert all(z < Z[i] for i, z in want)
assert all(z < Z[i] for i, z in cut.items())
def test_terrain_hole_matches_cut(ts):
"""활동체를 걷어낸 지표 = 지표 꼭짓점 − 잘린 셀 · 구멍 테두리는 활동면 메쉬와 같은 꼭짓점."""
cut = [i for i, _ in _py_cut(SPHERE)]
assert ts["cut"] == cut
assert ts["holed"]["n"] == ts["terrain"]["n"] - len(cut)
assert not set(ts["holed"]["cells"]) & set(cut)
assert _py_rim(set(cut)) <= set(ts["holed"]["cells"])
def test_slip_height_and_terrain_height(ts):
-1
View File
@@ -59,7 +59,6 @@ export const ui_locales_e04 = {
E04_Legend_Volume: ["임계 체적 (m³)", "Critical volume (m³)"],
E04_NoData: ["값 없음", "No data"],
E04_Tip_Station: ["가까운 측점", "Nearest station"],
E04_Translucent: ["지표 반투명", "Translucent ground"],
E04_SlipSurface: ["임계 활동면", "Critical slip surface"],
E04_Map_Empty: ["구간을 고르고 [해석]을 누르세요.", "Pick a section and press [Analyze]."],