feat(B06): 사면 이동 = 노견째 평행이동 · 늘어난 폭은 차도(151-2)

- 파이썬 · TS 짝(Engine_Design_Geometry ↔ cross_design_geometry) — 차도 반폭에도 이동량
- 확폭 출력(Engine_Design ↔ cross_design)에서 이동 몫 뺌 · 노폭 라벨 「+ 이동」
- 거울 시험 — 확폭 + 이동 겹친 사례 · 노견 폭 고정 · 차도 끝 이동 단정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
This commit is contained in:
2026-10-09 19:08:22 +09:00
co-authored by Claude Opus 5.5
parent 09b3b50efc
commit 19b167caff
6 changed files with 77 additions and 13 deletions
+8 -3
View File
@@ -214,7 +214,7 @@ def compute_cross_design(
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`.
shoulder_extend_left_m · shoulder_extend_right_m: 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 ·
shoulder_extend_left_m · shoulder_extend_right_m: 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 ·
139-6) — 사용자 값 · 저장분은 `stored_shoulder_extend`. TS 짝 `shoulderExtendLeftM` 등.
폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라
사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기).
@@ -444,8 +444,13 @@ def compute_cross_design(
"carriageway_width_m": round(geometry.half_road_left + geometry.half_road_right, 4),
# 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다.
"carriageway_standard_width_m": round(group["road_width_m"], 4),
"widening_left_m": round(geometry.half_road_left - geometry.half_road, 4),
"widening_right_m": round(geometry.half_road_right - geometry.half_road, 4),
# 확폭 = 곡선부 몫만 — 사면 이동(151-2)이 넓힌 차도는 뺀다(TS 짝 같음).
"widening_left_m": round(
geometry.half_road_left - geometry.half_road - geometry.shoulder_extend_left, 4
),
"widening_right_m": round(
geometry.half_road_right - geometry.half_road - geometry.shoulder_extend_right, 4
),
"cross_slope_pct": round(cross_slope_pct, 4),
"ditch": ditch_spec,
# 결과 — **실제로 섰나**. 읽는 쪽 여섯이 이 뜻으로 쓴다.
@@ -140,10 +140,13 @@ class _SectionGeometry:
#: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다.
self.ditch_choice = choice
# 설계선 쉬프트 = 쪽마다 노견 확장(≥ 0 · 139-6) — 단면유형 · 측구 판정은 **옮기기 전**
# 노견에서(위) · 노체 · 측구 · 사면이 같이 바깥으로 · 차도는 그대로. ⚠ TS 짝 `SectionGeometry`.
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
# 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도.
# ⚠ TS 짝 `SectionGeometry`.
self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0)
self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0)
self.half_road_left += self.shoulder_extend_left
self.half_road_right += self.shoulder_extend_right
self.left_extent += self.shoulder_extend_left
self.right_extent += self.shoulder_extend_right
@@ -235,6 +235,8 @@ export function appendCrossDesignOverlay(
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
// 사면 이동(151-2) — 노견째 바깥으로 옮겨 늘어난 차도 몫.
const moved = (design.shoulder_extend_left_m ?? 0) + (design.shoulder_extend_right_m ?? 0);
const standardWidth = design.carriageway_standard_width_m;
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
@@ -245,9 +247,13 @@ export function appendCrossDesignOverlay(
);
label.setAttribute("y", String(labelAt?.y ?? toDisplayY(edges.left.elevation_m) - 6));
label.setAttribute("class", "b06-chart__carriageway-label");
const extras = [
widened > 0.001 ? ` + 확폭 ${widened.toFixed(2)}` : "",
moved > 0.001 ? ` + 이동 ${moved.toFixed(2)}` : "",
].join("");
const widthText =
widened > 0.001 && typeof standardWidth === "number"
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})`
extras && typeof standardWidth === "number"
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)}${extras})`
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
label.textContent =
+8 -3
View File
@@ -113,7 +113,7 @@ export interface CrossDesignOptions {
* 짝: 파이썬 `compute_cross_design(spoil_fill=…)`.
*/
spoilFill?: { side: "left" | "right"; widthM: number; slopeRatioN?: number | null } | null;
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6). 짝: 파이썬 `shoulder_extend_*_m`. */
/** 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 · 139-6 · 151-2 노견째). 짝: 파이썬 `shoulder_extend_*_m`. */
shoulderExtendLeftM?: number;
shoulderExtendRightM?: number;
}
@@ -518,8 +518,13 @@ export function computeCrossDesign(
carriageway_width_m: round4(geometry.halfRoadLeft + geometry.halfRoadRight),
// 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다(짝: 파이썬).
carriageway_standard_width_m: round4(group.road_width_m),
widening_left_m: round4(geometry.halfRoadLeft - geometry.halfRoad),
widening_right_m: round4(geometry.halfRoadRight - geometry.halfRoad),
// 확폭 = 곡선부 몫만 — 사면 이동(151-2)이 넓힌 차도는 뺀다(짝: 파이썬).
widening_left_m: round4(
geometry.halfRoadLeft - geometry.halfRoad - geometry.shoulderExtendLeft,
),
widening_right_m: round4(
geometry.halfRoadRight - geometry.halfRoad - geometry.shoulderExtendRight,
),
cross_slope_pct: round4(crossSlopePct),
ditch: ditchSpec,
ditch_enabled: geometry.hasDitch,
@@ -108,7 +108,7 @@ export class SectionGeometry {
hasDitch: boolean;
/** 사용자가 정한 선택(`null` = 자동). 결과(`hasDitch`)와 다른 값이다. */
ditchChoice: boolean | null;
/** 노견 확장(m · 139-6) — 쪽마다. */
/** 사면 이동(m · 139-6 · 151-2) — 쪽마다 · 차도 반폭에 들어 있음. */
shoulderExtendLeft = 0;
shoulderExtendRight = 0;
ditchPoints: Array<[number, number]> = [];
@@ -210,10 +210,12 @@ export class SectionGeometry {
/** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */
this.ditchChoice = choice;
// 설계선 쉬프트 = 쪽마다 노견 확장(≥ 0 · 139-6) — 단면유형 · 측구 판정은 **옮기기 전** 노견에서
// (위) · 노체 · 측구 · 사면이 같이 바깥으로 · 차도는 그대로. ⚠ 파이썬 짝 `_SectionGeometry`.
// 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
// 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`.
this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0);
this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0);
this.halfRoadLeft += this.shoulderExtendLeft;
this.halfRoadRight += this.shoulderExtendRight;
this.leftExtent += this.shoulderExtendLeft;
this.rightExtent += this.shoulderExtendRight;
@@ -204,6 +204,20 @@ CASES = [
"shoulder_extend_left_m": 2.5,
},
},
{
# 151-2 — 곡선부 확폭과 사면 이동이 같은 쪽에 겹침(확폭 출력엔 이동 몫 안 섞임)
"name": "확폭 좌 + 사면 이동 좌 0.4",
"samples": _ground(0.28),
"design_elevation_m": 100.3,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"plan_radius_m": 18.0,
"curve_outer_side": "left",
"shoulder_extend_left_m": 0.4,
},
},
]
_TS_RUNNER = """
@@ -381,3 +395,32 @@ def test_시험이_빈_결과로_통과하지_않는다() -> None:
assert sum(1 for item in results if item["fill_area_m2"] > 0) >= 1
assert any(item.get("widening_left_m") or item.get("widening_right_m") for item in results)
assert any(item["cut_rock_area_m2"] > 0 for item in results)
def test_사면_이동은_노견째_평행이동_늘어난_폭은_차도() -> None:
"""151-2 — 노견 폭 그대로 · 차도 끝이 이동량만큼 바깥 · 확폭 출력엔 이동 몫 안 섞임."""
by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True))
base = compute_cross_design(
_ground(0.35),
100.4,
standard=STANDARD_CROSS_SECTION,
ground_type="soil",
section_mode="left_cut",
ditch_side="left",
)
moved = by_name["노견 확장 좌 0.7 · 우 1.2"]
for side, sign, extend in (("left", 1, 0.7), ("right", -1, 1.2)):
road = moved["road_edges"][side]["offset_m"]
lane = moved["carriageway_edges"][side]["offset_m"]
base_shoulder = (
base["road_edges"][side]["offset_m"] - base["carriageway_edges"][side]["offset_m"]
)
assert abs((road - lane) - base_shoulder) < 1e-9 # 노견 폭 고정
assert abs(lane - (base["carriageway_edges"][side]["offset_m"] + sign * extend)) < 1e-9
assert abs(moved["carriageway_width_m"] - (base["carriageway_width_m"] + 1.9)) < 1e-9
assert moved["widening_left_m"] == 0.0 and moved["widening_right_m"] == 0.0
curve = by_name["곡선부 확폭 — 바깥쪽 좌"]
both = by_name["확폭 좌 + 사면 이동 좌 0.4"]
assert both["widening_left_m"] == curve["widening_left_m"] > 0
assert abs(both["carriageway_width_m"] - (curve["carriageway_width_m"] + 0.4)) < 1e-9