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:
@@ -265,7 +265,10 @@ export async function previewCrossDesigns(
|
||||
/** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */
|
||||
rockBoundaryOffsets?: Record<string, number>;
|
||||
/** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */
|
||||
berms?: Record<string, { width_m: number; interval_m: number; slope_deg: number }>;
|
||||
berms?: Record<
|
||||
string,
|
||||
{ width_m: number; interval_m: number; slope_deg: number; depth_m?: number }
|
||||
>;
|
||||
/** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */
|
||||
cutSlopeRatios?: Record<string, number>;
|
||||
},
|
||||
|
||||
@@ -528,7 +528,7 @@ export interface CrossDesign {
|
||||
display_half_width_m?: number;
|
||||
/** 이 측점에 놓인 소단 제원(계획서 3-9). 없으면 계단 없음.
|
||||
* 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 계단이 서게 되싣는 값이다. */
|
||||
berm?: { width_m: number; interval_m: number; slope_deg: number };
|
||||
berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number };
|
||||
/** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07).
|
||||
* 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */
|
||||
cut_slope_ratio_user?: number;
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSpec } from "@util/common_util_cross_berm";
|
||||
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
@@ -189,9 +190,9 @@ function bermPayload(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
detail: SectionDetailResponse,
|
||||
): Record<string, { width_m: number; interval_m: number; slope_deg: number }> {
|
||||
): Record<string, BermSessionSpec> {
|
||||
const spans = readBermSpans(projectId, routeId);
|
||||
const out: Record<string, { width_m: number; interval_m: number; slope_deg: number }> = {};
|
||||
const out: Record<string, BermSessionSpec> = {};
|
||||
if (!spans.length) return out;
|
||||
for (const section of detail.cross_sections) {
|
||||
const spec = bermSpecAt(spans, section.chainage_m);
|
||||
@@ -256,7 +257,12 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
const bermAt = (chainageM: number): BermSpec | null => {
|
||||
const spec = bermSpecAt(bermSpans, chainageM);
|
||||
return spec
|
||||
? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg }
|
||||
? {
|
||||
widthM: spec.width_m,
|
||||
intervalM: spec.interval_m,
|
||||
slopeDeg: spec.slope_deg,
|
||||
depthM: spec.depth_m,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
|
||||
|
||||
@@ -539,6 +539,7 @@ def compute_cross_design(
|
||||
"width_m": round(float(berm.width_m), 4),
|
||||
"interval_m": round(float(berm.interval_m), 4),
|
||||
"slope_deg": round(float(berm.slope_deg), 4),
|
||||
"depth_m": round(max(float(berm.depth_m), 0.0), 4),
|
||||
}
|
||||
# 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
|
||||
for side, extend in (
|
||||
|
||||
@@ -217,11 +217,8 @@ class _SectionGeometry:
|
||||
self.cut_ratio,
|
||||
self.soil_cut_ratio,
|
||||
boundary,
|
||||
self.berm,
|
||||
# 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을
|
||||
# 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데
|
||||
# 토사 경사로 그려 절토가 조용히 커진다(2026-09-07).
|
||||
multi_knee=self.berm is not None,
|
||||
# 절토 소단 끔(178-2 · 소단 = 성토 사면 한 단) — 경계는 한 번만 꺾음(종전 규칙).
|
||||
None,
|
||||
)
|
||||
self._cut_points_cache[side] = points
|
||||
return points
|
||||
@@ -235,7 +232,11 @@ class _SectionGeometry:
|
||||
if side in self._fill_points_cache:
|
||||
return self._fill_points_cache[side]
|
||||
start_dist, start_z = self._slope_start(side)
|
||||
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm)
|
||||
# 소단 깊이 기준 = 그쪽 노면 끝(길어깨 끝) 표고 — 측구가 그쪽이어도 같음(178-2).
|
||||
edge_z = (
|
||||
self.road_z(self.left_extent) if side == "left" else self.road_z(-self.right_extent)
|
||||
)
|
||||
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm, top_z=edge_z)
|
||||
self._fill_points_cache[side] = points
|
||||
return points
|
||||
|
||||
@@ -428,8 +429,8 @@ class _SectionGeometry:
|
||||
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함)."""
|
||||
points = [0.0, self.left_extent, -self.right_extent]
|
||||
points.extend(offset for offset, _z in self.ditch_points)
|
||||
# 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
|
||||
if self.two_stage or self.berm is not None:
|
||||
# 절토 사면 꼭짓점(무릎) — 빠뜨리면 꺾임이 설계선에 안 실린다.
|
||||
if self.two_stage:
|
||||
for side in ("left", "right"):
|
||||
role = self.left_role if side == "left" else self.right_role
|
||||
if role != "cut":
|
||||
|
||||
@@ -15,6 +15,7 @@ from B06_Section.B06_Section_Engine_Design import (
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from common_util.common_util_cross_berm import (
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
@@ -456,6 +457,7 @@ def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
|
||||
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
|
||||
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
|
||||
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
|
||||
depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -500,6 +502,7 @@ def recompute_designs_for_alignment(
|
||||
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
|
||||
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
|
||||
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
|
||||
depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0),
|
||||
)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
|
||||
@@ -329,7 +329,7 @@ class CrossDesignPreviewRequest(BaseModel):
|
||||
# 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는
|
||||
# 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다.
|
||||
rock_boundary_offsets: dict[str, float] | None = None
|
||||
# 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로,
|
||||
# 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg, depth_m}). 위와 같은 성격으로,
|
||||
# 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9).
|
||||
# 값이 없는 측점은 소단 없음 — 종전 설계 그대로다.
|
||||
berms: dict[str, dict[str, float]] | None = None
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤.
|
||||
* ========================================================================== */
|
||||
|
||||
import { bermSpec, cutProfilePoints } from "../common_util/common_util_cross_berm";
|
||||
import { cutProfilePoints } from "../common_util/common_util_cross_berm";
|
||||
import type { BermSpec } from "../common_util/common_util_cross_berm";
|
||||
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
@@ -27,12 +27,12 @@ export function structureCutSpec(design?: Partial<CrossDesign> | null): Structur
|
||||
const offset = design?.rock_boundary_offset_m;
|
||||
const twoStage =
|
||||
design?.geometry_preset === "rock" && design.two_stage_slope === true && offset != null;
|
||||
const berm = design?.berm;
|
||||
return {
|
||||
cutRatio,
|
||||
soilRatio: Math.max(design?.soil_cut_slope_ratio ?? cutRatio, 0.1),
|
||||
rockOffsetM: twoStage ? offset : null,
|
||||
berm: berm ? bermSpec(berm.width_m, berm.interval_m, berm.slope_deg) : null,
|
||||
// 절토 소단 끔 — 본선 `cutPoints` 와 같음(178-2 · 소단 = 성토 사면 한 단).
|
||||
berm: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { DesignViewSave } from "../A00_Common/design/design_shared";
|
||||
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
||||
import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
@@ -59,6 +60,8 @@ export interface BermSessionSpec {
|
||||
width_m: number;
|
||||
interval_m: number;
|
||||
slope_deg: number;
|
||||
/** 노면 끝 아래 깊이(m · ≥ 0 · 178-2). */
|
||||
depth_m: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,13 +125,20 @@ export function bermSpansFromStructures(
|
||||
};
|
||||
const width = number("width_m", BERM_DEFAULT_WIDTH_M);
|
||||
const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M);
|
||||
if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다
|
||||
if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2)
|
||||
// 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0
|
||||
const rawDepth = Number(options.depth_m);
|
||||
const depth =
|
||||
options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth)
|
||||
? BERM_DEFAULT_DEPTH_M
|
||||
: Math.max(rawDepth, 0);
|
||||
spans.push({
|
||||
start_m: Math.min(start, end),
|
||||
end_m: Math.max(start, end),
|
||||
width_m: width,
|
||||
interval_m: interval,
|
||||
slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
||||
depth_m: depth,
|
||||
});
|
||||
}
|
||||
return spans;
|
||||
@@ -142,7 +152,12 @@ export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpe
|
||||
chainageM <= Math.max(span.start_m, span.end_m) + 1e-6,
|
||||
);
|
||||
return found
|
||||
? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg }
|
||||
? {
|
||||
width_m: found.width_m,
|
||||
interval_m: found.interval_m,
|
||||
slope_deg: found.slope_deg,
|
||||
depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2013,6 +2013,16 @@
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "depth_m",
|
||||
"label": "소단 위치(도로 아래)",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1.0,
|
||||
"required": false,
|
||||
"phase": "b05",
|
||||
"default_basis": "사용자 지정 — 성토 사면 노면 끝 아래 1 m · 0 미만 = 0(도로보다 높을 수 없음)"
|
||||
},
|
||||
{
|
||||
"key": "height_m",
|
||||
"label": "소단 높이",
|
||||
@@ -3561,6 +3571,7 @@
|
||||
"weep_hole_area_m2": false
|
||||
},
|
||||
"berm": {
|
||||
"height_m": false,
|
||||
"length_m": false,
|
||||
"before_m": false,
|
||||
"after_m": false,
|
||||
@@ -3795,7 +3806,7 @@
|
||||
"이름": "상세 내용",
|
||||
"옵션": [
|
||||
"width_m",
|
||||
"height_m"
|
||||
"depth_m"
|
||||
],
|
||||
"형상": {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""178-2 소단 = 성토 사면 노면 끝 아래 depth_m 에 수평 한 단 · 아래로 성토 이음 — 파이썬 · TS 거울.
|
||||
|
||||
짝: `common_util_cross_berm.py` ↔ `.ts` · `B06_Section_Engine_Design_Geometry.py` ↔
|
||||
`common_util_cross_design_geometry.ts`. 절토 소단은 끔 · 깊이 0 미만 = 0(도로보다 높을 수 없음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from common_util.common_util_cross_berm import BermSpec, fill_profile_points
|
||||
from config.config_system import STANDARD_CROSS_SECTION
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
|
||||
def _ground(slope: float) -> list[dict]:
|
||||
return [
|
||||
{"offset_m": o / 2, "elevation_m": round(100.0 + slope * o / 2, 4), "valid": True}
|
||||
for o in range(-40, 41)
|
||||
]
|
||||
|
||||
|
||||
def _case(name, slope, z, mode, berm):
|
||||
return {"name": name, "samples": _ground(slope), "z": z, "mode": mode, "berm": berm}
|
||||
|
||||
|
||||
# 좌절토 = 우측 성토(지반이 오른쪽으로 내려감)
|
||||
CASES = [
|
||||
_case("깊이 1.0 · 폭 0.5", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 1.0)),
|
||||
_case("깊이 0 = 노면 끝 높이", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 0.0)),
|
||||
_case("깊이 음수 = 0", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, -2.0)),
|
||||
_case("깊이 > 사면 높이 = 소단 없음", 0.15, 100.0, "left_cut", (0.5, 3.0, 0.0, 5.0)),
|
||||
_case("안쪽 기울기 5°", 0.6, 100.0, "left_cut", (1.0, 3.0, 5.0, 1.5)),
|
||||
_case("소단 없음", 0.6, 100.0, "left_cut", None),
|
||||
]
|
||||
|
||||
|
||||
def _python(case):
|
||||
berm = BermSpec(*case["berm"]) if case["berm"] else None
|
||||
return compute_cross_design(
|
||||
case["samples"],
|
||||
case["z"],
|
||||
standard=STANDARD_CROSS_SECTION,
|
||||
ground_type="soil",
|
||||
section_mode=case["mode"],
|
||||
ditch_side="left",
|
||||
berm=berm,
|
||||
)
|
||||
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { computeCrossDesign } from "./common_util_cross_design.js";
|
||||
import { fillProfilePoints } from "./common_util_cross_berm.js";
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const designs = input.cases.map((c) => computeCrossDesign(c.samples, c.z, {
|
||||
standard: input.standard, groundType: "soil", sectionMode: c.mode, ditchSide: "left",
|
||||
berm: c.berm ? { widthM: c.berm[0], intervalM: c.berm[1], slopeDeg: c.berm[2], depthM: c.berm[3] } : null,
|
||||
}));
|
||||
const profile = fillProfilePoints(4, 99.9, 1.5, { widthM: 0.5, intervalM: 3, slopeDeg: 0, depthM: 1 }, 20, 100);
|
||||
writeFileSync(process.argv[3], JSON.stringify({ designs, profile }));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts_results(tmp_path_factory):
|
||||
if not TSC.is_file():
|
||||
pytest.skip("프론트엔드 의존성 미설치")
|
||||
tmp = tmp_path_factory.mktemp("berm")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(ROOT / "common_util" / "common_util_cross_design.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.glob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
src, dst = tmp / "in.json", tmp / "out.json"
|
||||
src.write_text(
|
||||
json.dumps({"cases": CASES, "standard": STANDARD_CROSS_SECTION}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
|
||||
)
|
||||
return json.loads(dst.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _flats(design: dict) -> list[tuple[float, float, float]]:
|
||||
"""성토(오른쪽 · 음수 오프셋) 노면 끝 밖의 평탄 · 완만 도막 [(안 오프셋, 바깥 오프셋, 표고)]."""
|
||||
edge = design["road_edges"]["right"]["offset_m"]
|
||||
line = sorted(design["design_line"], key=lambda p: -p["offset_m"]) # 노면 → 바깥
|
||||
found: list[tuple[float, float, float]] = []
|
||||
for a, b in zip(line, line[1:], strict=False):
|
||||
run = a["offset_m"] - b["offset_m"]
|
||||
if a["offset_m"] > edge + 1e-6 or run <= 1e-6:
|
||||
continue
|
||||
if abs(b["elevation_m"] - a["elevation_m"]) / run < 0.1: # 소단(≤ 5°) · 지반 0.15 는 빠짐
|
||||
if found and abs(found[-1][1] - a["offset_m"]) < 1e-6: # 이어진 도막은 하나로
|
||||
found[-1] = (found[-1][0], b["offset_m"], found[-1][2])
|
||||
else:
|
||||
found.append((a["offset_m"], b["offset_m"], a["elevation_m"]))
|
||||
return found
|
||||
|
||||
|
||||
def _close(left, right, path=""):
|
||||
if isinstance(left, dict):
|
||||
assert set(left) == set(right), f"{path} 키 다름"
|
||||
for key in left:
|
||||
_close(left[key], right[key], f"{path}.{key}")
|
||||
elif isinstance(left, list):
|
||||
assert len(left) == len(right), f"{path} 길이 {len(left)} vs {len(right)}"
|
||||
for i, (a, b) in enumerate(zip(left, right, strict=True)):
|
||||
_close(a, b, f"{path}[{i}]")
|
||||
elif isinstance(left, (int, float)) and not isinstance(left, bool):
|
||||
assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}"
|
||||
else:
|
||||
assert left == right, f"{path}: {left!r} vs {right!r}"
|
||||
|
||||
|
||||
def test_파이썬_TS_설계선_같음(ts_results):
|
||||
for case, ts in zip(CASES, ts_results["designs"], strict=True):
|
||||
_close(_python(case), ts, case["name"])
|
||||
|
||||
|
||||
def test_사면_꼭짓점_거울():
|
||||
py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100)
|
||||
assert py[1] == pytest.approx((4 + 0.9 * 1.5, 99.0)) # 노면 끝 100 − 1.0
|
||||
assert py[2] == pytest.approx((4 + 0.9 * 1.5 + 0.5, 99.0)) # 폭 0.5 수평
|
||||
assert len(py) == 4 # 한 단만 · 그 아래 같은 경사로 끝까지
|
||||
|
||||
|
||||
def test_사면_꼭짓점_TS_같음(ts_results):
|
||||
py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100)
|
||||
_close([list(p) for p in py], ts_results["profile"], "profile")
|
||||
|
||||
|
||||
def test_한_단_깊이_폭():
|
||||
by = {case["name"]: _python(case) for case in CASES}
|
||||
edge_z = by["깊이 1.0 · 폭 0.5"]["road_edges"]["right"]["elevation_m"]
|
||||
flats = _flats(by["깊이 1.0 · 폭 0.5"])
|
||||
assert len(flats) == 1
|
||||
inner, outer, z = flats[0]
|
||||
assert z == pytest.approx(edge_z - 1.0, abs=1e-3)
|
||||
assert inner - outer == pytest.approx(0.5, abs=1e-3)
|
||||
assert by["깊이 1.0 · 폭 0.5"]["berm"]["depth_m"] == 1.0
|
||||
|
||||
zero = _flats(by["깊이 0 = 노면 끝 높이"])
|
||||
assert len(zero) == 1 and zero[0][2] == pytest.approx(edge_z, abs=1e-3)
|
||||
assert by["깊이 음수 = 0"]["design_line"] == by["깊이 0 = 노면 끝 높이"]["design_line"]
|
||||
assert by["깊이 음수 = 0"]["berm"]["depth_m"] == 0.0
|
||||
|
||||
assert _flats(by["깊이 > 사면 높이 = 소단 없음"]) == []
|
||||
assert _flats(by["소단 없음"]) == []
|
||||
# 소단이 선 만큼 성토 사면이 바깥으로 밀려 성토 면적이 늘어남
|
||||
assert by["깊이 1.0 · 폭 0.5"]["fill_area_m2"] > by["소단 없음"]["fill_area_m2"]
|
||||
|
||||
|
||||
def test_절토_소단_끔():
|
||||
"""좌측 절토 사면은 소단을 놓아도 소단 없는 단면과 같음."""
|
||||
with_berm = _python(CASES[0])
|
||||
plain = _python(CASES[-1])
|
||||
left = [p for p in with_berm["design_line"] if p["offset_m"] > 0]
|
||||
assert left == [p for p in plain["design_line"] if p["offset_m"] > 0]
|
||||
assert with_berm["cut_area_m2"] == plain["cut_area_m2"]
|
||||
|
||||
|
||||
def test_저장분_깊이_읽기():
|
||||
"""[저장] 재계산이 읽는 저장분 — 없으면 기본 1.0 · 음수는 0."""
|
||||
from B06_Section.B06_Section_Router_Design import stored_berm
|
||||
|
||||
assert stored_berm({"berm": {"width_m": 0.5}}).depth_m == 1.0
|
||||
assert stored_berm({"berm": {"width_m": 0.5, "depth_m": -1}}).depth_m == 0.0
|
||||
assert stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}}).depth_m == 2.5
|
||||
|
||||
|
||||
def test_좌측_패널_칸_구간_깊이_나름():
|
||||
"""구조물 옵션 depth_m → 세션 구간 → 측점 제원 · 서버 payload(같은 꼴)."""
|
||||
source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
assert "depthM: spec.depth_m" in refresh
|
||||
@@ -28,7 +28,7 @@ TABLE = {
|
||||
"옹벽",
|
||||
["wall_form", "length_m", "height_m", "before_m", "after_m"],
|
||||
),
|
||||
"berm": ("gbermdetail", "상세 내용", ["width_m", "height_m"]),
|
||||
"berm": ("gbermdetail", "상세 내용", ["width_m", "depth_m"]), # 높이 칸 숨김 · 깊이(178-2)
|
||||
}
|
||||
SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user