Files
Aislo/E04_Stab3D/logic/E04_Stab3D_Mesh.ts
T

172 lines
6.7 KiB
TypeScript

/* =============================================================================
* 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<number, number> {
const floor = new Map(best.floor ?? []);
const mass = best.cells ? new Set(best.cells) : null;
const out = new Map<number, number>();
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<number, number>,
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<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) */
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;
}