diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 42dacd1b..590c76c5 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -45,7 +45,14 @@ export interface StandardCrossGroup { /** 표준 횡단면 설정 패널 그룹 키. */ export type StandardCrossKey = "soil" | "rock" | "paved"; -export type StandardCrossSection = Record; +export type StandardCrossSection = Record & { + /** 절토 법정 기울기 판정에 쓸 별표2 줄 — 지반유형(리핑암·발파암) → `soft_rock`/`hard_rock`. + * + * 별표2 는 암을 **경암·연암**으로 가르고 프로그램은 **리핑암·발파암**으로 가르는데 둘을 잇는 + * 문장이 법령·교본에 없다. 그래서 법정 근거가 아니라 **사용자가 고르는 설정**으로 두고 + * 표준단면 설정과 함께 저장한다(2026-09-07 사용자 확정). 없으면 기본값을 쓴다. */ + cut_slope_class?: Record; +}; // 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를 // `@util/common_util_mass_haul_types` 한 곳에 두고 여기서는 재수출만 한다(사본 금지). @@ -77,6 +84,13 @@ export interface SectionContextResponse { /** 이 프로젝트에 **저장된** 표준 횡단면(사용자가 고쳐 확정한 값). 없으면 null. * 위 `standard_cross_section` 은 config 기본값이라 둘은 다른 것이다(2026-09-07). */ stored_standard_cross_section?: StandardCrossSection | null; + /** 절토 비탈 법정 기울기 범위(별표2) — `hard_rock`·`soft_rock`·`soil` → [최소, 최대]. + * 상수를 화면에 복제하지 않으려고 **서버가 준 값을 그대로** 쓴다(2026-09-07). */ + cut_slope_limits?: Record; + /** 지반유형(리핑암·발파암) → 별표2 줄. **사용자가 바꿀 수 있는 설정값**의 기본값이다. */ + cut_slope_class_default?: Record; + /** 절토 기울기 규정이 없는 등급(작업임도). */ + cut_slope_exempt_grades?: string[]; /** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */ rock_boundary_default_offset_m: number; rock_boundary_step_m: number; diff --git a/B06_Section/B06_Section_Cut_Slope_Check.ts b/B06_Section/B06_Section_Cut_Slope_Check.ts new file mode 100644 index 00000000..ba64fa88 --- /dev/null +++ b/B06_Section/B06_Section_Cut_Slope_Check.ts @@ -0,0 +1,137 @@ +/* ============================================================================= + * B06_Section_Cut_Slope_Check.ts + * 절토 비탈 **법정 기울기 검사**(별표2) — 순수 판정만 둔다(그리기는 카드 몫). + * + * 왜 필요한가 — 평면 곡선반경도, 성토사면 길이(5m·기슭막이 의무)도 위반 표시가 있는데 + * **절토 경사만 검사가 없었다**(2026-09-07). 절토 경사비는 표준 횡단면 설정에서 오는 + * 입력값일 뿐이라 별표2 범위를 벗어나도 아무 표시가 없었다. + * + * ⚠ **실효 경사로 보면 안 된다.** 소단이 서면 사면 전체를 하나로 잰 경사가 완만해져 + * **위반이 사라진 것처럼** 보인다(실측: 폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). + * 그래서 `design.cut_slope_segments`(소단을 뺀 **구간별** 경사)를 읽는다. + * + * 기준값·매핑은 **서버가 준 값**을 쓴다(config 상수를 화면에 복제하지 않는다). + * ========================================================================== */ + +import type { CrossSection, SectionContextResponse } from "./B06_Section_Api_Fetch"; + +/** 별표2 한 줄 — 수직 1 에 대한 수평(1:n 의 n) 범위. */ +export interface CutSlopeLimit { + min: number; + max: number; +} + +export interface CutSlopeCriteria { + /** 별표2 범위표 — `hard_rock`·`soft_rock`·`soil`. */ + limits: Record; + /** 지반유형(리핑암·발파암) → 별표2 줄. 사용자가 바꿀 수 있는 **설정값**이다. */ + classOf: Record; + /** 절토 기울기 규정이 없는 등급(작업임도). */ + exemptGrades: ReadonlyArray; +} + +export interface CutSlopeViolation { + side: string; + ratio: number; + /** 어느 별표2 줄로 판정했나 — 툴팁에 적는다. */ + limitKey: string; + limit: CutSlopeLimit; + /** 급한 쪽 위반인지(`steep`) 완만한 쪽 위반인지(`gentle`). */ + kind: "steep" | "gentle"; + startOffsetM: number; + endOffsetM: number; +} + +/** 서버 컨텍스트에서 기준을 꺼낸다. 값이 안 왔으면 null — 검사를 하지 않는다. */ +export function criteriaFrom(context: SectionContextResponse | null): CutSlopeCriteria | null { + const limits = context?.cut_slope_limits; + if (!limits || !Object.keys(limits).length) return null; + const table: Record = {}; + for (const [key, pair] of Object.entries(limits)) { + if (Array.isArray(pair) && pair.length >= 2) table[key] = { min: pair[0], max: pair[1] }; + } + return { + limits: table, + classOf: context?.cut_slope_class_default ?? {}, + exemptGrades: context?.cut_slope_exempt_grades ?? [], + }; +} + +/** + * 이 측점의 절토 구간이 별표2 범위를 벗어났는지. + * + * · 등급이 면제(작업임도)면 빈 목록. + * · 구간이 없으면(성토 측점 등) 빈 목록. + * · 암 구간은 측점의 `cut_rock_kind` 로 별표2 줄을 고른다 — 구간에는 `rock`/`soil` 만 있다. + */ +export function cutSlopeViolations( + section: CrossSection, + criteria: CutSlopeCriteria | null, + gradeClass: string | null, +): CutSlopeViolation[] { + if (!criteria) return []; + if (gradeClass && criteria.exemptGrades.includes(gradeClass)) return []; + const design = section.design as + { cut_slope_segments?: unknown; cut_rock_kind?: string | null } | undefined; + const segments = design?.cut_slope_segments; + if (!Array.isArray(segments) || !segments.length) return []; + + const rockKey = criteria.classOf[design?.cut_rock_kind ?? ""] ?? "hard_rock"; + const out: CutSlopeViolation[] = []; + for (const raw of segments) { + const segment = raw as { + side?: string; + ratio?: number; + material?: string | null; + start_offset_m?: number; + end_offset_m?: number; + }; + const ratio = segment.ratio; + if (typeof ratio !== "number" || !Number.isFinite(ratio)) continue; + const limitKey = segment.material === "rock" ? rockKey : "soil"; + const limit = criteria.limits[limitKey]; + if (!limit) continue; + // 경계값은 통과 — 1:0.8 은 「0.3~0.8」 안이다. + if (ratio >= limit.min - 1e-9 && ratio <= limit.max + 1e-9) continue; + out.push({ + side: segment.side ?? "", + ratio, + limitKey, + limit, + kind: ratio < limit.min ? "steep" : "gentle", + startOffsetM: segment.start_offset_m ?? 0, + endOffsetM: segment.end_offset_m ?? 0, + }); + } + return out; +} + +/** 별표2 줄 이름 — 화면·툴팁에 적는 말. */ +export function limitLabel(key: string): string { + if (key === "hard_rock") return "경암"; + if (key === "soft_rock") return "연암"; + return "토사"; +} + +/* ── 기억해 둔 기준 ────────────────────────────────────────────────────────── + * 카드는 그리는 자리마다 컨텍스트를 들고 있지 않다. 표준단면 기본값을 기억해 두는 것과 + * 같은 방식으로(`rememberStandardDefaults`) 여기 한 번 담아 두고 카드가 읽는다. + * ------------------------------------------------------------------------ */ +let remembered: CutSlopeCriteria | null = null; +let rememberedGrade: string | null = null; + +export function rememberCutSlopeCriteria(context: SectionContextResponse | null): void { + remembered = criteriaFrom(context); + rememberedGrade = context?.road_type ?? null; +} + +/** 사용자가 고른 매핑(리핑암·발파암 → 별표2 줄)을 얹는다. 없으면 기본값 그대로. */ +export function applyCutSlopeClassChoice(choice: Record | null | undefined): void { + if (!remembered || !choice) return; + remembered = { ...remembered, classOf: { ...remembered.classOf, ...choice } }; +} + +/** 지금 측점의 위반 목록 — 기준이 없거나 면제 등급이면 빈 목록. */ +export function violationsOf(section: CrossSection): CutSlopeViolation[] { + return cutSlopeViolations(section, remembered, rememberedGrade); +} diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index ff330c5b..14828de6 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -79,6 +79,9 @@ from config.config_db import get_db_pool, run_with_connection from config.config_system import ( EARTHWORK_CONVERSION_FACTORS, EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, + FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT, + FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES, + FOREST_ROAD_CUT_SLOPE_LIMITS, FOREST_ROAD_MIN_WIDTH_M, NATURAL_SPOIL_MIN_GROUND_SLOPE, SECTION_VERTICAL_EXAGGERATION, @@ -140,6 +143,11 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON ), standard_cross_section=STANDARD_CROSS_SECTION, stored_standard_cross_section=stored_standard, + cut_slope_limits={ + key: list(value) for key, value in FOREST_ROAD_CUT_SLOPE_LIMITS.items() + }, + cut_slope_class_default=dict(FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT), + cut_slope_exempt_grades=list(FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES), rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M, earthwork_conversion=EARTHWORK_CONVERSION_FACTORS, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 9b8dac61..c319150e 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -243,6 +243,11 @@ class SectionContextResponse(BaseModel): # **화면은 config 기본값으로, 서버는 저장분으로** 계산해 같은 측점이 갈렸다. # 기존 `standard_cross_section`(기본값)은 그대로 두고 **한 칸만 더한다**. stored_standard_cross_section: dict[str, Any] | None = None + # 절토 비탈 법정 기울기 범위(별표2)와 지반유형 → 별표2 줄 기본 매핑, 검사 제외 등급. + # 브라우저가 상수를 복제하지 않게 **서버가 준 값을 그대로 기억**한다(표준단면과 같은 방식). + cut_slope_limits: dict[str, list[float]] = Field(default_factory=dict) + cut_slope_class_default: dict[str, str] = Field(default_factory=dict) + cut_slope_exempt_grades: list[str] = Field(default_factory=list) # 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). rock_boundary_default_offset_m: float = -0.5 rock_boundary_step_m: float = 0.1 diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index d7cd8c5c..f716468a 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -13,6 +13,7 @@ import { buildRockBoundaryControl, sectionModeLabel, } from "./B06_Section_UI_Cross_Design"; +import { limitLabel, violationsOf } from "./B06_Section_Cut_Slope_Check"; import { type FillSlopeLength, fillSlopeLengths } from "./B06_Section_UI_Cross_Fit"; import { type DesignChangeHandler, @@ -85,6 +86,26 @@ export function appendCardHeader( openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); meta.append(openSlope); } + // 절토 비탈 법정 기울기(별표2) 위반 — 성토사면·미폐합 경고와 같은 자리·같은 모양이다. + // 소단이 서면 실효 경사가 완만해져 위반이 사라진 것처럼 보이므로, 판정은 **구간별** + // 경사(`cut_slope_segments`)로 한다(2026-09-07). + const cutViolations = violationsOf(section); + if (cutViolations.length) { + const worst = cutViolations[0]; + const badge = document.createElement("span"); + badge.className = "b06-cross-card__warning"; + badge.textContent = `⚠ 절토 1:${worst.ratio.toFixed(2)}`; + badge.title = cutViolations + .map( + (item) => + `${item.side === "left" ? "좌" : "우"} 1:${item.ratio.toFixed(2)}` + + ` — ${limitLabel(item.limitKey)} 법정 1:${item.limit.min}~${item.limit.max}` + + ` 범위 밖(${item.kind === "steep" ? "너무 급함" : "너무 완만함"})` + + ` · ${item.startOffsetM.toFixed(2)}~${item.endOffsetM.toFixed(2)}m`, + ) + .join("\n"); + meta.append(badge); + } const structureName = section.structure; if (structureName) { const structure = document.createElement("span"); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 30ba8b6d..4ff7af2e 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -2,6 +2,7 @@ import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; +import { rememberCutSlopeCriteria } from "./B06_Section_Cut_Slope_Check"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; @@ -647,6 +648,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); // 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다. rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m); + // 절토 비탈 법정 기울기 기준(별표2)도 같은 방식으로 기억해 둔다 — 카드가 읽는다. + rememberCutSlopeCriteria(context); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 9279ab3b..dc670c3d 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -26,6 +26,7 @@ import { } from "../A00_Common/b_page_state"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; +import { applyCutSlopeClassChoice } from "./B06_Section_Cut_Slope_Check"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; import { getCompanyStandard, @@ -396,8 +397,46 @@ export function createStandardPanel( } actions.append(resetButton); + // ── 절토 법정 기울기 판정 기준 (2026-09-07 사용자 확정) ────────────────── + // 별표2 는 암을 **경암·연암**으로 가르고 프로그램은 **리핑암·발파암**으로 가른다. 둘을 + // 잇는 문장이 법령·교본에 없어 **프로그램 설정**으로 두고 여기서 고르게 한다. 기본값은 + // 리핑암 → 연암 · 발파암 → 경암(자연스러운 읽기). 절토 경사비가 오는 자리 옆이라 여기 둔다. + const cutClassWrap = document.createElement("div"); + cutClassWrap.className = "b06-std__cutclass"; + const cutClassTitle = document.createElement("p"); + cutClassTitle.className = "b06-std__cutclass-title"; + cutClassTitle.textContent = "절토 법정 기울기 판정 기준 (별표2)"; + cutClassTitle.title = + "별표2: 경암 1:0.3~0.8 · 연암 1:0.5~1.2 · 토사 1:0.8~1.5. 작업임도는 규정 없음."; + cutClassWrap.append(cutClassTitle); + const CUT_CLASS_OPTIONS = [ + { value: "soft_rock", text: "연암 (1:0.5~1.2)" }, + { value: "hard_rock", text: "경암 (1:0.3~0.8)" }, + ]; + for (const [kind, label] of [ + ["ripping_rock", "리핑암"], + ["blasting_rock", "발파암"], + ] as const) { + const current = + state.cut_slope_class?.[kind] ?? (kind === "ripping_rock" ? "soft_rock" : "hard_rock"); + const field = createSelectField({ + label, + options: CUT_CLASS_OPTIONS, + value: current, + onChange: (value) => { + state.cut_slope_class = { ...(state.cut_slope_class ?? {}), [kind]: value }; + persist(); + applyCutSlopeClassChoice(state.cut_slope_class); + onApplyAll?.(); + }, + }); + cutClassWrap.append(field.root); + } + // 처음 세울 때도 고른 값을 검사에 반영한다(세션에 남아 있던 선택 포함). + applyCutSlopeClassChoice(state.cut_slope_class); + // 횡단 반폭은 사이드의 **별도 컨테이너**로 뺐다(2026-08-23) — 여기는 표준단면 설정만. - root.append(body, loader, actions); + root.append(body, cutClassWrap, loader, actions); return { root, diff --git a/config/config_system_design.py b/config/config_system_design.py index c239ab33..bfb4360d 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -46,6 +46,29 @@ ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "fire": 0.26, "branch": 0.28, "work": 0.40} FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "fire": 12.0, "branch": 10.0, "work": 6.0} +# ───────────────────────────────────────────────────────────────────────── +# 절토 비탈 법정 기울기 (별표2) +# +# 근거 — 지식DB `resources/knowledge/technical_info/01_임도/02_상세설계/절토_비탈면.md` §1. +# 그 문서의 [구현] 줄대로 **판정은 별표2 범위로** 한다(실무 관행값은 후보일 뿐 기준이 아님). +# 값은 수직 1 에 대한 수평(1:n 의 n). +# ───────────────────────────────────────────────────────────────────────── +FOREST_ROAD_CUT_SLOPE_LIMITS = { + "hard_rock": (0.3, 0.8), # 암석지 — 경암 + "soft_rock": (0.5, 1.2), # 암석지 — 연암 + "soil": (0.8, 1.5), # 토사지역 +} +# 프로그램 지반유형(리핑암·발파암) → 별표2 줄(연암·경암). +# **기본값이며 사용자가 바꿀 수 있다**(2026-09-07 사용자 확정 — 「사용자가 제어할 수 있게」). +# 별표2 는 경암·연암으로 가르고 프로그램은 리핑암·발파암으로 가르는데, 둘을 잇는 문장이 +# 법령·교본에 없다. 그래서 법정 근거가 아니라 **프로그램 설정**으로 두고 화면에서 고르게 한다. +FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT = { + "ripping_rock": "soft_rock", + "blasting_rock": "hard_rock", +} +# 절토 기울기 규정이 없는 등급 — 작업임도(별표2 §1 「작업임도: 절토 기울기 표 규정 없음」). +FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES = ("work",) + # 대안(정속경사) 파라미터 ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08")) ROUTE_ALT_MAX_GRADE = float(os.getenv("ROUTE_ALT_MAX_GRADE", "0.14"))