Files
Aislo/common_util/common_util_cross_berm.ts
T
eomsangdonandClaude Opus 5 e43ad18f97 feat(횡단): 절토 사면에 소단(계단) 기하를 넣음 — 파이썬·TS 짝 (계획서 3-9)
사용자 확정(2026-09-07)에 따라 소단을 **자동 적용하지 않고 사용자가 놓는 것**으로 만듦.
이 커밋은 그 기하 한 벌이고, 화면 폼은 다음 단계임.

새 짝 모듈 `common_util_cross_berm.py` · `.ts` — 절토 사면 꼭짓점을 만듦.
암 경계 무릎과 소단이 한 목록에 함께 들어가고, `breakpoints` 가 그 꼭짓점을 설계선에
실어 도면·면적·유토곡선·3D 가 계단을 그대로 봄.

기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°.
· 폭·간격은 별표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.
· 기울기 2°는 사용자 확정값이고 법령·교본 근거가 없어 지식DB 에 적지 않음(사용자 지시).

무릎은 종전대로 **한 번만** 꺾음. 여러 번 꺾게 풀면 소단이 없는 지금 측점 설계도 같이
바뀌므로 별건으로 미룸(실측: 물결 경계에서 0.0016m 차이, 경계를 다시 만나면 구조가 달라짐).

자체검증
- 거울 시험 10건 `tmp/tests/test_cross_berm_mirror.py` — 소단 켠 5경우 포함해 파이썬·TS
  꼭짓점이 1e-9 안에서 일치. 간격이 수평이 아니라 사면길이 기준인 것, 평탄부가 2° 기운 것,
  소단이 없으면 옛 무릎 방식과 완전히 같은 것까지 확인.
- 면적 시험 6건 `tmp/tests/test_cross_berm_area.py` — 절토 55.115 → 78.785㎡(기본값),
  폭·간격이 물량에 단조로 반영, 소단 모서리가 설계선에 실림, 소단을 안 주면 설계선까지 동일.
- ⚠ 어림식 두 번을 시험이 잡아냄. 「늘어난 면적 = 폭 × 그 위 높이」는 틀림(11.797 vs
  7.770㎡) — 설계선이 밀리면 지반과 만나는 점도 함께 밀려 절토가 더 길어짐. 「폭만큼 밀림」도
  틀림 — 참값은 「그 아래 소단 개수 × 폭」임. 시험은 그 참값으로 잼.
- 기존 거울 시험(`test_b06_cross_design_mirror.py`) 10건 그대로 통과 — 리팩터가 값을
  안 바꿨다는 증거임. 전체 537 passed · 18 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:54:18 +09:00

153 lines
6.2 KiB
TypeScript

/* =============================================================================
* 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 · 안쪽 기울기 2°.
* · 폭·간격은 별표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**.
* · 기울기 2°는 **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 = 2.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,
): 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;
// ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다.
if (rockBoundaryZ !== null && !inSoil) {
const diffNow = elevation - rockBoundaryZ(dist);
const diffNext = nextZ - rockBoundaryZ(nextDist);
if (diffNext >= 0) {
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 = true;
ratio = soilCutRatio;
continue;
}
}
dist = nextDist;
elevation = nextZ;
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);
}