From 6b9c115baa8f5bf07d385f41fd9efc90e5cdaec1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 17:52:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=A0=88=ED=86=A0=20=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=EB=A5=BC=20=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20?= =?UTF-8?q?=EB=84=A3=EA=B2=8C=20=E2=80=94=20=EC=B9=B4=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EC=95=94=20=EC=A0=88=ED=86=A0=EA=B0=81(=C2=B0),=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EB=8A=94=20=ED=91=9C=EC=A4=80=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-07): 「대신 경고부분은 삭제해주고 대신 각도를 사용자가 넣을수 있게 반영. 전체 공통으로 변경하는 경우에는 기본값 지정으로 하면 되지만 횡단도 하나만 변경하는 폼은 가져야함 / 개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함」 **화면** — 암 측점 카드 아래에 「암 절토 68.2° ↺」 칸이 섬. 값을 넣으면 그 측점만 사면이 새 경사로 다시 그려지고 절토량도 따라 바뀜. ↺ 는 표준값으로 되돌림. 전체를 바꾸는 자리는 종전대로 좌측 [표준 횡단면 설정]임. **계산에 넣는 자리는 한 곳씩** — 파이썬 `compute_cross_design(cut_slope_ratio=…)` · TS `computeCrossDesign({cutSlopeRatio})` 의 **그룹을 만든 바로 뒤**에서 경사비만 갈아 끼움. 부르는 쪽 9곳에서 표준값을 측점마다 복제하는 방식은 안 씀(한 곳만 빠져도 값이 조용히 사라지는 실패군). 기하·소단 코드는 한 줄도 안 건드림 — 25 확인대로 소단이 그 값을 읽어 서므로 **위치·개수가 새 경사를 저절로 따라옴**. ⚠ **경사는 나르는 값이 아니라 기하 입력임** — 계산 뒤에 키만 베껴 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 됨(소단에서 겪은 자리). 그래서 재계산 세 경로(포장 강제·세월교 하강· 선형 재계산)와 브라우저 재계산·서버 프리뷰 **모두 계산 인자로** 넘김. **되돌리기는 0 을 남김** — 세션에서 지우기만 하면 정본에 남은 옛 사용자 값이 되살아나 표준으로 못 돌아감. 0 = 「표준값을 씀」. 값의 길 — 세션 `cutslope`(등록표 한 줄) → 카드 입력 → [저장]·[확정]에서 `cross_patches` (`design.cut_slope_ratio_user`) → 정본. `USER_TOUCHED_KEYS` 양쪽에 넣어 **표준을 바꿔도 개별로 고친 측점은 그대로** 둠(사용자 원문 끝줄). 시험 — 새 7건(넣은 경사가 실제로 그려짐 · 무릎 위 토사 경사는 그대로 · 0 은 되돌림 · 재계산에도 남음 · **계산 전에 넘어감** · 각도↔경사비 · 칸은 암 측점에만), 거울 시험에 「암 절토 경사를 측점에서 바꿈」 한 갈래 추가, 7-3 회귀 한 줄 추가. 전체 **483 passed · 17 skipped**. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 4 + B06_Section/B06_Section_Api_Fetch.ts | 3 + B06_Section/B06_Section_Api_Types.ts | 7 ++ B06_Section/B06_Section_Cross_Refresh.ts | 35 ++++++ B06_Section/B06_Section_Engine_Design.py | 8 ++ B06_Section/B06_Section_Router.py | 4 + B06_Section/B06_Section_Router_Design.py | 30 +++++ B06_Section/B06_Section_Schema.py | 10 ++ .../B06_Section_UI_Cross_Card_Chrome.ts | 11 +- B06_Section/B06_Section_UI_Cross_CutSlope.ts | 114 ++++++++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 5 +- B06_Section/B06_Section_UI_Page.ts | 15 +++ B06_Section/B06_Section_UI_Page_Patches.ts | 6 + B06_Section/B06_Section_UI_Page_Persist.ts | 91 ++++++++++++++ B06_Section/B06_Section_UI_Section_View.ts | 4 + B06_Section/B06_Section_UI_Style_Cross.css | 35 ++++++ common_util/common_util_cross_design.ts | 12 +- 17 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_CutSlope.ts diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index fd30b73b..9c05c761 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -109,6 +109,10 @@ export const STATE_REGISTRY = { culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, /** 암 경계선 오프셋(측점별). */ rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, + /** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07). + * 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야 + * 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */ + cutslope: { bucket: "draft", scope: "route" }, /** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9. * 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을 * 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */ diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 26e84d89..e427bf2d 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -255,6 +255,8 @@ export async function previewCrossDesigns( rockBoundaryOffsets?: Record; /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ berms?: Record; + /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ + cutSlopeRatios?: Record; }, ): Promise { return requestJson( @@ -267,6 +269,7 @@ export async function previewCrossDesigns( full_designs: options?.fullDesigns ?? false, rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, berms: options?.berms ?? null, + cut_slope_ratios: options?.cutSlopeRatios ?? null, }), }, ); diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 42dacd1b..c902c575 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -461,6 +461,9 @@ export interface CrossDesign { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06). 확정 시 병합되며 세션 값이 우선이다. */ display_half_width_m?: number; + /** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07). + * 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */ + cut_slope_ratio_user?: number; } export interface CrossDesignResponse { @@ -482,6 +485,8 @@ export interface CrossDesignRequest { rock_boundary_offset_m?: number | null; /** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */ two_stage_slope?: boolean; + /** 이 측점만 쓰는 암 절토 경사비(1:n 의 n). 없으면 표준값(2026-09-07). */ + cut_slope_ratio?: number | null; /** 측구 생성 여부. null/미지정=자동 판정, true/false=수동 override. */ ditch_enabled?: boolean | null; /** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */ @@ -494,6 +499,8 @@ export interface CrossSectionPatch { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */ display_half_width_m?: number; + /** 측점별 암 절토 경사비(2026-09-07). 0 = 표준값으로 되돌림. */ + cut_slope_ratio_user?: number; inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; basin_adjust?: { innerWidthM: number; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 6509d231..09951ca5 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -42,6 +42,7 @@ import { toAlignmentBase, } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; +import { readState } from "../A00_Common/b_page_state"; /** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ export interface CrossRefreshEdits { @@ -83,6 +84,8 @@ export const USER_TOUCHED_KEYS = [ "revet_follow_grade", // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). "berm", + // 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다. + "cut_slope_ratio_user", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ @@ -154,6 +157,23 @@ function bermPayload( return out; } +/** + * 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다. + * + * **0 은 「표준값을 씀」**이다(되돌리기). 그래서 0 을 거르지 않고 그대로 싣는다 — 거르면 + * 저장분에 남은 옛 사용자 값이 되살아난다. + */ +function readCutSlopeRatios(projectId: string, routeId: number): Map { + const out = new Map(); + const raw = readState>("cutslope", projectId, routeId); + if (!raw) return out; + for (const [chainage, ratio] of Object.entries(raw)) { + const at = Number(chainage); + if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio); + } + return out; +} + /** * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). * @@ -184,6 +204,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); + // 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로 + // 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07). + const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId); // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 // 순간 계단이 사라진다(계획서 3-9). const bermSpans = readBermSpans(projectId, input.routeId); @@ -195,6 +218,13 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). + /** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */ + const cutSlopeAt = (chainageM: number, design: Record): number | null => { + const session = cutSlopeRatios.get(rockKey(chainageM)); + if (session !== undefined) return session > 0 ? session : null; + const stored = design.cut_slope_ratio_user; + return typeof stored === "number" && stored > 0 ? stored : null; + }; const choices = crossDesignChoices(projectId, input.routeId); const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); @@ -237,6 +267,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { standard, berm: bermAt(section.chainage_m), rockBoundaryOffsetM, + cutSlopeRatio: cutSlopeAt(section.chainage_m, design), twoStageSlope: choice?.two_stage_slope ?? (design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)), @@ -276,6 +307,10 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), berms: bermPayload(projectId, routeId, detail), + // 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만 + // 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07). + cutSlopeRatios: + readState>("cutslope", projectId, routeId) ?? undefined, }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 7c7a86a7..be090efc 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -545,6 +545,7 @@ def compute_cross_design( standard: dict[str, Any] | None = None, rock_boundary_offset_m: float | None = None, two_stage_slope: bool = True, + cut_slope_ratio: float | None = None, ditch_enabled: bool | None = None, surface_drop_m: float = 0.0, plan_radius_m: float | None = None, @@ -561,6 +562,8 @@ def compute_cross_design( standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). + cut_slope_ratio: 이 측점만 쓰는 절토 경사비(1:n 의 n). 사용자가 카드에서 넣은 값이며 + None 이면 표준 횡단면 설정값을 그대로 쓴다(2026-09-07 사용자 지시). plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는 입력이며, None·45m 이상이면 확폭이 없다. curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다 @@ -584,6 +587,11 @@ def compute_cross_design( if ditch_type == "l_type" and preset_key != "rock": raise ValueError("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.") group = _resolve_group(preset_key, standard) + # 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다. + # ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 결과에 값만 + # 베껴 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다. + if cut_slope_ratio is not None and float(cut_slope_ratio) > 0: + group = {**group, "cut_slope_ratio": float(cut_slope_ratio)} paved_group = _resolve_group("paved", standard) # 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"] diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 006ad692..1c445cd2 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -44,6 +44,7 @@ from B06_Section.B06_Section_Router_Design import ( ford_drop_at, ford_surface_drops, stored_berm, + stored_cut_slope, ) from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, @@ -555,6 +556,7 @@ async def preview_cross_designs( request.rock_boundary_offsets, project_root, request.berms, + request.cut_slope_ratios, ) await asyncio.to_thread(rebuild) @@ -636,6 +638,8 @@ async def compute_cross_section_design( standard=request.standard_cross_section, rock_boundary_offset_m=request.rock_boundary_offset_m, two_stage_slope=request.two_stage_slope, + # 측점별 암 절토 경사 — 요청값이 없으면 저장분에서 잇는다(2026-09-07). + cut_slope_ratio=request.cut_slope_ratio or stored_cut_slope(stored_design or {}), ditch_enabled=request.ditch_enabled, surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)), berm=stored_berm(stored_design or {}), diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index f94d15e0..5f1aaf8a 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -140,6 +140,9 @@ USER_TOUCHED_KEYS = ( "revet_follow_grade", # 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). "berm", + # 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다 + # (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」). + "cut_slope_ratio_user", ) @@ -193,6 +196,7 @@ def enforce_pavement_ranges( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), + cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), berm=stored_berm(design), @@ -245,6 +249,7 @@ def enforce_ford_surface_drops( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), + cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=wanted, berm=stored_berm(design), @@ -346,6 +351,18 @@ def attach_default_designs( continue +def stored_cut_slope(stored: dict[str, Any]) -> float | None: + """저장분에 남은 측점별 암 절토 경사비 — 없거나 0(표준값으로 되돌림)이면 None. + + ⚠ 소단과 같은 성격이다 — **경사는 나르는 값이 아니라 기하 입력**이라 계산에 넣어야 한다. + 다시 계산한 뒤 키만 베껴 붙이면 설계선은 옛 경사로 나오고 값만 새것이 되어 어긋난다. + """ + value = stored.get("cut_slope_ratio_user") + if isinstance(value, (int, float)) and float(value) > 0: + return float(value) + return None + + def stored_berm(stored: dict[str, Any]) -> BermSpec | None: """저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" spec = stored.get("berm") @@ -369,6 +386,7 @@ def recompute_designs_for_alignment( rock_boundary_offsets: dict[str, float] | None = None, project_root: Path | None = None, berms: dict[str, dict[str, float]] | None = None, + cut_slope_ratios: dict[str, float] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) @@ -378,6 +396,13 @@ def recompute_designs_for_alignment( round(float(record["chainage_m"]), 3): (record.get("design") or {}) for record in stored_designs } + # 측점별 암 절토 경사(2026-09-07) — 0 은 「표준값을 씀」이라 저장분을 덮어 지운다. + session_cut_slopes: dict[float, float] = {} + for raw_key, ratio in (cut_slope_ratios or {}).items(): + try: + session_cut_slopes[round(float(raw_key), 3)] = float(ratio) + except (TypeError, ValueError): + continue session_offsets: dict[float, float] = {} for raw_key, offset in (rock_boundary_offsets or {}).items(): try: @@ -415,6 +440,11 @@ def recompute_designs_for_alignment( stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M), ), two_stage_slope=bool(stored.get("two_stage_slope", True)), + cut_slope_ratio=( + (session_cut_slopes[key] or None) + if key in session_cut_slopes + else stored_cut_slope(stored) + ), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), berm=session_berms.get(key) or stored_berm(stored), diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 9b8dac61..8cf4525f 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -52,6 +52,9 @@ class CrossDesignRequest(BaseModel): rock_boundary_offset_m: float | None = None # 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능. two_stage_slope: bool = True + # 이 측점만 쓰는 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). + # None 이면 표준 횡단면 설정값을 쓴다. + cut_slope_ratio: float | None = Field(default=None, gt=0) # 측구 생성 여부. None=자동 판정(측구측 절토면만 생성), True/False=수동 override. ditch_enabled: bool | None = None # B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순. @@ -136,6 +139,10 @@ class CrossSectionPatch(BaseModel): rock_boundary_offset_m: float | None = None # 측점별 표시 반폭(m) — 카드 개별 조절값. 전역 반폭과 다를 때만 실린다(2026-08-06). display_half_width_m: float | None = Field(default=None, gt=0) + # 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). + # **0 은 「표준값을 씀」**(되돌리기)이라 gt=0 이 아니라 ge=0 이다 — 0 이 와야 + # 정본에 남은 옛 사용자 값이 지워진다. + cut_slope_ratio_user: float | None = Field(default=None, ge=0) inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None basin_adjust: BasinAdjustPatch | None = None # 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…). @@ -302,6 +309,9 @@ class CrossDesignPreviewRequest(BaseModel): # 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9). # 값이 없는 측점은 소단 없음 — 종전 설계 그대로다. berms: dict[str, dict[str, float]] | None = None + # 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 위와 같은 성격 — 확정 전 세션값을 + # 재계산에 반영한다(2026-09-07). **0 은 「표준값을 씀」**(되돌리기)이다. + cut_slope_ratios: dict[str, float] | None = None def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index d7cd8c5c..b6e1b8d5 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -8,6 +8,7 @@ import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; +import { buildCutSlopeControl, type CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { buildDesignControls, buildRockBoundaryControl, @@ -122,11 +123,12 @@ export function appendCardHeader( card.append(header); } -/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선 제어. */ +/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선·암 절토 경사 제어. */ export function appendCardFooter( card: HTMLElement, section: CrossSection, rockBoundary?: RockBoundaryControl, + cutSlope?: CutSlopeControl, ): void { const footer = document.createElement("footer"); const center = document.createElement("span"); @@ -138,5 +140,12 @@ export function appendCardFooter( rockControl.classList.add("b06-cross-card__rockb"); footer.append(rockControl); } + // 암 절토 경사(각도)는 암 측점에만 — 토사 측점은 암반이 없어 쓰이지 않는다 + // (2026-09-07 사용자 확정). 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이다. + if (cutSlope && section.design?.geometry_preset === "rock") { + const slopeControl = buildCutSlopeControl(section, cutSlope); + slopeControl.classList.add("b06-cross-card__cutslope"); + footer.append(slopeControl); + } card.append(footer); } diff --git a/B06_Section/B06_Section_UI_Cross_CutSlope.ts b/B06_Section/B06_Section_UI_Cross_CutSlope.ts new file mode 100644 index 00000000..23d1f842 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_CutSlope.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * B06_Section_UI_Cross_CutSlope.ts + * 측점 하나의 **암 절토 경사** 입력칸(2026-09-07 사용자 지시). + * + * 사용자 원문 — 「개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함 / 전체 변경을 위해서는 + * 좌측 패널의 표준 횡단면 설정을 이용」. 그래서 **전체 기본값은 좌측 패널**, **이 칸은 그 측점 + * 하나**만 바꾼다. 표준을 바꿔도 여기서 만진 측점은 그대로다(사용자 값이라 재계산이 살려 둔다). + * + * 화면에는 **각도(°)**로 보이고 속으로는 경사비(1:n 의 n)로 다닌다 — 소단 폼의 + * 「안쪽 기울기 (°)」와 같은 말법이다. 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°. + * + * ⚠ 경사는 **나르는 값이 아니라 기하 입력**이다. 계산이 끝난 결과에 값만 베껴 붙이면 설계선은 + * 옛 경사로 그려지고 숫자만 새것이 된다 — 값은 반드시 **계산 전에** 넘어가야 한다 + * (`computeCrossDesign(cutSlopeRatio)` · `compute_cross_design(cut_slope_ratio=…)`). + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; + +/** 측점별 암 절토 경사 제어기 — Page 가 세션 저장소와 연결해 구현한다. */ +export interface CutSlopeControl { + /** 세션 → design 저장값 → 표준값 순으로 지금 경사비를 돌려준다. */ + ratioFor: (section: CrossSection) => number; + /** 이 측점의 표준값(되돌릴 자리) — 사용자가 안 만진 상태의 값. */ + standardRatioFor: (section: CrossSection) => number; + /** 경사비를 넣는다. null 이면 표준값으로 되돌린다. */ + set: (chainageM: number, ratio: number | null) => void; +} + +/** 경사비(1:n 의 n) → 수평에서 잰 각도(°). n 이 작을수록 급하다. */ +export function ratioToDegrees(ratio: number): number { + return (Math.atan(1 / Math.max(ratio, 1e-6)) * 180) / Math.PI; +} + +/** 각도(°) → 경사비(1:n 의 n). 1~89° 밖은 사면이 서지 않아 잘라 받는다. */ +export function degreesToRatio(degrees: number): number { + const clamped = Math.min(Math.max(degrees, 1), 89); + return 1 / Math.tan((clamped * Math.PI) / 180); +} + +/** 0.1° 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */ +function sameDegrees(a: number, b: number): boolean { + return Math.abs(a - b) < 0.05; +} + +/** + * 카드 하단에 서는 「암 절토 68.2° ↺」 칸. 암 경계선 제어와 같은 자리·같은 모양이다. + * + * 되돌리기(↺)는 **표준 횡단면 설정값**으로 돌려놓는다 — 좌측 패널을 그 뒤에 바꾸면 이 측점도 + * 따라간다(사용자가 만진 흔적이 지워지므로). + */ +export function buildCutSlopeControl(section: CrossSection, control: CutSlopeControl): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b06-design__seg b06-design__cutslope"; + const legend = document.createElement("span"); + legend.className = "b06-design__seg-legend"; + legend.textContent = "암 절토"; + wrap.append(legend); + + const group = document.createElement("div"); + group.className = "b06-design__seg-buttons"; + const ratio = control.ratioFor(section); + const standard = control.standardRatioFor(section); + const degrees = ratioToDegrees(ratio); + + const input = document.createElement("input"); + input.type = "number"; + input.className = "b06-design__cutslope-input"; + input.step = "0.5"; + input.min = "1"; + input.max = "89"; + input.value = degrees.toFixed(1); + input.title = + `이 측점의 암 절토 경사 — 각도(°)로 넣는다.\n` + + `지금 1:${ratio.toFixed(2)} (${degrees.toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` + + `전체를 바꾸려면 좌측 [표준 횡단면 설정]을 쓴다.`; + const commit = (): void => { + const entered = Number(input.value); + if (!Number.isFinite(entered)) { + input.value = degrees.toFixed(1); + return; + } + // 표준값으로 되돌아온 입력은 사용자 값을 남기지 않는다 — 그래야 표준을 바꿀 때 따라간다. + const next = sameDegrees(entered, ratioToDegrees(standard)) ? null : degreesToRatio(entered); + control.set(section.chainage_m, next); + }; + input.addEventListener("change", commit); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } + }); + // 카드 클릭(선택·줌)이 입력을 뺏지 않게 한다 — 암 경계선 버튼과 같은 태도. + input.addEventListener("pointerdown", (event) => event.stopPropagation()); + + const unit = document.createElement("span"); + unit.className = "b06-design__cutslope-unit"; + unit.textContent = "°"; + + const reset = document.createElement("button"); + reset.type = "button"; + reset.className = "b06-design__rockb-btn is-reset"; + reset.textContent = "↺"; + reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`; + reset.disabled = sameDegrees(degrees, ratioToDegrees(standard)); + reset.addEventListener("click", (event) => { + event.stopPropagation(); + control.set(section.chainage_m, null); + }); + + group.append(input, unit, reset); + wrap.append(group); + return wrap; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index f2ac7b13..b5c18b79 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -48,6 +48,7 @@ import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Do import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; +import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; @@ -150,6 +151,8 @@ export function createCrossSectionCard( box?: BoxControl, /** 행 높이를 재며 이미 만들어 둔 기하 — 있으면 다시 계산하지 않는다(2026-09-06). */ plotBase?: CrossPlotBase | null, + /** 측점별 암 절토 경사 입력(2026-09-07) — 암 측점 하단 정보행에 선다. */ + cutSlope?: CutSlopeControl, ): CrossCardElement { // 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고 // 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다 @@ -704,7 +707,7 @@ export function createCrossSectionCard( card.append(chartWrap); } - appendCardFooter(card, section, rockBoundary); + appendCardFooter(card, section, rockBoundary, cutSlope); return card; } diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 3877af0a..f127f66f 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -26,6 +26,7 @@ import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { createBermPanel } from "./B06_Section_UI_Berm_Panel"; import { confirmCurrentSections, + createCutSlopeStore, createRockBoundaryStore, saveCurrentSections, type SectionPersistContext, @@ -46,6 +47,7 @@ import { } from "./B06_Section_UI_Page_Pipe_Options"; import { createStandardPanel, + effectiveStandardCross, rememberRockBoundaryDefault, type StandardPanelController, } from "./B06_Section_UI_Standard_Panel"; @@ -423,6 +425,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const rockOffsets = rockStore.offsets; const rockBoundaryControl = rockStore.control; + // 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다. + // 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이고, 여기 값은 그 측점 하나만 덮는다. + const cutSlopeStore = createCutSlopeStore({ + sessionKey: () => stateKey("cutslope", projectId, currentRouteId), + standard: () => (projectId ? effectiveStandardCross(projectId) : null), + refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + recompute: (chainageM) => recomputeIfRock(chainageM), + }); + const cutSlopeRatios = cutSlopeStore.ratios; + const stationControls = createStationControls({ // 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다. // 형변환을 두지 않는다: 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다 @@ -452,6 +464,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.revetLink, stationControls.ford, stationControls.box, + cutSlopeStore.control, ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); @@ -595,6 +608,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { flushCulvertOptions: () => stationControls.flushCulvertOptions(), patchSources: () => ({ rockOffsets, + cutSlopeRatios, stationWidths, inletStructures, basinAdjustments, @@ -665,6 +679,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { currentRouteId = context.route_id; rockStore.load(); + cutSlopeStore.load(); stationControls.load(); // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. void structuresPanel.load().then(restoreStructurePick); diff --git a/B06_Section/B06_Section_UI_Page_Patches.ts b/B06_Section/B06_Section_UI_Page_Patches.ts index cdb5025f..4853d895 100644 --- a/B06_Section/B06_Section_UI_Page_Patches.ts +++ b/B06_Section/B06_Section_UI_Page_Patches.ts @@ -16,6 +16,8 @@ import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert"; export interface CrossPatchSources { /** 암 경계선 오프셋(누가거리 문자열 키). */ rockOffsets: Map; + /** 측점별 암 절토 경사비(1:n 의 n). **0 은 「표준값을 씀」**(되돌리기)이다. */ + cutSlopeRatios: Map; /** 측점 개별 표시 반폭(m). */ stationWidths: Map; inletStructures: Map; @@ -42,6 +44,10 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch sources.rockOffsets.forEach((offset, chainage) => { patchFor(Number(chainage)).rock_boundary_offset_m = offset; }); + // 측점별 암 절토 경사(2026-09-07) — 0 이면 「표준값을 씀」이라 정본의 옛 값을 덮어 지운다. + sources.cutSlopeRatios.forEach((ratio, chainage) => { + patchFor(Number(chainage)).cut_slope_ratio_user = ratio; + }); // 개별 표시 반폭(2026-08-06) — design에 병합돼 재접근 시 유지된다. sources.stationWidths.forEach((width, chainage) => { patchFor(Number(chainage)).display_half_width_m = width; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 1f66dfc2..57844d2f 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -30,6 +30,7 @@ import { import { crossDesignChoices } 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"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { L } from "./B06_Section_UI_Page_Common"; @@ -198,6 +199,96 @@ export function createRockBoundaryStore(options: { }; } +/** 측점별 암 절토 경사비 저장소 — 값(Map)과 카드 제어기를 함께 낸다(2026-09-07). */ +export interface CutSlopeStore { + /** 측점키(누가거리 2자리) → 경사비(1:n 의 n). `buildCrossPatches` 가 그대로 읽는다. + * **0 은 「표준값을 씀」**이다 — 되돌리기(↺)가 남기는 값이라, 저장분에 옛 사용자 값이 + * 남아 있어도 0 이 그것을 덮어 표준으로 돌려놓는다(지우기만 하면 옛 값이 되살아난다). */ + ratios: Map; + control: CutSlopeControl; + load: () => void; +} + +/** + * 측점 하나만 다른 **암 절토 경사** 세션 저장소. + * + * 암 경계선 저장소와 같은 꼴이다 — 세션(sessionStorage)에 쌓고 [저장]·[확정]에서 + * `cross_patches`(`design.cut_slope_ratio_user`)로 정본에 나간다. + * + * ⚠ 값을 넣으면 **재계산을 부른다**. 경사는 나르는 값이 아니라 기하 입력이라, 값만 바꾸고 + * 다시 계산하지 않으면 설계선은 옛 경사로 남고 숫자만 새것이 된다. + */ +export function createCutSlopeStore(options: { + sessionKey: () => string | null; + /** 지금 표준 횡단면 설정(세션 편집값 우선) — 되돌릴 자리를 여기서 읽는다. */ + standard: () => StandardCrossSection | null; + refreshCard: (chainageM: number) => void; + /** 경사 변경 → 사면·소단·단면적 재계산. */ + recompute: (chainageM: number) => void; +}): CutSlopeStore { + const { sessionKey, standard, refreshCard, recompute } = options; + const ratios = new Map(); + const key = (chainageM: number): string => chainageM.toFixed(2); + + /** 이 측점이 쓰는 표준 경사비 — 지반 프리셋(암/토사)의 값. */ + const standardRatio = (section: { design?: { geometry_preset?: string } | null }): number => { + const preset = section.design?.geometry_preset === "soil" ? "soil" : "rock"; + const group = standard()?.[preset] as { cut_slope_ratio?: number } | undefined; + const value = group?.cut_slope_ratio; + return typeof value === "number" && value > 0 ? value : 0.4; + }; + + function persist(): void { + const storageKey = sessionKey(); + if (!storageKey) return; + try { + writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios))); + } catch { + /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ + } + } + + return { + ratios, + load(): void { + ratios.clear(); + const storageKey = sessionKey(); + if (!storageKey) return; + try { + const raw = readByKey(storageKey); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([chainage, ratio]) => { + // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. + if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio); + }); + } catch { + /* 손상된 세션 값은 무시 — 표준값으로 재시작. */ + } + }, + control: { + standardRatioFor: (section) => standardRatio(section), + ratioFor: (section) => { + const session = ratios.get(key(section.chainage_m)); + if (session === 0) return standardRatio(section); // 되돌림 — 저장분보다 세션이 먼저다. + if (typeof session === "number" && session > 0) return session; + const stored = (section.design as { cut_slope_ratio_user?: number } | undefined) + ?.cut_slope_ratio_user; + if (typeof stored === "number" && stored > 0) return stored; + return standardRatio(section); + }, + set: (chainageM, ratio) => { + // null = 되돌리기. 지우지 않고 0 을 남겨야 저장분에 있던 옛 값까지 표준으로 돌아간다. + if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) ratios.set(key(chainageM), 0); + else ratios.set(key(chainageM), Math.round(ratio * 10000) / 10000); + persist(); + refreshCard(chainageM); + recompute(chainageM); + }, + }, + }; +} + /** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */ export interface SectionPersistContext { projectId: string | null; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 5f75c087..0d5111e9 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -13,6 +13,7 @@ * 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다. * ========================================================================== */ +import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; @@ -146,6 +147,8 @@ export function createSectionView( revetLink?: RevetLinkControl, ford?: FordControl, box?: BoxControl, + /** 측점별 암 절토 경사 입력(2026-09-07). */ + cutSlope?: CutSlopeControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -457,6 +460,7 @@ export function createSectionView( ford, box, plotBase, + cutSlope, ); /** diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 2a4a874a..b2b89cd1 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -572,3 +572,38 @@ border-radius: var(--radius-inputs); padding: 1px 4px; } + +/* 측점별 암 절토 경사(2026-09-07) — 암 경계선이 행 가운데를 절대 배치로 쓰므로 + 이 칸은 행의 **오른쪽 끝**에 둔다. 중심고(왼쪽)와 겹치지 않는다. */ +.b06-cross-card__cutslope { + margin-left: auto; + flex: 0 0 auto; + border-radius: var(--radius-inputs); + padding: 1px 4px; +} + +.b06-design__cutslope-input { + width: 44px; + padding: 0 2px; + font-size: 0.72rem; + font-family: var(--font-mono); + text-align: right; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); +} + +/* 화살표(스피너)는 칸이 좁아 지운다 — 값은 직접 넣거나 ↺ 로 되돌린다. */ +.b06-design__cutslope-input::-webkit-outer-spin-button, +.b06-design__cutslope-input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.b06-design__cutslope-unit { + padding: 0 2px 0 1px; + font-size: 0.72rem; + color: var(--color-text-secondary); + align-self: center; +} diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 83f060b0..275fc89b 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -81,6 +81,9 @@ export interface CrossDesignOptions { standard: StandardCrossSectionSpec; rockBoundaryOffsetM?: number | null; twoStageSlope?: boolean; + /** 이 측점만 쓰는 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값. 없으면 표준값. + * 짝: 파이썬 `compute_cross_design(cut_slope_ratio=…)`. */ + cutSlopeRatio?: number | null; ditchEnabled?: boolean | null; /** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */ surfaceDropM?: number; @@ -250,7 +253,14 @@ export function computeCrossDesign( if (ditchType === "l_type" && presetKey !== "rock") { throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다."); } - const group = resolveGroup(presetKey, options.standard); + let group = resolveGroup(presetKey, options.standard); + // 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다. + // ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 값만 베껴 + // 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다. + const userCutRatio = options.cutSlopeRatio; + if (typeof userCutRatio === "number" && Number.isFinite(userCutRatio) && userCutRatio > 0) { + group = { ...group, cut_slope_ratio: userCutRatio }; + } const pavedGroup = resolveGroup("paved", options.standard); // 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct;