From d37efddc46ddd9fb4dd69e1177543ef03237e426 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:35:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(E04):=203D=20=EB=B3=B4=EA=B8=B0=20=3D=20?= =?UTF-8?q?=ED=99=9C=EB=8F=99=EC=B2=B4=20=EA=B1=B7=EC=96=B4=EB=82=B8=20?= =?UTF-8?q?=EC=A7=80=ED=91=9C=20+=20=EC=9E=98=EB=A6=B0=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=EB=A9=B4(=ED=85=8C=EB=91=90=EB=A6=AC=20=ED=8B=88=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C)=20=C2=B7=20=EB=85=B8=EC=84=A0=20=EA=B4=80?= =?UTF-8?q?=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EC=9D=B4=EB=A6=84=20=EC=86=8E?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=ED=86=A0=EA=B8=80=20=EA=BC=B4=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EB=8D=94=ED=95=A8=20(533-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_UI_Map.ts | 18 ++++-- E04_Stab3D/E04_Stab3D_UI_Page.ts | 8 +-- E04_Stab3D/E04_Stab3D_UI_Style.css | 19 +++++- E04_Stab3D/E04_Stab3D_UI_View3D.ts | 75 ++++++++++++------------ E04_Stab3D/logic/E04_Stab3D_Mesh.ts | 56 ++++++++++++++++-- resources/tester/test_533_3_e04_logic.py | 40 +++++++++++-- ui_template/ui_template_locale_e04.ts | 1 - 7 files changed, 155 insertions(+), 62 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_UI_Map.ts b/E04_Stab3D/E04_Stab3D_UI_Map.ts index 144db2268..85b8d5ebe 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Map.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Map.ts @@ -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(); diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index 5e6b07a1c..d75dc1d6d 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -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(); }, ), diff --git a/E04_Stab3D/E04_Stab3D_UI_Style.css b/E04_Stab3D/E04_Stab3D_UI_Style.css index 54a4a56ea..74fc63e89 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Style.css +++ b/E04_Stab3D/E04_Stab3D_UI_Style.css @@ -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; diff --git a/E04_Stab3D/E04_Stab3D_UI_View3D.ts b/E04_Stab3D/E04_Stab3D_UI_View3D.ts index 962d54ed3..3986d8681 100644 --- a/E04_Stab3D/E04_Stab3D_UI_View3D.ts +++ b/E04_Stab3D/E04_Stab3D_UI_View3D.ts @@ -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)); diff --git a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts index 94f7ab968..7ff8d3c66 100644 --- a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts +++ b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts @@ -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 { + const out = new Map(); + 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, +): Set { + const rim = new Set(); + 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, +): MeshArrays { + return gridMesh(grid, (index) => (cut.has(index) ? null : z[index])); } /** 지표 높이 쌍선형 보간(격자 밖 · 빈 셀 = null) */ diff --git a/resources/tester/test_533_3_e04_logic.py b/resources/tester/test_533_3_e04_logic.py index 4071f4ec2..dc95b46d5 100644 --- a/resources/tester/test_533_3_e04_logic.py +++ b/resources/tester/test_533_3_e04_logic.py @@ -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): diff --git a/ui_template/ui_template_locale_e04.ts b/ui_template/ui_template_locale_e04.ts index 49aef3f42..b541c2c9f 100644 --- a/ui_template/ui_template_locale_e04.ts +++ b/ui_template/ui_template_locale_e04.ts @@ -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]."],