feat(B06): 설계선 쉬프트 = 쪽마다 노견 확장 — 엔진 짝(판정은 옮기기 전 노견) · 세션 · [저장] patch · 스키마 · 사용자 값 두 벌 · 파이썬 재계산 길 저장값(139-6)

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-09 15:27:14 +09:00
co-authored by Claude Opus 5.5
parent 8e1d33eaeb
commit d066753f10
16 changed files with 191 additions and 6 deletions
+2
View File
@@ -172,6 +172,8 @@ export const STATE_REGISTRY = {
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
crossdesign: { bucket: "draft", scope: "route" },
/** 설계선 쉬프트 = 쪽마다 노견 확장 `{측점: {left, right}}`(139-6) — [저장] 때 patch 로. */
shoulderext: { bucket: "draft", scope: "route" },
/** B08 자재 집계표 셋 사용자 값 `{units, mix, surcharge}`(고친 칸만) — [저장] 때 프로젝트 설정으로(121). */
"b08-materials": { bucket: "draft", scope: "project" },
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */
+6 -1
View File
@@ -90,7 +90,11 @@ async def sync_uphill_overrides_into_designs(
양성(both_fill)은 측구가 없으므로 건드리지 않는다.
"""
# 지연 import — B06 라우터 모듈 로드는 이 함수가 실제 불릴 때만 필요하다.
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B06_Section.B06_Section_Engine_Design import (
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
)
from B06_Section.B06_Section_Repository import get_cross_section_designs
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Design import (
@@ -157,6 +161,7 @@ async def sync_uphill_overrides_into_designs(
ditch_choice=design.get("ditch_choice"),
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
**curve_widening_args(cross_record),
**stored_shoulder_extend(design), # 노견 확장(139-6)
)
next_design["status"] = design.get("status", "provisional")
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
+3
View File
@@ -571,6 +571,9 @@ export interface CrossSectionPatch {
display_half_width_m?: number;
/** 측점별 암 절토 경사비(2026-09-07). 0 = 표준값으로 되돌림. */
cut_slope_ratio_user?: number;
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림. */
shoulder_extend_left_m?: number;
shoulder_extend_right_m?: number;
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
pipe_length_m?: number;
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
@@ -68,3 +68,45 @@ export function crossDesignChoices(
}
return choices;
}
/* ── 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6) — 카드 버튼 선택과 따로 담음(버튼 선택이
* 통째로 덮어써도 안 지워지게). [저장]·[확정] 이 patch 로 싣고, 브라우저 재계산이 입력으로 씀. */
export type ShoulderSide = "left" | "right";
export type ShoulderExtends = Map<number, Partial<Record<ShoulderSide, number>>>;
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
export function readShoulderExtends(
projectId: string | null,
routeId: number | null,
): ShoulderExtends {
const out: ShoulderExtends = new Map();
if (!projectId || routeId === null) return out;
const raw = readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
"shoulderext",
projectId,
routeId,
);
for (const [key, value] of Object.entries(raw ?? {})) {
const chainage = Number(key);
if (Number.isFinite(chainage) && value) out.set(chainage, value);
}
return out;
}
/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */
export function writeShoulderExtend(
projectId: string,
routeId: number,
chainageM: number,
side: ShoulderSide,
valueM: number,
): void {
const all =
readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
"shoulderext",
projectId,
routeId,
) ?? {};
all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) };
writeState("shoulderext", all, projectId, routeId);
}
+17 -1
View File
@@ -30,7 +30,7 @@ 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 { BermSpec } from "@util/common_util_cross_berm";
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
import {
effectiveStandardCross,
readRockBoundaryDefault,
@@ -87,6 +87,9 @@ export const USER_TOUCHED_KEYS = [
"berm",
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
"cut_slope_ratio_user",
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
"shoulder_extend_left_m",
"shoulder_extend_right_m",
] as const;
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
@@ -257,6 +260,13 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
};
const choices = crossDesignChoices(projectId, input.routeId);
const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100);
// 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로
const shoulderExtends = readShoulderExtends(projectId, input.routeId);
const extendAt = (chainageM: number, design: Record<string, unknown>, side: "left" | "right") => {
const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side];
const stored = design[`shoulder_extend_${side}_m`];
return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0);
};
const updated: number[] = [];
for (const section of detail.cross_sections) {
@@ -274,6 +284,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
? storedOffset
: rockDefault;
const choice = choiceAt(section.chainage_m);
const extendLeft = extendAt(section.chainage_m, design, "left");
const extendRight = extendAt(section.chainage_m, design, "right");
let next;
try {
next = computeCrossDesign(
@@ -318,6 +330,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
? section.curve_outer_side
: null,
curveWideningM: section.curve_widening_m ?? null,
shoulderExtendLeftM: extendLeft,
shoulderExtendRightM: extendRight,
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
@@ -340,6 +354,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
next as unknown as NonNullable<CrossSection["design"]>,
previous,
);
section.design.shoulder_extend_left_m = extendLeft;
section.design.shoulder_extend_right_m = extendRight;
updated.push(section.chainage_m);
}
return updated;
+27
View File
@@ -141,6 +141,20 @@ def _ground_interpolator(valid: list[tuple[float, float]]):
return ground_at
def stored_shoulder_extend(stored: dict[str, Any] | None) -> dict[str, float]:
"""저장분의 설계선 쉬프트(쪽마다 노견 확장 · 139-6) — `compute_cross_design(**...)` 로 넘긴다.
⚠ 소단 · 암 절토 경사와 같은 **기하 입력**이다 — 다시 계산한 뒤 키만 베껴 붙이면 설계선은 옛
노견으로 나오고 값만 남아 어긋난다. 없거나 음수 = 0.
"""
out: dict[str, float] = {}
for side in ("left", "right"):
value = (stored or {}).get(f"shoulder_extend_{side}_m")
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
out[f"shoulder_extend_{side}_m"] = max(float(value), 0.0) if ok else 0.0
return out
def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]:
"""측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다.
@@ -178,6 +192,8 @@ def compute_cross_design(
curve_widening_m: float | None = None,
berm: BermSpec | None = None,
spoil_fill: dict[str, Any] | None = None,
shoulder_extend_left_m: float = 0.0,
shoulder_extend_right_m: float = 0.0,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -198,6 +214,8 @@ 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 ·
139-6) — 사용자 값 · 저장분은 `stored_shoulder_extend`. TS 짝 `shoulderExtendLeftM` 등.
폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라
사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기).
`slope_ratio_n` 이 비면 그 측점의 **노선 성토 기울기**를 그대로 쓴다.
@@ -279,6 +297,8 @@ def compute_cross_design(
widening_left_m=widening_left,
widening_right_m=widening_right,
berm=berm,
shoulder_extend_left_m=shoulder_extend_left_m,
shoulder_extend_right_m=shoulder_extend_right_m,
)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
@@ -515,6 +535,13 @@ def compute_cross_design(
"interval_m": round(float(berm.interval_m), 4),
"slope_deg": round(float(berm.slope_deg), 4),
}
# 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
for side, extend in (
("left", geometry.shoulder_extend_left),
("right", geometry.shoulder_extend_right),
):
if extend > 0:
result[f"shoulder_extend_{side}_m"] = round(extend, 4)
if paved:
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
# 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
@@ -59,6 +59,8 @@ class _SectionGeometry:
widening_left_m: float = 0.0,
widening_right_m: float = 0.0,
berm: BermSpec | None = None,
shoulder_extend_left_m: float = 0.0,
shoulder_extend_right_m: float = 0.0,
) -> None:
half_road = group["road_width_m"] / 2.0
# 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽).
@@ -138,6 +140,13 @@ class _SectionGeometry:
#: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다.
self.ditch_choice = choice
# 설계선 쉬프트 = 쪽마다 노견 확장(≥ 0 · 139-6) — 단면유형 · 측구 판정은 **옮기기 전**
# 노견에서(위) · 노체 · 측구 · 사면이 같이 바깥으로 · 차도는 그대로. ⚠ 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.left_extent += self.shoulder_extend_left
self.right_extent += self.shoulder_extend_right
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
self.ditch_points: list[tuple[float, float]] = []
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
@@ -24,6 +24,7 @@ from B06_Section.B06_Section_Engine_Design import (
_SLOPE_CLOSE_TOLERANCE_M,
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
)
from common_util.common_util_json import atomic_write_json
@@ -156,6 +157,7 @@ def extend_unclosed_sections(
surface_drop_m=ford_drop_at(chainage, drops),
berm=stored_berm(design),
**curve_widening_args(section),
**stored_shoulder_extend(design), # 노견 확장(139-6)
)
try:
+2 -1
View File
@@ -23,7 +23,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B06_Section.B06_Section_Engine_Design import compute_cross_design, stored_shoulder_extend
from common_util.common_util_structure_face_role import structure_face_role
#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다.
@@ -216,6 +216,7 @@ def enforce_spoil_fills(
"slope_ratio_n": slope_ratio_n,
},
**curve_widening_args(section),
**stored_shoulder_extend(design), # 노견 확장(139-6)
)
except (ValueError, KeyError):
return None
+6 -1
View File
@@ -20,7 +20,11 @@ from B05_Profile.B05_Profile_Engine_Sections import (
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B06_Section.B06_Section_Engine_Design import (
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
)
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures
from B06_Section.B06_Section_Repository import (
count_cross_sections,
@@ -593,6 +597,7 @@ async def compute_cross_section_design(
surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)),
berm=stored_berm(stored_design or {}),
**curve_widening_args(cross_record),
**stored_shoulder_extend(stored_design or {}), # 노견 확장(139-6)
)
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
design["status"] = "provisional"
+11 -1
View File
@@ -9,7 +9,11 @@ from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B06_Section.B06_Section_Engine_Design import (
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
)
from common_util.common_util_cross_berm import (
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
@@ -143,6 +147,9 @@ USER_TOUCHED_KEYS = (
# 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다
# (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」).
"cut_slope_ratio_user",
# 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`)
"shoulder_extend_left_m",
"shoulder_extend_right_m",
)
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
@@ -216,6 +223,7 @@ def enforce_pavement_ranges(
surface_drop_m=ford_drop_at(chainage, ford_drops),
berm=stored_berm(design),
**curve_widening_args(section),
**stored_shoulder_extend(design), # 노견 확장(139-6)
)
except (ValueError, KeyError):
continue
@@ -270,6 +278,7 @@ def enforce_ford_surface_drops(
surface_drop_m=wanted,
berm=stored_berm(design),
**curve_widening_args(section),
**stored_shoulder_extend(design), # 노견 확장(139-6)
)
except (ValueError, KeyError):
continue
@@ -466,6 +475,7 @@ def recompute_designs_for_alignment(
surface_drop_m=ford_drop_at(chainage, ford_drops),
berm=session_berms.get(key) or stored_berm(stored),
**curve_widening_args(section),
**stored_shoulder_extend(stored), # 노견 확장(139-6)
)
except (ValueError, KeyError):
continue
+3
View File
@@ -147,6 +147,9 @@ class CrossSectionPatch(BaseModel):
# **0 은 「표준값을 씀」**(되돌리기)이라 gt=0 이 아니라 ge=0 이다 — 0 이 와야
# 정본에 남은 옛 사용자 값이 지워진다.
cut_slope_ratio_user: float | None = Field(default=None, ge=0)
# 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림(정본의 옛 값을 지움)
shoulder_extend_left_m: float | None = Field(default=None, ge=0, le=20)
shoulder_extend_right_m: float | None = Field(default=None, ge=0, le=20)
inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None
basin_adjust: BasinAdjustPatch | None = None
# 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
+7 -1
View File
@@ -28,7 +28,7 @@ import {
STRUCTURE_ROW_KEYS,
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
@@ -386,6 +386,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
// 자동으로 되돌아가지 않으므로(켬/끔 둘 뿐) 지금은 손해가 없다(2026-09-09).
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
});
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 서버가 [저장] 때 이 값으로 설계선을 다시 냄
readShoulderExtends(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => {
const patch = patchFor(chainage);
if (sides.left !== undefined) patch.shoulder_extend_left_m = sides.left;
if (sides.right !== undefined) patch.shoulder_extend_right_m = sides.right;
});
if (detail) {
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
+13
View File
@@ -113,6 +113,9 @@ 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`. */
shoulderExtendLeftM?: number;
shoulderExtendRightM?: number;
}
export interface CrossDesignEdge {
@@ -137,6 +140,9 @@ export interface CrossDesignResult {
/** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */
widening_left_m?: number;
widening_right_m?: number;
/** 노견 확장(m · 139-6) — 넓힌 쪽만. */
shoulder_extend_left_m?: number;
shoulder_extend_right_m?: number;
cross_slope_pct: number;
ditch: Record<string, unknown>;
/** 결과 — **실제로 섰나**. */
@@ -356,6 +362,8 @@ export function computeCrossDesign(
ditchEnabled: options.ditchEnabled ?? null,
ditchChoice: options.ditchChoice ?? null,
berm: options.berm ?? null,
shoulderExtendLeftM: options.shoulderExtendLeftM ?? 0,
shoulderExtendRightM: options.shoulderExtendRightM ?? 0,
});
// 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만).
@@ -575,6 +583,11 @@ export function computeCrossDesign(
slope_deg: round4(options.berm.slopeDeg),
};
}
// 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬)
if (geometry.shoulderExtendLeft > 0)
result.shoulder_extend_left_m = round4(geometry.shoulderExtendLeft);
if (geometry.shoulderExtendRight > 0)
result.shoulder_extend_right_m = round4(geometry.shoulderExtendRight);
if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m);
if (presetKey === "rock" && rockBoundaryOffsetM !== null) {
result.rock_boundary_offset_m = round4(rockBoundaryOffsetM);
@@ -108,6 +108,9 @@ export class SectionGeometry {
hasDitch: boolean;
/** 사용자가 정한 선택(`null` = 자동). 결과(`hasDitch`)와 다른 값이다. */
ditchChoice: boolean | null;
/** 노견 확장(m · 139-6) — 쪽마다. */
shoulderExtendLeft = 0;
shoulderExtendRight = 0;
ditchPoints: Array<[number, number]> = [];
private groundAt: ((offsetM: number) => number) | null;
private rockOffset: number;
@@ -138,6 +141,9 @@ export class SectionGeometry {
wideningRightM?: number;
/** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
berm?: BermSpec | null;
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6). */
shoulderExtendLeftM?: number;
shoulderExtendRightM?: number;
}) {
const { group } = params;
const halfRoad = group.road_width_m / 2;
@@ -204,6 +210,13 @@ export class SectionGeometry {
/** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */
this.ditchChoice = choice;
// 설계선 쉬프트 = 쪽마다 노견 확장(≥ 0 · 139-6) — 단면유형 · 측구 판정은 **옮기기 전** 노견에서
// (위) · 노체 · 측구 · 사면이 같이 바깥으로 · 차도는 그대로. ⚠ 파이썬 짝 `_SectionGeometry`.
this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0);
this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0);
this.leftExtent += this.shoulderExtendLeft;
this.rightExtent += this.shoulderExtendRight;
// 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
const outward = params.ditchSide === "left" ? 1 : -1;
@@ -178,6 +178,32 @@ CASES = [
"spoil_fill": {"side": "left", "width_m": 8.0, "slope_ratio_n": 2.0},
},
},
{
# 설계선 쉬프트(139-6) — 쪽마다 노견 확장 · 측구 쪽(절토)과 성토 쪽을 같이 넓힘
"name": "노견 확장 좌 0.7 · 우 1.2",
"samples": _ground(0.35),
"design_elevation_m": 100.4,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"shoulder_extend_left_m": 0.7,
"shoulder_extend_right_m": 1.2,
},
},
{
# 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮기기 전 노견에서
"name": "노견 확장 — 판정은 옮기기 전 노견",
"samples": _ground(-0.05),
"design_elevation_m": 100.0,
"options": {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "right",
"shoulder_extend_right_m": 3.0,
"shoulder_extend_left_m": 2.5,
},
},
]
_TS_RUNNER = """
@@ -198,6 +224,8 @@ const camel = {
plan_radius_m: "planRadiusM",
curve_outer_side: "curveOuterSide",
curve_widening_m: "curveWideningM",
shoulder_extend_left_m: "shoulderExtendLeftM",
shoulder_extend_right_m: "shoulderExtendRightM",
};
// 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다.
const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" };