"""B07 도면 조립 지원 — 파일·매니페스트 읽기, 도면 목록·장 배치, 확정 저장. 라우터에서 HTTP 처리와 무관한 동기 헬퍼만 떼어 놓은 모듈이다(단일 파일 700줄 제한). """ import json import logging import re from pathlib import Path from typing import Any from B06_Section.B06_Section_Engine_Design import compute_cross_design from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, build_cross_drawing, infer_station_interval, station_no_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import ( build_blank_drawing, build_cover_drawing, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( LANDUSE_LABEL, build_landuse_drawing, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import ( LIDAR_LABEL, build_lidar_plan_drawing, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( PLAN_KINDS, build_plan_drawing, plan_chunks, plan_drawing_id, plan_drawing_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, build_cross_sheet, plan_cross_sheets, section_block_size, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import ( STANDARD_LABEL, build_standard_cross_drawing, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( QUANTITY_VALUE_KEYS, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields # 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04). # 여기서 그대로 다시 내보내 호출부(`B07_DesignDetail_Router.py`)의 import 경로는 불변이다. from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F401 _BASIN_MAX_DISTANCE_M, CONTOUR_FILE, LANDUSE_ID, LIDAR_ID, PLAN_ID, STREAM_FILE, _basins_crs, _clip_segment, _geojson_features, _geojson_payload, _geometry_lines, _too_far_from_route, clip_line_to_box, landuse_source, lidar_source, plan_source, plan_stations, watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _cross_files, _design_root, _read_json, _read_manifest, _station_map, _write_manifest, ) from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingItem, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal logger = logging.getLogger(__name__) _STAGE_DIR = "B07_DesignDetail" _CROSS_ID = re.compile(r"^cross_(\d+)m$") _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") # 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다. MASS_HAUL_ID = "mass_haul" WATERSHED_ID = "watershed" COVER_ID = "cover" # 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. CROSS_STANDARD_ID = "cross_standard" # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). # 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (("blank_standard", "표준도"),) BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) def _drawing_list( project_root: Path, longitudinal_path: Path, designs: dict[int, dict[str, Any]] | None = None, ) -> list[DesignDrawingItem]: longitudinal = _read_json(longitudinal_path) station_by_chainage = _station_map(longitudinal) manifest_drawings = _read_manifest(project_root)["drawings"] # 종단도: 측점 30개 초과 시 30개 단위 분할 도면을 각각 목록에 노출한다(N-1-1). drawings = [ DesignDrawingItem( id=str(chunk["id"]), kind="longitudinal", label=str(chunk["label"]), confirmed=bool(manifest_drawings.get(str(chunk["id"]), {}).get("confirmed")), ) for chunk in longitudinal_chunks(longitudinal) ] # 횡단 장 계획이 실패해도 나머지 도면은 목록에 남긴다 — 예전에는 `cross_sections/` # 하나가 없으면 표지·종단면도·토적도·유역도까지 함께 사라졌다(2026-09-01 지적). try: sheets = _cross_sheet_plan(project_root, longitudinal_path, designs) except (FileNotFoundError, ValueError, OSError) as exc: logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc) sheets = [] # 장 라벨은 누가거리 범위가 아니라 **그 장의 시작 측점**으로 짓는다(2026-09-04 사용자 # 지시) — 「1060~1070m」식 범위는 좌측 패널 2열 단추를 넘칠 만큼 길었다. interval = infer_station_interval(longitudinal.get("stations") or []) for sheet in sheets: chainages = sheet["chainages"] first = station_by_chainage.get(chainages[0], {}) station = station_no_label(float(first.get("chainage_m", chainages[0])), interval) drawings.append( DesignDrawingItem( id=sheet["id"], kind="cross", label=f"{sheet['number']}장 ({station})", chainage_m=float(first.get("chainage_m", chainages[0])), confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), ) ) # 계획평면도 3종 — 축척 1/1,200 고정이라 노선이 길면 장이 나뉜다(장수는 노선이 정한다). plan_sheets = plan_chunks(plan_stations(longitudinal)) for kind, _label, *_rest in PLAN_KINDS: for chunk in plan_sheets: drawing_id = plan_drawing_id(kind, chunk, len(plan_sheets)) drawings.append( DesignDrawingItem( id=drawing_id, kind="plan", label=plan_drawing_label(kind, chunk, len(plan_sheets)), confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) # 계획평면도(라이다) — 지표면 음영기복 배경. 같은 축척·같은 장 나눔. for chunk in plan_sheets: drawing_id = "plan_lidar" if len(plan_sheets) <= 1 else f"plan_lidar_{chunk['number']}" drawings.append( DesignDrawingItem( id=drawing_id, kind="plan_lidar", label=LIDAR_LABEL if len(plan_sheets) <= 1 else f"{LIDAR_LABEL} {chunk['number']}장", confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) # 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다. for chunk in plan_sheets: drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}" drawings.append( DesignDrawingItem( id=drawing_id, kind="landuse", label=LANDUSE_LABEL if len(plan_sheets) <= 1 else f"{LANDUSE_LABEL} {chunk['number']}장", confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) # 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다). for drawing_id, kind, label in ( (COVER_ID, "cover", "표지"), (CROSS_STANDARD_ID, "cross_standard", STANDARD_LABEL), (MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"), (WATERSHED_ID, "watershed", "유역도(배수 유역도)"), ): drawings.append( DesignDrawingItem( id=drawing_id, kind=kind, label=label, confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) # 아직 내용을 만들지 않은 도면도 **빈 도각으로 열린다**(2026-09-01 사용자 지시). # 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다. for drawing_id, label in BLANK_DRAWINGS: drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label)) _store_drawing_numbers(project_root, drawings) return drawings def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]) -> None: """도면번호(목록 순번)를 manifest 에 적어 둔다. 단건 조회는 목록 순서를 모른다 — 알려면 횡단 장 계획을 다시 계산해야 하고, 그것을 도면 열 때마다 하면 비싸다. 목록은 화면에 들어올 때 늘 먼저 뜨므로, 그때 매긴 번호를 적어 두고 단건 조회는 그것을 읽는다. 목록이 바뀌면 다음 조회에서 다시 적힌다. """ manifest = _read_manifest(project_root) entries = manifest["drawings"] changed = False for number, item in enumerate(drawings, start=1): entry = entries.setdefault(item.id, {}) if entry.get("number") != number: entry["number"] = number changed = True if changed: _write_manifest(project_root, manifest) def _cross_sheet_plan( project_root: Path, longitudinal_path: Path, designs: dict[int, dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다. 한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 + 설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다. """ longitudinal = _read_json(longitudinal_path) blocks: list[tuple[int, float, float]] = [] for path in sorted(_cross_files(longitudinal_path, longitudinal)): match = _CROSS_ID.fullmatch(path.stem) if not match: continue chainage = int(match.group(1)) source = _read_json(path) design = (designs or {}).get(chainage) design_line = _cross_design_line(longitudinal_path, source, design) width, height = section_block_size(source, design_line) blocks.append((chainage, width, height)) return plan_cross_sheets(blocks) def _quantity_table( source: dict[str, Any], design: dict[str, Any] | None = None ) -> dict[str, float | None]: """횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다 (납품 양식 키). center_z→지반고, design_elevation_m→계획고, 절토고/성토고는 파생 초기값. 나머지 항목은 source["quantities"]에 같은 키가 있으면 읽고 없으면 None으로 두어 CAD 테이블에서 사용자가 채운다. **계획고는 횡단 설계(design)에도 있다** — 장 배치 입력의 원본에는 그 값이 없어 계획고·절토고·성토고 세 칸이 통째로 비어 나갔다(2026-09-03 실측: 장 확정 시 21개 항목 중 지반고 하나만 채워짐). 원본에 없으면 설계에서 읽는다. """ def num(value: Any) -> float | None: return float(value) if isinstance(value, (int, float)) else None ground = num(source.get("center_z")) planned = num(source.get("planned_elevation_m", source.get("design_elevation_m"))) if planned is None and isinstance(design, dict): planned = num(design.get("design_elevation_m")) cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {} table: dict[str, float | None] = { "ground": ground, "planned": planned, "cut": cut, "fill": fill, } for key in QUANTITY_VALUE_KEYS: table.setdefault(key, num(quantities.get(key))) return table def _route_cross_frame( longitudinal_path: Path, longitudinal: dict[str, Any] ) -> dict[str, float] | None: """노선 전체 횡단면의 기준 레이아웃 {half, half_height}를 구한다. 측점별 단면 높이(최대-최소 표고)와 폭의 노선 최대값 — 모든 횡단도가 자기 콘텐츠 중심 기준으로 같은 크기 레이아웃에 배치되도록 하는 기준값(테이블 y 고정용). 실패 시 None(측점 자체 범위 폴백). """ half = half_height = 0.0 found = False try: for path in _cross_files(longitudinal_path, longitudinal): source = _read_json(path) min_e: float | None = None max_e: float | None = None for sample in source.get("samples", []): if not isinstance(sample, dict) or not sample.get("valid", False): continue offset = sample.get("offset_m") elevation = sample.get("elevation_m") if isinstance(offset, (int, float)): half = max(half, abs(float(offset))) if isinstance(elevation, (int, float)): value = float(elevation) min_e = value if min_e is None else min(min_e, value) max_e = value if max_e is None else max(max_e, value) if min_e is not None and max_e is not None: half_height = max(half_height, (max_e - min_e) / 2.0) found = True except (OSError, ValueError, json.JSONDecodeError, FileNotFoundError): return None if not found: return None # 여유: 측구 깊이·사면 연장 등 설계선이 지반 포락선을 소폭 벗어나는 분 반영. return {"half": half or 12.0, "half_height": half_height + 1.5} 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 _cross_section_input( longitudinal_path: Path, longitudinal: dict[str, Any], chainage: int, design: dict[str, Any] | None, ) -> dict[str, Any] | None: """한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.""" path = longitudinal_path.parent.parent / "cross_sections" / f"cross_{chainage:05d}m.json" if not path.is_file(): return None source = _read_json(path) interval = infer_station_interval(longitudinal.get("stations") or []) return { "chainage": chainage, "source": source, "design": design, "design_line": _cross_design_line(longitudinal_path, source, design), "quantity_table": _quantity_table(source, design), "title": station_no_label(float(source.get("chainage_m", chainage)), interval), } def _read_cross_sheet( project_root: Path, longitudinal_path: Path, drawing_id: str, stored_designs: dict[str, Any] | None, ) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]: """횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정} 묶음이다.""" designs = stored_designs if isinstance(stored_designs, dict) else {} plan = _cross_sheet_plan(project_root, longitudinal_path, designs) sheet = next((item for item in plan if item["id"] == drawing_id), None) if sheet is None: raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.") longitudinal = _read_json(longitudinal_path) sections = [ section for section in ( _cross_section_input(longitudinal_path, longitudinal, chainage, designs.get(chainage)) for chainage in sheet["chainages"] ) if section is not None ] label = f"횡단면도 {sheet['number']}장" return "cross", label, build_cross_sheet(sheet, sections), False, None def _read_drawing( 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, {}) number = manifest_entry.get("number") if isinstance(number, int): add_title_fields({"도면번호": str(number)}) saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json" if manifest_entry.get("confirmed") and saved_path.is_file(): saved = _read_json(saved_path) # 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고 # 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도. if saved.get("format") == DRAWING_FORMAT: if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID): kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_ID.fullmatch(drawing_id): kind = "plan" elif LANDUSE_ID.fullmatch(drawing_id): kind = "landuse" elif LIDAR_ID.fullmatch(drawing_id): kind = "plan_lidar" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) stored_table = manifest_entry.get("quantity_table") table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None return kind, label, saved, True, table if drawing_id in BLANK_LABELS: # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다. 확정 대상이 아니다. label = BLANK_LABELS[drawing_id] return "blank", label, build_blank_drawing(drawing_id, label), False, None if drawing_id == COVER_ID: # 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다. return "cover", "표지", build_cover_drawing(drawing_id), False, None if drawing_id == MASS_HAUL_ID: # stored_design = 확정 종단 DB row의 mass_haul 산출물(라우터가 실어 준다). if not isinstance(stored_design, dict): raise FileNotFoundError("확정 종단에 유토곡선 산출물이 없습니다.") longitudinal = _read_json(longitudinal_path) return ( "mass_haul", "토적도(유토곡선)", build_mass_haul_drawing(longitudinal, stored_design, drawing_id), False, None, ) if drawing_id == CROSS_STANDARD_ID: # stored_design = B06 표준 횡단면 설정값(라우터가 실어 준다). 없으면 config 기본값. standard = stored_design if isinstance(stored_design, dict) else None return ( "cross_standard", STANDARD_LABEL, build_standard_cross_drawing(drawing_id, STANDARD_LABEL, standard), False, None, ) if LIDAR_ID.fullmatch(drawing_id): # stored_design = lidar_source()가 만든 노선 + 지표면 음영기복 그림. if not isinstance(stored_design, dict): raise FileNotFoundError("라이다 계획평면도 자료가 없습니다.") label = str(stored_design.get("label") or LIDAR_LABEL) return ( "plan_lidar", label, build_lidar_plan_drawing( drawing_id, label, stored_design.get("route_xy") or [], stored_design.get("shade_image"), stored_design.get("shade_box"), ), False, None, ) if LANDUSE_ID.fullmatch(drawing_id): # stored_design = landuse_source()가 모아 준 노선·등고선·지적·행정 경계(사업지 CRS). if not isinstance(stored_design, dict): raise FileNotFoundError("용지도 자료가 없습니다.") label = str(stored_design.get("label") or LANDUSE_LABEL) return ( "landuse", label, build_landuse_drawing( drawing_id, label, stored_design.get("route_xy") or [], stored_design.get("contours") or [], stored_design.get("parcels") or [], stored_design.get("emd_rings") or [], stored_design.get("sgg_rings") or [], ), False, None, ) if PLAN_ID.fullmatch(drawing_id): # stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS). if not isinstance(stored_design, dict): raise FileNotFoundError("계획평면도 자료가 없습니다.") longitudinal = _read_json(longitudinal_path) interval = infer_station_interval(longitudinal.get("stations") or []) label = str(stored_design.get("label") or drawing_id) return ( "plan", label, build_plan_drawing( str(stored_design.get("kind") or "plan_terrain"), drawing_id, label, stored_design.get("route_xy") or [], stored_design.get("stations") or [], stored_design.get("contours") or [], stored_design.get("streams") or [], stored_design.get("structures") or [], interval, ), False, None, ) if drawing_id == WATERSHED_ID: # stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). if not isinstance(stored_design, dict): raise FileNotFoundError("배수 유역 산출물이 없습니다.") longitudinal = _read_json(longitudinal_path) interval = infer_station_interval(longitudinal.get("stations") or []) return ( "watershed", "유역도(배수 유역도)", build_watershed_drawing( drawing_id, stored_design.get("route_xy") or [], stored_design.get("basins") or [], stored_design.get("contours") or [], stored_design.get("streams") or [], interval, ), False, None, ) if _LONG_ID.fullmatch(drawing_id): source = _read_json(longitudinal_path) chunk = next( (item for item in longitudinal_chunks(source) if item["id"] == drawing_id), None ) if chunk is None: raise FileNotFoundError("요청한 종단도 분할 도면을 찾을 수 없습니다.") return ( "longitudinal", str(chunk["label"]), build_longitudinal_drawing(source, drawing_id, chunk), False, None, ) if CROSS_SHEET_ID.fullmatch(drawing_id): return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design) if not _CROSS_ID.fullmatch(drawing_id): raise ValueError("올바르지 않은 도면 ID입니다.") path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json" if not path.is_file(): raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.") source = _read_json(path) label = str(source.get("label") or drawing_id) design_line = _cross_design_line(longitudinal_path, source, stored_design) quantity_table = _quantity_table(source, stored_design) # 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식) longitudinal = _read_json(longitudinal_path) interval = infer_station_interval(longitudinal.get("stations") or []) title = station_no_label(float(source.get("chainage_m", 0.0)), interval) # 계획고(로컬좌표 기준) + 노선 공통 범위 — 측점 이동 시 화면 배치 고정. design_elevation = design_elevation_from_longitudinal( longitudinal, float(source.get("chainage_m", 0.0)) ) frame = _route_cross_frame(longitudinal_path, longitudinal) return ( "cross", label, build_cross_drawing( source, drawing_id, design_line, stored_design, quantity_table, title, design_elevation, frame, ), False, quantity_table, ) def _store_confirmed_drawing( project_root: Path, item: DesignDrawingItem, drawing: dict[str, Any], expected_ids: set[str], quantity_table: dict[str, Any] | None = None, quantity_tables: dict[str, Any] | None = None, ) -> bool: if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list): raise ValueError("CAD 도면 스키마가 올바르지 않습니다.") # CAD 앱 직렬화본에는 format이 없으므로 저장 시 현재 포맷 버전을 스탬프한다. drawing = {"format": DRAWING_FORMAT, **drawing} drawings_dir = _design_root(project_root) / "drawings" drawings_dir.mkdir(parents=True, exist_ok=True) path = drawings_dir / f"{item.id}.json" temporary = path.with_suffix(".tmp") temporary.write_text(json.dumps(drawing, ensure_ascii=False, indent=2), encoding="utf-8") temporary.replace(path) manifest = _read_manifest(project_root) entry: dict[str, Any] = { "kind": item.kind, "label": item.label, "confirmed": True, "file": f"drawings/{item.id}.json", } if item.kind == "cross" and isinstance(quantity_table, dict): entry["quantity_table"] = quantity_table if isinstance(quantity_tables, dict) and quantity_tables: # 장 도면: 측점별 수량표를 그대로 보관한다(뒷단계 수량산출이 측점 단위). entry["quantity_tables"] = quantity_tables manifest["drawings"][item.id] = entry _write_manifest(project_root, manifest) confirmed_ids = { item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed") } return expected_ids.issubset(confirmed_ids) def _invalidate_drawing(project_root: Path, drawing_id: str) -> None: manifest = _read_manifest(project_root) entry = manifest["drawings"].get(drawing_id) if entry: entry["confirmed"] = False _write_manifest(project_root, manifest) def _recompute_confirmed_design( longitudinal_path: Path, cross_stem: 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"{cross_stem}.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