/* ============================================================================= * E04_Stab3D_Mesh.ts * E04 3D 보기 메쉬(PLAN 533-3) — 합성 지표(원지형 + 노선면) 격자 메쉬 · 임계 활동면(구 · 타원체를 * 지표 아래만 남긴 잘린 면) · 지표 높이 보간. 좌표 = 모델(x 동 · y 북 · z 위) 그대로 — * 화면 좌표 변환은 화면 파일 몫. 순수 함수만(DOM · three 금지). * ========================================================================== */ import type { Grid, SlipBest } from "./E04_Stab3D_Raster"; export interface MeshArrays { /** 꼭짓점 [x, y, z, …] */ positions: Float32Array; /** 삼각형 꼭짓점 번호 */ indices: Uint32Array; /** 꼭짓점마다 래스터 셀 번호(색칠 · 툴팁용) */ cells: Int32Array; } /** 격자 위 값이 있는 꼭짓점만 이어 메쉬 — 네 모서리 중 셋 이상 있으면 삼각형 */ function gridMesh(grid: Grid, height: (index: number) => number | null): MeshArrays { const vertexOf = new Int32Array(grid.nx * grid.ny).fill(-1); const positions: number[] = []; const cells: number[] = []; for (let iy = 0; iy < grid.ny; iy += 1) { for (let ix = 0; ix < grid.nx; ix += 1) { const index = iy * grid.nx + ix; const z = height(index); if (z === null) continue; vertexOf[index] = cells.length; positions.push(grid.x0 + ix * grid.dx, grid.y0 + iy * grid.dx, z); cells.push(index); } } const indices: number[] = []; for (let iy = 0; iy + 1 < grid.ny; iy += 1) { for (let ix = 0; ix + 1 < grid.nx; ix += 1) { const a = vertexOf[iy * grid.nx + ix]; const b = vertexOf[iy * grid.nx + ix + 1]; const c = vertexOf[(iy + 1) * grid.nx + ix + 1]; const d = vertexOf[(iy + 1) * grid.nx + ix]; // 반시계(위에서 봄) — a b c · a c d if (a >= 0 && b >= 0 && c >= 0 && d >= 0) indices.push(a, b, c, a, c, d); else if (a >= 0 && b >= 0 && c >= 0) indices.push(a, b, c); else if (a >= 0 && c >= 0 && d >= 0) indices.push(a, c, d); else if (a >= 0 && b >= 0 && d >= 0) indices.push(a, b, d); else if (b >= 0 && c >= 0 && d >= 0) indices.push(b, c, d); } } return { positions: Float32Array.from(positions), indices: Uint32Array.from(indices), cells: Int32Array.from(cells), }; } /** 합성 지표 메쉬 — z 가 null 인 셀은 뺌 */ export function terrainMesh(grid: Grid, z: readonly (number | null)[]): MeshArrays { return gridMesh(grid, (index) => z[index]); } /** 활동면 깊이 — 구 · 타원체 아래 반쪽 높이(밖이면 null) */ export function slipHeight(best: SlipBest, x: number, y: number): number | null { const az = (best.azimuth_deg * Math.PI) / 180; const dx = x - best.x; const dy = y - best.y; // 활동 방향 성분 · 회전축(수직) 성분 — 축 방향만 ratio 로 나눔 const along = dx * Math.sin(az) + dy * Math.cos(az); const axis = (dx * Math.cos(az) - dy * Math.sin(az)) / Math.max(best.ratio, 1e-9); const rest = best.r * best.r - along * along - axis * axis; return rest < 0 ? null : best.z - Math.sqrt(rest); } /** 잘린 셀 → 활동면 높이(활동면이 지표보다 낮은 셀만 · 못 뚫는 층 윗면 아래로는 안 내려감 · 엔진 덩이 셀만) */ export function cutCells( grid: Grid, z: readonly (number | null)[], best: SlipBest, ): Map { const floor = new Map(best.floor ?? []); const mass = best.cells ? new Set(best.cells) : null; const out = new Map(); z.forEach((top, index) => { if (top === null || (mass && !mass.has(index))) 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); const base = Math.max(slip ?? top, floor.get(index) ?? -Infinity); // 엔진 덩이 셀 = 그대로(셀 안 일부만 잘린 가장자리 = 두께 0) · 옛 저장본 = 셀 중심이 잘린 칸만 if (mass) out.set(index, Math.min(base, top)); else if (base < top) out.set(index, base); }); return out; } /** 임계 활동체 평면 중심 = 잘린 셀 중심의 평균(없으면 구 중심) — 지도 최소 표시 자리 */ export function footprintCenter( grid: Grid, cut: ReadonlyMap, best: SlipBest, ): { x: number; y: number } { if (!cut.size) return { x: best.x, y: best.y }; let sx = 0; let sy = 0; for (const index of cut.keys()) { sx += grid.x0 + (index % grid.nx) * grid.dx; sy += grid.y0 + Math.floor(index / grid.nx) * grid.dx; } return { x: sx / cut.size, y: sy / cut.size }; } /** 잘린 셀의 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) */ export function heightAt( grid: Grid, z: readonly (number | null)[], x: number, y: number, ): number | null { const fx = (x - grid.x0) / grid.dx; const fy = (y - grid.y0) / grid.dx; const ix = Math.floor(fx); const iy = Math.floor(fy); if (ix < 0 || iy < 0 || ix + 1 >= grid.nx || iy + 1 >= grid.ny) return null; const at = (i: number, j: number): number | null => z[j * grid.nx + i]; const z00 = at(ix, iy); const z10 = at(ix + 1, iy); const z01 = at(ix, iy + 1); const z11 = at(ix + 1, iy + 1); if (z00 === null || z10 === null || z01 === null || z11 === null) return null; const tx = fx - ix; const ty = fy - iy; return (z00 * (1 - tx) + z10 * tx) * (1 - ty) + (z01 * (1 - tx) + z11 * tx) * ty; }