- 쌓기 · 최소 장수 함수 지움 · grid_slots(n, 열, 행) 칸 가운데(좌하단 → 위 → 오른쪽) · 1/100 그대로
- 저장 B07_DesignDetail/cross_layout.json {default [4,4], sheets {장 번호: [열, 행]}} · GET · PUT cross-layout
- 배치 바꾸면 담긴 측점이 바뀐 확정 장 풂 · 그 측점 설계 잠정 · CROSS_SHEET_LAYOUT = 3
- 장 나눔이 설계 · 덧그림과 무관 — 목록 · 확정 · 수정에서 덧그림 재기 빠짐
- 좌측 정보 칸 「배치 열 × 행 [적용] · [모든 장 기본]」 → 다시 세워 같은 장 번호 엶
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
615 lines
26 KiB
Python
615 lines
26 KiB
Python
"""B07 도면 조립 지원 — 파일·매니페스트 읽기, 도면 목록·장 배치, 확정 저장.
|
||
|
||
라우터에서 HTTP 처리와 무관한 동기 헬퍼만 떼어 놓은 모듈이다(단일 파일 700줄 제한).
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||
CROSS_KEY_PATTERN,
|
||
cross_station,
|
||
find_cross_file,
|
||
parse_cross_key,
|
||
)
|
||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||
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_cover_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_PlanKinds import build_plan_kind_drawing
|
||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||
CROSS_SHEET_ID,
|
||
build_cross_sheet,
|
||
plan_cross_sheets,
|
||
)
|
||
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_Cross_Quantity import derived_cells
|
||
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,
|
||
LIDAR_ID,
|
||
PLAN_ID,
|
||
STREAM_FILE,
|
||
_basins_crs,
|
||
_clip_segment,
|
||
_geojson_features,
|
||
_geojson_payload,
|
||
_geometry_lines,
|
||
_too_far_from_route,
|
||
clip_line_to_box,
|
||
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_Router_Support_Plan import (
|
||
PLAN_KIND_ID,
|
||
WATERSHED_LAYOUT,
|
||
plan_kind_items,
|
||
)
|
||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||
DesignDrawingItem,
|
||
)
|
||
from common_util.common_util_json import atomic_write_json
|
||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||
from config.config_system import DRAWING_SCALE_PLAN
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
_STAGE_DIR = "B07_DesignDetail"
|
||
|
||
_CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$")
|
||
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
||
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
|
||
MASS_HAUL_ID = "mass_haul"
|
||
COVER_ID = "cover"
|
||
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
|
||
CROSS_STANDARD_ID = "cross_standard"
|
||
# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(140-3). 이 판 전에 확정한 장은 옛 배치다.
|
||
CROSS_SHEET_LAYOUT = 3
|
||
# 장마다 열 × 행 — {default: [열, 행], sheets: {장 번호: [열, 행]}}(140-3).
|
||
CROSS_LAYOUT_FILE = "cross_layout.json"
|
||
CROSS_GRID_DEFAULT = [4, 4]
|
||
_CROSS_GRID_MAX = 10
|
||
|
||
|
||
def _cross_grid(value: Any) -> list[int]:
|
||
if (
|
||
not isinstance(value, (list, tuple))
|
||
or len(value) != 2
|
||
or not all(isinstance(n, int) and 1 <= n <= _CROSS_GRID_MAX for n in value)
|
||
):
|
||
raise ValueError(f"배치는 열 · 행 1~{_CROSS_GRID_MAX} 사이 정수로 적어 주세요.")
|
||
return [int(value[0]), int(value[1])]
|
||
|
||
|
||
def read_cross_layout(project_root: Path) -> dict[str, Any]:
|
||
"""횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 만."""
|
||
path = _design_root(project_root) / CROSS_LAYOUT_FILE
|
||
try:
|
||
return checked_cross_layout(_read_json(path))
|
||
except (OSError, ValueError, AttributeError):
|
||
return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}}
|
||
|
||
|
||
def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]:
|
||
sheets = layout.get("sheets") or {}
|
||
if not all(str(key).isdigit() and int(key) >= 1 for key in sheets):
|
||
raise ValueError("장 번호가 올바르지 않습니다.")
|
||
return {
|
||
"default": _cross_grid(layout.get("default") or CROSS_GRID_DEFAULT),
|
||
"sheets": {str(int(key)): _cross_grid(grid) for key, grid in sheets.items()},
|
||
}
|
||
|
||
|
||
def write_cross_layout(
|
||
project_root: Path, longitudinal_path: Path, layout: dict[str, Any]
|
||
) -> list[int | float]:
|
||
"""배치를 쓰고 담긴 측점이 바뀐 확정 장을 푼다 — 풀린 장의 옛 측점들(설계 상태를 되돌릴 몫)."""
|
||
checked = checked_cross_layout(layout)
|
||
before = _cross_sheet_plan(project_root, longitudinal_path)
|
||
atomic_write_json(_design_root(project_root) / CROSS_LAYOUT_FILE, checked)
|
||
after = {
|
||
sheet["id"]: sheet["chainages"]
|
||
for sheet in _cross_sheet_plan(project_root, longitudinal_path)
|
||
}
|
||
manifest = _read_manifest(project_root)
|
||
stale: list[int | float] = []
|
||
for sheet in before:
|
||
entry = manifest["drawings"].get(sheet["id"])
|
||
if entry and entry.get("confirmed") and after.get(sheet["id"]) != sheet["chainages"]:
|
||
entry["confirmed"] = False
|
||
stale.extend(sheet["chainages"])
|
||
if stale:
|
||
_write_manifest(project_root, manifest)
|
||
return stale
|
||
|
||
|
||
def _drawing_list(project_root: Path, longitudinal_path: Path) -> 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)
|
||
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")),
|
||
)
|
||
)
|
||
# 계획평면도 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가) × 장(PLAN 60장).
|
||
for entry in plan_kind_items(project_root, longitudinal):
|
||
drawings.append(
|
||
DesignDrawingItem(
|
||
**entry,
|
||
kind="plan",
|
||
confirmed=bool(manifest_drawings.get(entry["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", "토적도(유토곡선)"),
|
||
):
|
||
drawings.append(
|
||
DesignDrawingItem(
|
||
id=drawing_id,
|
||
kind=kind,
|
||
label=label,
|
||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||
)
|
||
)
|
||
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④.
|
||
_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) -> list[dict[str, Any]]:
|
||
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
|
||
|
||
장마다 열 × 행 칸 수만큼 담는다(140-3 · 단면 크기와 무관 — 설계 · 덧그림에 장 id 가 안 흔들림).
|
||
"""
|
||
longitudinal = _read_json(longitudinal_path)
|
||
chainages: list[int | float] = []
|
||
for path in sorted(_cross_files(longitudinal_path, longitudinal)):
|
||
match = _CROSS_ID.fullmatch(path.stem)
|
||
if not match:
|
||
continue
|
||
source = _read_json(path)
|
||
# 측점은 파일 속 값으로 — 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 이름으로 못 앎
|
||
chainages.append(cross_station(source.get("chainage_m") or parse_cross_key(match.group(1))))
|
||
return plan_cross_sheets(chainages, read_cross_layout(project_root))
|
||
|
||
|
||
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개
|
||
항목 중 지반고 하나만 채워짐). 원본에 없으면 설계에서 읽는다.
|
||
|
||
⚠ **본문 칸도 설계에서 온다**(2026-09-09) — `source["quantities"]` 키는 원본 파일에
|
||
아예 없어 열일곱 칸이 통째로 비어 나갔다. 채울 수 있는 것은 `_Engine_Cross_Quantity`
|
||
가 낸다(단면적은 저장값, 사면 계열은 B08 이 쓰는 함수 그대로). 별표2 법정 요구
|
||
여덟 중 뒤 넷이 비던 자리다.
|
||
"""
|
||
|
||
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,
|
||
}
|
||
chainage = num(source.get("chainage_m")) or 0.0
|
||
table.update(derived_cells(chainage, design))
|
||
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",
|
||
**curve_widening_args(source),
|
||
)
|
||
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 | float,
|
||
design: dict[str, Any] | None,
|
||
overlay: tuple[float, float, float, float] | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
|
||
path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
|
||
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),
|
||
"overlay": overlay,
|
||
}
|
||
|
||
|
||
def _read_cross_sheet(
|
||
project_root: Path,
|
||
longitudinal_path: Path,
|
||
drawing_id: str,
|
||
stored_designs: dict[str, Any] | None,
|
||
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
|
||
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
|
||
"""횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정}, overlays는 {측점: 덧그림 범위}."""
|
||
designs = stored_designs if isinstance(stored_designs, dict) else {}
|
||
overlays = overlays or {}
|
||
plan = _cross_sheet_plan(project_root, longitudinal_path)
|
||
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),
|
||
overlays.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,
|
||
overlays: dict[int | float, tuple[float, float, float, float]] | 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, CROSS_STANDARD_ID):
|
||
kind = drawing_id # id와 kind가 같은 단장 도면
|
||
elif PLAN_KIND_ID.fullmatch(drawing_id):
|
||
kind = "plan"
|
||
else:
|
||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||
label = str(manifest_entry.get("label") or drawing_id)
|
||
if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT:
|
||
saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내
|
||
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 == 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 PLAN_KIND_ID.fullmatch(drawing_id):
|
||
# stored_design = plan_kind_source()가 계산 · 저장한 조각 중 그 종류가 고른 것.
|
||
if not isinstance(stored_design, dict):
|
||
raise FileNotFoundError("계획평면도 자료가 없습니다.")
|
||
label = str(stored_design.get("label") or drawing_id)
|
||
if stored_design.get("layout") == WATERSHED_LAYOUT:
|
||
# 유역도 양식 — watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
|
||
longitudinal = _read_json(longitudinal_path)
|
||
drawing = 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 [],
|
||
infer_station_interval(longitudinal.get("stations") or []),
|
||
)
|
||
else:
|
||
drawing = build_plan_kind_drawing(
|
||
drawing_id,
|
||
label,
|
||
stored_design["box"],
|
||
stored_design.get("pieces") or {},
|
||
stored_design.get("scale") or DRAWING_SCALE_PLAN,
|
||
)
|
||
return "plan", label, drawing, 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, overlays
|
||
)
|
||
|
||
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
||
if not cross_match:
|
||
raise ValueError("올바르지 않은 도면 ID입니다.")
|
||
path = find_cross_file(
|
||
longitudinal_path.parent.parent / "cross_sections", parse_cross_key(cross_match.group(1))
|
||
)
|
||
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":
|
||
entry["layout"] = CROSS_SHEET_LAYOUT
|
||
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)
|