From b1218e9d7973897df7f5334c64cea5e0080d11ab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 21 Jul 2026 20:12:44 +0900 Subject: [PATCH] 260721 --- .../B06_wf3_ProfileCross_Api_Fetch.ts | 50 ++++ .../B06_wf3_ProfileCross_Engine_Design.py | 236 ++++++++++++++++ .../B06_wf3_ProfileCross_Repository.py | 175 ++++++++++++ .../B06_wf3_ProfileCross_Router.py | 196 ++++++++++++- .../B06_wf3_ProfileCross_Schema.py | 23 +- .../B06_wf3_ProfileCross_UI_Cross_Design.ts | 155 +++++++++++ .../B06_wf3_ProfileCross_UI_Page.ts | 51 +++- .../B06_wf3_ProfileCross_UI_Section_View.ts | 103 ++++--- .../B06_wf3_ProfileCross_UI_Style.css | 78 ++++++ .../B07_wf4_DesignDetail_Api_Fetch.ts | 18 ++ .../B07_wf4_DesignDetail_Router.py | 263 +++++++++++++++--- .../B07_wf4_DesignDetail_Schema.py | 4 + .../B07_wf4_DesignDetail_UI_Page.ts | 117 +++++++- .../B07_wf4_DesignDetail_UI_Style.css | 67 +++++ config/config_db.py | 1 - config/config_system.py | 28 ++ ui_template/ui_template_locale.ts | 38 +++ 17 files changed, 1523 insertions(+), 80 deletions(-) create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 1b486f42..cfb37e9d 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -117,6 +117,8 @@ export interface LongitudinalSection { export interface CrossSection extends SectionStation { samples: SectionSample[]; + /** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */ + design?: CrossDesign; } export interface SectionDetailResponse { @@ -132,6 +134,42 @@ export interface SectionConfirmResponse { confirmed: boolean; } +/** 측점 표준횡단 설계 지정값 (버튼 상태). */ +export type GroundType = "soil" | "ripping_rock" | "blasting_rock"; +export type SectionMode = "left_cut" | "right_cut" | "both_cut" | "both_fill"; +export type DitchSide = "left" | "right"; + +/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */ +export interface CrossDesign { + ground_type: GroundType; + geometry_preset: "soil" | "rock"; + section_mode: SectionMode; + ditch_side: DitchSide; + cut_slope_ratio: number; + fill_slope_ratio: number; + roadbed_width_m: number; + carriageway_width_m: number; + ditch: { width_m: number; depth_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 }>; +} + +export interface CrossDesignResponse { + status: string; + chainage_m: number; + design: CrossDesign; +} + +export interface CrossDesignRequest { + chainage_m: number; + ground_type: GroundType; + section_mode: SectionMode; + ditch_side?: DitchSide | null; +} + /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ async function requestJson(path: string, init: RequestInit): Promise { const controller = new AbortController(); @@ -195,6 +233,18 @@ export async function regenerateSections( ); } +/** 측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산·저장하고 잠정 결과를 반환한다. */ +export async function computeCrossDesign( + projectId: string, + routeId: number, + request: CrossDesignRequest, +): Promise { + return requestJson( + `/projects/${projectId}/sections/${routeId}/cross-design`, + { method: "POST", body: JSON.stringify(request) }, + ); +} + /** 경로의 종·횡단면을 확정한다. */ export async function confirmSections( projectId: string, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py new file mode 100644 index 00000000..e7b0b04f --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py @@ -0,0 +1,236 @@ +"""B06 측점 표준횡단 설계 계산 엔진. + +지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단 +설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가 +버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서 +확정치로 대체된다. + +좌표 규약(generate_sections cad_exchange 준수): offset_m 양수=좌, 음수=우. +경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). +""" + +from typing import Any + +from config.config_system import ( + SECTION_CARRIAGEWAY_WIDTH_M, + SECTION_DESIGN_TEMPLATES, + SECTION_DITCH_SIDES, + SECTION_FILL_SLOPE_RATIO, + SECTION_GROUND_TYPE_PRESET, + SECTION_MODES, + SECTION_ROADBED_WIDTH_M, +) + + +def _side_role(section_mode: str) -> tuple[str, str]: + """단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'.""" + if section_mode == "left_cut": + return "cut", "fill" + if section_mode == "right_cut": + return "fill", "cut" + if section_mode == "both_cut": + return "cut", "cut" + if section_mode == "both_fill": + return "fill", "fill" + raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}") + + +def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str: + """측구(배수) 배치 측을 결정한다. + + 편절편성은 절토측이 곧 측구측이라 자동 결정하고, 양절·양성은 배수 방향을 + 사용자 지정(ditch_side)에 맡긴다(미지정 시 좌측 기본). + """ + if section_mode == "left_cut": + return "left" + if section_mode == "right_cut": + return "right" + if ditch_side in SECTION_DITCH_SIDES: + return ditch_side + 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의 설계 표고를 절토/성토 규칙으로 계산한다. + + 절토측: 노면 가장자리에서 경사면이 위로 올라가다 지반선을 만나면 지반을 따른다. + 성토측: 가장자리에서 경사면이 아래로 내려가다 지반선을 만나면 지반을 따른다. + """ + 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 _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, float]: + """오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 (절토, 성토) 면적을 반환한다. + + diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서 + 나눠 절·성토가 섞이지 않게 한다. + """ + cut_area = 0.0 + fill_area = 0.0 + for index in range(1, len(offsets)): + x0, x1 = offsets[index - 1], offsets[index] + d0, d1 = diffs[index - 1], diffs[index] + width = x1 - x0 + if width <= 0: + continue + if d0 == 0 and d1 == 0: + continue + if d0 * d1 < 0: + # 부호 변화: 영교점에서 두 삼각형으로 분리 + zero_ratio = d0 / (d0 - d1) + x_zero = x0 + width * zero_ratio + left_area = 0.5 * (x_zero - x0) * abs(d0) + right_area = 0.5 * (x1 - x_zero) * abs(d1) + if d0 > 0: + cut_area += left_area + fill_area += right_area + else: + fill_area += left_area + cut_area += right_area + continue + area = 0.5 * (d0 + d1) * width + if area >= 0: + cut_area += area + else: + fill_area += -area + return cut_area, fill_area + + +def compute_cross_design( + samples: list[dict[str, Any]], + design_elevation_m: float | None, + *, + 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, +) -> dict[str, Any]: + """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. + + samples: [{offset_m, elevation_m, valid}] 지반선 원시 샘플. + design_elevation_m: 중심선 계획고(노면고). None이면 계산 불가. + """ + 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 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) + resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side) + + valid = sorted( + ( + (float(s["offset_m"]), float(s["elevation_m"])) + for s in samples + if s.get("valid") is not False + and s.get("offset_m") is not None + and s.get("elevation_m") is not None + ), + key=lambda pair: pair[0], + ) + if len(valid) < 2: + raise ValueError("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.") + + 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, + ) + 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 { + "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}, + "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, + } + + +def design_elevation_from_longitudinal( + longitudinal: dict[str, Any], chainage_m: float +) -> float | None: + """종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다. + + 프론트 designElevationAt과 동일 규칙(범위 밖 양 끝값 클램프). 계획선이 없으면 + None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다. + """ + profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None + if not isinstance(profiles, list) or not profiles: + return None + samples = [ + s + for s in profiles[0].get("samples", []) + if isinstance(s.get("elevation_m"), (int, float)) + and isinstance(s.get("chainage_m"), (int, float)) + ] + if not samples: + return None + if chainage_m <= samples[0]["chainage_m"]: + return float(samples[0]["elevation_m"]) + last = samples[-1] + if chainage_m >= last["chainage_m"]: + return float(last["elevation_m"]) + for index in range(1, len(samples)): + previous = samples[index - 1] + current = samples[index] + if chainage_m > current["chainage_m"]: + continue + span = current["chainage_m"] - previous["chainage_m"] + if span <= 0: + return float(current["elevation_m"]) + ratio = (chainage_m - previous["chainage_m"]) / span + return float( + previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio + ) + return float(last["elevation_m"]) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index 79535892..77c25a5c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -268,6 +268,181 @@ async def count_cross_sections(connection: aiomysql.Connection, route_id: int) - return int(row[0]) if row else 0 +async def update_cross_section_design( + connection: aiomysql.Connection, + *, + route_id: int, + chainage_m: float, + design: dict[str, Any], +) -> bool: + """측점 하나의 data.design(잠정 설계 지정·단면적)을 병합 저장한다. + + 기존 data 요약을 보존하고 design 키만 갱신한다. 대상 측점을 chainage 근사로 + 찾으며(부동소수 오차 허용), 갱신 여부를 반환한다. + """ + 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 = {} + 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_cross_section_design_by_round( + connection: aiomysql.Connection, + *, + route_id: int, + chainage_int: int, + patch: dict[str, Any], +) -> bool: + """정수 chainage(m) 측점의 data.design에 patch를 병합 저장한다. + + B07 도면 확정(전체 설계 재계산 결과) 및 확정 해제(status 되돌림)에서 사용한다. + 도면 ID가 정수 m라 ROUND로 매칭한다. 갱신 여부를 반환한다. + """ + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT id, data FROM cross_sections + WHERE route_id = %s AND ROUND(chainage_m) = %s + ORDER BY id DESC + LIMIT 1 + """, + (route_id, chainage_int), + ) + 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 get_cross_section_design( + connection: aiomysql.Connection, route_id: int, chainage_int: int +) -> dict[str, Any] | None: + """정수 chainage(m)에 해당하는 측점의 잠정 설계(data.design)를 반환한다. + + B07이 도면 ID(cross_{정수m}m)로 조회하므로 ROUND로 근사 매칭한다. + """ + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT data FROM cross_sections + WHERE route_id = %s AND ROUND(chainage_m) = %s + ORDER BY id DESC + LIMIT 1 + """, + (route_id, chainage_int), + ) + row = await cursor.fetchone() + if not row or not row[0]: + return None + data = row[0] + if isinstance(data, str): + data = json.loads(data) + design = data.get("design") if isinstance(data, dict) else None + return design if isinstance(design, dict) and design.get("ground_type") else None + + +async def get_cross_section_designs( + connection: aiomysql.Connection, route_id: int +) -> list[dict[str, Any]]: + """경로 측점별 저장된 설계 지정(data.design)을 chainage와 함께 반환한다. + + 상세 조회가 파일 기반이라 DB에만 있는 잠정 설계 지정을 화면 복원용으로 얹기 위함. + """ + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT chainage_m, data FROM cross_sections WHERE route_id = %s", (route_id,) + ) + rows = await cursor.fetchall() + designs: list[dict[str, Any]] = [] + for row in rows: + data = row[1] + if isinstance(data, str): + data = json.loads(data) + design = data.get("design") if isinstance(data, dict) else None + if isinstance(design, dict) and design.get("ground_type"): + designs.append({"chainage_m": float(row[0]), "design": design}) + return designs + + +async def count_cross_sections_without_design( + connection: aiomysql.Connection, route_id: int +) -> int: + """지반유형(data.design.ground_type)이 아직 지정되지 않은 측점 수를 반환한다. + + 확정 게이팅에 사용한다. JSON 함수 대신 애플리케이션에서 판정해 + MariaDB JSON 함수 가용성에 의존하지 않는다. + """ + async with connection.cursor() as cursor: + await cursor.execute("SELECT data FROM cross_sections WHERE route_id = %s", (route_id,)) + rows = await cursor.fetchall() + missing = 0 + for row in rows: + data = row[0] + if isinstance(data, str): + data = json.loads(data) + design = data.get("design") if isinstance(data, dict) else None + if not isinstance(design, dict) or not design.get("ground_type"): + missing += 1 + return missing + + +async def get_cross_sections_missing_design_chainages( + connection: aiomysql.Connection, route_id: int +) -> list[float]: + """지반유형이 아직 지정되지 않은 측점의 chainage(m) 목록을 반환한다. + + 확정 시 기본값(토사/좌절토)으로 일괄 채우기 위해 사용한다. + """ + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT chainage_m, data FROM cross_sections WHERE route_id = %s", (route_id,) + ) + rows = await cursor.fetchall() + chainages: list[float] = [] + for row in rows: + data = row[1] + if isinstance(data, str): + data = json.loads(data) + design = data.get("design") if isinstance(data, dict) else None + if not isinstance(design, dict) or not design.get("ground_type"): + chainages.append(float(row[0])) + return chainages + + 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 a3f20ffb..3332b38c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -13,22 +13,32 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Engine_Sections import ( + cross_filename, prune_stale_cross_files, run_section_generation, ) from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import ( + compute_cross_design, + design_elevation_from_longitudinal, +) from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( confirm_sections_for_route, count_cross_sections, create_longitudinal_section, delete_sections_for_route, get_confirmed_route_context, + get_cross_section_designs, + get_cross_sections_missing_design_chainages, get_latest_section_options, get_longitudinal_section, get_route_generation_source, insert_cross_sections, + update_cross_section_design, ) from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( + CrossDesignRequest, + CrossDesignResponse, SectionConfirmResponse, SectionContextResponse, SectionDetailResponse, @@ -154,12 +164,24 @@ async def get_section_detail( content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, ) stored_path = await get_project_storage_relative_path(connection, project_id) + designs = await get_cross_section_designs(connection, route_id) project_root = Path(resolve_stored_project_path(stored_path)) detail = await asyncio.to_thread( _read_section_detail, project_root, str(longitudinal["longitudinal_file_path"]), ) + # DB에만 있는 잠정 설계 지정을 chainage 근사로 각 횡단에 얹어 화면 복원을 돕는다. + for record in designs: + for section in detail["cross_sections"]: + if abs(float(section.get("chainage_m", 0.0)) - record["chainage_m"]) < 0.01: + section["design"] = record["design"] + break + # 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다. + # (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.) + await asyncio.to_thread( + _attach_default_designs, detail["longitudinal"], detail["cross_sections"] + ) return SectionDetailResponse(**detail) except FileNotFoundError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) @@ -281,11 +303,164 @@ async def regenerate_sections( ) +def _read_cross_design_inputs( + project_root: Path, longitudinal_file_path: str, chainage_m: float +) -> tuple[list[dict], float | None]: + """측점 하나의 지반 샘플과 계획고를 파일에서 읽는다 (경로 이탈 검증 포함).""" + root = project_root.resolve() + longitudinal_path = (root / longitudinal_file_path).resolve() + if root not in longitudinal_path.parents: + raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.") + if not longitudinal_path.is_file(): + raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.") + longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m) + + cross_dir = longitudinal_path.parent.parent / "cross_sections" + cross_path = (cross_dir / cross_filename(chainage_m)).resolve() + if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file(): + raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.") + cross = json.loads(cross_path.read_text(encoding="utf-8")) + samples = cross.get("samples") if isinstance(cross, dict) else None + if not isinstance(samples, list): + raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") + return samples, design_elevation + + +def _attach_default_designs( + longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]] +) -> None: + """지정 설계가 없는 횡단에 기본값(토사/좌절토) 프리뷰 설계를 즉석 계산해 얹는다. + + detail 조회가 이미 읽어온 samples와 종단 계획선을 그대로 써서 추가 파일 I/O 없이 + 전 측점 프리뷰를 만든다(미저장). 계산 불가 측점은 건너뛴다. + """ + for section in cross_sections: + if section.get("design"): + continue + try: + design = compute_cross_design( + section.get("samples", []), + design_elevation_from_longitudinal( + longitudinal, float(section.get("chainage_m", 0.0)) + ), + ground_type="soil", + section_mode="left_cut", + ) + design["status"] = "provisional" + section["design"] = design + except (ValueError, KeyError): + continue + + +def _compute_default_designs( + project_root: Path, longitudinal_file_path: str, chainages: list[float] +) -> list[tuple[float, dict[str, Any]]]: + """미지정 측점들을 기본값(토사/좌절토)으로 계산한 (chainage, design) 목록을 만든다. + + 계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함). + """ + root = project_root.resolve() + longitudinal_path = (root / longitudinal_file_path).resolve() + if root not in longitudinal_path.parents or not longitudinal_path.is_file(): + return [] + longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + cross_dir = longitudinal_path.parent.parent / "cross_sections" + results: list[tuple[float, dict[str, Any]]] = [] + for chainage_m in chainages: + cross_path = cross_dir / cross_filename(chainage_m) + if not cross_path.is_file(): + continue + try: + cross = json.loads(cross_path.read_text(encoding="utf-8")) + samples = cross.get("samples") + if not isinstance(samples, list): + continue + design = compute_cross_design( + samples, + design_elevation_from_longitudinal(longitudinal, chainage_m), + ground_type="soil", + section_mode="left_cut", + ) + design["status"] = "provisional" + results.append((chainage_m, design)) + except (ValueError, KeyError, OSError, json.JSONDecodeError): + continue + return results + + +@router.post("/{project_id}/sections/{route_id}/cross-design", response_model=CrossDesignResponse) +async def compute_cross_section_design( + project_id: UUID, route_id: int, request: CrossDesignRequest +) -> CrossDesignResponse | JSONResponse: + """측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산해 잠정치로 저장한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + longitudinal = await get_longitudinal_section(connection, project_id, route_id) + if not longitudinal: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, + ) + 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( + _read_cross_design_inputs, + project_root, + str(longitudinal["longitudinal_file_path"]), + request.chainage_m, + ) + design = compute_cross_design( + samples, + design_elevation, + ground_type=request.ground_type, + section_mode=request.section_mode, + ditch_side=request.ditch_side, + ) + # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. + design["status"] = "provisional" + async with pool.acquire() as connection: + await connection.begin() + try: + updated = await update_cross_section_design( + connection, + route_id=route_id, + chainage_m=request.chainage_m, + design=design, + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + if not updated: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "해당 측점 횡단 레코드가 없습니다."}, + ) + return CrossDesignResponse(chainage_m=request.chainage_m, design=design) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception( + "B06 측점 횡단 설계 계산 실패: project_id=%s route_id=%s", project_id, route_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "횡단 설계 계산 중 오류가 발생했습니다."}, + ) + + @router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) async def confirm_sections( project_id: UUID, route_id: int ) -> SectionConfirmResponse | JSONResponse: - """경로의 종횡단면을 확정(CONFIRMED)한다.""" + """경로의 종횡단면을 확정(CONFIRMED)한다. + + 지반유형을 지정하지 않은 측점은 기본값(토사/좌절토)으로 자동 채운 뒤 확정한다. + """ pool = get_db_pool() try: async with pool.acquire() as connection: @@ -295,8 +470,27 @@ async def confirm_sections( status_code=404, content={"status": "error", "message": "확정할 종횡단이 없습니다."}, ) + stored_path = await get_project_storage_relative_path(connection, project_id) + missing = await get_cross_sections_missing_design_chainages(connection, route_id) + + # 미지정 측점을 기본값으로 계산해 채운다 (계산 불가 측점은 조용히 건너뜀). + default_designs: list[tuple[float, dict[str, Any]]] = [] + if missing: + project_root = Path(resolve_stored_project_path(stored_path)) + default_designs = await asyncio.to_thread( + _compute_default_designs, + project_root, + str(existing["longitudinal_file_path"]), + missing, + ) + + async with pool.acquire() as connection: await connection.begin() try: + for chainage_m, design in default_designs: + await update_cross_section_design( + connection, route_id=route_id, chainage_m=chainage_m, design=design + ) 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 974cdd34..da5fa008 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -1,6 +1,6 @@ """B06 종횡단 조회·확정 응답 검증 모델.""" -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, Field @@ -11,6 +11,27 @@ class SectionRegenerateRequest(BaseModel): cross_half_width_m: float = Field(..., gt=0) +class CrossDesignRequest(BaseModel): + """측점 하나의 표준횡단 설계(지반유형·단면유형) 지정 요청. + + 버튼 클릭 즉시 절·성토 단면적을 계산해 잠정치로 저장한다. 편절편성은 절토측이 + 측구측이라 ditch_side가 무시되고, 양절·양성에서만 배수 방향으로 사용된다. + """ + + chainage_m: float = Field(..., ge=0) + 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 + + +class CrossDesignResponse(BaseModel): + """측점 표준횡단 설계 계산 결과(잠정치).""" + + status: str = "success" + chainage_m: float + design: dict[str, Any] + + class SectionConfirmResponse(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 new file mode 100644 index 00000000..0dc9d7d3 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts @@ -0,0 +1,155 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_Cross_Design.ts + * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치)과 설계선 오버레이. + * + * 카드 헤더 아래에 세그먼트 버튼을 배치하고, 지반유형·단면유형이 모두 선택되면 + * onChange로 계산을 요청한다. 계산 결과(section.design)는 상위에서 다시 렌더될 때 + * 절·성토 단면적 표시와 설계선 오버레이로 반영된다. 편절편성은 측구위치가 자동 + * 결정되어 컨트롤을 숨기고, 양절·양성에서만 배수 방향 선택을 노출한다. + * ========================================================================== */ + +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import type { + CrossDesign, + CrossSection, + DitchSide, + GroundType, + SectionMode, +} from "./B06_wf3_ProfileCross_Api_Fetch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +const GROUND_OPTIONS: Array<[GroundType, keyof typeof ui_locales]> = [ + ["soil", "B06_Design_Ground_Soil"], + ["ripping_rock", "B06_Design_Ground_Ripping"], + ["blasting_rock", "B06_Design_Ground_Blasting"], +]; +const MODE_OPTIONS: Array<[SectionMode, keyof typeof ui_locales]> = [ + ["left_cut", "B06_Design_Mode_LeftCut"], + ["right_cut", "B06_Design_Mode_RightCut"], + ["both_cut", "B06_Design_Mode_BothCut"], + ["both_fill", "B06_Design_Mode_BothFill"], +]; +const DITCH_OPTIONS: Array<[DitchSide, keyof typeof ui_locales]> = [ + ["left", "B06_Design_Ditch_Left"], + ["right", "B06_Design_Ditch_Right"], +]; + +export interface CrossDesignChange { + ground_type: GroundType; + section_mode: SectionMode; + ditch_side: DitchSide | null; +} + +function segment( + legend: string, + options: Array<[T, keyof typeof ui_locales]>, + selected: T | null, + onPick: (value: T) => void, +): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b06-design__seg"; + const legendEl = document.createElement("span"); + legendEl.className = "b06-design__seg-legend"; + legendEl.textContent = legend; + wrap.append(legendEl); + const group = document.createElement("div"); + group.className = "b06-design__seg-buttons"; + for (const [value, labelKey] of options) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `b06-design__btn${value === selected ? " b06-design__btn--active" : ""}`; + button.textContent = L(labelKey); + button.setAttribute("aria-pressed", value === selected ? "true" : "false"); + button.addEventListener("click", () => onPick(value)); + group.append(button); + } + wrap.append(group); + return wrap; +} + +/** 카드 헤더용 설계 지정 컨트롤 바를 만든다. */ +export function buildDesignControls( + section: CrossSection, + onChange: (chainageM: number, change: CrossDesignChange) => void, +): HTMLElement { + const design = section.design; + // 기본값: 토사(soil) + 좌절토(left_cut). 미지정 측점은 확정 시 이 기본값으로 채워진다. + const state: { ground: GroundType; mode: SectionMode; ditch: DitchSide | null } = { + ground: design?.ground_type ?? "soil", + mode: design?.section_mode ?? "left_cut", + ditch: design?.ditch_side ?? null, + }; + const bar = document.createElement("div"); + bar.className = "b06-design"; + // 컨트롤 상호작용이 카드 선택 클릭으로 전파되지 않게 한다. + bar.addEventListener("click", (event) => event.stopPropagation()); + + const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill"; + const emit = (): void => { + if (!state.ground || !state.mode) return; + onChange(section.chainage_m, { + ground_type: state.ground, + section_mode: state.mode, + ditch_side: needsDitch() ? (state.ditch ?? "left") : null, + }); + }; + + bar.append( + segment(L("B06_Design_Ground_Legend"), GROUND_OPTIONS, state.ground, (value) => { + state.ground = value; + emit(); + }), + segment(L("B06_Design_Mode_Legend"), MODE_OPTIONS, state.mode, (value) => { + state.mode = value; + emit(); + }), + ); + if (needsDitch()) { + bar.append( + segment(L("B06_Design_Ditch_Legend"), DITCH_OPTIONS, state.ditch, (value) => { + state.ditch = value; + emit(); + }), + ); + } + + const readout = document.createElement("div"); + readout.className = "b06-design__areas"; + if (design) { + const cut = document.createElement("span"); + cut.className = "b06-design__area b06-design__area--cut"; + cut.textContent = `${L("B06_Design_Cut_Area")} ${design.cut_area_m2.toFixed(2)}㎡`; + const fill = document.createElement("span"); + fill.className = "b06-design__area b06-design__area--fill"; + fill.textContent = `${L("B06_Design_Fill_Area")} ${design.fill_area_m2.toFixed(2)}㎡`; + readout.append(cut, fill); + } else { + const unset = document.createElement("span"); + unset.className = "b06-design__area b06-design__area--unset"; + unset.textContent = L("B06_Design_Unset"); + readout.append(unset); + } + bar.append(readout); + return bar; +} + +/** 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. */ +export function appendCrossDesignOverlay( + svg: SVGSVGElement, + design: CrossDesign, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, +): void { + const line = design.design_line; + if (!line || line.length < 2) return; + const points = line.map((point) => `${x(point.offset_m)},${toDisplayY(point.elevation_m)}`); + const polyline = document.createElementNS(SVG_NS, "polyline"); + polyline.setAttribute("points", points.join(" ")); + polyline.setAttribute("class", "b06-chart__design-cross"); + svg.append(polyline); +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index e822490f..e66a22e5 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -16,6 +16,7 @@ import { type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { + computeCrossDesign, confirmSections, fetchSectionContext, fetchSectionDetail, @@ -25,7 +26,7 @@ import { type SectionDetailResponse, type SectionSummaryResponse, } from "./B06_wf3_ProfileCross_Api_Fetch"; -import { createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View"; +import { type CrossDesignChange, createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View"; import "./B06_wf3_ProfileCross_UI_Style.css"; function L(key: keyof typeof ui_locales): string { @@ -125,7 +126,53 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; leftForm.append(routeGroup, resultGroup, displayGroup, actionRow); - const sectionView = createSectionView(); + + // 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다. + const designRequestSeq = new Map(); + + /** + * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), + * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. + */ + async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise { + if (!projectId || currentRouteId === null || !sectionDetail) return; + const target = sectionDetail.cross_sections.find( + (section) => Math.abs(section.chainage_m - chainageM) < 0.01, + ); + if (!target) return; + + // (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체. + if (target.design) { + target.design = { + ...target.design, + ground_type: change.ground_type, + section_mode: change.section_mode, + ditch_side: change.ditch_side ?? target.design.ditch_side, + }; + sectionView.refreshCard(chainageM); + } + + // (2) 서버 계산 — 최신 요청만 반영. + const seq = (designRequestSeq.get(chainageM) ?? 0) + 1; + designRequestSeq.set(chainageM, seq); + try { + const response = await computeCrossDesign(projectId, currentRouteId, { + chainage_m: chainageM, + ...change, + }); + if (designRequestSeq.get(chainageM) !== seq) return; + target.design = response.design; + sectionView.refreshCard(chainageM); + } catch (error) { + if (designRequestSeq.get(chainageM) !== seq) return; + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Design_Failed")}${detail}`, "error"); + } + } + + const sectionView = createSectionView((chainageM, change) => { + void handleDesignChange(chainageM, change); + }); function renderMessage(message: string): void { const text = document.createElement("p"); 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 415a73c3..8cc51539 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -6,6 +6,14 @@ import type { SectionDetailResponse, SectionSample, } from "./B06_wf3_ProfileCross_Api_Fetch"; +import { + appendCrossDesignOverlay, + buildDesignControls, + type CrossDesignChange, +} from "./B06_wf3_ProfileCross_UI_Cross_Design"; + +export type { CrossDesignChange }; +export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void; const SVG_NS = "http://www.w3.org/2000/svg"; const LONG_WIDTH = 1200; @@ -379,6 +387,7 @@ export function createCrossSectionCard( widthPx = CROSS_WIDTH, heightPx = CROSS_HEIGHT, designElevation?: number, + onDesignChange?: DesignChangeHandler, ): HTMLElement { const card = document.createElement("article"); card.id = `cross-${section.station_id}`; @@ -405,6 +414,7 @@ export function createCrossSectionCard( : L("B06_Profile_View_Kind_Station"); header.append(title, kind); card.append(header); + if (onDesignChange) card.append(buildDesignControls(section, onDesignChange)); const sourceSamples = section.samples.filter( (sample) => @@ -505,6 +515,12 @@ export function createCrossSectionCard( svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), ); + if (section.design) { + const toDisplayY = (elevation: number): number => + y(elevationMid + (elevation - elevationMid) * exaggeration); + appendCrossDesignOverlay(svg, section.design, x, toDisplayY); + } + const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => { if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) return sample; @@ -584,11 +600,13 @@ export interface SectionViewController { crossHalfWidth?: number, stationInterval?: number, ) => void; + /** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */ + refreshCard: (chainageM: number) => void; clear: () => void; dispose: () => void; } -export function createSectionView(): SectionViewController { +export function createSectionView(onDesignChange?: DesignChangeHandler): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; let currentDetail: SectionDetailResponse | null = null; @@ -598,6 +616,10 @@ export function createSectionView(): SectionViewController { let currentStationInterval: number | undefined; let renderWidth = 0; let resizeTimer = 0; + // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) + let cachedYScale: YScaleOptions | undefined; + let cachedStationInterval = 1; + let cachedCardWidth = CROSS_WIDTH; const contentWidth = (): number => { const style = getComputedStyle(root); @@ -607,34 +629,55 @@ export function createSectionView(): SectionViewController { ); }; + const selectStation = (stationId: string, scroll: boolean): void => { + selectedStationId = stationId; + draw(); + if (scroll) { + document + .getElementById(`cross-${stationId}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }; + + const buildCrossCard = (section: CrossSection): HTMLElement => + createCrossSectionCard( + section, + section.station_id === selectedStationId, + currentExaggeration, + cachedYScale, + (stationId) => selectStation(stationId, false), + cachedStationInterval, + currentCrossHalfWidth, + cachedCardWidth, + CROSS_HEIGHT, + currentDetail + ? designElevationAt(currentDetail.longitudinal.design_profiles, section.chainage_m) + : undefined, + onDesignChange, + ); + const draw = (): void => { if (!currentDetail || renderWidth <= 0) return; root.replaceChildren(); const detail = currentDetail; - const yScale = calculateYScale(detail); - const stationInterval = + cachedYScale = calculateYScale(detail); + cachedStationInterval = currentStationInterval ?? inferStationInterval(detail.longitudinal.stations); - const selectStation = (stationId: string, scroll: boolean): void => { - selectedStationId = stationId; - draw(); - if (scroll) { - document - .getElementById(`cross-${stationId}`) - ?.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }; const longitudinalPanel = document.createElement("section"); longitudinalPanel.className = "b06-section__panel"; - const longitudinalMinWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval); + const longitudinalMinWidth = longitudinalMinimumWidth( + detail.longitudinal, + cachedStationInterval, + ); longitudinalPanel.append( createLongitudinalProfile( detail.longitudinal, selectedStationId, currentExaggeration, - yScale, + cachedYScale, (stationId) => selectStation(stationId, true), - stationInterval, + cachedStationInterval, Math.max(renderWidth, longitudinalMinWidth), LONG_HEIGHT, longitudinalMinWidth, @@ -655,30 +698,25 @@ export function createSectionView(): SectionViewController { 1, Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)), ); - const cardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount; + cachedCardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount; if (detail.cross_sections.length) { - detail.cross_sections.forEach((section) => - grid.append( - createCrossSectionCard( - section, - section.station_id === selectedStationId, - currentExaggeration, - yScale, - (stationId) => selectStation(stationId, false), - stationInterval, - currentCrossHalfWidth, - cardWidth, - CROSS_HEIGHT, - designElevationAt(detail.longitudinal.design_profiles, section.chainage_m), - ), - ), - ); + detail.cross_sections.forEach((section) => grid.append(buildCrossCard(section))); } else { grid.append(emptyView(L("B06_Profile_View_NoCross"))); } root.append(longitudinalPanel, crossHeading, grid); }; + const refreshCard = (chainageM: number): void => { + if (!currentDetail) return; + const section = currentDetail.cross_sections.find( + (candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01, + ); + if (!section) return; + const existing = document.getElementById(`cross-${section.station_id}`); + if (existing) existing.replaceWith(buildCrossCard(section)); + }; + const resizeObserver = new ResizeObserver(() => { const nextWidth = contentWidth(); if (nextWidth <= 0 || Math.abs(nextWidth - renderWidth) < 1) return; @@ -704,6 +742,7 @@ export function createSectionView(): SectionViewController { draw(); if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root)); }, + refreshCard, clear() { currentDetail = null; selectedStationId = null; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index 71655d4b..f1e89370 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -365,3 +365,81 @@ fill: var(--color-danger); font-weight: var(--font-weight-bold); } + +/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) */ +.b06-design { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-16); + padding: var(--spacing-8) 0; + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--spacing-8); +} + +.b06-design__seg { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.b06-design__seg-legend { + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.b06-design__seg-buttons { + display: inline-flex; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + overflow: hidden; +} + +.b06-design__btn { + padding: 3px 8px; + font-size: 0.72rem; + color: var(--color-text-secondary); + background: var(--color-surface); + border: none; + border-left: 1px solid var(--color-border); + cursor: pointer; +} + +.b06-design__btn:first-child { + border-left: none; +} + +.b06-design__btn--active { + color: var(--color-surface); + background: var(--color-royal-amethyst); + font-weight: var(--font-weight-medium); +} + +.b06-design__areas { + display: inline-flex; + gap: var(--spacing-8); + margin-left: auto; + font-size: 0.72rem; + font-family: var(--font-mono); +} + +.b06-design__area--cut { + color: rgb(220 38 38); +} + +.b06-design__area--fill { + color: rgb(37 99 235); +} + +.b06-design__area--unset { + color: var(--color-text-muted); +} + +/* 횡단 표준단면 설계선 오버레이 */ +.b06-chart__design-cross { + fill: none; + stroke: var(--color-royal-amethyst); + stroke-width: 1.8; + stroke-linejoin: round; +} diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts index 26c7cfea..db5315e6 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts @@ -30,6 +30,22 @@ export interface DesignDrawingListResponse { /** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */ export type QuantityTable = Record; +/** B06에서 지정한 설계(지반정보·계획정보). 횡단도에만 존재. status로 잠정/확정 구분. */ +export interface CrossDesignInfo { + ground_type: "soil" | "ripping_rock" | "blasting_rock"; + geometry_preset: "soil" | "rock"; + section_mode: "left_cut" | "right_cut" | "both_cut" | "both_fill"; + ditch_side: "left" | "right"; + cut_slope_ratio: number; + fill_slope_ratio: number; + roadbed_width_m: number; + ditch: { width_m: number; depth_m: number }; + design_elevation_m: number; + cut_area_m2: number; + fill_area_m2: number; + status?: "provisional" | "confirmed"; +} + export interface DesignDrawingResponse { status: string; project_id: string; @@ -40,6 +56,7 @@ export interface DesignDrawingResponse { drawing: CadDrawing; confirmed: boolean; quantity_table?: QuantityTable | null; + design?: CrossDesignInfo | null; } export interface DesignDrawingConfirmResponse { @@ -48,6 +65,7 @@ export interface DesignDrawingConfirmResponse { id: string; confirmed: boolean; all_confirmed: boolean; + design?: CrossDesignInfo | null; } async function requestJson(path: string, init: RequestInit = {}): Promise { diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py index fc12327a..32251cc7 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py @@ -13,9 +13,15 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Engine_Sections import prune_stale_cross_files +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import ( + compute_cross_design, + design_elevation_from_longitudinal, +) from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( get_confirmed_route_context, + get_cross_section_design, get_longitudinal_section, + merge_cross_section_design_by_round, ) from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Schema import ( DesignDrawingConfirmRequest, @@ -34,6 +40,11 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) _CROSS_ID = re.compile(r"^cross_(\d+)m$") _GROUND_LAYER_ID = "b07-ground" +_GROUND_COLOR = "#f5f7fa" +# 계획선(계획 종단선/횡단 설계선) 레이어. 편집 최소화하되 구조물 부착·선 트림이 가능하도록 +# 잠금 해제로 두고, 지반선과 색을 구분한다(계획=amethyst 계열). +_DESIGN_LAYER_ID = "b07-design" +_DESIGN_COLOR = "#b794f6" _STAGE_DIR = "B07_wf4_DesignDetail" @@ -144,9 +155,12 @@ def _line_entity( start: tuple[float, float], end: tuple[float, float], layer_id: str = _GROUND_LAYER_ID, - color: str = "#f5f7fa", + color: str = _GROUND_COLOR, ) -> dict[str, Any]: - entity_id = str(uuid5(UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96"), f"{drawing_id}:{index}")) + # layer_id를 seed에 포함해 지반선·계획선 자식 Line의 uuid 충돌을 막는다. + entity_id = str( + uuid5(UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96"), f"{drawing_id}:{layer_id}:{index}") + ) return { "id": entity_id, "type": "Line", @@ -160,6 +174,67 @@ def _line_entity( } +def _points_from_samples(samples: list[Any], x_key: str) -> list[tuple[float, float]]: + """유효 샘플에서 (x, elevation) 점열을 뽑는다 (x_key: chainage_m 또는 offset_m).""" + points: list[tuple[float, float]] = [] + for sample in samples: + if not isinstance(sample, dict) or not sample.get("valid", False): + continue + x = sample.get(x_key) + y = sample.get("elevation_m", sample.get("z")) + if isinstance(x, (int, float)) and isinstance(y, (int, float)): + points.append((float(x), float(y))) + return points + + +def _polyline_entity( + drawing_id: str, points: list[tuple[float, float]], layer_id: str, color: str +) -> dict[str, Any] | None: + """점열을 openwebcad PolyLine(자식 Line 묶음)으로 직렬화한다 (점 2개 미만이면 None).""" + if len(points) < 2: + return None + children = [ + _line_entity(drawing_id, index, points[index], points[index + 1], layer_id, color) + for index in range(len(points) - 1) + ] + return { + "id": str(uuid5(UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8"), f"{drawing_id}:{layer_id}")), + "type": "PolyLine", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": None, + "children": children, + } + + +def _design_points( + source: dict[str, Any], kind: str, design_line: list[Any] | None +) -> list[tuple[float, float]]: + """계획선 점열을 만든다. 종단=design_profiles의 계획고, 횡단=설계 design_line. + + 계획선 샘플은 지반선과 달리 valid 플래그가 없어 좌표 유효성만으로 판정한다. + """ + if kind == "longitudinal": + profiles = source.get("design_profiles") + if not isinstance(profiles, list) or not profiles: + return [] + raw = profiles[0].get("samples", []) + x_key = "chainage_m" + else: + raw = design_line if isinstance(design_line, list) else [] + x_key = "offset_m" + points: list[tuple[float, float]] = [] + for point in raw: + if not isinstance(point, dict): + continue + x = point.get(x_key) + y = point.get("elevation_m") + if isinstance(x, (int, float)) and isinstance(y, (int, float)): + points.append((float(x), float(y))) + return points + + # 수량 산출표 항목 키 (프론트 편집 테이블과 1:1 대응). center_z→지반고, # planned_elevation_m→계획고, cut/fill은 파생값, 나머지는 source["quantities"]에서 읽는다. _QUANTITY_ITEM_KEYS = ( @@ -205,40 +280,31 @@ def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: return table -def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str, Any]: - """B06 샘플을 openwebcad PolyLine 직렬화 형식으로 변환한다.""" +def _cad_drawing( + source: dict[str, Any], + drawing_id: str, + kind: str, + design_line: list[Any] | None = None, +) -> dict[str, Any]: + """B06 샘플을 openwebcad PolyLine 직렬화 형식으로 변환한다. + + 지반선(b07-ground)과 계획선(b07-design)을 각각 별도 레이어의 PolyLine으로 emit한다. + 계획선은 종단도=계획고(design_profiles), 횡단도=표준단면 design_line에서 만든다. + """ x_key = "chainage_m" if kind == "longitudinal" else "offset_m" - points: list[tuple[float, float]] = [] - for sample in source.get("samples", []): - if not isinstance(sample, dict) or not sample.get("valid", False): - continue - x = sample.get(x_key) - y = sample.get("elevation_m", sample.get("z")) - if isinstance(x, (int, float)) and isinstance(y, (int, float)): - points.append((float(x), float(y))) - children = [ - _line_entity(drawing_id, index, points[index], points[index + 1]) - for index in range(len(points) - 1) - ] + ground_points = _points_from_samples(source.get("samples", []), x_key) + design_points = _design_points(source, kind, design_line) + entities: list[dict[str, Any]] = [] - if children: - entities.append( - { - "id": str( - uuid5( - UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8"), - drawing_id, - ) - ), - "type": "PolyLine", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": _GROUND_LAYER_ID, - "shapeData": None, - "children": children, - } - ) + ground = _polyline_entity(drawing_id, ground_points, _GROUND_LAYER_ID, _GROUND_COLOR) + if ground: + entities.append(ground) + design = _polyline_entity(drawing_id, design_points, _DESIGN_LAYER_ID, _DESIGN_COLOR) + if design: + entities.append(design) + # 수량 산출표는 정적 도면 엔티티가 아니라 편집 가능한 HTML 테이블로 분리되었다. + # 계획선 레이어는 편집 가능(잠금 해제)으로 두어 구조물 부착·선 트림을 허용한다. return { "entities": entities, "layers": [ @@ -248,17 +314,46 @@ def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str "isVisible": True, "isLocked": False, }, + { + "id": _DESIGN_LAYER_ID, + "name": "Design Plan", + "isVisible": True, + "isLocked": False, + }, ], } +def _cross_design_line( + longitudinal_path: Path, source: dict[str, Any], stored_design: dict[str, Any] | None +) -> list[Any] | None: + """횡단 CAD 계획선용 design_line을 정한다: 저장 설계 우선, 없으면 기본값 계산.""" + if isinstance(stored_design, dict) and isinstance(stored_design.get("design_line"), list): + return stored_design["design_line"] + try: + longitudinal = _read_json(longitudinal_path) + design = compute_cross_design( + source.get("samples", []), + design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))), + ground_type="soil", + section_mode="left_cut", + ) + return design["design_line"] + except (ValueError, KeyError, OSError, json.JSONDecodeError): + return None + + def _read_drawing( - project_root: Path, longitudinal_path: Path, drawing_id: str + project_root: Path, + longitudinal_path: Path, + drawing_id: str, + stored_design: dict[str, Any] | None = None, ) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]: """(kind, label, drawing, confirmed, quantity_table)를 반환한다. quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자 - 편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다. + 편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다. 횡단도의 계획선은 + stored_design(없으면 기본값)에서 만든다. """ manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {}) saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json" @@ -285,7 +380,14 @@ def _read_drawing( raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.") source = _read_json(path) label = str(source.get("label") or drawing_id) - return "cross", label, _cad_drawing(source, drawing_id, "cross"), False, _quantity_table(source) + design_line = _cross_design_line(longitudinal_path, source, stored_design) + return ( + "cross", + label, + _cad_drawing(source, drawing_id, "cross", design_line), + False, + _quantity_table(source), + ) def _store_confirmed_drawing( @@ -359,8 +461,17 @@ async def get_design_drawing( """선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다.""" try: route_id, project_root, longitudinal_path = await _confirmed_source(project_id) + # 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다. + design: dict[str, Any] | None = None + cross_match = _CROSS_ID.fullmatch(drawing_id) + if cross_match: + pool = get_db_pool() + async with pool.acquire() as connection: + design = await get_cross_section_design( + connection, route_id, int(cross_match.group(1)) + ) kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread( - _read_drawing, project_root, longitudinal_path, drawing_id + _read_drawing, project_root, longitudinal_path, drawing_id, design ) return DesignDrawingResponse( project_id=str(project_id), @@ -371,6 +482,7 @@ async def get_design_drawing( drawing=drawing, confirmed=confirmed, quantity_table=quantity_table, + design=design, ) except ValueError as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) @@ -388,6 +500,34 @@ async def get_design_drawing( ) +def _recompute_confirmed_design( + longitudinal_path: Path, drawing_id: str, designation: dict[str, Any] +) -> dict[str, Any]: + """B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다. + + B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형· + 측구위치)과 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다. + """ + longitudinal = _read_json(longitudinal_path) + cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json" + source = _read_json(cross_path) + samples = source.get("samples") + if not isinstance(samples, list): + raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") + design_elevation = design_elevation_from_longitudinal( + longitudinal, float(source.get("chainage_m", 0.0)) + ) + design = compute_cross_design( + samples, + design_elevation, + ground_type=designation["ground_type"], + section_mode=designation["section_mode"], + ditch_side=designation.get("ditch_side"), + ) + design["status"] = "confirmed" + return design + + @router.put( "/{project_id}/design-drawings/{drawing_id}/confirm", response_model=DesignDrawingConfirmResponse, @@ -395,26 +535,57 @@ async def get_design_drawing( async def confirm_design_drawing( project_id: UUID, drawing_id: str, request: DesignDrawingConfirmRequest ) -> DesignDrawingConfirmResponse | JSONResponse: - """현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.""" + """현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다. + + 횡단도 확정 시 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다. + """ try: - _, project_root, longitudinal_path = await _confirmed_source(project_id) + route_id, project_root, longitudinal_path = await _confirmed_source(project_id) items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) item = next((candidate for candidate in items if candidate.id == drawing_id), None) if not item: raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.") + # 단계 완료 기준은 횡단도(cross)만 본다. 종단도(longitudinal)는 확정 여부와 무관. all_confirmed = await asyncio.to_thread( _store_confirmed_drawing, project_root, item, request.drawing, - {candidate.id for candidate in items}, + {candidate.id for candidate in items if candidate.kind == "cross"}, request.quantity_table, ) + # 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음). + confirmed_design: dict[str, Any] | None = None + chainage_int: int | None = None + cross_match = _CROSS_ID.fullmatch(drawing_id) pool = get_db_pool() + if item.kind == "cross" and cross_match: + chainage_int = int(cross_match.group(1)) + async with pool.acquire() as connection: + designation = await get_cross_section_design(connection, route_id, chainage_int) + if designation: + try: + confirmed_design = await asyncio.to_thread( + _recompute_confirmed_design, longitudinal_path, drawing_id, designation + ) + except (ValueError, KeyError, FileNotFoundError, OSError): + logger.warning( + "B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s", + drawing_id, + exc_info=True, + ) + async with pool.acquire() as connection: await connection.begin() try: + if confirmed_design is not None and chainage_int is not None: + await merge_cross_section_design_by_round( + connection, + route_id=route_id, + chainage_int=chainage_int, + patch=confirmed_design, + ) async with connection.cursor() as cursor: if all_confirmed: await complete_stage(cursor, str(project_id), 4) @@ -429,6 +600,7 @@ async def confirm_design_drawing( id=drawing_id, confirmed=True, all_confirmed=all_confirmed, + design=confirmed_design, ) except ValueError as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) @@ -453,16 +625,25 @@ async def invalidate_design_drawing( ) -> DesignDrawingInvalidateResponse | JSONResponse: """확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다.""" try: - _, project_root, longitudinal_path = await _confirmed_source(project_id) + route_id, project_root, longitudinal_path = await _confirmed_source(project_id) items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) if drawing_id not in {item.id for item in items}: raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.") await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id) + cross_match = _CROSS_ID.fullmatch(drawing_id) pool = get_db_pool() async with pool.acquire() as connection: await connection.begin() try: + # 확정 도면을 편집하면 해당 측점 설계도 잠정 상태로 되돌린다. + if cross_match: + await merge_cross_section_design_by_round( + connection, + route_id=route_id, + chainage_int=int(cross_match.group(1)), + patch={"status": "provisional"}, + ) async with connection.cursor() as cursor: await start_stage(cursor, str(project_id), 4) await connection.commit() diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py index e12adfe4..ccd69445 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py @@ -37,6 +37,8 @@ class DesignDrawingResponse(BaseModel): confirmed: bool = False # 횡단도 편집용 수량 산출표 값 (미산정 항목은 null). 종단도는 None. quantity_table: dict[str, float | None] | None = None + # B06에서 지정한 잠정 설계(지반유형·단면유형·절성토 단면적). 횡단도만, 없으면 None. + design: dict[str, Any] | None = None class DesignDrawingConfirmRequest(BaseModel): @@ -55,6 +57,8 @@ class DesignDrawingConfirmResponse(BaseModel): id: str confirmed: bool all_confirmed: bool + # 횡단도 확정 시 재계산된 확정 설계(status=confirmed). 종단도·재계산 불가 시 None. + design: dict[str, Any] | None = None class DesignDrawingInvalidateResponse(BaseModel): diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts index 6fecd3d9..e99eab8b 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts @@ -33,6 +33,7 @@ import { fetchDesignDrawingList, invalidateDesignDrawing, type CadDrawing, + type CrossDesignInfo, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, @@ -161,6 +162,92 @@ function buildDrawingSidePanel( return panel; } +const GROUND_TYPE_LABEL: Record = { + soil: "B06_Design_Ground_Soil", + ripping_rock: "B06_Design_Ground_Ripping", + blasting_rock: "B06_Design_Ground_Blasting", +}; + +/** 단면유형에서 절토측 표기를 유도한다. */ +function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string { + if (mode === "left_cut") return L("B06_Design_Ditch_Left"); + if (mode === "right_cut") return L("B06_Design_Ditch_Right"); + if (mode === "both_cut") return L("B06_Design_Mode_BothCut"); + return L("B06_Design_Mode_BothFill"); +} + +function infoRow(label: string, value: string): HTMLElement { + const row = document.createElement("div"); + row.className = "b07-info__row"; + const key = document.createElement("span"); + key.className = "b07-info__key"; + key.textContent = label; + const val = document.createElement("span"); + val.className = "b07-info__val"; + val.textContent = value; + row.append(key, val); + return row; +} + +/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */ +function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement { + const panel = document.createElement("div"); + panel.className = "b07-info"; + const heading = document.createElement("div"); + heading.className = "b07-info__heading"; + const stationName = document.createElement("strong"); + stationName.textContent = `${L("B07_Info_Station")} ${title}`; + const confirmed = design?.status === "confirmed"; + const badge = document.createElement("span"); + badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`; + badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional"); + heading.append(stationName, badge); + panel.append(heading); + + if (!design) { + const empty = document.createElement("p"); + empty.className = "b07-info__empty"; + empty.textContent = L("B07_Info_NoDesign"); + panel.append(empty); + return panel; + } + + const ground = document.createElement("section"); + ground.className = "b07-info__block"; + const groundTitle = document.createElement("h4"); + groundTitle.textContent = L("B07_Info_Ground_Title"); + ground.append( + groundTitle, + infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])), + infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)), + infoRow( + L("B07_Info_DitchSide"), + design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"), + ), + ); + + const plan = document.createElement("section"); + plan.className = "b07-info__block"; + const planTitle = document.createElement("h4"); + planTitle.textContent = L("B07_Info_Plan_Title"); + plan.append( + planTitle, + infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`), + infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`), + infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`), + infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`), + infoRow( + L("B07_Info_Ditch"), + `${design.ditch.width_m.toFixed(2)}×${design.ditch.depth_m.toFixed(2)}m`, + ), + infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`), + infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`), + ); + + panel.append(ground, plan); + return panel; +} + /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ @@ -207,9 +294,26 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { let currentDrawing: DesignDrawingItem | undefined; let currentIndex = -1; let currentConfirmed = false; - let allDrawingsConfirmed = drawings.length > 0 && drawings.every((item) => item.confirmed); + // 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관). + const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross"; + let allDrawingsConfirmed = + drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed); let resolveSave: ((payload: SaveResult) => void) | undefined; let drawingListEl: HTMLElement | undefined; + const infoPanelHost = document.createElement("div"); + infoPanelHost.className = "b07-info-host"; + + const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => { + if (drawing.kind !== "cross") { + infoPanelHost.replaceChildren(); + return; + } + const title = + typeof drawing.chainage_m === "number" + ? stationLabel(drawing.chainage_m, stationInterval) + : drawing.label; + infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null)); + }; const confirmButton = createButton({ label: "현재 도면 확정", @@ -268,6 +372,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { currentIndex = index; currentConfirmed = response.confirmed; confirmButton.disabled = response.confirmed; + updateInfoPanel(drawing, response); sendLoad(response.drawing, buildMeta(drawing, response, index)); } catch (error) { cadHost.dataset.loading = "false"; @@ -316,6 +421,14 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { confirmButton.disabled = true; const button = findButton(currentDrawing.id); if (button) button.dataset.confirmed = "true"; + // 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다. + if (currentDrawing.kind === "cross") { + const infoTitle = + typeof currentDrawing.chainage_m === "number" + ? stationLabel(currentDrawing.chainage_m, stationInterval) + : currentDrawing.label; + infoPanelHost.replaceChildren(buildDesignInfoPanel(infoTitle, result.design ?? null)); + } showToast("현재 도면을 확정하고 저장했습니다.", "success"); if (result.all_confirmed) { allDrawingsConfirmed = true; @@ -400,7 +513,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { }); confirmActions.append(tempB08Btn); - drawingPanel.append(confirmActions); + drawingPanel.append(infoPanelHost, confirmActions); const layout = createWorkflowLayout({ title: L("B07_Design_Title"), diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css index 0cd60e52..3d10cc32 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css @@ -157,3 +157,70 @@ color: var(--color-text-muted); text-decoration: underline; } + +/* 선택 횡단도의 지반정보/계획정보 (잠정치) */ +.b07-info-host:empty { + display: none; +} + +.b07-info { + margin-top: var(--spacing-16); + padding-top: var(--spacing-16); + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.b07-info__heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.b07-info__badge { + padding: 1px 6px; + font-size: 10px; + color: var(--color-primary); + background: var(--color-mist-violet); + border-radius: var(--radius-buttons); +} + +.b07-info__badge--confirmed { + color: var(--color-surface); + background: var(--color-success); +} + +.b07-info__block { + display: flex; + flex-direction: column; + gap: 2px; +} + +.b07-info__block h4 { + margin: var(--spacing-8) 0 2px; + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.b07-info__row { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); + font-size: 0.76rem; +} + +.b07-info__key { + color: var(--color-text-muted); +} + +.b07-info__val { + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +.b07-info__empty { + font-size: 0.76rem; + color: var(--color-text-muted); +} diff --git a/config/config_db.py b/config/config_db.py index 5d12fcd4..1a9bfc6f 100644 --- a/config/config_db.py +++ b/config/config_db.py @@ -21,7 +21,6 @@ from .config_system import ( # 글로벌 DB 풀 (앱 시작/종료 시 관리) db_pool: Optional[aiomysql.Pool] = None - async def init_db_pool() -> aiomysql.Pool: """MariaDB 연결 풀 초기화""" diff --git a/config/config_system.py b/config/config_system.py index 8b44a5f2..83f6ab11 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -244,6 +244,34 @@ SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5} +# ───────────────────────────────────────────────────────────────────────── +# 5-4-1. 횡단 표준단면 설계 기준 (B06 WF3) +# +# 출처: 첨부 표준횡단도(토사/암 구간). 노면폭 4.5m = 길어깨 0.5 + 차도 3.5 + 길어깨 0.5. +# 지반유형 라벨은 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")) +# 성토 경사(수평:수직 = 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_GROUND_TYPE_PRESET = { + "soil": "soil", + "ripping_rock": "rock", + "blasting_rock": "rock", +} +# 단면유형: 좌절/우절(편절편성), 양절, 양성. 좌=양(+)offset, 우=음(-)offset. +SECTION_MODES = ("left_cut", "right_cut", "both_cut", "both_fill") +SECTION_DITCH_SIDES = ("left", "right") + + # ───────────────────────────────────────────────────────────────────────── # 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) # diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 972af8f8..04adb83f 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -797,6 +797,28 @@ export const ui_locales = { "No cross-section data to display.", ], + /* --- B06 측점 표준횡단 설계 지정 --- */ + B06_Design_Ground_Legend: ["지반유형", "Ground type"], + B06_Design_Ground_Soil: ["토사", "Soil"], + B06_Design_Ground_Ripping: ["리핑암", "Ripping rock"], + B06_Design_Ground_Blasting: ["발파암", "Blasting rock"], + B06_Design_Mode_Legend: ["단면유형", "Section type"], + B06_Design_Mode_LeftCut: ["좌 절토", "Left cut"], + B06_Design_Mode_RightCut: ["우 절토", "Right cut"], + B06_Design_Mode_BothCut: ["양절", "Both cut"], + B06_Design_Mode_BothFill: ["양성", "Both fill"], + B06_Design_Ditch_Legend: ["측구위치", "Ditch side"], + B06_Design_Ditch_Left: ["좌", "Left"], + B06_Design_Ditch_Right: ["우", "Right"], + B06_Design_Cut_Area: ["절토", "Cut"], + B06_Design_Fill_Area: ["성토", "Fill"], + B06_Design_Unset: ["미지정", "Not set"], + B06_Design_Failed: ["횡단 설계 계산에 실패했습니다.", "Failed to compute cross-section design."], + B06_Profile_Confirm_NeedDesign: [ + "지반유형이 지정되지 않은 측점이 있습니다.", + "Some stations have no ground type assigned.", + ], + /* --- B07_wf4_DesignDetail 상세 설계 --- */ B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"], B07_Cad_Side_Pending: [ @@ -805,6 +827,22 @@ export const ui_locales = { ], B07_Cad_Loading: ["도면을 불러오는 중...", "Loading drawing..."], B07_Cad_Load_Failed: ["도면을 불러오지 못했습니다.", "Failed to load drawing."], + B07_Info_Ground_Title: ["지반정보", "Ground info"], + B07_Info_Plan_Title: ["계획정보", "Plan info"], + B07_Info_GroundType: ["지반유형", "Ground type"], + B07_Info_CutSide: ["절토측", "Cut side"], + B07_Info_DitchSide: ["측구위치", "Ditch side"], + B07_Info_DesignElevation: ["계획고", "Design elevation"], + B07_Info_CutSlope: ["절토경사", "Cut slope"], + B07_Info_FillSlope: ["성토경사", "Fill slope"], + B07_Info_RoadWidth: ["노면폭", "Road width"], + B07_Info_Ditch: ["측구규격", "Ditch spec"], + B07_Info_CutArea: ["절토 단면적", "Cut area"], + B07_Info_FillArea: ["성토 단면적", "Fill area"], + B07_Info_Provisional: ["잠정", "Provisional"], + B07_Info_Confirmed: ["확정", "Confirmed"], + B07_Info_NoDesign: ["지반·계획 지정 데이터가 없습니다.", "No ground/plan designation data."], + B07_Info_Station: ["측점", "Station"], /* --- B08_wf5_Quantity 수량 산출 --- */ B08_Quantity_Title: ["5차 · 수량 산출", "Step 5 · Quantity Takeoff"],