/* ============================================================================= * common_util/common_util_cross_berm.ts * 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9) * * ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다. * 거울 시험: `tmp/tests/test_cross_berm_mirror.py` * * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. * * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°. * · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**. * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. * · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나 * 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다. * ========================================================================== */ /** 소단 기본값 — 근거는 위 설명. */ export const BERM_DEFAULT_WIDTH_M = 0.5; export const BERM_DEFAULT_INTERVAL_M = 3.0; export const BERM_DEFAULT_SLOPE_DEG = 0.0; /** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ const STEP_M = 0.05; const MAX_REACH_M = 200.0; /** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */ export interface BermSpec { widthM: number; intervalM: number; slopeDeg: number; } export function bermSpec( widthM = BERM_DEFAULT_WIDTH_M, intervalM = BERM_DEFAULT_INTERVAL_M, slopeDeg = BERM_DEFAULT_SLOPE_DEG, ): BermSpec { return { widthM, intervalM, slopeDeg }; } /** * 짝: `cut_profile_points`. 절토 사면 꼭짓점 `[[거리, 표고], ...]` — 시작에서 바깥으로. * * `rockBoundaryZ` 가 null 이면 2단계 절토가 아니라 경사가 하나다. 있으면 경계를 만나는 * 자리에서 암 → 토사로 **한 번** 꺾는다. * * ⚠ 한 번만 꺾는 것은 **기존 규칙을 그대로 지킨 것**이다. 여러 번 꺾게 바꾸면 소단이 없는 * 지금 측점들의 설계도 같이 바뀌므로 별건으로 미룬다(2026-09-07). */ export function cutProfilePoints( startDist: number, startZ: number, cutRatio: number, soilCutRatio: number, rockBoundaryZ: ((dist: number) => number) | null, berm: BermSpec | null, maxReachM: number = MAX_REACH_M, multiKnee = false, ): Array<[number, number]> { const points: Array<[number, number]> = [[startDist, startZ]]; let dist = startDist; let elevation = startZ; let slantSinceBerm = 0; const limit = startDist + maxReachM; const bermRise = berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0; // 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다). let inSoil = rockBoundaryZ === null || elevation >= rockBoundaryZ(dist); let ratio = rockBoundaryZ !== null && inSoil ? soilCutRatio : cutRatio; while (dist < limit) { const rise = STEP_M / ratio; const slant = Math.hypot(STEP_M, rise); // ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다. if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) { const remain = Math.max(berm.intervalM - slantSinceBerm, 0); const run = remain / Math.hypot(1, 1 / ratio); dist += run; elevation += run / ratio; points.push([dist, elevation]); // 소단 안쪽 모서리 dist += berm.widthM; elevation += bermRise; points.push([dist, elevation]); // 소단 바깥 모서리 slantSinceBerm = 0; continue; } const nextDist = dist + STEP_M; const nextZ = elevation + rise; // ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다. // `multiKnee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙). if (rockBoundaryZ !== null && (multiKnee || !inSoil)) { const diffNow = elevation - rockBoundaryZ(dist); const diffNext = nextZ - rockBoundaryZ(nextDist); const crossed = !inSoil ? diffNext >= 0 : diffNext < 0; if (crossed) { const span = diffNext - diffNow; let share = Math.abs(span) > 1e-12 ? -diffNow / span : 0; share = Math.min(Math.max(share, 0), 1); const kneeDist = dist + STEP_M * share; const kneeZ = elevation + rise * share; slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation); dist = kneeDist; elevation = kneeZ; points.push([dist, elevation]); // 무릎 inSoil = !inSoil; ratio = inSoil ? soilCutRatio : cutRatio; continue; } } dist = nextDist; elevation = nextZ; slantSinceBerm += slant; } points.push([dist, elevation]); return dedupe(points); } /** * 짝: `fill_profile_points`. 성토 사면 꼭짓점 — 시작에서 바깥으로 **내려간다**. * * 절토와 달리 경사가 하나뿐이라 무릎이 없다. 소단 규칙은 같다. */ export function fillProfilePoints( startDist: number, startZ: number, fillRatio: number, berm: BermSpec | null, maxReachM: number = MAX_REACH_M, ): Array<[number, number]> { const points: Array<[number, number]> = [[startDist, startZ]]; let dist = startDist; let elevation = startZ; let slantSinceBerm = 0; const limit = startDist + maxReachM; const bermRise = berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0; const ratio = Math.max(fillRatio, 1e-6); while (dist < limit) { const drop = STEP_M / ratio; const slant = Math.hypot(STEP_M, drop); if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) { const remain = Math.max(berm.intervalM - slantSinceBerm, 0); const run = remain / Math.hypot(1, 1 / ratio); dist += run; elevation -= run / ratio; points.push([dist, elevation]); // 소단 안쪽 모서리 dist += berm.widthM; elevation += bermRise; points.push([dist, elevation]); // 소단 바깥 모서리 slantSinceBerm = 0; continue; } dist += STEP_M; elevation -= drop; slantSinceBerm += slant; } points.push([dist, elevation]); return dedupe(points); } /** 같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게. */ function dedupe(points: Array<[number, number]>): Array<[number, number]> { const out: Array<[number, number]> = []; for (const point of points) { const last = out[out.length - 1]; if (last && Math.abs(point[0] - last[0]) < 1e-9 && Math.abs(point[1] - last[1]) < 1e-9) { continue; } out.push(point); } return out; } /** 짝: `elevation_at`. 꼭짓점 목록에서 거리 하나의 표고(사이는 직선, 끝은 연장). */ export function elevationAt(points: Array<[number, number]>, dist: number): number { if (points.length === 0) return 0; if (dist <= points[0][0]) return points[0][1]; for (let index = 1; index < points.length; index += 1) { const [x0, z0] = points[index - 1]; const [x1, z1] = points[index]; if (dist > x1 + 1e-12) continue; const span = x1 - x0; if (span <= 1e-12) return z1; return z0 + (z1 - z0) * ((dist - x0) / span); } const [x0, z0] = points.length > 1 ? points[points.length - 2] : points[points.length - 1]; const [x1, z1] = points[points.length - 1]; const span = x1 - x0; if (span <= 1e-12) return z1; return z1 + ((z1 - z0) / span) * (dist - x1); }