feat(b06): 소단 = 성토 사면 노면 끝 아래 depth_m 에 수평 한 단 · 아래로 성토 이음 · 절토 소단 끔 · 높이 칸 숨김 · 깊이 칸(기본 1.0 · 0 미만 = 0) · 파이썬 · TS 거울 시험(178-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 11:56:48 +09:00
co-authored by Claude Opus 5.5
parent 24aba4008d
commit 922a2876eb
16 changed files with 321 additions and 54 deletions
+16 -12
View File
@@ -1,7 +1,7 @@
"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9).
짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며
`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다.
`resources/tester/test_178_2_berm_fill_mirror.py` 가 그것을 지킨다.
**왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다.
지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을
@@ -18,6 +18,8 @@
· 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다).
기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다.
그래서 지식DB 에도 적지 않는다(사용자 지시).
· 성토 소단(178-2) = 노면 끝 아래 깊이 `depth_m`(기본 1.0 · 사용자 값)에 **한 단만** ·
0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음.
"""
import logging
@@ -30,6 +32,7 @@ logger = logging.getLogger(__name__)
BERM_DEFAULT_WIDTH_M = 0.5
BERM_DEFAULT_INTERVAL_M = 3.0
BERM_DEFAULT_SLOPE_DEG = 0.0
BERM_DEFAULT_DEPTH_M = 1.0
# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다.
_STEP_M = 0.05
@@ -41,11 +44,12 @@ _MAX_STEPS = int(_MAX_REACH_M / _STEP_M) * 10
class BermSpec(NamedTuple):
"""소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도)."""
"""소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도) · 깊이(노면 끝 아래 m · 성토)."""
width_m: float = BERM_DEFAULT_WIDTH_M
interval_m: float = BERM_DEFAULT_INTERVAL_M
slope_deg: float = BERM_DEFAULT_SLOPE_DEG
depth_m: float = BERM_DEFAULT_DEPTH_M
def cut_profile_points(
@@ -166,16 +170,16 @@ def fill_profile_points(
fill_ratio: float,
berm: BermSpec | None,
max_reach_m: float = _MAX_REACH_M,
top_z: float | None = None,
) -> list[tuple[float, float]]:
"""성토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로 **내려간다**.
절토와 달리 경사가 하나뿐이라 무릎이 없다(암 경계는 절토만 본다). 소단은 같은 규칙 —
사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣고, 그 평탄부는
안쪽이 낮도록 `slope_deg` 만큼 기울어 **바깥으로 갈수록 조금 올라간다**(물이 노면 쪽으로).
소단은 **한 단**(178-2) — 표고가 `노면 끝(top_z) − depth_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
@@ -183,24 +187,24 @@ def fill_profile_points(
else 0.0
)
ratio = max(fill_ratio, 1e-6)
top = start_z if top_z is None else top_z
berm_z = top - max(berm.depth_m, 0.0) if berm is not None and berm.width_m > 0 else None
placed = berm_z is None
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)
if not placed and berm_z is not None and elevation - drop <= berm_z:
run = max(elevation - berm_z, 0.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
placed = True
continue
dist += _STEP_M
elevation -= drop
slant_since_berm += slant
points.append((dist, elevation))
return _dedupe(points)
+21 -12
View File
@@ -3,7 +3,7 @@
* 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9)
*
* ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다.
* 거울 시험: `tmp/tests/test_cross_berm_mirror.py`
* 거울 시험: `resources/tester/test_178_2_berm_fill_mirror.py`
*
* 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다.
* 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다.
@@ -15,12 +15,15 @@
* 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**.
* · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나
* 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다.
* · 성토 소단(178-2) = 노면 끝 아래 깊이 `depthM`(기본 1.0 · 사용자 값)에 **한 단만** ·
* 0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음.
* ========================================================================== */
/** 소단 기본값 — 근거는 위 설명. */
export const BERM_DEFAULT_WIDTH_M = 0.5;
export const BERM_DEFAULT_INTERVAL_M = 3.0;
export const BERM_DEFAULT_SLOPE_DEG = 0.0;
export const BERM_DEFAULT_DEPTH_M = 1.0;
/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */
const STEP_M = 0.05;
@@ -30,19 +33,21 @@ const MAX_REACH_M = 200.0;
* 짝: 파이썬 `_MAX_STEPS`. */
const MAX_STEPS = (MAX_REACH_M / STEP_M) * 10;
/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */
/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도) · 깊이(노면 끝 아래 m · 성토). */
export interface BermSpec {
widthM: number;
intervalM: number;
slopeDeg: number;
depthM?: number;
}
export function bermSpec(
widthM = BERM_DEFAULT_WIDTH_M,
intervalM = BERM_DEFAULT_INTERVAL_M,
slopeDeg = BERM_DEFAULT_SLOPE_DEG,
depthM = BERM_DEFAULT_DEPTH_M,
): BermSpec {
return { widthM, intervalM, slopeDeg };
return { widthM, intervalM, slopeDeg, depthM };
}
/**
@@ -148,7 +153,9 @@ export function cutProfilePoints(
/**
* 짝: `fill_profile_points`. 성토 사면 꼭짓점 — 시작에서 바깥으로 **내려간다**.
*
* 절토와 달리 경사가 하나뿐이라 무릎이 없다. 소단 규칙은 같다.
* 소단은 **한 단** — 표고가 `노면 끝(topZ) − 깊이` 에 닿으면 폭만큼 수평(안쪽 기울기)
* 놓고 그 아래로 같은 성토 경사로 이어 내린다(178-2). 그 깊이 전에 원지반을 만나면
* 지반 교점이 먼저라 소단이 안 선다.
*/
export function fillProfilePoints(
startDist: number,
@@ -156,34 +163,36 @@ export function fillProfilePoints(
fillRatio: number,
berm: BermSpec | null,
maxReachM: number = MAX_REACH_M,
topZ: number = startZ,
): 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);
const bermZ =
berm !== null && berm.widthM > 0
? topZ - Math.max(berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)
: null;
let placed = bermZ === null;
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);
if (!placed && bermZ !== null && elevation - drop <= bermZ) {
const run = Math.max(elevation - bermZ, 0) * ratio;
dist += run;
elevation -= run / ratio;
points.push([dist, elevation]); // 소단 안쪽 모서리
dist += berm.widthM;
dist += (berm as BermSpec).widthM;
elevation += bermRise;
points.push([dist, elevation]); // 소단 바깥 모서리
slantSinceBerm = 0;
placed = true;
continue;
}
dist += STEP_M;
elevation -= drop;
slantSinceBerm += slant;
}
points.push([dist, elevation]);
+3 -2
View File
@@ -23,7 +23,7 @@
* 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다.
* ========================================================================== */
import type { BermSpec } from "./common_util_cross_berm";
import { BERM_DEFAULT_DEPTH_M, type BermSpec } from "./common_util_cross_berm";
import type { SpoilFillSection } from "./common_util_spoil_fill";
import { spoilFillSection } from "./common_util_spoil_fill";
import {
@@ -182,7 +182,7 @@ export interface CrossDesignResult {
* 안 들어간다. 소단 기하가 이 셈 위에 서 있어 남겨 둔다. 되살릴 때는 **계산해서** 쓸 것. */
cut_slope_segments: CutSlopeSegment[];
/** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */
berm?: { width_m: number; interval_m: number; slope_deg: number };
berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number };
surface_drop_m?: number;
pavement_thickness_m?: number;
rock_boundary_offset_m?: number;
@@ -586,6 +586,7 @@ export function computeCrossDesign(
width_m: round4(options.berm.widthM),
interval_m: round4(options.berm.intervalM),
slope_deg: round4(options.berm.slopeDeg),
depth_m: round4(Math.max(options.berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)),
};
}
// 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬)
@@ -283,10 +283,8 @@ export class SectionGeometry {
this.cutRatio,
this.soilCutRatio,
boundary,
this.berm,
undefined,
// 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07).
this.berm !== null,
// 절토 소단 끔(178-2 · 소단 = 성토 사면 한 단) — 경계는 한 번만 꺾음(종전 규칙).
null,
);
this.cutPointsCache.set(side, points);
return points;
@@ -302,7 +300,16 @@ export class SectionGeometry {
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);
// 소단 깊이 기준 = 그쪽 노면 끝(길어깨 끝) 표고 — 측구가 그쪽이어도 같음(178-2).
const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent);
const points = fillProfilePoints(
startDist,
startZ,
this.fillRatio,
this.berm,
undefined,
edgeZ,
);
this.fillPointsCache.set(side, points);
return points;
}
@@ -467,8 +474,8 @@ export class SectionGeometry {
breakpoints(): number[] {
const points = [0, this.leftExtent, -this.rightExtent];
for (const [offset] of this.ditchPoints) points.push(offset);
// 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
if (this.twoStage || this.berm !== null) {
// 절토 사면 꼭짓점(무릎) — 빠뜨리면 꺾임이 설계선에 안 실린다.
if (this.twoStage) {
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
if (role !== "cut") continue;