Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-10 12:00:20 +09:00
23 changed files with 488 additions and 72 deletions
+3 -1
View File
@@ -146,13 +146,15 @@ def is_station_planting_type(item: StructureType) -> bool:
- A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다.
- D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면
그만큼 여러 장이 나온다(사용자 확정).
- C군 구간형(사면안정 — 옹벽·흙막이·소단 등): D군 구간형과 같이 시작·기준·종료.
안 심으면 측점 격자 사이에 놓인 벽이 횡단도에 안 얹힌다.
측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단
(`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다.
"""
if item.group == "A":
return True
return item.group == "D" and item.placement == "interval"
return item.group in ("C", "D") and item.placement == "interval"
def station_planting_labels() -> set[str]:
@@ -23,7 +23,12 @@
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash";
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
import {
culvertOwnerFor,
ownWallsDrawAt,
restrictToSide,
tierSpanOf,
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { computeFordLayout, fordAdjustOf } from "../B06_Section/B06_Section_UI_Cross_Ford";
@@ -277,9 +282,9 @@ export function buildCorridorStructures(
// 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위
// culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다
// (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙).
const revetLayout = section.culvert
? null
: computeRevetmentLayout(section, section.design?.revet_adjust?.own);
const revetLayout = ownWallsDrawAt(section, culvertOwnerFor(section, crossSections))
? computeRevetmentLayout(section, section.design?.revet_adjust?.own)
: null;
const paveChute = fordPavementChute(section, crossSections);
if (!layout && !fordLayout && !section.box && !revetLayout && !paveChute) continue;
const chainage = section.chainage_m;
+4 -1
View File
@@ -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>;
},
+1 -1
View File
@@ -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;
+9 -3
View File
@@ -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;
};
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
+1
View File
@@ -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":
+3
View File
@@ -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
+1 -1
View File
@@ -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
+4 -2
View File
@@ -22,6 +22,7 @@ import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_
import {
computeCardCulvert,
culvertLinkFor,
ownWallsDrawAt,
tierSpanOf,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
@@ -123,8 +124,9 @@ export function computeStoredLayouts(section: CrossSection, sections: readonly C
culvert,
);
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
const own =
!section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null;
const own = ownWallsDrawAt(section, link?.source)
? computeRevetmentLayout(section, design.revet_adjust?.own)
: null;
return { design, link, culvert, box, ford, own };
}
@@ -262,6 +262,15 @@ export function culvertWallsStandAt(owner: CrossSection, section: CrossSection):
return keys.some((key) => wallStandsAt(owner, section, key));
}
/**
* 독립 벽(사면안정 C군 · 옛 D경로)을 이 측점에 그리나 — 카드 · 서버 면적 · B07 · 3D 가
* 함께 쓰는 가드 한 벌(178-1). 관 세트가 붙은 측점이거나 연동 관(`owner`)의 벽이 이
* 측점에 실제로 서면 배관 경로가 그린다(겹침 방지) — 링크만 있고 벽이 안 서면 그린다.
*/
export function ownWallsDrawAt(section: CrossSection, owner: CrossSection | undefined): boolean {
return !section.culvert && !(owner && culvertWallsStandAt(owner, section));
}
export function culvertLinkFor(
section: CrossSection,
sections: readonly CrossSection[],
@@ -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,
};
}
+2 -3
View File
@@ -20,7 +20,7 @@ import {
hideFordPavedRoad,
appendFordSurfaceDropPlan,
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch";
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
@@ -465,8 +465,7 @@ export function createCrossSectionCard(
// 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다
// (둘 다 그리면 이웃 카드에 벽이 겹친다). 가드는 **그 링크의 벽이 이 카드에 실제로
// 설 때만** 막는다(2026-09-09 — 링크만 있어도 막으면 구조물이 안 보였다).
const linkedWallsHere = !!culvertLink && culvertWallsStandAt(culvertLink.source, section);
if (!section.culvert && !linkedWallsHere) {
if (ownWallsDrawAt(section, culvertLink?.source)) {
const ownAdjust = revetOffset?.adjustFor(section, "own");
const ownLayout = computeRevetmentLayout(section, ownAdjust);
ownDesignTrim = ownLayout?.designTrim;
+17 -2
View File
@@ -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;
}
+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;
+12 -1
View File
@@ -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": "소단 높이",
@@ -3563,6 +3573,7 @@
"weep_hole_area_m2": false
},
"berm": {
"height_m": false,
"length_m": false,
"before_m": false,
"after_m": false,
@@ -3797,7 +3808,7 @@
"이름": "상세 내용",
"옵션": [
"width_m",
"height_m"
"depth_m"
],
"형상": {}
}
@@ -0,0 +1,105 @@
"""178-1 사면안정 벽(흙막이 · 옹벽) 가드 한 벌 — 카드 · 서버 면적 · B07 · 3D.
흠: 카드는 「연동 관 벽이 이 측점에 실제로 설 때만」 독립 벽을 건너뛰는데, 서버 면적
(`computeStoredLayouts` → Node) · B07 은 링크만 있어도 건너뛰고 3D 는 관 측점만 봤다.
⇒ 관 링크는 있으나 관 벽이 안 서는 측점에서 카드엔 흙막이가 서고 서버 면적 · B07 엔 없음.
고침: `ownWallsDrawAt` 한 벌을 세 곳이 함께 씀. 서버 면적은 같은 TS 를 Node 로 돌리므로
번들을 새로 빌드해 그 결과로 판정한다.
"""
import copy
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
FIXTURE = ROOT / "resources" / "tester" / "fixtures" / "b06_73_4_body_guard_sections.json"
SOIL_GUARD = {
"structure_id": "w1",
"type_id": "soil_guard",
"name": "흙막이",
"start_m": 150,
"end_m": 370,
"anchor_m": 260,
"form": "돌쌓기(찰)",
"height_m": 1.5,
}
@pytest.fixture(scope="module")
def bundle(tmp_path_factory) -> Path:
if not VITE.is_file():
pytest.skip("프론트엔드 의존성 미설치")
out = tmp_path_factory.mktemp("server_calc")
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(VITE),
"build",
"--configLoader",
"runner",
"--ssr",
"../B06_Section/B06_Section_Server_Calc_Node.ts",
"--outDir",
str(out),
"--logLevel",
"error",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return out / "B06_Section_Server_Calc_Node.js"
def _areas(bundle: Path, sections: list, tmp_path: Path) -> dict[float, tuple]:
src, dst = tmp_path / "in.json", tmp_path / "out.json"
src.write_text(json.dumps({"detail": {"cross_sections": sections}}), encoding="utf-8")
subprocess.run(["node", str(bundle), str(src), str(dst)], check=True, cwd=str(ROOT)) # noqa: S603
rows = json.loads(dst.read_text(encoding="utf-8"))["areas"]
return {row["chainage_m"]: (row["cut_area_m2"], row["fill_area_m2"]) for row in rows}
def _sections() -> list:
return copy.deepcopy(json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"])
def test_링크만_있고_관_벽이_안_서면_흙막이_면적_행이_나온다(bundle, tmp_path):
"""173.087 관의 다단 구간값(20 m)이 160 · 180 을 덮어 링크는 서지만 관 벽(전 · 후 5 m)은 안 섬."""
sections = [s for s in _sections() if s["chainage_m"] < 300]
sections[1]["design"]["extra_spans"] = {"extra0": {"before_m": 20, "after_m": 20}}
for section in sections:
if not section.get("culvert"):
section["revetment"] = SOIL_GUARD
areas = _areas(bundle, sections, tmp_path)
assert 160 in areas and 180 in areas, f"흙막이가 선 측점 면적 행 없음: {sorted(areas)}"
# 표준 성토(160 = 1.45 · 180 = 5.32 ㎡)보다 벽이 사면을 끊어 줄어듦
assert areas[160][1] < 1.45 and areas[180][1] < 5.32
def test_관_벽이_서는_측점은_독립_벽을_안_그린다(bundle, tmp_path):
"""340 · 360 은 352.14 관 벽이 섬 — 흙막이를 얹어도 면적이 관 벽 그대로(겹쳐 그리지 않음)."""
plain = _areas(bundle, _sections(), tmp_path)
walled = _sections()
for section in walled:
if not section.get("culvert"):
section["revetment"] = SOIL_GUARD
areas = _areas(bundle, walled, tmp_path)
assert areas[340] == plain[340] and areas[360] == plain[360]
def test_카드_서버_3D_가_가드_한_벌을_쓴다():
files = {
"카드": ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts",
"서버 면적 · B07": ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts",
"3D": ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Structures.ts",
}
for where, path in files.items():
source = path.read_text(encoding="utf-8")
assert "ownWallsDrawAt(" in source, f"{where} 가 가드 한 벌을 안 씀"
assert "culvertWallsStandAt(" not in source, f"{where} 에 따로 가드가 남음"
@@ -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
+1 -1
View File
@@ -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 = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]
@@ -133,17 +133,10 @@ def test_group_a_point_and_revetment_facility_join(tmp_path):
def test_other_groups_do_not_plant_stations(tmp_path):
"""C·E·G군은 측점을 만들지 않는다 — 지금 켠 것은 A군과 D군 구간형뿐이다."""
"""E·G군은 측점을 만들지 않는다 — 켠 것은 A군과 C·D군 구간형뿐이다(178-1)."""
_write_structures(
tmp_path,
[
{
"type_id": "retaining_wall",
"chainage_m": 40.0,
"start_m": 40.0,
"end_m": 60.0,
"placement": "interval",
},
{
"type_id": "pavement_concrete",
"chainage_m": 300.0,
@@ -157,6 +150,40 @@ def test_other_groups_do_not_plant_stations(tmp_path):
assert [chainage for chainage, _label in extras] == [84.3, 149.73, 200.92, 275.71]
def test_slope_group_interval_plants_start_anchor_end(tmp_path):
"""C군 구간형(옹벽·흙막이·소단)도 시작·기준·종료 셋을 심는다 — 격자 사이 벽도 횡단에 얹힘(178-1)."""
_write_structures(
tmp_path,
[
{
"type_id": "soil_guard",
"chainage_m": 45.0,
"start_m": 40.0,
"end_m": 50.0,
"placement": "interval",
},
{
"type_id": "retaining_wall",
"chainage_m": 125.0,
"start_m": 120.0,
"end_m": 130.0,
"placement": "interval",
},
{
"type_id": "berm",
"chainage_m": 165.0,
"start_m": 160.0,
"end_m": 170.0,
"placement": "interval",
},
],
)
extras = resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS))
assert [c for c, label in extras if label == "흙막이"] == [40.0, 45.0, 50.0]
assert [c for c, label in extras if label == "옹벽"] == [120.0, 125.0, 130.0]
assert [c for c, label in extras if label == "소단"] == [160.0, 165.0, 170.0]
def test_broken_structures_file_keeps_pipe_derived_stations(tmp_path):
route_dir = tmp_path / "B05_Profile" / "route"
route_dir.mkdir(parents=True, exist_ok=True)