From 7b453d7a46ccd002449f71aab8ed7f884165080e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 24 Jul 2026 20:47:22 +0900 Subject: [PATCH] =?UTF-8?q?B06=5F=EA=B0=9C=EC=84=A0=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 2 + .../B05_wf2_Route_Engine_Sections.py | 61 ++++ .../B05_wf2_Route_Engine_Sections_Core.py | 19 ++ B05_wf2_Route/B05_wf2_Route_Router.py | 55 ++- B05_wf2_Route/B05_wf2_Route_Schema.py | 13 +- B05_wf2_Route/B05_wf2_Route_UI_Markers.ts | 35 ++ B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 121 ++++++- .../B06_wf3_ProfileCross_Api_Fetch.ts | 75 ++++- .../B06_wf3_ProfileCross_Engine_Design.py | 317 +++++++++++++++--- .../B06_wf3_ProfileCross_Repository.py | 87 +++++ .../B06_wf3_ProfileCross_Router.py | 111 +++++- .../B06_wf3_ProfileCross_Schema.py | 28 ++ .../B06_wf3_ProfileCross_UI_Cross_Design.ts | 204 ++++++++++- .../B06_wf3_ProfileCross_UI_Cross_View.ts | 18 +- .../B06_wf3_ProfileCross_UI_Page.ts | 200 +++++++---- .../B06_wf3_ProfileCross_UI_Section_View.ts | 9 +- .../B06_wf3_ProfileCross_UI_Standard_Panel.ts | 246 ++++++++++++++ .../B06_wf3_ProfileCross_UI_Style.css | 107 ++++++ config/config_system.py | 82 ++++- ui_template/ui_template_locale.ts | 37 ++ 20 files changed, 1655 insertions(+), 172 deletions(-) create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 55afb957..60637049 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -227,6 +227,8 @@ export interface RouteConfirmRequest { smooth?: boolean; surface_model_id?: number; irregular_stations?: Array<{ chainage_m: number; structure: string }>; + /** 측점 상단측(=측구 방향) 사용자 변경분 — 3D 램프 클릭으로 지정. */ + uphill_overrides?: Array<{ chainage_m: number; side: "left" | "right" }>; } /** 프로젝트의 최신 경로를 확정한다. 비정규 측점이 있으면 그 횡단까지 생성한다. */ diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index 7765e34b..56046154 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -19,6 +19,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import ( ) from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler from common_util.common_util_json import atomic_write_json +from config.config_system import FOREST_ROAD_PROFILE_CRITERIA logger = logging.getLogger(__name__) @@ -109,6 +110,61 @@ def _append_design_profiles( return {"id": profile["id"], **profile["summary"]} +def _annotate_pavement_suggestions( + longitudinal: dict[str, Any], grade_options: GradeDesignOptions | None +) -> None: + """계획선 국소 종단경사가 비포장 법정 상한을 넘는 측점에 포장 제안을 표기한다. + + 근거: 「임도설치 및 관리 등에 관한 규정」[별표 1-2] — 기준(설계속도×지형) 초과 + 구간은 노면포장 시에 한해 상한(`paved_exception_grade_pct`)까지 허용된다. + stations에 `pavement_suggested`(bool)와 판정 근거(경사·상한)를 남기며, B06이 + 포장 기본값과 법정 근거 문구 표기에 그대로 사용한다. 실패해도 종횡단은 유지한다. + """ + if grade_options is None: + return + profiles = longitudinal.get("design_profiles") or [] + samples = profiles[0].get("samples", []) if profiles else [] + points = [ + (float(s["chainage_m"]), float(s["elevation_m"])) + for s in samples + if isinstance(s.get("chainage_m"), (int, float)) + and isinstance(s.get("elevation_m"), (int, float)) + ] + stations = longitudinal.get("stations") + if len(points) < 2 or not isinstance(stations, list): + return + criteria = FOREST_ROAD_PROFILE_CRITERIA["design_speed"].get(grade_options.design_speed_kph) + if not criteria: + return + terrain = grade_options.terrain_type if grade_options.terrain_type else "normal" + unpaved_limit = float( + criteria["max_grade_pct"].get(terrain, criteria["max_grade_pct"]["normal"]) + ) + + def local_grade_pct(chainage: float) -> float: + """측점을 감싸는 인접 계획선 구간들의 경사 중 최댓값(절댓값 %)을 돌려준다.""" + worst = 0.0 + for index in range(1, len(points)): + c0, z0 = points[index - 1] + c1, z1 = points[index] + if c1 < chainage - 1e-6 or c0 > chainage + 1e-6: + continue + span = c1 - c0 + if span <= 1e-9: + continue + worst = max(worst, abs((z1 - z0) / span) * 100.0) + return worst + + for station in stations: + chainage = station.get("chainage_m") + if not isinstance(chainage, (int, float)): + continue + grade_pct = local_grade_pct(float(chainage)) + station["pavement_suggested"] = grade_pct > unpaved_limit + 1e-6 + station["pavement_grade_pct"] = round(grade_pct, 2) + station["pavement_grade_limit_pct"] = round(unpaved_limit, 2) + + def run_section_generation( project_root: Path, route_data_path: str, @@ -152,6 +208,11 @@ def run_section_generation( grade_options, (result.get("options") or {}).get("station_interval_m"), ) + # 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적. + try: + _annotate_pavement_suggestions(result["longitudinal"], grade_options) + except Exception: + logger.exception("B05 포장 제안 판정 실패 (종횡단은 유지)") long_file = long_dir / "longitudinal.json" atomic_write_json(long_file, result["longitudinal"]) long_summary = { diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py index 61606c40..684ac0cd 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py @@ -176,6 +176,11 @@ def generate_sections( all_cross_z = all_cross_z.reshape(len(station_chainage), len(offsets)) all_cross_valid = all_cross_valid.reshape(len(station_chainage), len(offsets)) + # 측점별 좌/우 상단측(등고가 높은 쪽) 판정용 마스크. B06 측구 배치 기본값과 + # 3D 원형 램프 표시에 쓴다(좌=+offset, 우=-offset). + left_offset_mask = offsets > 1e-9 + right_offset_mask = offsets < -1e-9 + stations: list[dict[str, Any]] = [] cross_sections: list[dict[str, Any]] = [] for index, value in enumerate(station_chainage): @@ -205,6 +210,18 @@ def generate_sections( "left_xy": [round(float(left[0]), 9), round(float(left[1]), 9)], "up_xyz": [0.0, 0.0, 1.0], } + # 상단측 판정: 유효 샘플의 좌/우 평균 표고 비교. 어느 한쪽이 전부 무효이거나 + # 차이가 미미하면 None(미상) — 프론트는 좌측 기본값으로 폴백한다. + valid_row = all_cross_valid[index] + left_z = all_cross_z[index][left_offset_mask & valid_row] + right_z = all_cross_z[index][right_offset_mask & valid_row] + uphill_side: str | None = None + if left_z.size and right_z.size: + left_mean = float(np.mean(left_z)) + right_mean = float(np.mean(right_z)) + if abs(left_mean - right_mean) > 1e-6: + uphill_side = "left" if left_mean > right_mean else "right" + station = { "station_id": station_id, "chainage_m": round(float(value), 6), @@ -214,6 +231,8 @@ def generate_sections( "center_y": round(float(station_xy[index, 1]), 6), "center_z": _float_or_none(center_z), "azimuth_deg": round(azimuth, 6), + # 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). 사용자 변경 시 확정에서 덮어쓴다. + "uphill_side": uphill_side, "frame": frame, } # 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지). diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index a09ae85a..e3204061 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -128,6 +128,38 @@ def _section_options_from_stored(stored: dict[str, Any] | None) -> SectionGenera ) +def _merge_uphill_overrides_into_longitudinal( + project_root: Path, longitudinal_file_path: str, overrides: list[dict[str, Any]] +) -> None: + """종단 정본 파일 stations의 uphill_side를 사용자 변경값으로 덮어쓴다. + + solve가 자동 판정한 상단측(=측구 방향)을 3D 램프 클릭으로 바꾼 경우, 확정 시점에 + 정본에 반영해 B06이 값만 읽으면 되게 한다. 사용자 지정임을 소스 필드로 남긴다. + """ + if not overrides: + return + root = project_root.resolve() + path = (root / longitudinal_file_path).resolve() + if root not in path.parents or not path.is_file(): + return + data = json.loads(path.read_text(encoding="utf-8")) + stations = data.get("stations") + if not isinstance(stations, list): + return + by_chainage = {round(float(item["chainage_m"]), 3): str(item["side"]) for item in overrides} + changed = False + for station in stations: + side = by_chainage.get(round(float(station.get("chainage_m", -1.0)), 3)) + if side is None: + continue + station["uphill_side"] = side + station["uphill_side_source"] = "user" + changed = True + if changed: + data["stations"] = stations + atomic_write_json(path, data) + + def _merge_irregular_into_longitudinal( project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]] ) -> None: @@ -591,7 +623,28 @@ async def confirm_latest_route( await _append_irregular_cross_sections(connection, project_id, latest, request) except Exception: logger.exception( - "B05 비정규 측점 횡단 생성 실패 (경로 확정은 진행): project_id=%s route_id=%s", + "B05 비정규 측점 횡단 생성 실패 (경로 확정은 진행): " + "project_id=%s route_id=%s", + project_id, + latest["id"], + ) + # 상단측(측구 방향) 사용자 변경분을 종단 정본에 병합한다 — 비치명적. + if request.uphill_overrides: + try: + stored_path = await get_project_storage_relative_path(connection, project_id) + longitudinal = await get_longitudinal_section( + connection, project_id, latest["id"] + ) + if longitudinal: + await asyncio.to_thread( + _merge_uphill_overrides_into_longitudinal, + Path(resolve_stored_project_path(stored_path)), + str(longitudinal["longitudinal_file_path"]), + [item.model_dump() for item in request.uphill_overrides], + ) + except Exception: + logger.exception( + "B05 상단측 변경 병합 실패 (경로 확정은 진행): project_id=%s route_id=%s", project_id, latest["id"], ) diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index 3687f641..267a66fb 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -1,6 +1,6 @@ """B05 경로 설계 요청·응답 검증 모델.""" -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -232,6 +232,15 @@ class IrregularStationInput(BaseModel): structure: str = Field(default="") +class UphillSideOverride(BaseModel): + """측점 상단측(=측구 방향) 사용자 변경값. 3D 원형 램프 클릭으로 지정된다.""" + + model_config = ConfigDict(extra="forbid") + + chainage_m: float = Field(ge=0) + side: Literal["left", "right"] + + class RouteConfirmRequest(BaseModel): """경로 확정 요청. @@ -247,6 +256,8 @@ class RouteConfirmRequest(BaseModel): smooth: bool = False surface_model_id: int | None = None irregular_stations: list[IrregularStationInput] = Field(default_factory=list) + # 측점 상단측(측구 방향) 사용자 변경분 — solve 자동 판정을 확정 시 덮어쓴다. + uphill_overrides: list[UphillSideOverride] = Field(default_factory=list) def extra_stations(self) -> tuple[tuple[float, str], ...]: return tuple((item.chainage_m, item.structure) for item in self.irregular_stations) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts index e2ca6dd1..99d3005a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts @@ -30,9 +30,15 @@ export interface SectionStationMarker { center_x: number; center_y: number; center_z: number | null; + /** 상단측(등고 높은 쪽) — 램프 컬러 표시용. Page가 사용자 변경분을 반영해 넘긴다. */ + uphill_side?: "left" | "right" | null; frame: { left_xy: [number, number] }; } +// 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색. +const UPHILL_LAMP_COLOR = 0xf97316; +const UPHILL_LAMP_INACTIVE_COLOR = 0x9ca3af; + const COLORS: Record = { bp: 0x10b981, ep: 0xef4444, @@ -82,6 +88,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou let changeListener: ((points: RouteDesignPoints) => void) | undefined; let selectionListener: ((point: PlacedRoutePoint | null) => void) | undefined; let stationSelectionListener: ((stationId: string | null) => void) | undefined; + let uphillPickListener: ((stationId: string, side: "left" | "right") => void) | undefined; let selectedStationId: string | null = null; function allPoints(): PlacedRoutePoint[] { @@ -259,6 +266,23 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou line.userData.stationId = station.station_id; if (selected) line.material.linewidth = 2; stationGroup.add(line); + + // 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색. + // 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick). + (["left", "right"] as const).forEach((side, endIndex) => { + const active = station.uphill_side === side; + const lamp = new THREE.Mesh( + new THREE.SphereGeometry(1.1, 14, 10), + new THREE.MeshBasicMaterial({ + color: active ? UPHILL_LAMP_COLOR : UPHILL_LAMP_INACTIVE_COLOR, + }), + ); + lamp.position.copy(points[endIndex]); + lamp.position.y += 0.6; + lamp.userData.uphillStationId = station.station_id; + lamp.userData.uphillSide = side; + stationGroup.add(lamp); + }); }); } @@ -291,6 +315,14 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou updateSelected, deleteSelected, selectObject(object: THREE.Object3D | undefined) { + // 상단측 램프 클릭: 해당 측을 측구 방향으로 지정(측점 선택보다 우선 판정). + if (typeof object?.userData.uphillStationId === "string") { + uphillPickListener?.( + object.userData.uphillStationId, + object.userData.uphillSide as "left" | "right", + ); + return; + } if (typeof object?.userData.stationId === "string") { selectStation(object.userData.stationId); selectionListener?.(null); @@ -327,6 +359,9 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou onStationSelectionChange(listener: (stationId: string | null) => void) { stationSelectionListener = listener; }, + onUphillPick(listener: (stationId: string, side: "left" | "right") => void) { + uphillPickListener = listener; + }, dispose() { disposeGroup(interactionGroup); disposeGroup(routeGroup); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index ac0efd38..61b2d44c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -208,6 +208,74 @@ export async function renderB05Route(root: HTMLElement): Promise { // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). let selectionSyncing = false; + /* ── 측점 상단측(=측구 방향) 사용자 변경분 ───────────────────────────── + * solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가 + * 경로 확정 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */ + const uphillSessionKey = `b05:uphill:${activeProjectId}`; + const uphillOverrides = new Map(); + const uphillKey = (chainage: number): string => chainage.toFixed(3); + try { + const raw = window.sessionStorage.getItem(uphillSessionKey); + if (raw) { + Object.entries(JSON.parse(raw) as Record).forEach( + ([chainage, side]) => { + if (side === "left" || side === "right") uphillOverrides.set(chainage, side); + }, + ); + } + } catch { + /* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */ + } + function persistUphillOverrides(): void { + try { + window.sessionStorage.setItem( + uphillSessionKey, + JSON.stringify(Object.fromEntries(uphillOverrides)), + ); + } catch { + /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ + } + } + + /* ── 최신 경로/설정값 세션 캐시 ──────────────────────────────────────── + * 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를 + * 우선 사용해 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고, + * solve·확정 성공 시 신선한 값으로 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). */ + const latestCacheKey = `b05:latest:${activeProjectId}`; + + function readLatestCache(): RouteLatestResponse | null { + try { + const raw = window.sessionStorage.getItem(latestCacheKey); + return raw ? (JSON.parse(raw) as RouteLatestResponse) : null; + } catch { + return null; + } + } + + function writeLatestCache(value: RouteLatestResponse): void { + try { + window.sessionStorage.setItem(latestCacheKey, JSON.stringify(value)); + } catch { + // 용량 초과 등 저장 실패 시 캐시를 비워 다음 진입은 DB 조회로 폴백한다. + try { + window.sessionStorage.removeItem(latestCacheKey); + } catch { + /* noop */ + } + } + } + + /** 캐시 우선 latest 로드. forceFresh=true(solve/확정 직후)는 항상 DB를 읽고 캐시를 갱신한다. */ + async function loadLatest(forceFresh = false): Promise { + if (!forceFresh) { + const cached = readLatestCache(); + if (cached) return cached; + } + const fresh = await fetchLatestRoute(activeProjectId); + writeLatestCache(fresh); + return fresh; + } + const panel = createRoutePanel({ onSolve: () => void solve(), onConfirm: () => void confirm(), @@ -255,6 +323,25 @@ export async function renderB05Route(root: HTMLElement): Promise { profilePanel.setSelectedStation(stationId); syncIrregularSelection(stationId); }); + // 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더. + viewer.markers.onUphillPick((stationId, side) => { + if (!currentSectionDetail) return; + const base = currentSectionDetail.longitudinal.stations.find( + (station) => station.station_id === stationId, + ); + let chainage = base?.chainage_m; + if (chainage === undefined) { + const prefix = irregularStationId(""); + if (stationId.startsWith(prefix)) { + const id = stationId.slice(prefix.length); + chainage = irregularStations.find((entry) => entry.id === id)?.chainage_m; + } + } + if (chainage === undefined) return; + uphillOverrides.set(uphillKey(chainage), side); + persistUphillOverrides(); + renderStationLines(currentSectionDetail); + }); viewer.root.append(panel.viewControls); function restorePanel(next: RouteLatestResponse): void { @@ -289,10 +376,13 @@ export async function renderB05Route(root: HTMLElement): Promise { irregularStations, detail.longitudinal.length_m, ); - viewer.renderStationLines( - [...detail.longitudinal.stations, ...injected], - roadWidths[panel.values().gradeClass] / 2, - ); + // 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용. + const withUphill = [...detail.longitudinal.stations, ...injected].map((station) => ({ + ...station, + uphill_side: + uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null, + })); + viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2); } /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */ @@ -375,6 +465,14 @@ export async function renderB05Route(root: HTMLElement): Promise { try { await viewer.reloadContours(interval); await updateContourInterval(activeProjectId, interval); + // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. + const cached = readLatestCache(); + if (cached) { + writeLatestCache({ + ...cached, + surface_params: { ...cached.surface_params, contour_interval_m: interval }, + }); + } } catch (error) { showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); } finally { @@ -422,7 +520,10 @@ export async function renderB05Route(root: HTMLElement): Promise { start_elevation_offset_m: values.startElevationOffset, end_elevation_offset_m: values.endElevationOffset, }); - renderLatest(await fetchLatestRoute(activeProjectId)); + // 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용). + uphillOverrides.clear(); + persistUphillOverrides(); + renderLatest(await loadLatest(true)); await restoreSections(solved.route_id); if (solved.cross_section_count === null) { showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); @@ -454,8 +555,13 @@ export async function renderB05Route(root: HTMLElement): Promise { chainage_m: station.chainage_m, structure: station.structure, })), + // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. + uphill_overrides: [...uphillOverrides.entries()].map(([chainage, side]) => ({ + chainage_m: Number(chainage), + side, + })), }); - renderLatest(await fetchLatestRoute(activeProjectId)); + renderLatest(await loadLatest(true)); showToast("경로를 확정했습니다.", "success"); goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[3]); } catch (error) { @@ -469,7 +575,8 @@ export async function renderB05Route(root: HTMLElement): Promise { await Promise.all([ fetchWorkflowState(activeProjectId), listSurfaceModels(activeProjectId), - fetchLatestRoute(activeProjectId), + // 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다. + loadLatest(), fetchSectionContext(activeProjectId), fetchRoadWidths(activeProjectId), ]); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index e58ce747..9648419b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -24,6 +24,33 @@ export interface SectionOptionDefaults { vertical_exaggeration: number; } +/** 측구 규격(상단폭/저폭/깊이, m). */ +export interface DitchSpec { + top_width_m: number; + bottom_width_m: number; + depth_m: number; +} + +/** 지반그룹 하나의 표준 횡단면 기본값 (config STANDARD_CROSS_SECTION 사본). */ +export interface StandardCrossGroup { + road_width_m: number; + shoulder_left_m: number; + shoulder_right_m: number; + ditch: DitchSpec; + /** 암 그룹만 존재: L형 측구(폭/깊이, m). */ + ditch_l_type?: { width_m: number; depth_m: number }; + cross_slope_pct: { min: number; max: number }; + fill_slope_ratio: number; + cut_slope_ratio: number; + /** 포장 그룹만 존재: 포장층 두께(m). */ + pavement_thickness_m?: number; +} + +/** 표준 횡단면 설정 패널 그룹 키. */ +export type StandardCrossKey = "soil" | "rock" | "paved"; + +export type StandardCrossSection = Record; + export interface SectionContextResponse { project_id: string; route_id: number | null; @@ -32,6 +59,11 @@ export interface SectionContextResponse { smooth: boolean | null; crs_epsg: number | null; defaults: SectionOptionDefaults; + /** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */ + standard_cross_section: StandardCrossSection; + /** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */ + rock_boundary_default_offset_m: number; + rock_boundary_step_m: number; } /** 종단 요약 조회 결과 (SectionSummaryResponse) */ @@ -64,6 +96,8 @@ export interface SectionStation { azimuth_deg: number | null; center_x: number; center_y: number; + /** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */ + uphill_side?: "left" | "right" | null; frame: { left_xy: [number, number] }; } @@ -147,6 +181,7 @@ export interface SectionConfirmResponse { export type GroundType = "soil" | "ripping_rock" | "blasting_rock"; export type SectionMode = "left_cut" | "right_cut" | "both_cut" | "both_fill"; export type DitchSide = "left" | "right"; +export type DitchType = "standard" | "l_type"; /** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */ export interface CrossDesign { @@ -154,16 +189,34 @@ export interface CrossDesign { geometry_preset: "soil" | "rock"; section_mode: SectionMode; ditch_side: DitchSide; + /** 측구 형식(일반/L형). 양성(측구 없음)은 null. */ + ditch_type: DitchType | null; cut_slope_ratio: number; fill_slope_ratio: number; roadbed_width_m: number; carriageway_width_m: number; - ditch: { width_m: number; depth_m: number }; + cross_slope_pct: number; + ditch: + | { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number } + | { type: "l_type"; width_m: number; depth_m: number } + | { type: "none" }; + /** 포장 중첩 여부와 포장층 두께(포장 시). */ + paved: boolean; + pavement_thickness_m?: number; + /** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */ + pavement_suggested?: boolean; + /** 노면 양 끝점(포장층 박스·노면 렌더링 기준). */ + road_edges: { + left: { offset_m: number; elevation_m: number }; + right: { offset_m: number; elevation_m: number }; + }; design_elevation_m: number; cut_area_m2: number; fill_area_m2: number; ditch_area_m2: number; design_line: Array<{ offset_m: number; elevation_m: number }>; + /** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */ + rock_boundary_offset_m?: number; } export interface CrossDesignResponse { @@ -177,6 +230,18 @@ export interface CrossDesignRequest { ground_type: GroundType; section_mode: SectionMode; ditch_side?: DitchSide | null; + /** 측구 형식(일반/L형). L형은 암 지반에서만 허용된다. */ + ditch_type?: DitchType; + /** 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산된다. */ + paved?: boolean; + /** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */ + standard_cross_section?: StandardCrossSection; +} + +/** 확정 시 측점별 data.design에 병합할 프론트 세션 보관값. */ +export interface CrossSectionPatch { + chainage_m: number; + rock_boundary_offset_m?: number; } /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ @@ -254,12 +319,18 @@ export async function computeCrossDesign( ); } -/** 경로의 종·횡단면을 확정한다. */ +/** 경로의 종·횡단면을 확정한다. 표준 횡단면 설정값·측점별 세션 보관값을 함께 저장할 수 있다. */ export async function confirmSections( projectId: string, routeId: number, + standardCrossSection?: StandardCrossSection, + crossPatches?: CrossSectionPatch[], ): Promise { + const body: Record = {}; + if (standardCrossSection) body.standard_cross_section = standardCrossSection; + if (crossPatches?.length) body.cross_patches = crossPatches; return requestJson(`/projects/${projectId}/sections/${routeId}/confirm`, { method: "POST", + body: JSON.stringify(body), }); } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py index e7b0b04f..eb3c5888 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py @@ -5,6 +5,12 @@ 버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서 확정치로 대체된다. +표준단면 기하는 config STANDARD_CROSS_SECTION(단일 진실 원천)을 읽고, B06 설정 +패널 편집값(standard 인자)이 오면 요청값 → config 기본값 순으로 우선한다. +설계선은 도면 표준대로 횡단경사(측구 방향), 노견, 측구(사다리꼴/L형), 절·성토 +사면을 모두 포함하며, 면적은 지반 샘플과 설계 꼭짓점을 합친 오프셋 격자에서 +사다리꼴 적분한다(측구 굴착이 절토 면적에 자연 포함). + 좌표 규약(generate_sections cad_exchange 준수): offset_m 양수=좌, 음수=우. 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ @@ -12,13 +18,11 @@ from typing import Any from config.config_system import ( - SECTION_CARRIAGEWAY_WIDTH_M, - SECTION_DESIGN_TEMPLATES, SECTION_DITCH_SIDES, - SECTION_FILL_SLOPE_RATIO, + SECTION_DITCH_TYPES, SECTION_GROUND_TYPE_PRESET, SECTION_MODES, - SECTION_ROADBED_WIDTH_M, + STANDARD_CROSS_SECTION, ) @@ -50,26 +54,48 @@ def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str: return "left" -def _design_elevation_on_side( - offset_m: float, - ground_m: float, - role: str, - design_elevation_m: float, - half_width_m: float, - cut_slope_ratio: float, - fill_slope_ratio: float, -) -> float: - """노체 밖 한 offset의 설계 표고를 절토/성토 규칙으로 계산한다. +def _as_float(value: Any, fallback: float) -> float: + """패널 편집값을 안전하게 float으로 읽는다(손상 시 config 기본값).""" + try: + parsed = float(value) + except (TypeError, ValueError): + return fallback + return parsed if parsed >= 0 else fallback - 절토측: 노면 가장자리에서 경사면이 위로 올라가다 지반선을 만나면 지반을 따른다. - 성토측: 가장자리에서 경사면이 아래로 내려가다 지반선을 만나면 지반을 따른다. - """ - edge_distance = abs(offset_m) - half_width_m - if role == "cut": - slope_line = design_elevation_m + edge_distance / cut_slope_ratio - return min(slope_line, ground_m) - fill_line = design_elevation_m - edge_distance / fill_slope_ratio - return max(fill_line, ground_m) + +def _resolve_group(preset_key: str, standard: dict[str, Any] | None) -> dict[str, float]: + """config 기본값 위에 패널 편집값(standard[preset_key])을 덮어 평탄화한다.""" + base = STANDARD_CROSS_SECTION[preset_key] + override = standard.get(preset_key) if isinstance(standard, dict) else None + override = override if isinstance(override, dict) else {} + base_ditch = base["ditch"] + over_ditch = override.get("ditch") + over_ditch = over_ditch if isinstance(over_ditch, dict) else {} + base_l = base.get("ditch_l_type", {}) + over_l = override.get("ditch_l_type") + over_l = over_l if isinstance(over_l, dict) else {} + base_slope = base["cross_slope_pct"] + over_slope = override.get("cross_slope_pct") + over_slope = over_slope if isinstance(over_slope, dict) else {} + return { + "road_width_m": _as_float(override.get("road_width_m"), base["road_width_m"]), + "shoulder_left_m": _as_float(override.get("shoulder_left_m"), base["shoulder_left_m"]), + "shoulder_right_m": _as_float(override.get("shoulder_right_m"), base["shoulder_right_m"]), + "ditch_top_width_m": _as_float(over_ditch.get("top_width_m"), base_ditch["top_width_m"]), + "ditch_bottom_width_m": _as_float( + over_ditch.get("bottom_width_m"), base_ditch["bottom_width_m"] + ), + "ditch_depth_m": _as_float(over_ditch.get("depth_m"), base_ditch["depth_m"]), + "l_ditch_width_m": _as_float(over_l.get("width_m"), base_l.get("width_m", 0.5)), + "l_ditch_depth_m": _as_float(over_l.get("depth_m"), base_l.get("depth_m", 0.1)), + # 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값). + "cross_slope_pct": _as_float(over_slope.get("min"), base_slope["min"]), + "fill_slope_ratio": _as_float(override.get("fill_slope_ratio"), base["fill_slope_ratio"]), + "cut_slope_ratio": _as_float(override.get("cut_slope_ratio"), base["cut_slope_ratio"]), + "pavement_thickness_m": _as_float( + override.get("pavement_thickness_m"), base.get("pavement_thickness_m", 0.2) + ), + } def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, float]: @@ -109,6 +135,138 @@ def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, f return cut_area, fill_area +def _ground_interpolator(valid: list[tuple[float, float]]): + """정렬된 (offset, 지반고) 샘플의 선형 보간 함수를 만든다(범위 밖 끝값 클램프).""" + + def ground_at(offset_m: float) -> float: + if offset_m <= valid[0][0]: + return valid[0][1] + if offset_m >= valid[-1][0]: + return valid[-1][1] + for index in range(1, len(valid)): + x1, z1 = valid[index] + if offset_m > x1: + continue + x0, z0 = valid[index - 1] + span = x1 - x0 + if span <= 0: + return z1 + ratio = (offset_m - x0) / span + return z0 + (z1 - z0) * ratio + return valid[-1][1] + + return ground_at + + +class _SectionGeometry: + """설계선 피스와이즈 평가기. 노면 → 측구 → 사면 순으로 offset의 설계고를 계산한다.""" + + def __init__( + self, + *, + design_elevation_m: float, + group: dict[str, float], + section_mode: str, + ditch_side: str, + ditch_type: str, + cross_slope_pct: float, + ) -> None: + half_road = group["road_width_m"] / 2.0 + self.left_extent = half_road + group["shoulder_left_m"] # 좌(+) 노면 끝 + self.right_extent = half_road + group["shoulder_right_m"] # 우(-) 노면 끝 + self.z_center = design_elevation_m + self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) + self.fill_ratio = max(group["fill_slope_ratio"], 1e-6) + self.left_role, self.right_role = _side_role(section_mode) + self.ditch_side = ditch_side + # 성토만(양성)이면 측구 없음(합의). 절토가 있는 단면만 측구를 판다. + self.has_ditch = section_mode != "both_fill" + self.ditch_type = ditch_type + # 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). + slope = cross_slope_pct / 100.0 + self.slope_per_offset = -slope if ditch_side == "left" else slope + + # 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). + self.ditch_points: list[tuple[float, float]] = [] + edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent + outward = 1.0 if ditch_side == "left" else -1.0 + edge_z = self.road_z(edge_offset) + if self.has_ditch: + if ditch_type == "l_type": + # L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 한 조각. + width = group["l_ditch_width_m"] + depth = group["l_ditch_depth_m"] + self.ditch_points = [ + (edge_offset, edge_z), + (edge_offset + outward * width, edge_z - depth), + ] + else: + # 일반: 상단폭/저폭/깊이 사다리꼴. + top = group["ditch_top_width_m"] + bottom = min(group["ditch_bottom_width_m"], top) + depth = group["ditch_depth_m"] + inset = (top - bottom) / 2.0 + self.ditch_points = [ + (edge_offset, edge_z), + (edge_offset + outward * inset, edge_z - depth), + (edge_offset + outward * (inset + bottom), edge_z - depth), + (edge_offset + outward * top, edge_z), + ] + + def road_z(self, offset_m: float) -> float: + """노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면.""" + return self.z_center + self.slope_per_offset * offset_m + + def _slope_start(self, side: str) -> tuple[float, float]: + """사면 시작점(오프셋 절대값 기준 거리, 표고)을 계산한다.""" + if side == "left": + edge_offset, edge_z = self.left_extent, self.road_z(self.left_extent) + else: + edge_offset, edge_z = self.right_extent, self.road_z(-self.right_extent) + if side == self.ditch_side and self.ditch_points: + outer = self.ditch_points[-1] + return abs(outer[0]), outer[1] + return edge_offset, edge_z + + def design_z(self, offset_m: float, ground_m: float) -> float: + """offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종).""" + side = "left" if offset_m >= 0 else "right" + extent = self.left_extent if side == "left" else self.right_extent + if abs(offset_m) <= extent + 1e-9: + return self.road_z(offset_m) + # 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). + if side == self.ditch_side and self.ditch_points: + inner = abs(self.ditch_points[0][0]) + outer = abs(self.ditch_points[-1][0]) + if inner - 1e-9 <= abs(offset_m) <= outer + 1e-9: + points = self.ditch_points + for index in range(1, len(points)): + x0, z0 = abs(points[index - 1][0]), points[index - 1][1] + x1, z1 = abs(points[index][0]), points[index][1] + if abs(offset_m) > x1 + 1e-9: + continue + span = x1 - x0 + if span <= 1e-9: + return z1 + ratio = (abs(offset_m) - x0) / span + return z0 + (z1 - z0) * ratio + return points[-1][1] + role = self.left_role if side == "left" else self.right_role + start_dist, start_z = self._slope_start(side) + run = abs(offset_m) - start_dist + if role == "cut": + slope_line = start_z + run / self.cut_ratio + return min(slope_line, ground_m) + fill_line = start_z - run / self.fill_ratio + return max(fill_line, ground_m) + + def breakpoints(self) -> list[float]: + """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록.""" + points = [0.0, self.left_extent, -self.right_extent] + points.extend(offset for offset, _z in self.ditch_points) + return points + + def compute_cross_design( samples: list[dict[str, Any]], design_elevation_m: float | None, @@ -116,28 +274,34 @@ def compute_cross_design( ground_type: str, section_mode: str, ditch_side: str | None = None, - roadbed_width_m: float = SECTION_ROADBED_WIDTH_M, - fill_slope_ratio: float = SECTION_FILL_SLOPE_RATIO, + ditch_type: str = "standard", + paved: bool = False, + standard: dict[str, Any] | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. samples: [{offset_m, elevation_m, valid}] 지반선 원시 샘플. design_elevation_m: 중심선 계획고(노면고). None이면 계산 불가. + ditch_type: 일반(standard)/L형(l_type, 암 구간 전용). + paved: 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 바꾼다(기하는 지반유형). + standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. """ if ground_type not in SECTION_GROUND_TYPE_PRESET: raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}") if section_mode not in SECTION_MODES: raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}") + if ditch_type not in SECTION_DITCH_TYPES: + raise ValueError(f"지원하지 않는 측구 형식입니다: {ditch_type}") if design_elevation_m is None: raise ValueError("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.") preset_key = SECTION_GROUND_TYPE_PRESET[ground_type] - preset = SECTION_DESIGN_TEMPLATES[preset_key] - cut_slope_ratio = float(preset["cut_slope_ratio"]) - ditch_width_m = float(preset["ditch_width_m"]) - ditch_depth_m = float(preset["ditch_depth_m"]) - half_width_m = roadbed_width_m / 2.0 - left_role, right_role = _side_role(section_mode) + if ditch_type == "l_type" and preset_key != "rock": + raise ValueError("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.") + group = _resolve_group(preset_key, standard) + paved_group = _resolve_group("paved", standard) + # 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. + cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"] resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side) valid = sorted( @@ -153,48 +317,93 @@ def compute_cross_design( if len(valid) < 2: raise ValueError("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.") + geometry = _SectionGeometry( + design_elevation_m=design_elevation_m, + group=group, + section_mode=section_mode, + ditch_side=resolved_ditch_side, + ditch_type=ditch_type, + cross_slope_pct=cross_slope_pct, + ) + ground_at = _ground_interpolator(valid) + + # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 + # 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다. + min_offset, max_offset = valid[0][0], valid[-1][0] + merged: list[float] = [offset for offset, _z in valid] + merged.extend(point for point in geometry.breakpoints() if min_offset <= point <= max_offset) + merged = sorted(set(round(offset, 6) for offset in merged)) + offsets: list[float] = [] diffs: list[float] = [] design_line: list[dict[str, float]] = [] - for offset_m, ground_m in valid: - if abs(offset_m) <= half_width_m + 1e-9: - design_z = design_elevation_m - else: - role = left_role if offset_m > 0 else right_role - design_z = _design_elevation_on_side( - offset_m, - ground_m, - role, - design_elevation_m, - half_width_m, - cut_slope_ratio, - fill_slope_ratio, - ) + for offset_m in merged: + ground_m = ground_at(offset_m) + design_z = geometry.design_z(offset_m, ground_m) offsets.append(offset_m) diffs.append(ground_m - design_z) design_line.append({"offset_m": round(offset_m, 4), "elevation_m": round(design_z, 4)}) + # 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지). cut_area, fill_area = _trapezoid_areas(offsets, diffs) - # 측구는 절토측에서 굴착되므로 절토 단면적에 사다리꼴 근사로 가산한다. - ditch_area = ditch_depth_m * (ditch_width_m + ditch_width_m * 0.5) / 2.0 - cut_area += ditch_area - return { + # 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사. + if not geometry.has_ditch: + ditch_area = 0.0 + ditch_spec: dict[str, Any] = {"type": "none"} + elif ditch_type == "l_type": + ditch_area = group["l_ditch_width_m"] * group["l_ditch_depth_m"] / 2.0 + ditch_spec = { + "type": "l_type", + "width_m": group["l_ditch_width_m"], + "depth_m": group["l_ditch_depth_m"], + } + else: + ditch_area = ( + (group["ditch_top_width_m"] + group["ditch_bottom_width_m"]) + / 2.0 + * group["ditch_depth_m"] + ) + ditch_spec = { + "type": "standard", + "top_width_m": group["ditch_top_width_m"], + "bottom_width_m": group["ditch_bottom_width_m"], + "depth_m": group["ditch_depth_m"], + } + + result: dict[str, Any] = { "ground_type": ground_type, "geometry_preset": preset_key, "section_mode": section_mode, "ditch_side": resolved_ditch_side, - "cut_slope_ratio": round(cut_slope_ratio, 4), - "fill_slope_ratio": round(fill_slope_ratio, 4), - "roadbed_width_m": round(roadbed_width_m, 4), - "carriageway_width_m": round(SECTION_CARRIAGEWAY_WIDTH_M, 4), - "ditch": {"width_m": ditch_width_m, "depth_m": ditch_depth_m}, + "ditch_type": ditch_type if geometry.has_ditch else None, + "cut_slope_ratio": round(geometry.cut_ratio, 4), + "fill_slope_ratio": round(geometry.fill_ratio, 4), + "roadbed_width_m": round(geometry.left_extent + geometry.right_extent, 4), + "carriageway_width_m": round(group["road_width_m"], 4), + "cross_slope_pct": round(cross_slope_pct, 4), + "ditch": ditch_spec, + "paved": bool(paved), + # 노면 양 끝점(프론트 포장층 박스·노면 렌더링 기준). + "road_edges": { + "left": { + "offset_m": round(geometry.left_extent, 4), + "elevation_m": round(geometry.road_z(geometry.left_extent), 4), + }, + "right": { + "offset_m": round(-geometry.right_extent, 4), + "elevation_m": round(geometry.road_z(-geometry.right_extent), 4), + }, + }, "design_elevation_m": round(float(design_elevation_m), 4), "cut_area_m2": round(cut_area, 4), "fill_area_m2": round(fill_area, 4), "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, } + if paved: + result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4) + return result def design_elevation_from_longitudinal( diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index 77c25a5c..7163aebe 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -443,6 +443,93 @@ async def get_cross_sections_missing_design_chainages( return chainages +async def merge_cross_section_design_patch( + connection: aiomysql.Connection, + *, + route_id: int, + chainage_m: float, + patch: dict[str, Any], +) -> bool: + """측점 하나(부동소수 chainage 근사 매칭)의 data.design에 patch를 병합한다. + + 확정 시 프론트 세션 보관값(암 경계선 오프셋 등)을 기존 설계를 보존한 채 얹기 + 위해 사용한다. design이 없던 측점이면 patch만으로 design을 만든다. + """ + if not patch: + return False + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT id, data FROM cross_sections + WHERE route_id = %s AND ABS(chainage_m - %s) < 0.01 + ORDER BY id DESC + LIMIT 1 + """, + (route_id, chainage_m), + ) + row = await cursor.fetchone() + if not row: + return False + data = row[1] + if isinstance(data, str): + data = json.loads(data) + if not isinstance(data, dict): + data = {} + design = data.get("design") + if not isinstance(design, dict): + design = {} + design.update(patch) + data["design"] = design + await cursor.execute( + "UPDATE cross_sections SET data = %s WHERE id = %s", + (json.dumps(data, ensure_ascii=False), int(row[0])), + ) + return True + + +async def merge_longitudinal_section_options( + connection: aiomysql.Connection, + *, + route_id: int, + options_patch: dict[str, Any], +) -> bool: + """경로 최신 종단면 data.options에 patch를 병합 저장한다. + + 표준 횡단면 설정 등 확정 시점 옵션을 기존 생성 옵션 스냅샷을 보존한 채 갱신한다. + 갱신 여부를 반환한다. + """ + if not options_patch: + return False + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT id, data FROM longitudinal_sections + WHERE route_id = %s + ORDER BY id DESC + LIMIT 1 + """, + (route_id,), + ) + row = await cursor.fetchone() + if not row: + return False + data = row[1] + if isinstance(data, str): + data = json.loads(data) + if not isinstance(data, dict): + data = {} + options = data.get("options") + if not isinstance(options, dict): + options = {} + options.update(options_patch) + data["options"] = options + await cursor.execute( + "UPDATE longitudinal_sections SET data = %s WHERE id = %s", + (json.dumps(data, ensure_ascii=False), int(row[0])), + ) + return True + + async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None: """경로의 종횡단면 상태를 CONFIRMED로 변경한다.""" async with connection.cursor() as cursor: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 3332b38c..1cd17df3 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -8,7 +8,7 @@ from typing import Any from uuid import UUID import aiomysql -from fastapi import APIRouter +from fastapi import APIRouter, Body from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path @@ -34,11 +34,14 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( get_longitudinal_section, get_route_generation_source, insert_cross_sections, + merge_cross_section_design_patch, + merge_longitudinal_section_options, update_cross_section_design, ) from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( CrossDesignRequest, CrossDesignResponse, + SectionConfirmRequest, SectionConfirmResponse, SectionContextResponse, SectionDetailResponse, @@ -50,7 +53,13 @@ from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import complete_stage, get_workflow_state from config.config_db import get_db_pool -from config.config_system import FOREST_ROAD_MIN_WIDTH_M, SECTION_VERTICAL_EXAGGERATION +from config.config_system import ( + FOREST_ROAD_MIN_WIDTH_M, + SECTION_VERTICAL_EXAGGERATION, + STANDARD_CROSS_SECTION, + STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + STANDARD_ROCK_BOUNDARY_STEP_M, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) @@ -80,6 +89,9 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON long_sample_interval_m=defaults.long_sample_interval_m, vertical_exaggeration=SECTION_VERTICAL_EXAGGERATION, ), + standard_cross_section=STANDARD_CROSS_SECTION, + rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M, ) except Exception: logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id) @@ -305,8 +317,8 @@ async def regenerate_sections( def _read_cross_design_inputs( project_root: Path, longitudinal_file_path: str, chainage_m: float -) -> tuple[list[dict], float | None]: - """측점 하나의 지반 샘플과 계획고를 파일에서 읽는다 (경로 이탈 검증 포함).""" +) -> tuple[list[dict], float | None, bool]: + """측점 하나의 지반 샘플·계획고·포장 제안 여부를 파일에서 읽는다 (경로 이탈 검증 포함).""" root = project_root.resolve() longitudinal_path = (root / longitudinal_file_path).resolve() if root not in longitudinal_path.parents: @@ -315,6 +327,7 @@ def _read_cross_design_inputs( raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.") longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m) + pavement_suggested = _pavement_suggestions(longitudinal).get(round(chainage_m, 3), False) cross_dir = longitudinal_path.parent.parent / "cross_sections" cross_path = (cross_dir / cross_filename(chainage_m)).resolve() @@ -324,40 +337,76 @@ def _read_cross_design_inputs( samples = cross.get("samples") if isinstance(cross, dict) else None if not isinstance(samples, list): raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") - return samples, design_elevation + return samples, design_elevation, pavement_suggested + + +def _pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]: + """측점별 포장 제안(B05 solve가 법정 경사 기준으로 판정) 매핑을 만든다.""" + stations = longitudinal.get("stations") + mapping: dict[float, bool] = {} + if isinstance(stations, list): + for station in stations: + suggested = station.get("pavement_suggested") + if isinstance(suggested, bool): + mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested + return mapping + + +def _default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: + """측점별 기본 단면유형 매핑: 상단측(uphill_side)이 절토측이 되는 편절편성. + + B05 solve가 자동 판정하고 사용자가 3D 램프로 바꾼 값(확정 시 정본 병합)을 그대로 + 소비한다. 판정 불가 측점은 매핑에서 빠지고 호출부가 좌절토로 폴백한다. + """ + stations = longitudinal.get("stations") + mapping: dict[float, str] = {} + if isinstance(stations, list): + for station in stations: + side = station.get("uphill_side") + if side in ("left", "right"): + mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut" + return mapping def _attach_default_designs( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]] ) -> None: - """지정 설계가 없는 횡단에 기본값(토사/좌절토) 프리뷰 설계를 즉석 계산해 얹는다. + """지정 설계가 없는 횡단에 기본값(토사 + 상단측 절토) 프리뷰 설계를 즉석 계산해 얹는다. detail 조회가 이미 읽어온 samples와 종단 계획선을 그대로 써서 추가 파일 I/O 없이 전 측점 프리뷰를 만든다(미저장). 계산 불가 측점은 건너뛴다. """ + default_modes = _default_section_modes(longitudinal) + pavement = _pavement_suggestions(longitudinal) for section in cross_sections: if section.get("design"): continue try: + chainage_m = float(section.get("chainage_m", 0.0)) + suggested = pavement.get(round(chainage_m, 3), False) design = compute_cross_design( section.get("samples", []), - design_elevation_from_longitudinal( - longitudinal, float(section.get("chainage_m", 0.0)) - ), + design_elevation_from_longitudinal(longitudinal, chainage_m), ground_type="soil", - section_mode="left_cut", + section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), + paved=suggested, ) design["status"] = "provisional" + design["pavement_suggested"] = suggested section["design"] = design except (ValueError, KeyError): continue def _compute_default_designs( - project_root: Path, longitudinal_file_path: str, chainages: list[float] + project_root: Path, + longitudinal_file_path: str, + chainages: list[float], + standard: dict[str, Any] | None = None, ) -> list[tuple[float, dict[str, Any]]]: """미지정 측점들을 기본값(토사/좌절토)으로 계산한 (chainage, design) 목록을 만든다. + standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다. 계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함). """ root = project_root.resolve() @@ -366,6 +415,8 @@ def _compute_default_designs( 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) @@ -376,13 +427,17 @@ def _compute_default_designs( 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="soil", - section_mode="left_cut", + section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), + paved=suggested, + standard=standard, ) design["status"] = "provisional" + design["pavement_suggested"] = suggested results.append((chainage_m, design)) except (ValueError, KeyError, OSError, json.JSONDecodeError): continue @@ -405,7 +460,7 @@ async def compute_cross_section_design( ) stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) - samples, design_elevation = await asyncio.to_thread( + samples, design_elevation, pavement_suggested = await asyncio.to_thread( _read_cross_design_inputs, project_root, str(longitudinal["longitudinal_file_path"]), @@ -417,9 +472,14 @@ async def compute_cross_section_design( ground_type=request.ground_type, section_mode=request.section_mode, ditch_side=request.ditch_side, + ditch_type=request.ditch_type, + paved=request.paved, + standard=request.standard_cross_section, ) # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. design["status"] = "provisional" + # 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다. + design["pavement_suggested"] = pavement_suggested async with pool.acquire() as connection: await connection.begin() try: @@ -455,11 +515,14 @@ async def compute_cross_section_design( @router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) async def confirm_sections( - project_id: UUID, route_id: int + project_id: UUID, + route_id: int, + request: SectionConfirmRequest | None = Body(default=None), ) -> SectionConfirmResponse | JSONResponse: """경로의 종횡단면을 확정(CONFIRMED)한다. 지반유형을 지정하지 않은 측점은 기본값(토사/좌절토)으로 자동 채운 뒤 확정한다. + 표준 횡단면 설정값이 함께 오면 longitudinal_sections.data.options에 저장한다. """ pool = get_db_pool() try: @@ -482,6 +545,7 @@ async def confirm_sections( project_root, str(existing["longitudinal_file_path"]), missing, + request.standard_cross_section if request else None, ) async with pool.acquire() as connection: @@ -491,6 +555,25 @@ async def confirm_sections( await update_cross_section_design( connection, route_id=route_id, chainage_m=chainage_m, design=design ) + if request and request.standard_cross_section: + await merge_longitudinal_section_options( + connection, + route_id=route_id, + options_patch={"standard_cross_section": request.standard_cross_section}, + ) + # 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합. + if request and request.cross_patches: + for patch_item in request.cross_patches: + patch: dict[str, Any] = {} + if patch_item.rock_boundary_offset_m is not None: + patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m + if patch: + await merge_cross_section_design_patch( + connection, + route_id=route_id, + chainage_m=patch_item.chainage_m, + patch=patch, + ) await confirm_sections_for_route(connection, route_id) async with connection.cursor() as cursor: await complete_stage(cursor, str(project_id), 3) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index da5fa008..876b0fbf 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -22,6 +22,12 @@ class CrossDesignRequest(BaseModel): ground_type: Literal["soil", "ripping_rock", "blasting_rock"] section_mode: Literal["left_cut", "right_cut", "both_cut", "both_fill"] ditch_side: Literal["left", "right"] | None = None + # 측구 형식: 일반(사다리꼴)/L형. L형은 암 구간에서만 허용된다. + ditch_type: Literal["standard", "l_type"] = "standard" + # 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산한다. + paved: bool = False + # B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순. + standard_cross_section: dict[str, Any] | None = None class CrossDesignResponse(BaseModel): @@ -32,6 +38,23 @@ class CrossDesignResponse(BaseModel): design: dict[str, Any] +class CrossSectionPatch(BaseModel): + """확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" + + chainage_m: float = Field(..., ge=0) + # 암 경계선 오프셋(m, 지면선 기준 하향 음수 — 계획선 아님). 암 지반 측점만 의미 있다. + rock_boundary_offset_m: float | None = None + + +class SectionConfirmRequest(BaseModel): + """종횡단 확정 요청. 확정 시 저장할 표준 횡단면 설정값(선택)을 함께 받는다.""" + + # B06 표준 횡단면 설정 패널 편집값(토사/암/포장). longitudinal_sections.data.options에 저장. + standard_cross_section: dict[str, Any] | None = None + # 측점별 세션 보관값(암 경계선 오프셋 등) — 확정 시점에 일괄 DB 병합. + cross_patches: list[CrossSectionPatch] | None = None + + class SectionConfirmResponse(BaseModel): """종횡단 확정 결과.""" @@ -61,6 +84,11 @@ class SectionContextResponse(BaseModel): smooth: bool | None = None crs_epsg: int | None = None defaults: SectionOptionDefaults + # 표준 횡단면 설정 패널(토사/암/포장) 기본값. config STANDARD_CROSS_SECTION 사본. + standard_cross_section: dict[str, Any] = Field(default_factory=dict) + # 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). + rock_boundary_default_offset_m: float = -0.5 + rock_boundary_step_m: float = 0.1 class SectionSummaryResponse(BaseModel): diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts index 0dc9d7d3..17c6c352 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts @@ -1,11 +1,17 @@ /* ============================================================================= * B06_wf3_ProfileCross_UI_Cross_Design.ts - * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치)과 설계선 오버레이. + * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식·포장)과 + * 설계선·암 경계선·포장층 오버레이. * - * 카드 헤더 아래에 세그먼트 버튼을 배치하고, 지반유형·단면유형이 모두 선택되면 - * onChange로 계산을 요청한다. 계산 결과(section.design)는 상위에서 다시 렌더될 때 - * 절·성토 단면적 표시와 설계선 오버레이로 반영된다. 편절편성은 측구위치가 자동 - * 결정되어 컨트롤을 숨기고, 양절·양성에서만 배수 방향 선택을 노출한다. + * 카드 헤더 아래에 세그먼트 버튼을 배치하고, 선택이 바뀌면 onChange로 계산을 + * 요청한다. 계산 결과(section.design)는 상위에서 다시 렌더될 때 절·성토 단면적 + * 표시와 오버레이로 반영된다. 편절편성은 측구위치가 자동 결정되어 컨트롤을 숨기고, + * 양절·양성에서만 배수 방향 선택을 노출한다. + * + * 암(리핑/발파) 지반에서만: 측구형식(일반/L형) 세그먼트와 암 경계선 상/하/리셋 + * 버튼(B05 측점 선 제어 ▲/▼/↺ 패턴 재활용)을 노출한다. 암 경계선 오프셋은 + * 서버 재계산 없이 프론트 세션에 보관되고(RockBoundaryControl), 확정 시 DB에 + * 병합된다. * ========================================================================== */ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; @@ -13,6 +19,7 @@ import type { CrossDesign, CrossSection, DitchSide, + DitchType, GroundType, SectionMode, } from "./B06_wf3_ProfileCross_Api_Fetch"; @@ -38,11 +45,34 @@ const DITCH_OPTIONS: Array<[DitchSide, keyof typeof ui_locales]> = [ ["left", "B06_Design_Ditch_Left"], ["right", "B06_Design_Ditch_Right"], ]; +const DITCH_TYPE_OPTIONS: Array<[DitchType, keyof typeof ui_locales]> = [ + ["standard", "B06_Design_DitchType_Standard"], + ["l_type", "B06_Design_DitchType_LType"], +]; export interface CrossDesignChange { ground_type: GroundType; section_mode: SectionMode; ditch_side: DitchSide | null; + ditch_type: DitchType; + paved: boolean; +} + +/** + * 암 경계선 세션 제어기. Page가 세션 저장소·카드 갱신과 연결해 구현한다. + * 오프셋은 지면선(지반선) 기준 상대값(m, 음수=하향)이다 — 계획선 기준이 아니다. + */ +export interface RockBoundaryControl { + stepM: number; + defaultOffsetM: number; + /** 세션 → design 저장값 → 기본값 순으로 현재 오프셋을 돌려준다. */ + offsetFor: (section: CrossSection) => number; + adjust: (chainageM: number, deltaM: number) => void; + reset: (chainageM: number) => void; +} + +function isRock(ground: GroundType): boolean { + return ground === "ripping_rock" || ground === "blasting_rock"; } function segment( @@ -72,17 +102,73 @@ function segment( return wrap; } +/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). */ +function rockBoundaryRow(section: CrossSection, control: RockBoundaryControl): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b06-design__seg b06-design__rockb"; + const legendEl = document.createElement("span"); + legendEl.className = "b06-design__seg-legend"; + legendEl.textContent = L("B06_Design_RockBoundary_Legend"); + wrap.append(legendEl); + + const group = document.createElement("div"); + group.className = "b06-design__seg-buttons"; + const readout = document.createElement("span"); + readout.className = "b06-design__rockb-readout"; + const currentOffset = control.offsetFor(section); + readout.textContent = `${currentOffset >= 0 ? "+" : ""}${currentOffset.toFixed(1)}m`; + + const makeButton = ( + label: string, + title: string, + className: string, + onClick: () => void, + ): HTMLButtonElement => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `b06-design__rockb-btn ${className}`; + button.textContent = label; + button.title = title; + button.addEventListener("click", onClick); + return button; + }; + + group.append( + makeButton("▲", `${L("B06_Design_RockBoundary_Up")} (+${control.stepM}m)`, "is-up", () => + control.adjust(section.chainage_m, control.stepM), + ), + makeButton("▼", `${L("B06_Design_RockBoundary_Down")} (-${control.stepM}m)`, "is-down", () => + control.adjust(section.chainage_m, -control.stepM), + ), + makeButton("↺", L("B06_Design_RockBoundary_Reset"), "is-reset", () => + control.reset(section.chainage_m), + ), + readout, + ); + wrap.append(group); + return wrap; +} + /** 카드 헤더용 설계 지정 컨트롤 바를 만든다. */ export function buildDesignControls( section: CrossSection, onChange: (chainageM: number, change: CrossDesignChange) => void, + rockBoundary?: RockBoundaryControl, ): HTMLElement { const design = section.design; - // 기본값: 토사(soil) + 좌절토(left_cut). 미지정 측점은 확정 시 이 기본값으로 채워진다. - const state: { ground: GroundType; mode: SectionMode; ditch: DitchSide | null } = { + // 기본값: 토사(soil) + 상단측 절토(uphill_side, 미상이면 좌절토) + 일반측구 + 비포장. + const state: { + ground: GroundType; + mode: SectionMode; + ditch: DitchSide | null; + ditchType: DitchType; + paved: boolean; + } = { ground: design?.ground_type ?? "soil", - mode: design?.section_mode ?? "left_cut", + mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"), ditch: design?.ditch_side ?? null, + ditchType: design?.ditch_type ?? "standard", + paved: design?.paved ?? false, }; const bar = document.createElement("div"); bar.className = "b06-design"; @@ -92,10 +178,14 @@ export function buildDesignControls( const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill"; const emit = (): void => { if (!state.ground || !state.mode) return; + // L형 측구는 암 전용 — 토사로 되돌리면 일반측구로 강등해 서버 거부를 예방한다. + if (!isRock(state.ground)) state.ditchType = "standard"; onChange(section.chainage_m, { ground_type: state.ground, section_mode: state.mode, ditch_side: needsDitch() ? (state.ditch ?? "left") : null, + ditch_type: state.ditchType, + paved: state.paved, }); }; @@ -117,6 +207,49 @@ export function buildDesignControls( }), ); } + // 측구형식(일반/L형): 암 지반 + 측구가 존재하는 단면(양성 제외)에서만 노출. + if (isRock(state.ground) && state.mode !== "both_fill") { + bar.append( + segment(L("B06_Design_DitchType_Legend"), DITCH_TYPE_OPTIONS, state.ditchType, (value) => { + state.ditchType = value; + emit(); + }), + ); + } + // 포장 토글: 지반유형과 중첩 적용(횡단경사·포장층만 변경). + const pavedWrap = document.createElement("div"); + pavedWrap.className = "b06-design__seg"; + const pavedLegend = document.createElement("span"); + pavedLegend.className = "b06-design__seg-legend"; + pavedLegend.textContent = L("B06_Design_Paved_Legend"); + const pavedButtons = document.createElement("div"); + pavedButtons.className = "b06-design__seg-buttons"; + const pavedButton = document.createElement("button"); + pavedButton.type = "button"; + pavedButton.className = `b06-design__btn${state.paved ? " b06-design__btn--active" : ""}`; + pavedButton.textContent = state.paved ? L("B06_Design_Paved_On") : L("B06_Design_Paved_Off"); + pavedButton.setAttribute("aria-pressed", state.paved ? "true" : "false"); + pavedButton.addEventListener("click", () => { + state.paved = !state.paved; + emit(); + }); + pavedButtons.append(pavedButton); + pavedWrap.append(pavedLegend, pavedButtons); + // B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다. + if (design?.pavement_suggested) { + pavedButton.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"); + pavedWrap.append(badge); + } + bar.append(pavedWrap); + + // 암 경계선 제어: 암 지반에서만 노출(서버 재계산 없이 세션 보관, 확정 시 DB 병합). + if (rockBoundary && isRock(state.ground)) { + bar.append(rockBoundaryRow(section, rockBoundary)); + } const readout = document.createElement("div"); readout.className = "b06-design__areas"; @@ -153,3 +286,58 @@ export function appendCrossDesignOverlay( polyline.setAttribute("class", "b06-chart__design-cross"); svg.append(polyline); } + +/** + * 암 경계선(지면선 복사 + 상하 오프셋, 점선)을 겹쳐 그린다. + * 계획선(설계선)이 아니라 **지반선(지면선)**을 복사해 이동하는 것이 규칙이다. + * 리핑암·발파암 지반에서만 호출한다. offsetM 음수 = 하향. + * 무효 샘플 구간은 지반선과 동일하게 선을 끊어 그린다. + */ +export function appendRockBoundaryOverlay( + svg: SVGSVGElement, + groundSamples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>, + offsetM: number, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, +): void { + const segments: string[][] = []; + let current: string[] = []; + for (const sample of groundSamples) { + const elevation = sample.elevation_m; + if (sample.valid === false || elevation === null || !Number.isFinite(elevation ?? NaN)) { + if (current.length > 1) segments.push(current); + current = []; + continue; + } + current.push(`${x(sample.offset_m ?? 0)},${toDisplayY((elevation as number) + offsetM)}`); + } + if (current.length > 1) segments.push(current); + for (const points of segments) { + const polyline = document.createElementNS(SVG_NS, "polyline"); + polyline.setAttribute("points", points.join(" ")); + polyline.setAttribute("class", "b06-chart__rock-boundary"); + svg.append(polyline); + } +} + +/** 포장 측점의 노면 포장층 박스를 겹쳐 그린다 (노면 양 끝점 기준, 두께만큼 하향). */ +export function appendPavementOverlay( + svg: SVGSVGElement, + design: CrossDesign, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, +): void { + if (!design.paved || !design.road_edges) return; + const thickness = design.pavement_thickness_m ?? 0.2; + const { left, right } = design.road_edges; + const points = [ + `${x(left.offset_m)},${toDisplayY(left.elevation_m)}`, + `${x(right.offset_m)},${toDisplayY(right.elevation_m)}`, + `${x(right.offset_m)},${toDisplayY(right.elevation_m - thickness)}`, + `${x(left.offset_m)},${toDisplayY(left.elevation_m - thickness)}`, + ]; + const polygon = document.createElementNS(SVG_NS, "polygon"); + polygon.setAttribute("points", points.join(" ")); + polygon.setAttribute("class", "b06-chart__pavement"); + svg.append(polygon); +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index 99911c72..d5fe2953 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -10,7 +10,10 @@ import type { CrossSection, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch"; import { appendCrossDesignOverlay, + appendPavementOverlay, + appendRockBoundaryOverlay, buildDesignControls, + type RockBoundaryControl, } from "./B06_wf3_ProfileCross_UI_Cross_Design"; import { CROSS_HEIGHT, @@ -117,6 +120,7 @@ export function createCrossSectionCard( forcedHeightPx?: number, designElevation?: number, onDesignChange?: DesignChangeHandler, + rockBoundary?: RockBoundaryControl, ): HTMLElement { const card = document.createElement("article"); card.id = `cross-${section.station_id}`; @@ -154,7 +158,7 @@ export function createCrossSectionCard( meta.append(kind); header.append(title, meta); card.append(header); - if (onDesignChange) card.append(buildDesignControls(section, onDesignChange)); + if (onDesignChange) card.append(buildDesignControls(section, onDesignChange, rockBoundary)); const metrics = crossPlotMetrics( section, @@ -253,7 +257,19 @@ export function createCrossSectionCard( if (section.design) { const toDisplayY = (elevation: number): number => y(elevationMid + (elevation - elevationMid) * exaggeration); + // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. + appendPavementOverlay(svg, section.design, x, toDisplayY); appendCrossDesignOverlay(svg, section.design, x, toDisplayY); + // 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의. + if (rockBoundary && section.design.geometry_preset === "rock") { + appendRockBoundaryOverlay( + svg, + sourceSamples, + rockBoundary.offsetFor(section), + x, + toDisplayY, + ); + } } const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index e66a22e5..a3470c6b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -18,15 +18,24 @@ import { import { computeCrossDesign, confirmSections, + type CrossSectionPatch, fetchSectionContext, fetchSectionDetail, getSections, regenerateSections, type SectionContextResponse, type SectionDetailResponse, - type SectionSummaryResponse, + type StandardCrossSection, } from "./B06_wf3_ProfileCross_Api_Fetch"; -import { type CrossDesignChange, createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View"; +import { + type CrossDesignChange, + createSectionView, + type RockBoundaryControl, +} from "./B06_wf3_ProfileCross_UI_Section_View"; +import { + createStandardPanel, + type StandardPanelController, +} from "./B06_wf3_ProfileCross_UI_Standard_Panel"; import "./B06_wf3_ProfileCross_UI_Style.css"; function L(key: keyof typeof ui_locales): string { @@ -43,54 +52,17 @@ function buildGroup(legend: string): HTMLElement { return group; } -function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement } { - const root = document.createElement("div"); - root.className = "b06-profile__info-line"; - const key = document.createElement("span"); - key.textContent = label; - const value = document.createElement("strong"); - value.textContent = "-"; - root.append(key, value); - return { root, value }; -} - -function metricRow(label: string, value: string): HTMLElement { - const row = document.createElement("div"); - row.className = "b06-profile__metric"; - const key = document.createElement("span"); - key.className = "b06-profile__metric-key"; - key.textContent = label; - const metricValue = document.createElement("span"); - metricValue.className = "b06-profile__metric-val"; - metricValue.textContent = value; - row.append(key, metricValue); - return row; -} - export async function renderB06ProfileCross(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); let currentRouteId: number | null = null; let sectionDetail: SectionDetailResponse | null = null; let stationInterval: number | undefined; - const routeGroup = buildGroup(L("B06_Profile_Group_Route")); - const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId")); - const filterInfo = buildInfoLine(L("B06_Profile_Field_Filter")); - const methodInfo = buildInfoLine(L("B06_Profile_Field_Method")); - const smoothInfo = buildInfoLine(L("B06_Profile_Field_Smooth")); - const crsInfo = buildInfoLine(L("B06_Profile_Field_Crs")); - routeGroup.append( - routeIdInfo.root, - filterInfo.root, - methodInfo.root, - smoothInfo.root, - crsInfo.root, - ); - - const resultGroup = buildGroup(L("B06_Profile_Result_Title")); - const resultBody = document.createElement("div"); - resultBody.className = "b06-profile__result-body"; - resultGroup.append(resultBody); + // 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다. + const standardGroup = buildGroup(L("B06_Std_Title")); + const standardPanelSlot = document.createElement("div"); + standardGroup.append(standardPanelSlot); + let standardPanel: StandardPanelController | null = null; const displayGroup = buildGroup(L("B06_Profile_Group_Display")); const verticalExaggerationField = createInputField({ @@ -125,7 +97,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; - leftForm.append(routeGroup, resultGroup, displayGroup, actionRow); + leftForm.append(standardGroup, displayGroup, actionRow); // 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다. const designRequestSeq = new Map(); @@ -133,6 +105,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { /** * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. + * 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다. */ async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise { if (!projectId || currentRouteId === null || !sectionDetail) return; @@ -148,6 +121,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ground_type: change.ground_type, section_mode: change.section_mode, ditch_side: change.ditch_side ?? target.design.ditch_side, + ditch_type: change.ditch_type, + paved: change.paved, }; sectionView.refreshCard(chainageM); } @@ -159,6 +134,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const response = await computeCrossDesign(projectId, currentRouteId, { chainage_m: chainageM, ...change, + standard_cross_section: standardPanel?.getValues(), }); if (designRequestSeq.get(chainageM) !== seq) return; target.design = response.design; @@ -170,27 +146,89 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } + /* ── 암 경계선 오프셋(측점별) 세션 저장소 ───────────────────────────── + * 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시 + * cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다. + * 기본 오프셋·스텝은 context(config) 값으로 갱신된다. */ + let rockBoundaryDefault = -0.5; + let rockBoundaryStep = 0.1; + const rockOffsets = new Map(); + const rockKey = (chainageM: number): string => chainageM.toFixed(2); + const rockSessionKey = (): string | null => + projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null; + + function loadRockOffsets(): void { + rockOffsets.clear(); + const key = rockSessionKey(); + if (!key) return; + try { + const raw = window.sessionStorage.getItem(key); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([chainage, offset]) => { + if (Number.isFinite(offset)) rockOffsets.set(chainage, offset); + }); + } catch { + /* 손상된 세션 값은 무시 — 기본값으로 재시작. */ + } + } + + function persistRockOffsets(): void { + const key = rockSessionKey(); + if (!key) return; + try { + window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(rockOffsets))); + } catch { + /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ + } + } + + const rockBoundaryControl: RockBoundaryControl = { + get stepM() { + return rockBoundaryStep; + }, + get defaultOffsetM() { + return rockBoundaryDefault; + }, + offsetFor: (section) => + rockOffsets.get(rockKey(section.chainage_m)) ?? + section.design?.rock_boundary_offset_m ?? + rockBoundaryDefault, + adjust: (chainageM, deltaM) => { + const key = rockKey(chainageM); + const stored = sectionDetail?.cross_sections.find( + (section) => Math.abs(section.chainage_m - chainageM) < 0.01, + )?.design?.rock_boundary_offset_m; + const current = rockOffsets.get(key) ?? stored ?? rockBoundaryDefault; + rockOffsets.set(key, Math.round((current + deltaM) * 100) / 100); + persistRockOffsets(); + sectionView.refreshCard(chainageM); + }, + reset: (chainageM) => { + rockOffsets.set(rockKey(chainageM), rockBoundaryDefault); + persistRockOffsets(); + sectionView.refreshCard(chainageM); + }, + }; + const sectionView = createSectionView((chainageM, change) => { void handleDesignChange(chainageM, change); - }); + }, rockBoundaryControl); + + // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. + const mainArea = document.createElement("div"); + mainArea.className = "b06-profile__main"; + mainArea.append(sectionView.root); + + function showSectionView(): void { + if (!mainArea.contains(sectionView.root)) mainArea.replaceChildren(sectionView.root); + } function renderMessage(message: string): void { const text = document.createElement("p"); text.className = "b06-profile__empty"; text.textContent = message; - resultBody.replaceChildren(text); - } - - function renderSummary(result: SectionSummaryResponse): void { - const path = result.longitudinal?.longitudinal_file_path; - resultBody.replaceChildren( - metricRow( - L("B06_Profile_Result_Length"), - result.length_m === null ? "-" : result.length_m.toFixed(2), - ), - metricRow(L("B06_Profile_Result_CrossCount"), String(result.cross_section_count)), - metricRow(L("B06_Profile_Result_Path"), typeof path === "string" ? path : "-"), - ); + mainArea.replaceChildren(text); } function verticalExaggeration(): number { @@ -214,8 +252,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } function renderSectionDetail(): void { - if (sectionDetail) + if (sectionDetail) { + showSectionView(); sectionView.render(sectionDetail, verticalExaggeration(), crossHalfWidth(), stationInterval); + } } async function applyCrossHalfWidth(): Promise { @@ -243,7 +283,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (!projectId || currentRouteId === null) return; showLoadingOverlay(); try { - await confirmSections(projectId, currentRouteId); + // 세션 보관 중인 측점별 암 경계선 오프셋을 확정 시점에 DB로 병합한다. + const crossPatches: CrossSectionPatch[] = [...rockOffsets.entries()].map( + ([chainage, offset]) => ({ + chainage_m: Number(chainage), + rock_boundary_offset_m: offset, + }), + ); + await confirmSections( + projectId, + currentRouteId, + standardPanel?.getValues(), + crossPatches.length ? crossPatches : undefined, + ); showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]); } catch (error) { @@ -271,7 +323,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { steps: workflowSteps(), activeStep: 3, leftPanel: leftForm, - mainContent: sectionView.root, + mainContent: mainArea, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, @@ -291,16 +343,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return; } - routeIdInfo.value.textContent = context.route_id === null ? "-" : String(context.route_id); - filterInfo.value.textContent = context.filter_key ?? "-"; - methodInfo.value.textContent = context.method ?? "-"; - smoothInfo.value.textContent = context.smooth - ? L("B06_Profile_Smooth_On") - : L("B06_Profile_Smooth_Off"); - crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`; verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration); crossHalfWidthField.input.value = String(context.defaults.cross_half_width_m); stationInterval = context.defaults.station_interval_m; + rockBoundaryDefault = context.rock_boundary_default_offset_m; + rockBoundaryStep = context.rock_boundary_step_m; + + // 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값). + standardPanel = createStandardPanel(projectId, context.standard_cross_section); + standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { renderMessage(L("B06_Profile_Calculate_In_B05")); @@ -308,19 +359,26 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } currentRouteId = context.route_id; + loadRockOffsets(); try { const existing = await getSections(projectId, context.route_id); if (!existing.longitudinal) { renderMessage(L("B06_Profile_Calculate_In_B05")); return; } - renderSummary(existing); sectionDetail = await fetchSectionDetail(projectId, context.route_id); // 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정 const summaryData = existing.longitudinal.data as { - options?: { cross_half_width_m?: number; station_interval_m?: number }; + options?: { + cross_half_width_m?: number; + station_interval_m?: number; + standard_cross_section?: StandardCrossSection; + }; } | null; const storedOptions = summaryData?.options; + // 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시). + if (storedOptions?.standard_cross_section) + standardPanel?.applyStored(storedOptions.standard_cross_section); const storedHalfWidth = storedOptions?.cross_half_width_m && storedOptions.cross_half_width_m > 0 ? storedOptions.cross_half_width_m diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index bad97b00..0a050d95 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -11,6 +11,7 @@ * ========================================================================== */ import type { CrossSection, SectionDetailResponse } from "./B06_wf3_ProfileCross_Api_Fetch"; +import type { RockBoundaryControl } from "./B06_wf3_ProfileCross_UI_Cross_Design"; import { createCrossSectionCard, crossCardNaturalHeight, @@ -34,7 +35,7 @@ import { type YScaleOptions, } from "./B06_wf3_ProfileCross_UI_Section_Common"; -export type { CrossDesignChange, DesignChangeHandler }; +export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; export interface SectionViewController { root: HTMLElement; @@ -50,7 +51,10 @@ export interface SectionViewController { dispose: () => void; } -export function createSectionView(onDesignChange?: DesignChangeHandler): SectionViewController { +export function createSectionView( + onDesignChange?: DesignChangeHandler, + rockBoundary?: RockBoundaryControl, +): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; let currentDetail: SectionDetailResponse | null = null; @@ -99,6 +103,7 @@ export function createSectionView(onDesignChange?: DesignChangeHandler): Section ? designElevationAt(currentDetail.longitudinal.design_profiles, section.chainage_m) : undefined, onDesignChange, + rockBoundary, ); const draw = (): void => { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts new file mode 100644 index 00000000..b4c60083 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts @@ -0,0 +1,246 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_Standard_Panel.ts + * 좌측 사이드 "표준 횡단면 설정" 패널 (토사 / 암 / 포장 3그룹). + * + * 각 그룹의 노폭·노견·측구 규격·경사값을 편집한다. 기본값은 백엔드 config + * (STANDARD_CROSS_SECTION, context.standard_cross_section)에서 내려오고, 사용자가 + * 편집한 값은 프론트 세션(sessionStorage)에 프로젝트 단위로 보관한다. 종·횡단 + * 확정 시 이 값을 백엔드/DB에 저장하기 위해 getValues()를 노출한다. + * + * 암 그룹은 일반 측구 + L형 측구 두 세트를 보관하며, 둘 중 선택은 각 횡단면도 + * 카드에서 이뤄진다(여기서는 값만 보관). + * ========================================================================== */ + +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createButton, createInputField } from "@ui/ui_template_elements"; +import type { + StandardCrossGroup, + StandardCrossKey, + StandardCrossSection, +} from "./B06_wf3_ProfileCross_Api_Fetch"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [ + ["soil", "B06_Std_Group_Soil"], + ["rock", "B06_Std_Group_Rock"], + ["paved", "B06_Std_Group_Paved"], +]; + +const SESSION_PREFIX = "b06:std-cross:"; + +function sessionKey(projectId: string): string { + return `${SESSION_PREFIX}${projectId}`; +} + +/** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */ +function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection { + return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection; +} + +/** 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. */ +function readSession(projectId: string): StandardCrossSection | null { + try { + const raw = window.sessionStorage.getItem(sessionKey(projectId)); + return raw ? (JSON.parse(raw) as StandardCrossSection) : null; + } catch { + return null; + } +} + +function writeSession(projectId: string, value: StandardCrossSection): void { + try { + window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(value)); + } catch { + /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ + } +} + +export interface StandardPanelController { + root: HTMLElement; + /** 확정 시 백엔드/DB 저장에 쓰는 현재 편집값. */ + getValues: () => StandardCrossSection; + /** + * DB에 확정 저장된 값으로 복원한다. 단, 세션에 미확정 편집값이 있으면 + * 그쪽을 우선하고 무시한다(진행 중 편집 보호). + */ + applyStored: (stored: StandardCrossSection) => void; +} + +interface NumberFieldSpec { + label: string; + get: (group: StandardCrossGroup) => number; + set: (group: StandardCrossGroup, value: number) => void; + /** 암 그룹의 L형 측구처럼 특정 그룹에만 존재하는 필드는 조건으로 거른다. */ + only?: StandardCrossKey; +} + +/** 그룹 하나에 노출할 편집 필드 정의. 순서 = 화면 표기 순서. */ +const FIELD_SPECS: NumberFieldSpec[] = [ + { + label: "B06_Std_Field_RoadWidth", + get: (g) => g.road_width_m, + set: (g, v) => (g.road_width_m = v), + }, + { + label: "B06_Std_Field_ShoulderLeft", + get: (g) => g.shoulder_left_m, + set: (g, v) => (g.shoulder_left_m = v), + }, + { + label: "B06_Std_Field_ShoulderRight", + get: (g) => g.shoulder_right_m, + set: (g, v) => (g.shoulder_right_m = v), + }, + { + label: "B06_Std_Field_DitchTop", + get: (g) => g.ditch.top_width_m, + set: (g, v) => (g.ditch.top_width_m = v), + }, + { + label: "B06_Std_Field_DitchBottom", + get: (g) => g.ditch.bottom_width_m, + set: (g, v) => (g.ditch.bottom_width_m = v), + }, + { + label: "B06_Std_Field_DitchDepth", + get: (g) => g.ditch.depth_m, + set: (g, v) => (g.ditch.depth_m = v), + }, + { + label: "B06_Std_Field_LDitchWidth", + only: "rock", + get: (g) => g.ditch_l_type?.width_m ?? 0, + set: (g, v) => { + g.ditch_l_type = { width_m: v, depth_m: g.ditch_l_type?.depth_m ?? 0 }; + }, + }, + { + label: "B06_Std_Field_LDitchDepth", + only: "rock", + get: (g) => g.ditch_l_type?.depth_m ?? 0, + set: (g, v) => { + g.ditch_l_type = { width_m: g.ditch_l_type?.width_m ?? 0, depth_m: v }; + }, + }, + { + label: "B06_Std_Field_CutSlope", + get: (g) => g.cut_slope_ratio, + set: (g, v) => (g.cut_slope_ratio = v), + }, + { + label: "B06_Std_Field_FillSlope", + get: (g) => g.fill_slope_ratio, + set: (g, v) => (g.fill_slope_ratio = v), + }, + { + label: "B06_Std_Field_CrossSlopeMin", + get: (g) => g.cross_slope_pct.min, + set: (g, v) => (g.cross_slope_pct.min = v), + }, + { + label: "B06_Std_Field_CrossSlopeMax", + get: (g) => g.cross_slope_pct.max, + set: (g, v) => (g.cross_slope_pct.max = v), + }, +]; + +/** + * 표준 횡단면 설정 패널을 만든다. + * @param projectId 세션 캐시 스코프. + * @param defaults config에서 내려온 기본값(복원 기준). + */ +export function createStandardPanel( + projectId: string, + defaults: StandardCrossSection, +): StandardPanelController { + // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. + const sessionValue = readSession(projectId); + const hadSession = sessionValue !== null; + const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults); + + const root = document.createElement("div"); + root.className = "b06-std"; + + // 그룹 재구성(리셋 시) 편의를 위해 본문 컨테이너를 분리한다. + const body = document.createElement("div"); + body.className = "b06-std__body"; + + const persist = (): void => writeSession(projectId, state); + + const buildGroup = (key: StandardCrossKey, legendKey: keyof typeof ui_locales): HTMLElement => { + const group = state[key]; + const fieldset = document.createElement("fieldset"); + fieldset.className = "b06-std__group"; + const legend = document.createElement("legend"); + legend.className = "b06-std__legend"; + legend.textContent = L(legendKey); + fieldset.append(legend); + + const grid = document.createElement("div"); + grid.className = "b06-std__grid"; + for (const spec of FIELD_SPECS) { + if (spec.only && spec.only !== key) continue; + const field = createInputField({ + label: L(spec.label as keyof typeof ui_locales), + type: "number", + value: String(spec.get(group)), + onInput: (raw) => { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + spec.set(group, parsed); + persist(); + }, + }); + field.input.step = "0.1"; + field.input.min = "0"; + grid.append(field.root); + } + fieldset.append(grid); + + if (key === "rock") { + const note = document.createElement("p"); + note.className = "b06-std__note"; + note.textContent = L("B06_Std_LType_Note"); + fieldset.append(note); + } + return fieldset; + }; + + const renderBody = (): void => { + body.replaceChildren(...GROUP_ORDER.map(([key, legendKey]) => buildGroup(key, legendKey))); + }; + renderBody(); + + const resetButton = createButton({ + label: L("B06_Std_Reset"), + variant: "ghost", + onClick: () => { + const fresh = cloneDefaults(defaults); + (Object.keys(fresh) as StandardCrossKey[]).forEach((key) => { + state[key] = fresh[key]; + }); + persist(); + renderBody(); + }, + }); + const actions = document.createElement("div"); + actions.className = "b06-std__actions"; + actions.append(resetButton); + + root.append(body, actions); + + return { + root, + getValues: () => state, + applyStored: (stored) => { + if (hadSession) return; // 진행 중 세션 편집값이 우선. + (Object.keys(stored) as StandardCrossKey[]).forEach((key) => { + if (stored[key]) state[key] = JSON.parse(JSON.stringify(stored[key])) as StandardCrossGroup; + }); + renderBody(); + }, + }; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index cf1dddc8..463b3b2d 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -79,6 +79,54 @@ min-width: 0; } +/* --- 표준 횡단면 설정 패널 --- */ +.b06-std { + display: flex; + flex-direction: column; + gap: var(--spacing-16); +} + +.b06-std__body { + display: flex; + flex-direction: column; + gap: var(--spacing-16); +} + +.b06-std__group { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + margin: 0; + padding: var(--spacing-8) var(--spacing-16) var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background-color: var(--color-surface); +} + +.b06-std__legend { + padding: 0 var(--spacing-8); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + +.b06-std__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-8); +} + +.b06-std__note { + margin: 0; + font-size: var(--text-caption); + color: var(--color-text-muted); +} + +.b06-std__actions { + display: flex; + gap: var(--spacing-8); +} + /* --- 우측 결과 --- */ .b06-profile__result { display: flex; @@ -455,3 +503,62 @@ stroke-width: 1.8; stroke-linejoin: round; } + +/* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */ +.b06-chart__rock-boundary { + fill: none; + stroke: var(--color-warning); + stroke-width: 1.6; + stroke-dasharray: 6 4; + stroke-linejoin: round; + opacity: 0.9; +} + +/* 포장층 박스: 노면 양 끝점 기준 두께만큼 하향 채움 */ +.b06-chart__pavement { + fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent); + stroke: var(--color-text-secondary); + stroke-width: 1; +} + +/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */ +.b06-design__rockb-btn { + width: 22px; + height: 22px; + padding: 0; + font-size: 0.7rem; + line-height: 1; + color: var(--color-text-secondary); + background: var(--color-surface); + border: none; + border-left: 1px solid var(--color-border); + cursor: pointer; +} + +.b06-design__rockb-btn:first-child { + border-left: none; +} + +.b06-design__rockb-btn:hover { + color: var(--color-text); + background: var(--color-surface-raised); +} + +.b06-design__rockb-btn.is-reset { + color: var(--color-warning); +} + +.b06-design__rockb-readout { + padding: 0 var(--spacing-8); + font-size: 0.72rem; + font-family: var(--font-mono); + color: var(--color-warning); + align-self: center; +} + +/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */ +.b06-design__paved-badge { + font-size: 0.72rem; + color: var(--color-warning); + cursor: help; +} diff --git a/config/config_system.py b/config/config_system.py index bccf8f5f..ce2ca9a7 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -245,22 +245,80 @@ FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5} # ───────────────────────────────────────────────────────────────────────── -# 5-4-1. 횡단 표준단면 설계 기준 (B06 WF3) +# 5-4-1. 표준 횡단면 단일 진실 원천 (도면 판독값, 2026-07-24 사용자 확정) # -# 출처: 첨부 표준횡단도(토사/암 구간). 노면폭 4.5m = 길어깨 0.5 + 차도 3.5 + 길어깨 0.5. +# 출처: `08 표준횡단도면(울진 울진 대흥 산65 외2(3공구)).bmp` 좌측 영역 판독값. +# 지반그룹 3종: soil(토사) / rock(암, 리핑·발파 공유) / paved(포장). +# 단위: 길이 m, 경사비 수평:수직=ratio:1, 횡단경사 %. +# +# 이 상수가 **표준 횡단면 기하의 유일한 정의처**다. B06 설정 패널 기본값과 +# 절·성토 단면적 엔진(B06_wf3_ProfileCross_Engine_Design)이 모두 여기를 읽으며, +# 아래 5-4-2의 파생 상수 외에 같은 값을 별도로 정의하지 않는다. +# ───────────────────────────────────────────────────────────────────────── +STANDARD_CROSS_SECTION = { + "soil": { + "road_width_m": 3.0, # 노폭(차도) + "shoulder_left_m": 0.5, # 노견(좌) + "shoulder_right_m": 0.5, # 노견(우) + # 측구(상단폭/저폭/깊이). 토사 = 900/300/300mm. + "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "cross_slope_pct": {"min": 3.0, "max": 5.0}, # 횡단경사(측구방향) + "fill_slope_ratio": 1.2, # 성토 1:1.2 + "cut_slope_ratio": 1.0, # 절토 1:1.0 + }, + "rock": { # 리핑암·발파암 공유 + "road_width_m": 3.0, + "shoulder_left_m": 0.5, + "shoulder_right_m": 0.5, + # 일반 측구 = 690/300/300mm. + "ditch": {"top_width_m": 0.69, "bottom_width_m": 0.3, "depth_m": 0.3}, + # L형 측구 = 폭500 × 깊이100mm (횡단면도에서 일반/L형 중 선택). + "ditch_l_type": {"width_m": 0.5, "depth_m": 0.1}, + "cross_slope_pct": {"min": 3.0, "max": 3.0}, + "fill_slope_ratio": 1.2, + "cut_slope_ratio": 0.4, # 절토(암) 1:0.4 (규정 1:0.3 이상) + }, + "paved": { # 포장: 토사와 동일 기하 + 횡단경사만 다름 + "road_width_m": 3.0, + "shoulder_left_m": 0.5, + "shoulder_right_m": 0.5, + "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "cross_slope_pct": {"min": 1.5, "max": 2.0}, + "fill_slope_ratio": 1.2, + "cut_slope_ratio": 1.0, + # 포장층 두께(도면 미표기 — 임도 콘크리트 포장 실무 표준 0.2m, 패널에서 편집 가능). + "pavement_thickness_m": 0.2, + }, +} +# 암 경계선(지면선 복사 — 계획선 아님) 기본 오프셋과 상/하 제어 스텝(m). +STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M = -0.5 +STANDARD_ROCK_BOUNDARY_STEP_M = 0.1 + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-2. 횡단 표준단면 엔진 파생 상수 (B06 WF3) +# +# 전부 위 5-4-1 STANDARD_CROSS_SECTION에서 파생한다(중복 정의 금지). # 지반유형 라벨은 3종(토사/리핑암/발파암)이나, 표준단면 기하는 토사/암반 2종 # 프리셋만 존재한다(리핑암·발파암은 절토경사·측구 동일, 단가만 B08/B09에서 구분). -# 성토 경사는 지반유형과 무관하게 고정값을 적용한다. # ───────────────────────────────────────────────────────────────────────── -SECTION_ROADBED_WIDTH_M = float(os.getenv("SECTION_ROADBED_WIDTH_M", "4.5")) -SECTION_CARRIAGEWAY_WIDTH_M = float(os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", "3.5")) +_SCS_SOIL = STANDARD_CROSS_SECTION["soil"] +# 노면폭(노견 포함) = 0.5 + 3.0 + 0.5 = 4.0m. env로만 개별 재정의 가능. +SECTION_ROADBED_WIDTH_M = float( + os.getenv( + "SECTION_ROADBED_WIDTH_M", + str( + _SCS_SOIL["road_width_m"] + _SCS_SOIL["shoulder_left_m"] + _SCS_SOIL["shoulder_right_m"] + ), + ) +) +SECTION_CARRIAGEWAY_WIDTH_M = float( + os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", str(_SCS_SOIL["road_width_m"])) +) # 성토 경사(수평:수직 = ratio:1). 지반유형 무관 고정. -SECTION_FILL_SLOPE_RATIO = float(os.getenv("SECTION_FILL_SLOPE_RATIO", "1.2")) -# 표준단면 기하 프리셋: 절토경사(수평:수직=ratio:1)와 측구 규격(m). -SECTION_DESIGN_TEMPLATES = { - "soil": {"cut_slope_ratio": 1.2, "ditch_width_m": 1.0, "ditch_depth_m": 0.4}, - "rock": {"cut_slope_ratio": 0.6, "ditch_width_m": 0.79, "ditch_depth_m": 0.4}, -} +SECTION_FILL_SLOPE_RATIO = float( + os.getenv("SECTION_FILL_SLOPE_RATIO", str(_SCS_SOIL["fill_slope_ratio"])) +) # 지반유형(저장 라벨) → 기하 프리셋 키. 견적 단가 구분은 라벨 자체로 유지한다. SECTION_GROUND_TYPE_PRESET = { "soil": "soil", @@ -270,6 +328,8 @@ SECTION_GROUND_TYPE_PRESET = { # 단면유형: 좌절/우절(편절편성), 양절, 양성. 좌=양(+)offset, 우=음(-)offset. SECTION_MODES = ("left_cut", "right_cut", "both_cut", "both_fill") SECTION_DITCH_SIDES = ("left", "right") +# 측구 형식: 일반(사다리꼴) / L형(암 구간 전용 선택지). +SECTION_DITCH_TYPES = ("standard", "l_type") # ───────────────────────────────────────────────────────────────────────── diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index e7ed99ce..bb4c1824 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -813,12 +813,49 @@ export const ui_locales = { B06_Design_Cut_Area: ["절토", "Cut"], B06_Design_Fill_Area: ["성토", "Fill"], B06_Design_Unset: ["미지정", "Not set"], + 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_Paved_Suggested: [ + "종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)", + "Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)", + ], + B06_Design_RockBoundary_Legend: ["암 경계", "Rock boundary"], + B06_Design_RockBoundary_Up: ["암 경계선 올림", "Raise rock boundary"], + B06_Design_RockBoundary_Down: ["암 경계선 내림", "Lower rock boundary"], + B06_Design_RockBoundary_Reset: ["암 경계선 기본값 복원", "Reset rock boundary"], B06_Design_Failed: ["횡단 설계 계산에 실패했습니다.", "Failed to compute cross-section design."], B06_Profile_Confirm_NeedDesign: [ "지반유형이 지정되지 않은 측점이 있습니다.", "Some stations have no ground type assigned.", ], + /* --- B06 표준 횡단면 설정 패널 --- */ + B06_Std_Title: ["표준 횡단면 설정", "Standard cross-section"], + B06_Std_Group_Soil: ["토사 구간", "Soil section"], + B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"], + B06_Std_Group_Paved: ["포장 구간", "Paved section"], + B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"], + B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"], + B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"], + B06_Std_Field_DitchTop: ["측구 상단폭(m)", "Ditch top (m)"], + B06_Std_Field_DitchBottom: ["측구 저폭(m)", "Ditch bottom (m)"], + B06_Std_Field_DitchDepth: ["측구 깊이(m)", "Ditch depth (m)"], + B06_Std_Field_LDitchWidth: ["L형 측구 폭(m)", "L-ditch width (m)"], + B06_Std_Field_LDitchDepth: ["L형 측구 깊이(m)", "L-ditch depth (m)"], + B06_Std_Field_CrossSlopeMin: ["횡단경사 최소(%)", "Cross slope min (%)"], + B06_Std_Field_CrossSlopeMax: ["횡단경사 최대(%)", "Cross slope max (%)"], + B06_Std_Field_CutSlope: ["절토경사(1:n)", "Cut slope (1:n)"], + B06_Std_Field_FillSlope: ["성토경사(1:n)", "Fill slope (1:n)"], + B06_Std_LType_Note: [ + "L형 측구는 각 횡단면도에서 선택합니다.", + "L-type ditch is chosen per cross-section drawing.", + ], + B06_Std_Reset: ["기본값 복원", "Restore defaults"], + /* --- B07_wf4_DesignDetail 상세 설계 --- */ B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"], B07_Cad_Side_Pending: [