diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 16f1a143b..aafbd6091 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -108,6 +108,14 @@ const PRESERVED_KEYS = [ ...SPOIL_KEYS, ] as const; +/** + * 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다. + * 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다. + */ +export function pavedBySpecs(section: Pick): boolean { + return Boolean(section.pavement?.length) || Boolean(section.ford_pavement); +} + function preserveUserFields( next: NonNullable, previous: CrossSection["design"], @@ -293,7 +301,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ditchType: choice?.ditch_type ?? (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), - paved: choice?.paved ?? Boolean(design.paved), + paved: pavedBySpecs(section), standard, berm: bermAt(section.chainage_m), rockBoundaryOffsetM, diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index a1963de5a..074fd5e88 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -44,6 +44,8 @@ from B06_Section.B06_Section_Router_Design import ( USER_TOUCHED_KEYS, ford_drop_at, ford_surface_drops, + paved_at, + pavement_ranges, stored_berm, stored_cut_slope, ) @@ -640,7 +642,7 @@ async def compute_cross_section_design( section_mode=request.section_mode, ditch_side=request.ditch_side, ditch_type=request.ditch_type, - paved=request.paved, + paved=paved_at(request.chainage_m, pavement_ranges(project_root)), standard=request.standard_cross_section, rock_boundary_offset_m=request.rock_boundary_offset_m, two_stage_slope=request.two_stage_slope, diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 7722522ce..b12160741 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -110,12 +110,17 @@ def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float: return 0.0 -def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool: - """이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장).""" - for start, end in ranges: - if start - 1e-9 <= chainage_m <= end + 1e-9: - return True - return bool(stored) if isinstance(stored, bool) else False +# 범위 끝 허용오차(m) — 브라우저 짝(`common_util_structure_pavement` · `_culvert_sets`)과 같은 값. +_PAVED_EDGE_TOLERANCE_M = 0.02 + + +def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool: + """이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시). + + 브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1). + """ + tol = _PAVED_EDGE_TOLERANCE_M + return any(start - tol <= chainage_m <= end + tol for start, end in ranges) # 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.** @@ -152,7 +157,6 @@ CARRY_INPUT_KEYS = ( "ground_type", "ditch_type", "ditch_choice", - "paved", "two_stage_slope", "rock_boundary_offset_m", "cut_slope_ratio_user", @@ -178,23 +182,23 @@ def enforce_pavement_ranges( project_root: Path, standard: dict[str, Any] | None = None, ) -> int: - """포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다. + """포장 = 노면공 · 물넘이 범위 안만 — 저장분과 다르면 켬 · 끔 양쪽으로 맞춘다(138-1). 사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정). + 범위 밖 옛 `paved=true`(지운 노면공 · 옛 카드 단추)는 비포장으로 되돌린다. 포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다. 사용자 조작값(반폭·구조물 조정)은 그대로 승계한다. """ ranges = pavement_ranges(project_root) - if not ranges: - return 0 ford_drops = ford_surface_drops(project_root) changed = 0 for section in cross_sections: design = section.get("design") - if not isinstance(design, dict) or design.get("paved"): + if not isinstance(design, dict): continue chainage = float(section.get("chainage_m", 0.0)) - if not paved_at(chainage, ranges): + wanted = paved_at(chainage, ranges) + if bool(design.get("paved")) == wanted: continue try: recomputed = compute_cross_design( @@ -204,7 +208,7 @@ def enforce_pavement_ranges( section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), - paved=True, + paved=wanted, standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M @@ -449,7 +453,7 @@ def recompute_designs_for_alignment( section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")), ditch_side=stored.get("ditch_side"), ditch_type=str(stored.get("ditch_type") or "standard"), - paved=paved_at(chainage, paved_ranges, stored.get("paved")), + paved=paved_at(chainage, paved_ranges), standard=standard, rock_boundary_offset_m=session_offsets.get( key, @@ -527,7 +531,7 @@ def compute_default_designs( section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), ditch_type=str(user.get("ditch_type") or "standard"), # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). - paved=paved_at(chainage_m, paved_ranges, user.get("paved")), + paved=paved_at(chainage_m, paved_ranges), standard=standard, rock_boundary_offset_m=( STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 19e1e8dbc..813759369 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B06_Section_UI_Cross_Design.ts - * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식·포장)과 + * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식)과 * 설계선·암 경계선·포장층 오버레이. * * 카드 헤더 아래에 세그먼트 버튼을 배치하고, 선택이 바뀌면 onChange로 계산을 @@ -439,25 +439,13 @@ export function buildDesignControls( !rockCut, L("B06_Design_Disabled_RockCut"), ); - // 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7. - const paved = toggle("", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), () => { - state.paved = !state.paved; - emit(); - }); - // B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다. - if (design?.pavement_suggested) { - paved.button.title = L("B06_Design_Paved_Suggested"); - const badge = document.createElement("span"); - badge.className = "b06-design__paved-badge"; - badge.textContent = "⚠"; - badge.title = L("B06_Design_Paved_Suggested"); - paved.wrap.append(badge); - } + // 포장 단추는 없다(138-1) — 포장 = 노면공 · 물넘이 범위만(`pavedBySpecs`) · 「포장 권장」은 알림 줄. + // `state.paved` 는 저장값 그대로 실어 보낸다(재계산이 범위로 다시 정함). // 오버플로 드롭다운(N-2-2): 컨트롤이 카드 폭에서 한 행을 넘치면 뒤쪽부터 우측 "⋯" // 드롭다운(세로 배치)으로 옮겨 다음 행으로 밀리는 것을 막는다. 경사 방향은 항상 인라인, - // 이동 우선순위(뒤에서부터): 포장 → 복합경사 → 측구형식 → 측구 토글. - const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap, paved.wrap]; + // 이동 우선순위(뒤에서부터): 복합경사 → 측구형식 → 측구 토글. + const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap]; const more = document.createElement("details"); more.className = "b06-design__more"; const moreSummary = document.createElement("summary"); diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index 6db3cdb63..82f665e0f 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -4,6 +4,7 @@ * (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만. * 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다. * ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③). + * ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리). * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; @@ -15,6 +16,7 @@ import { type FillSlopeWarning, } from "./B06_Section_UI_Cross_FillSlope_Warn"; import { L, stationLabel } from "./B06_Section_UI_Section_Common"; +import { pavedBySpecs } from "./B06_Section_Cross_Refresh"; export interface FillSlopeNotice { root: HTMLElement; @@ -46,7 +48,8 @@ function noticeBlock(className: string): { const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`; }); - button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`; + button.textContent = + `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim(); button.dataset.stationId = section.station_id; return button; }), @@ -59,7 +62,9 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill const root = document.createElement("div"); const warn = noticeBlock("b06-section__notice"); const info = noticeBlock("b06-section__notice b06-section__notice--info"); - root.append(warn.root, info.root); + const pave = noticeBlock("b06-section__notice"); + pave.root.title = L("B06_Design_Paved_Suggested"); + root.append(warn.root, pave.root, info.root); root.addEventListener("click", (event) => { const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; if (id) onPick(id); @@ -73,6 +78,13 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill fillSlopeWarnings(sections, fillSlopeLengths), stationInterval, ); + pave.fill( + `⚠ ${L("B06_Notice_PaveSuggested")}`, + sections + .filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section)) + .map((section) => ({ section, sides: [] })), + stationInterval, + ); info.fill( `ⓘ ${BERM_INFO_TEXT}`, bermReviewStations(sections, slopeRunLengths), diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 6f08716c6..f526528d2 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -404,13 +404,6 @@ align-self: center; } -/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */ -.b06-design__paved-badge { - font-size: 0.72rem; - color: var(--color-warning); - cursor: help; -} - /* 세월교 구체(2026-08-25) — 바닥판은 콘크리트. 측벽은 ㄴ형 집수정 형상을 그대로 쓰므로 `.b06-chart__culvert-basin` 색을 공유한다. */ .b06-chart__ford-slab { diff --git a/resources/tester/test_74_route_confirm_initial.py b/resources/tester/test_74_route_confirm_initial.py index 0211134d8..1c79e715b 100644 --- a/resources/tester/test_74_route_confirm_initial.py +++ b/resources/tester/test_74_route_confirm_initial.py @@ -25,7 +25,8 @@ def test_이월은_사용자_입력만() -> None: "ditch_side", ): assert result_key not in CARRY_INPUT_KEYS - assert {"ground_type", "paved", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS) + assert {"ground_type", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS) + assert "paved" not in CARRY_INPUT_KEYS # 포장 = 노면공 범위로 새로 정함(138-1) def test_체인이_옛_설계를_통째로_옮기지_않는다() -> None: diff --git a/resources/tester/test_b06_pavement_and_ford.py b/resources/tester/test_b06_pavement_and_ford.py index 975cc3fc3..584c0c472 100644 --- a/resources/tester/test_b06_pavement_and_ford.py +++ b/resources/tester/test_b06_pavement_and_ford.py @@ -126,17 +126,42 @@ def test_missing_truth_files_are_not_an_error(tmp_path): # ── 포장 판정 규칙 ───────────────────────────────────────────────────────── -def test_outside_range_keeps_user_value_and_defaults_unpaved(): - """경사 제안은 더 이상 포장을 켜지 않는다 — 구간 밖은 사용자 값(없으면 비포장).""" +def test_outside_range_is_unpaved(): + """포장 = 노면공 · 물넘이 범위만(138-1) — 밖은 비포장(옛 저장값 인자 없음).""" ranges = [(10.0, 20.0)] assert paved_at(30.0, ranges) is False - assert paved_at(30.0, ranges, True) is True - assert paved_at(30.0, ranges, False) is False + assert paved_at(15.0, ranges) is True + assert paved_at(20.02, ranges) is True # 끝 허용오차 = 브라우저 짝 0.02 m + assert paved_at(20.03, ranges) is False + assert paved_at(15.0, []) is False -def test_inside_range_overrides_user_off_switch(): - """구간 안은 강제 포장 — 물넘이는 콘크리트 노면이고 지정 구간은 자동 반영이다.""" - assert paved_at(15.0, [(10.0, 20.0)], False) is True +def test_enforce_turns_paved_on_and_off(monkeypatch): + """재계산은 켬↔끔 양쪽 — 범위 밖 옛 `paved=true` 는 비포장으로 되돌림(138-1).""" + from pathlib import Path + + from B06_Section import B06_Section_Router_Design as design_mod + + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 50.0)]) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) + monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) + monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + monkeypatch.setattr( + design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + ) + sections = [ + {"chainage_m": 20.0, "design": {"paved": False}}, # 범위 안 · 끔 → 켬 + {"chainage_m": 80.0, "design": {"paved": True}}, # 범위 밖 · 켬 → 끔 + {"chainage_m": 30.0, "design": {"paved": True}}, # 그대로 + {"chainage_m": 90.0, "design": {"paved": False}}, # 그대로 + ] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2 + assert [s["design"]["paved"] for s in sections] == [True, False, True, False] + + # 노면공을 다 지워도(범위 없음) 옛 켬은 꺼진다 + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: []) + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2 + assert not any(s["design"]["paved"] for s in sections) # ── 독립 기슭막이 ───────────────────────────────────────────────────────── diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index bc98b3039..b23da94cb 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -447,9 +447,6 @@ export const ui_locales_b2 = { B06_Design_DitchType_Legend: ["측구형식", "Ditch type"], B06_Design_DitchType_Standard: ["일반", "Standard"], B06_Design_DitchType_LType: ["L형", "L-type"], - B06_Design_Paved_Legend: ["포장", "Pavement"], - B06_Design_Paved_On: ["포장", "Paved"], - B06_Design_Paved_Off: ["비포장", "Unpaved"], B06_Design_TwoStage_Legend: ["2단계 경사", "Two-stage slope"], B06_Design_TwoStage_On: ["복합경사", "Compound slope"], B06_Design_TwoStage_Off: ["단경사", "Single slope"], @@ -475,6 +472,7 @@ export const ui_locales_b2 = { "측구를 생성한 경우에만 형식을 선택할 수 있습니다.", "Available only when a ditch is created.", ], + B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"], B06_Design_Paved_Suggested: [ "종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)", "Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",