From 80c2a265f923ad383193aee7bbbc7329e9370a78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 28 Aug 2026 12:16:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05,B06):=20=EB=AC=BC=EB=84=98=EC=9D=B4=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=ED=91=9C=ED=98=84=20+=20=ED=8F=AC?= =?UTF-8?q?=EC=9E=A5=20=EA=B5=AC=EA=B0=84=20=EC=88=98=EA=B8=B0=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95,=20=EA=B2=BD=EC=82=AC=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=8F=AC=EC=9E=A5=20=ED=8F=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-28 사용자 확정 스펙. 물넘이포장 — 노면을 판 자리로 그린다 - 서버가 _ford_pavement_set(월류 폭·월류 높이·바닥 경사)을 만들고 물넘이만 span 연동을 켜 **범위 안 측점 전부**에 얹는다. 깊이가 없으면 None으로 두어 화면이 그리지 않는다(수치를 지어내지 않는다). - 횡단도: 기존 계획고 점선 + 물넘이 바닥 실선 + 진한 회색 빗금 포장. 깊이는 노선 중심 기준, 바닥은 유입(상단측)이 높게 기운다. 경사를 비우면 그 측점의 노면 횡단경사를 쓴다. - 물넘이 폼에 "바닥 경사 유입→유출(%)" 칸을 추가하고, 월류 폭 기본값 리터럴을 config_frontend 상수로 모아 서버 값과 짝지었다. 포장 — 사용자가 구간으로 지정한다 - 구조물 레지스트리 G군 pavement_concrete 를 되살려 기준측점 + 길이 + 전/후로 받는다(기슭막이와 같은 폼). 길이 기본값은 0 = 미지정. - pavement_ranges/paved_at 이 구간을 판정하고, enforce_pavement_ranges 가 저장분이 비포장이어도 구간 안이면 포장으로 다시 계산한다(사용자 조작값 승계). - **종단경사 자동 포장 적용을 없앴다** — paved=suggested 3곳 제거. 별표1-2 상한 초과 경고(pavement_suggested 배지·근거 문구)는 그대로 남는다. - 포장 구간이 물넘이를 통째로 품으면 모달로 알리고 앞/뒤로 나눠 저장하고, 끝만 걸치면 값을 고치라고 안내하고 멈춘다. 700줄 제한: _compute_default_designs 를 Router_Design 으로 옮겼다(657/306줄). 검증: pytest 238 passed / 7 skipped(신규 9건). 공용 브라우저 실측 — 물넘이 임시 투입 시 240m 카드에만 파임 3요소가 그려지고(일반 포장 박스 0), 바닥이 계획고보다 0.4m 아래·노면 전폭 4.0m에 1.5% 기울기, 220·260은 비포장 유지. 겹침 규칙은 브라우저에서 모듈을 직접 불러 3분할·안내·취소를 확인했다. 실측용 임시 관 지점은 매번 원래 정본 4건으로 복구했다. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 30 +++- .../B05_Profile_UI_Drainage_Facility.ts | 20 ++- .../B05_Profile_UI_Structures_Panel.ts | 27 ++- .../B05_Profile_UI_Structures_Pavement.ts | 78 +++++++++ B06_Section/B06_Section_Api_Fetch.ts | 6 + B06_Section/B06_Section_Engine_Culvert.py | 44 ++++- B06_Section/B06_Section_Router.py | 64 ++----- B06_Section/B06_Section_Router_Design.py | 165 +++++++++++++++++- .../B06_Section_UI_Cross_Ford_Pavement.ts | 122 +++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 12 +- .../B06_Section_UI_Style_Cross_Areas.css | 26 +++ config/config_frontend.ts | 6 + 12 files changed, 535 insertions(+), 65 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Pavement.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 7545eb2c..5a150485 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -296,6 +296,14 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false } ] }, @@ -1300,7 +1308,6 @@ }, { "type_id": "pavement_concrete", - "enabled": false, "group": "G", "name": "콘크리트 포장", "placement": "interval", @@ -1310,6 +1317,27 @@ }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 0 + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 0 + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 0 + }, { "key": "thickness_cm", "label": "두께", diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 1c19c4e4..9ad5659c 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -26,6 +26,10 @@ * 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다. * ========================================================================== */ +import { + FORD_BRIDGE_DEFAULT_WIDTH_M, + FORD_PAVEMENT_DEFAULT_WIDTH_M, +} from "@config/config_frontend"; import type { DetailPipeInput, PipeFacility, @@ -266,6 +270,11 @@ export function createFacilityOptionsForm( labeled("월류 폭 (m)", fordWidth), labeled("월류 높이 (m)", fordHeight), ); + // 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정). + // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. + const fordSlope = numberInput("0.1"); + fordSlope.placeholder = "노면 기울기"; + const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", fordSlope)); const fordSummary = document.createElement("p"); fordSummary.className = "b05-drainage__facility-note"; /** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */ @@ -324,6 +333,7 @@ export function createFacilityOptionsForm( wingInFields.root, wingOutFields.root, fordWidthRow, + fordSlopeRow, fordRow, fordSummary, ); @@ -347,6 +357,8 @@ export function createFacilityOptionsForm( const isFord = current === "ford_pavement" || current === "ford_bridge"; fordRow.hidden = current !== "ford_bridge"; fordWidthRow.hidden = !isFord; + // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. + fordSlopeRow.hidden = current !== "ford_pavement"; fordSummary.hidden = !isFord; if (isFord) syncFordSummary(); if (isBox) syncBoxSize(); @@ -438,9 +450,12 @@ export function createFacilityOptionsForm( fordCount.value = isFord ? text("pipe_count") : ""; fordWidth.value = text("ford_width_m"); fordHeight.value = text("ford_height_m"); - // 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정). + fordSlope.value = text("ford_slope_pct"); + // 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처). if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) { - fordWidth.value = facility === "ford_bridge" ? "10" : "5"; + fordWidth.value = String( + facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M, + ); } syncVisibility(); }, @@ -472,6 +487,7 @@ export function createFacilityOptionsForm( } else if (current === "ford_pavement") { putNumber(options, "ford_width_m", fordWidth.value); putFordHeight(options); + putNumber(options, "ford_slope_pct", fordSlope.value); } else if (current === "ford_bridge") { options.pipe_kind = pipeMaterial.value; options.pipe_diameter_mm = Number(pipeDiameter.value); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index fcb6912e..2b05d9bc 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -28,6 +28,7 @@ import { } from "./B05_Profile_UI_Drainage_Facility"; import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; +import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { formatStation } from "./B05_Profile_Util_Station"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. @@ -645,7 +646,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu callbacks.onChange([...structures]); } - function commit(): void { + async function commit(): Promise { const type = currentType(); if (!type) return; const step = interval(); @@ -745,11 +746,29 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu geometry: null, }; + // 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다 + // (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다. + const spans = + type.type_id === "pavement_concrete" && start !== null && end !== null + ? await splitPavementRange(start, end, pipeFacilities) + : [{ start, end }]; + if (!spans) return; + const records = spans.map((span) => ({ + ...base, + start_m: span.start, + end_m: span.end, + chainage_m: + span.start === null || span.end === null + ? anchor + : Math.min(Math.max(anchor as number, span.start), span.end), + })); + if (editingId) { const index = structures.findIndex((entry) => entry.structure_id === editingId); - if (index >= 0) structures[index] = { ...structures[index], ...base }; + if (index >= 0) structures[index] = { ...structures[index], ...records[0] }; + records.slice(1).forEach((record) => structures.push({ ...record, structure_id: null })); } else { - structures.push({ ...base, structure_id: null }); + records.forEach((record) => structures.push({ ...record, structure_id: null })); } loadForm(null); emit(); @@ -781,7 +800,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu callbacks.onSelect(null); } }); - primary.addEventListener("click", commit); + primary.addEventListener("click", () => void commit()); removeButton.addEventListener("click", () => { // 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18). if (tempPipeChainage !== null) { diff --git a/B05_Profile/B05_Profile_UI_Structures_Pavement.ts b/B05_Profile/B05_Profile_UI_Structures_Pavement.ts new file mode 100644 index 00000000..a2c43555 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Pavement.ts @@ -0,0 +1,78 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Pavement.ts + * 포장 구간과 물넘이포장의 겹침 정리(2026-08-28 사용자 확정). + * + * 물넘이포장은 그 자체가 콘크리트 노면이라 포장 구간이 그 위를 덮을 수 없다. + * · 포장 구간이 물넘이를 **통째로 품으면** → 알리고 앞·뒤 두 구간으로 나눈다 + * (물넘이가 가운데 한 구간 — 합쳐서 3구간). + * · **끝만 걸치면** → 어디까지 겹치는지 알리고 저장을 멈춘다. 사용자가 값을 고친다. + * ========================================================================== */ + +import { FORD_PAVEMENT_DEFAULT_WIDTH_M } from "@config/config_frontend"; +import { showConfirmDialog } from "@ui/ui_template_elements"; +import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel"; + +/** 누가거리 비교 허용오차(m) — 정본이 0.01m로 끊어 쓴다. */ +const EPS = 0.005; + +export interface Span { + start: number; + end: number; +} + +/** 물넘이포장이 차지하는 누가거리 구간 — 기준 측점 ± 월류 폭/2. */ +export function fordSpans(pipes: PipeFacilityItem[]): Span[] { + return pipes + .filter((pipe) => pipe.facility === "ford_pavement") + .map((pipe) => { + const raw = Number(pipe.options?.ford_width_m); + const width = Number.isFinite(raw) && raw > 0 ? raw : FORD_PAVEMENT_DEFAULT_WIDTH_M; + return { start: pipe.chainage_m - width / 2, end: pipe.chainage_m + width / 2 }; + }) + .sort((left, right) => left.start - right.start); +} + +const format = (value: number): string => `${value.toFixed(2)}m`; + +/** + * 포장 구간에서 물넘이 몫을 덜어낸다. + * 반환: 저장할 구간 목록. `null`이면 저장하지 않는다(사용자가 값을 고쳐야 한다). + */ +export async function splitPavementRange( + start: number, + end: number, + pipes: PipeFacilityItem[], +): Promise { + const overlaps = fordSpans(pipes).filter( + (ford) => ford.end > start + EPS && ford.start < end - EPS, + ); + if (!overlaps.length) return [{ start, end }]; + + // 끝만 걸친 물넘이 — 자르면 사용자가 찍은 구간이 조용히 줄어든다. 값을 고치게 안내한다. + const partial = overlaps.find((ford) => ford.start <= start + EPS || ford.end >= end - EPS); + if (partial) { + await showConfirmDialog( + `지정한 포장 구간(${format(start)}~${format(end)})이 물넘이포장` + + `(${format(partial.start)}~${format(partial.end)})의 끝에 걸칩니다.\n` + + "물넘이는 그 자체가 포장이라 겹칠 수 없습니다 — 기준 측점·길이·전후 값을 고쳐 주세요.", + "확인", + ); + return null; + } + + const segments: Span[] = []; + let cursor = start; + for (const ford of overlaps) { + if (ford.start - cursor > EPS) segments.push({ start: cursor, end: ford.start }); + cursor = ford.end; + } + if (end - cursor > EPS) segments.push({ start: cursor, end }); + + const inside = overlaps.map((ford) => `${format(ford.start)}~${format(ford.end)}`).join(", "); + const proceed = await showConfirmDialog( + `포장 구간 안에 물넘이포장(${inside})이 있습니다.\n` + + `물넘이는 그 자체가 포장이라 그 몫을 빼고 ${segments.length}개 구간으로 나눠 저장합니다.`, + "나눠 저장", + ); + return proceed ? segments : null; +} diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 8d2d1d48..581d7c01 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -67,6 +67,10 @@ export type { BalloonOffsets, } from "@util/common_util_mass_haul_types"; +/** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */ +import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement"; +export type { FordPavementSpec }; + export interface SectionContextResponse { project_id: string; route_id: number | null; @@ -276,6 +280,8 @@ export interface CrossSection extends SectionStation { ford?: FordSet; /** BOX암거 구체가 걸치는 측점의 세트 제원(있을 때만). */ box?: BoxSet; + /** 물넘이포장이 파는 노면 제원(있을 때만). 월류 폭 안의 측점 전부에 붙는다. */ + ford_pavement?: FordPavementSpec; } export interface SectionDetailResponse { diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 3745c52c..dec6cd75 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -28,6 +28,7 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_BOX, PIPE_FACILITY_FORD_BRIDGE, + PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_PIPE, parse_pipe_points, ) @@ -42,12 +43,17 @@ logger = logging.getLogger(__name__) # 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. _CHAINAGE_TOLERANCE_M = 0.02 +# 세트 종류 -> 횡단 dict 키. 그림이 다른 시설끼리 소비처가 섞이지 않게 나눠 둔다. +_SECTION_KEYS = {"ford": "ford", "box": "box", "ford_pavement": "ford_pavement"} + # 구조물 종류별 **인접 측점 연동**(2026-08-25 사용자 확정). 이름을 넣으면 도로 방향 # 폭(`span_m`)의 절반만큼 앞뒤 측점에도 같은 스펙이 붙어 횡단도가 한 벌 더 그려진다. -# 지금은 전부 끔 — 세월교·BOX암거 모두 소유 측점 한 곳에만 선다. 3D는 그 측점에서 -# `span_m` 전체를 스윕하므로 길이가 줄지 않는다(인접 측점 기준으로 자르지 않는다). +# 세월교·BOX암거는 끔 — 소유 측점 한 곳에만 선다. 3D는 그 측점에서 `span_m` 전체를 +# 스윕하므로 길이가 줄지 않는다(인접 측점 기준으로 자르지 않는다). +# 물넘이포장만 켠다 — 노면을 월류 폭만큼 파는 시설이라 **범위 안 모든 횡단도**에 +# 파임이 그려져야 한다(2026-08-28 사용자 확정). # ※ 기슭막이 연동은 이 경로가 아니다 — 프론트 `culvertOwnerFor`가 따로 판정한다. -_SPAN_LINKED_TYPES: frozenset[str] = frozenset() +_SPAN_LINKED_TYPES: frozenset[str] = frozenset({"ford_pavement"}) # ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2 # 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다. @@ -75,6 +81,9 @@ FORD_SLAB_THICKNESS_M = 0.3 FORD_WALL_THICKNESS_M = 0.2 # 월류 폭 기본값(m) — B05 폼이 세월교에 채우는 값과 같다. FORD_DEFAULT_WIDTH_M = 10.0 +# 물넘이포장 월류 폭 기본값(m) — 같은 폼이 물넘이에 채우는 값(2026-08-18 사용자 확정, +# 지식DB 폭 수치 근거 없음). +FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0 # BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한다 # (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다. @@ -261,6 +270,27 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]: } +def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: + """물넘이포장 1개소 — 노면을 월류 폭만큼, 노선 중심에서 월류 높이만큼 판 자리. + + 구조물을 얹는 것이 아니라 **계획고를 파낸 노면**이라 다른 세트와 형상이 다르다. + 바닥은 유입(상류) 쪽이 높고 유출 쪽이 낮게 기운다 — 경사를 사용자가 비워 두면 + 화면이 노면 일반 기울기로 채운다(2026-08-28 사용자 확정). 파임 폭은 노견까지 전폭. + """ + defaults = _registry_defaults("ford_pavement") + values = dict(options or {}) + width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None)) + depth = _number(values.get("ford_height_m"), None) + slope = _number(values.get("ford_slope_pct"), None) + return { + "type": "ford_pavement", + "span_m": width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M, + # 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + "depth_m": depth if depth and depth > 0 else None, + "slope_pct": slope, + } + + def _box_set(options: dict[str, Any] | None) -> dict[str, Any]: """BOX암거 1개소의 세트 제원(구체 + 날개벽 연장). @@ -305,13 +335,15 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: return {} sets: dict[float, dict[str, Any]] = {} for point in parse_pipe_points(document.get("points")): - # 물넘이포장은 그림이 다르다 — 배관·세월교·BOX암거만 그린다. if point.facility == PIPE_FACILITY_PIPE: spec = _culvert_set(point.options) elif point.facility == PIPE_FACILITY_FORD_BRIDGE: spec = _ford_set(point.options) elif point.facility == PIPE_FACILITY_BOX: spec = _box_set(point.options) + elif point.facility == PIPE_FACILITY_FORD_PAVEMENT: + # 구조물이 아니라 파인 노면이다 — 스펙 모양도 소비처도 다르다(2026-08-28). + spec = _ford_pavement_set(point.options) else: continue sets[round(float(point.chainage_m), 2)] = spec @@ -334,9 +366,9 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]] if spec.get("type") in _SPAN_LINKED_TYPES: reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2 if abs(chainage - pipe_chainage) <= reach: - # 세월교·BOX암거는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. + # 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. kind = spec.get("type") - section["ford" if kind == "ford" else "box" if kind == "box" else "culvert"] = spec + section[_SECTION_KEYS.get(str(kind), "culvert")] = spec attached += 1 break return attached diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 713f2522..d7d7e4f4 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -41,6 +41,10 @@ from B06_Section.B06_Section_Router_Design import ( ) from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, + compute_default_designs as _compute_default_designs, + enforce_pavement_ranges as _enforce_pavement_ranges, + pavement_ranges as _pavement_ranges, + paved_at as _paved_at, ) from B06_Section.B06_Section_Router_Design import ( default_section_modes as _default_section_modes, @@ -302,10 +306,20 @@ async def get_section_detail( if abs(float(section.get("chainage_m", 0.0)) - record["chainage_m"]) < 0.01: section["design"] = record["design"] break + # 포장 구간·물넘이 범위는 저장분이 비포장이어도 포장으로 맞춘다(2026-08-28). + await asyncio.to_thread( + _enforce_pavement_ranges, + detail["longitudinal"], + detail["cross_sections"], + project_root, + ) # 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다. # (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.) await asyncio.to_thread( - _attach_default_designs, detail["longitudinal"], detail["cross_sections"] + _attach_default_designs, + detail["longitudinal"], + detail["cross_sections"], + project_root, ) return SectionDetailResponse(**detail, balloon_offsets=_read_balloon_offsets(longitudinal)) except FileNotFoundError as exc: @@ -510,6 +524,7 @@ async def preview_cross_designs( designs, request.standard_cross_section, request.rock_boundary_offsets, + project_root, ) await asyncio.to_thread(rebuild) @@ -548,53 +563,6 @@ async def preview_cross_designs( ) -def _compute_default_designs( - project_root: Path, - longitudinal_file_path: str, - chainages: list[float], - standard: dict[str, Any] | None = None, -) -> list[tuple[float, dict[str, Any]]]: - """미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다. - - standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다. - 계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함). - """ - root = project_root.resolve() - longitudinal_path = (root / longitudinal_file_path).resolve() - if root not in longitudinal_path.parents or not longitudinal_path.is_file(): - return [] - longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) - cross_dir = longitudinal_path.parent.parent / "cross_sections" - default_modes = _default_section_modes(longitudinal) - pavement = _pavement_suggestions(longitudinal) - results: list[tuple[float, dict[str, Any]]] = [] - for chainage_m in chainages: - cross_path = cross_dir / cross_filename(chainage_m) - if not cross_path.is_file(): - continue - try: - cross = json.loads(cross_path.read_text(encoding="utf-8")) - samples = cross.get("samples") - if not isinstance(samples, list): - continue - suggested = pavement.get(round(chainage_m, 3), False) - design = compute_cross_design( - samples, - design_elevation_from_longitudinal(longitudinal, chainage_m), - ground_type="ripping_rock", - section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), - paved=suggested, - standard=standard, - rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, - ) - design["status"] = "provisional" - design["pavement_suggested"] = suggested - results.append((chainage_m, design)) - except (ValueError, KeyError, OSError, json.JSONDecodeError): - continue - return results - - @router.post("/{project_id}/sections/{route_id}/cross-design", response_model=CrossDesignResponse) async def compute_cross_section_design( project_id: UUID, route_id: int, request: CrossDesignRequest diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index dc9a5eaf..2ef4d800 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -1,14 +1,20 @@ """B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산.""" import json +import logging from pathlib import Path from typing import Any from B05_Profile.B05_Profile_Engine_Sections import cross_filename +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 from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M +logger = logging.getLogger(__name__) + PREVIEW_DESIGN_FIELDS = ( "ground_type", "roadbed_width_m", @@ -32,6 +38,104 @@ def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]: return mapping +def pavement_ranges(project_root: Path) -> list[tuple[float, float]]: + """포장으로 볼 누가거리 구간 — 구조물 정본 G군 + 물넘이포장(포장 필수). + + 포장 지정은 **사용자가 구간으로 준다**(2026-08-28 사용자 확정). 종단경사 자동 판정은 + 더 이상 포장을 켜지 않는다 — 경고 표기(`pavement_suggested`)만 남는다. + 물넘이포장은 콘크리트 등으로 노면을 만드는 시설이라 범위 안이 항상 포장이다. + 읽기 실패는 비치명 — 빈 목록이면 사용자 지정이 없는 것과 같다. + """ + ranges: list[tuple[float, float]] = [] + try: + types = structure_type_map() + for structure in load_structures(str(project_root))[1]: + definition = types.get(structure.type_id) + if definition is None or definition.group != "G": + continue + anchor = structure.chainage_m + start = structure.start_m if structure.start_m is not None else anchor + end = structure.end_m if structure.end_m is not None else anchor + if start is None or end is None: + continue + ranges.append((min(float(start), float(end)), max(float(start), float(end)))) + except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 + logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)") + for chainage, spec in load_culvert_sets(project_root).items(): + if spec.get("type") != "ford_pavement": + continue + half = float(spec.get("span_m") or 0.0) / 2.0 + ranges.append((chainage - half, chainage + half)) + return ranges + + +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 + + +# 사용자 조작값 — 포장 강제로 다시 계산해도 그대로 승계한다. +_USER_TOUCHED_KEYS = ( + "display_half_width_m", + "inlet_structure", + "basin_adjust", + "revet_adjust", + "ford_adjust", + "box_adjust", + "extra_wall_counts", + "revet_link_detached", + "revet_follow_grade", +) + + +def enforce_pavement_ranges( + longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], project_root: Path +) -> int: + """포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다. + + 사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정). + 포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다. + 사용자 조작값(반폭·구조물 조정)은 그대로 승계한다. + """ + ranges = pavement_ranges(project_root) + if not ranges: + return 0 + changed = 0 + for section in cross_sections: + design = section.get("design") + if not isinstance(design, dict) or design.get("paved"): + continue + chainage = float(section.get("chainage_m", 0.0)) + if not paved_at(chainage, ranges): + continue + try: + recomputed = compute_cross_design( + section.get("samples", []), + design_elevation_from_longitudinal(longitudinal, chainage), + ground_type=str(design.get("ground_type") or "ripping_rock"), + 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, + rock_boundary_offset_m=design.get( + "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M + ), + two_stage_slope=bool(design.get("two_stage_slope", True)), + ditch_enabled=design.get("ditch_enabled"), + ) + except (ValueError, KeyError): + continue + for key in ("status", "pavement_suggested", *_USER_TOUCHED_KEYS): + if design.get(key) is not None: + recomputed[key] = design[key] + section["design"] = recomputed + changed += 1 + return changed + + def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: mapping: dict[float, str] = {} stations = longitudinal.get("stations") @@ -66,10 +170,13 @@ def read_cross_design_inputs( def attach_default_designs( - longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]] + longitudinal: dict[str, Any], + cross_sections: list[dict[str, Any]], + project_root: Path | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) + paved_ranges = pavement_ranges(project_root) if project_root else [] for section in cross_sections: if section.get("design"): continue @@ -81,7 +188,8 @@ def attach_default_designs( design_elevation_from_longitudinal(longitudinal, chainage), ground_type="ripping_rock", section_mode=modes.get(round(chainage, 3), "left_cut"), - paved=suggested, + # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). + paved=paved_at(chainage, paved_ranges), rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, ) design.update(status="provisional", pavement_suggested=suggested) @@ -96,9 +204,11 @@ def recompute_designs_for_alignment( stored_designs: list[dict[str, Any]], standard: dict[str, Any] | None, rock_boundary_offsets: dict[str, float] | None = None, + project_root: Path | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) + paved_ranges = pavement_ranges(project_root) if project_root else [] stored_by_chainage = { round(float(record["chainage_m"]), 3): (record.get("design") or {}) for record in stored_designs @@ -122,7 +232,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=bool(stored.get("paved", suggested)), + paved=paved_at(chainage, paved_ranges, stored.get("paved")), standard=standard, rock_boundary_offset_m=session_offsets.get( key, @@ -145,3 +255,52 @@ def recompute_designs_for_alignment( if stored.get("box_adjust") is not None: design["box_adjust"] = stored["box_adjust"] section["design"] = design + + +def compute_default_designs( + project_root: Path, + longitudinal_file_path: str, + chainages: list[float], + standard: dict[str, Any] | None = None, +) -> list[tuple[float, dict[str, Any]]]: + """미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다. + + standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다. + 계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함). + """ + root = project_root.resolve() + longitudinal_path = (root / longitudinal_file_path).resolve() + if root not in longitudinal_path.parents or not longitudinal_path.is_file(): + return [] + longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + cross_dir = longitudinal_path.parent.parent / "cross_sections" + default_modes = default_section_modes(longitudinal) + pavement = pavement_suggestions(longitudinal) + paved_ranges = pavement_ranges(root) + results: list[tuple[float, dict[str, Any]]] = [] + for chainage_m in chainages: + cross_path = cross_dir / cross_filename(chainage_m) + if not cross_path.is_file(): + continue + try: + cross = json.loads(cross_path.read_text(encoding="utf-8")) + samples = cross.get("samples") + if not isinstance(samples, list): + continue + suggested = pavement.get(round(chainage_m, 3), False) + design = compute_cross_design( + samples, + design_elevation_from_longitudinal(longitudinal, chainage_m), + ground_type="ripping_rock", + section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), + # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). + paved=paved_at(chainage_m, paved_ranges), + standard=standard, + rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + ) + design["status"] = "provisional" + design["pavement_suggested"] = suggested + results.append((chainage_m, design)) + except (ValueError, KeyError, OSError, json.JSONDecodeError): + continue + return results diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts new file mode 100644 index 00000000..865f3036 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Ford_Pavement.ts + * 물넘이포장 — 계획고를 파낸 노면을 횡단도에 그린다(2026-08-28 사용자 확정). + * + * 다른 시설은 구조물을 얹지만 물넘이는 **노면 자체가 내려앉는다**. 그래서 그림도 다르다. + * · 파임 범위 = 노견까지 **전폭**. + * · 깊이 = **노선 중심**에서 잰 월류 높이(`depth_m`). + * · 바닥은 **유입(상류)이 높고 유출이 낮게** 기운다. 경사를 비우면 그 측점의 노면 + * 횡단경사(`cross_slope_pct`)를 쓴다 — 사용자가 폼에서 바꿀 수 있다. + * · 기존 계획고는 **점선**, 물넘이 바닥은 **실선**. + * · 포장 필수 — 일반 포장층과 구분되게 진한 회색 + 빗금으로 채운다. + * + * 유입측 판정은 `section_mode`(상단측 절토 = 등고가 높은 쪽 = 상류)를 따른다. + * ========================================================================== */ + +import type { CrossDesign } from "./B06_Section_Api_Fetch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */ +let hatchSeq = 0; + +/** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */ +export interface FordPavementSpec { + span_m: number; + /** 노선 중심에서 잰 파임 깊이(m). 없으면 그리지 않는다 — 수치를 지어내지 않는다. */ + depth_m: number | null; + /** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */ + slope_pct: number | null; +} + +interface Edge { + offset_m: number; + elevation_m: number; +} + +/** 유입측(상류) 부호 — 그 방향으로 갈수록 바닥이 높아진다. */ +function inflowSign(design: CrossDesign, offsetM: number): number { + const inflowLeft = design.section_mode !== "right_cut"; + const towardLeft = offsetM > 0; + return towardLeft === inflowLeft ? 1 : -1; +} + +function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number { + const depth = spec.depth_m ?? 0; + const slope = (spec.slope_pct ?? design.cross_slope_pct) / 100; + const center = design.design_elevation_m ?? edge.elevation_m; + return center - depth + slope * Math.abs(edge.offset_m) * inflowSign(design, edge.offset_m); +} + +function line(points: string[], className: string): SVGPolylineElement { + const polyline = document.createElementNS(SVG_NS, "polyline"); + polyline.setAttribute("points", points.join(" ")); + polyline.setAttribute("class", className); + return polyline; +} + +/** + * 물넘이 파임을 그린다. 그렸으면 true — 호출부는 일반 포장층 박스를 건너뛴다 + * (같은 자리에 두 겹으로 깔리면 색 구분이 사라진다). + */ +export function appendFordPavementOverlay( + svg: SVGElement, + spec: FordPavementSpec | undefined, + design: CrossDesign, + x: (offset: number) => number, + y: (elevation: number) => number, +): boolean { + if (!spec || !spec.depth_m || !design.road_edges) return false; + const { left, right } = design.road_edges; + const bottomLeft = bottomAt(design, spec, left); + const bottomRight = bottomAt(design, spec, right); + + // ① 기존 계획고(점선) — 파기 전 노면이 어디였는지 남긴다. + svg.append( + line( + [ + `${x(left.offset_m)},${y(left.elevation_m)}`, + `${x(right.offset_m)},${y(right.elevation_m)}`, + ], + "b06-chart__ford-deck-plan", + ), + ); + + // ② 포장층 — 바닥선에서 두께만큼 아래로. 일반 포장과 색·빗금으로 구분한다. + const thickness = design.pavement_thickness_m ?? 0.2; + const hatchId = `b06-ford-hatch-${(hatchSeq += 1)}`; + const defs = document.createElementNS(SVG_NS, "defs"); + const pattern = document.createElementNS(SVG_NS, "pattern"); + pattern.setAttribute("id", hatchId); + pattern.setAttribute("patternUnits", "userSpaceOnUse"); + pattern.setAttribute("width", "6"); + pattern.setAttribute("height", "6"); + const stroke = document.createElementNS(SVG_NS, "path"); + stroke.setAttribute("d", "M0,6 L6,0"); + stroke.setAttribute("class", "b06-chart__ford-pavement-hatch"); + pattern.append(stroke); + defs.append(pattern); + svg.append(defs); + + const polygon = document.createElementNS(SVG_NS, "polygon"); + polygon.setAttribute( + "points", + [ + `${x(left.offset_m)},${y(bottomLeft)}`, + `${x(right.offset_m)},${y(bottomRight)}`, + `${x(right.offset_m)},${y(bottomRight - thickness)}`, + `${x(left.offset_m)},${y(bottomLeft - thickness)}`, + ].join(" "), + ); + polygon.setAttribute("class", "b06-chart__ford-pavement"); + polygon.setAttribute("fill", `url(#${hatchId})`); + svg.append(polygon); + + // ③ 물넘이 바닥(실선) — 횡단 기준선이라 가장 위에 올린다. + svg.append( + line( + [`${x(left.offset_m)},${y(bottomLeft)}`, `${x(right.offset_m)},${y(bottomRight)}`], + "b06-chart__ford-deck", + ), + ); + return true; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index bd60d90d..b1ef5754 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -14,6 +14,7 @@ import { buildAreaReadout, type CrossAreaKey, } from "./B06_Section_UI_Cross_Areas"; +import { appendFordPavementOverlay } from "./B06_Section_UI_Cross_Ford_Pavement"; import { appendCrossDesignOverlay, appendPavementOverlay, @@ -430,7 +431,16 @@ export function createCrossSectionCard( const boxLayout = computeBoxLayout(section, section.samples, bodies.boxAdjust()); const fordLayout = computeFordLayout(section, section.samples, bodies.fordAdjust()); // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. - appendPavementOverlay(plotLayer, section.design, x, toDisplayY); + // 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반 + // 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다). + const fordPaved = appendFordPavementOverlay( + plotLayer, + section.ford_pavement, + section.design, + x, + toDisplayY, + ); + if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY); appendCrossDesignOverlay( plotLayer, section.design, diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 4813878c..2c2dfdcb 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -164,6 +164,32 @@ stroke-width: 1; } +/* 물넘이포장(2026-08-28): 파인 노면 — 기존 계획고 점선 + 바닥 실선 + 진한 회색 빗금 포장 */ +.b06-chart__ford-deck-plan { + fill: none; + stroke: var(--color-text-secondary); + stroke-width: 1; + stroke-dasharray: 5 4; +} + +.b06-chart__ford-deck { + fill: none; + stroke: var(--color-text-primary); + stroke-width: 1.8; + stroke-linejoin: round; +} + +.b06-chart__ford-pavement { + stroke: var(--color-text-primary); + stroke-width: 1; +} + +.b06-chart__ford-pavement-hatch { + stroke: color-mix(in srgb, var(--color-text-primary) 70%, transparent); + stroke-width: 1.2; + fill: none; +} + /* 배수관 세트(2026-08-19): 배관·기슭막이·돌붙임 — 분리 도형 3종 + 집수정 라벨 */ /* 관은 실무 도면처럼 점선 윤곽 2줄 느낌 — 채움은 식별용으로만 아주 옅게. */ .b06-chart__culvert-pipe { diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 699f83c1..ec63566f 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -147,6 +147,12 @@ export const PIPE_DEFAULT_TYPE = "파형강관"; /** 자동 지정 기본 관경 — 별표2 (나) 예외 하한 800mm. 유효직경이 더 크면 바로 위 규격. */ export const PIPE_DEFAULT_DIAMETER_MM = 800; +/** 월류 폭 기본값(m) — 세월교 10m·물넘이포장 5m (2026-08-18 사용자 확정, 지식DB 근거 없음). + * 백엔드 `B06_Section_Engine_Culvert.FORD_DEFAULT_WIDTH_M`·`FORD_PAVEMENT_DEFAULT_WIDTH_M`과 + * 같은 값이어야 한다. */ +export const FORD_BRIDGE_DEFAULT_WIDTH_M = 10; +export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 5; + /** 물넘이 개수로 조도계수 — KDS 표 2.7-1 콘크리트 수로 "보통"값이자 실무 물넘이 관측치. * 백엔드 `config_system.FORD_MANNING_N`과 같은 값이어야 한다. */ export const FORD_MANNING_N = 0.017;