feat(횡단): 성토면 소단 + 옹벽 의무 판정을 「소단 사이 구간별 최대」로 (계획서 3-9 ⑥)

한 묶음으로 처리했음 — 화면에 5m 판정이 틀린 채 서 있는 시간이 없게.

성토 사면 꼭짓점 짝 신설(`fill_profile_points` · `fillProfilePoints`). 절토와 달리
경사가 하나뿐이라 무릎이 없고, 소단 규칙은 같음. 설계선·지반 교차·꼭짓점 목록이 모두
그 선을 봄.

⚠ **옹벽 의무 판정을 함께 고쳤음** — 「성토사면 길이 5m 이내, 넘으면 옹벽·석축 의무」
(`성토_비탈면.md` §2)를 재는 `fillSlopeLengths` 가 **「성토선은 1:n 직선」을 전제로
수평거리 × 기울기**로 재고 있었음(주석에도 그 전제가 적혀 있었음). 소단이 들어가면 그
전제가 깨져 값이 틀림.

고침 — 소단이 있으면 설계선을 걸어가며 **소단으로 끊긴 한 도막**의 최대 길이를 잼.
· 전체를 한 줄로 재면 소단을 넣어도 5m 를 넘어 **의무가 사라지지 않음**
· 실효 경사로 재면 완만해져 **의무가 사라진 것처럼** 보임
둘 다 틀리므로 「끊긴 한 도막」이 맞는 기준임.

**소단이 없는 측점은 종전 식을 그대로 탐** — `design.berm` 이 없으면 예전 계산 그대로라
값이 한 톨도 안 바뀜(구조로 보장). 화면 전후 대조는 다음 단계에서 냄.

자체검증 — 새 시험 2건(성토면에 계단이 서고 같은 거리에서 덜 내려감 · 소단이 없으면
성토선이 곧은 한 줄). 전체 542 passed · 18 skipped. TS 타입 검사·ruff 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 16:38:33 +09:00
co-authored by Claude Opus 5
parent a066023da9
commit db4f17fb1c
6 changed files with 207 additions and 12 deletions
+46
View File
@@ -132,6 +132,52 @@ def cut_profile_points(
return _dedupe(points)
def fill_profile_points(
start_dist: float,
start_z: float,
fill_ratio: float,
berm: BermSpec | None,
max_reach_m: float = _MAX_REACH_M,
) -> list[tuple[float, float]]:
"""성토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로 **내려간다**.
절토와 달리 경사가 하나뿐이라 무릎이 없다(암 경계는 절토만 본다). 소단은 같은 규칙 —
사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣고, 그 평탄부는
안쪽이 낮도록 `slope_deg` 만큼 기울어 **바깥으로 갈수록 조금 올라간다**(물이 노면 쪽으로).
"""
points: list[tuple[float, float]] = [(start_dist, start_z)]
dist, elevation = start_dist, start_z
slant_since_berm = 0.0
limit = start_dist + max_reach_m
berm_rise = (
math.tan(math.radians(berm.slope_deg)) * berm.width_m
if berm is not None and berm.width_m > 0
else 0.0
)
ratio = max(fill_ratio, 1e-6)
while dist < limit:
drop = _STEP_M / ratio
slant = math.hypot(_STEP_M, drop)
if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m:
remain = max(berm.interval_m - slant_since_berm, 0.0)
run = remain / math.hypot(1.0, 1.0 / ratio)
dist += run
elevation -= run / ratio
points.append((dist, elevation)) # 소단 안쪽 모서리
dist += berm.width_m
elevation += berm_rise
points.append((dist, elevation)) # 소단 바깥 모서리
slant_since_berm = 0.0
continue
dist += _STEP_M
elevation -= drop
slant_since_berm += slant
points.append((dist, elevation))
return _dedupe(points)
def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게."""
out: list[tuple[float, float]] = []
+45
View File
@@ -122,6 +122,51 @@ export function cutProfilePoints(
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]> = [];
@@ -6,7 +6,12 @@
* `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다.
* ========================================================================== */
import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm";
import {
type BermSpec,
cutProfilePoints,
elevationAt,
fillProfilePoints,
} from "./common_util_cross_berm";
/** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */
export interface CutSlopeSegment {
@@ -107,6 +112,7 @@ export class SectionGeometry {
/** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */
berm: BermSpec | null = null;
private cutPointsCache = new Map<string, Array<[number, number]>>();
private fillPointsCache = new Map<string, Array<[number, number]>>();
private cutCross = new Map<string, number | null>();
private fillCross = new Map<string, number | null>();
@@ -251,6 +257,21 @@ export class SectionGeometry {
return elevationAt(this.cutPoints(side), dist);
}
/** 짝: `fill_points`. 성토 사면 꼭짓점 — 소단이 들어 있다(무릎은 없다). */
fillPoints(side: string): Array<[number, number]> {
const cached = this.fillPointsCache.get(side);
if (cached !== undefined) return cached;
const [startDist, startZ] = this.slopeStart(side);
const points = fillProfilePoints(startDist, startZ, this.fillRatio, this.berm);
this.fillPointsCache.set(side, points);
return points;
}
/** 짝: `_fill_slope_z`. 성토 사면선 표고(소단 반영, 지반 클램프 없음). */
private fillSlopeZ(side: string, dist: number): number {
return elevationAt(this.fillPoints(side), dist);
}
/** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */
cutCrossDist(side: string): number | null {
const cached = this.cutCross.get(side);
@@ -279,12 +300,12 @@ export class SectionGeometry {
if (cached !== undefined) return cached;
let result: number | null = null;
if (this.groundAt !== null) {
const [startDist, startZ] = this.slopeStart(side);
const [startDist] = this.slopeStart(side);
let dist = startDist;
const maxDist = startDist + CROSS_MAX_M;
while (dist <= maxDist) {
const signed = side === "left" ? dist : -dist;
const fillLine = startZ - (dist - startDist) / this.fillRatio;
const fillLine = this.fillSlopeZ(side, dist);
if (fillLine - this.groundAt(signed) <= 0) {
result = dist;
break;
@@ -339,9 +360,7 @@ export class SectionGeometry {
}
}
const role = side === "left" ? this.leftRole : this.rightRole;
const [startDist, startZ] = this.slopeStart(side);
const dist = Math.abs(offsetM);
const run = dist - startDist;
if (role === "cut") {
const cross = this.cutCrossDist(side);
if (cross !== null && dist >= cross) return groundM;
@@ -349,7 +368,7 @@ export class SectionGeometry {
}
const cross = this.fillCrossDist(side);
if (cross !== null && dist >= cross) return groundM;
return Math.max(startZ - run / this.fillRatio, groundM);
return Math.max(this.fillSlopeZ(side, dist), groundM);
}
/**
@@ -419,6 +438,18 @@ export class SectionGeometry {
}
}
}
// 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다.
if (this.berm !== null) {
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
if (role !== "fill") continue;
const cross = this.fillCrossDist(side);
for (const [offset] of this.fillPoints(side)) {
if (cross !== null && offset > cross + 1e-9) break;
points.push(side === "left" ? offset : -offset);
}
}
}
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side);