feat(B07): 횡단면도 장 배치 장마다 열 × 행 칸 가운데 · 도각 고정 그림칸(140-3)

- 쌓기 · 최소 장수 함수 지움 · 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
This commit is contained in:
2026-10-09 14:56:34 +09:00
co-authored by Claude Opus 5.5
parent 6df477a522
commit a504f561ad
9 changed files with 451 additions and 162 deletions
@@ -34,7 +34,6 @@ 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,
@@ -82,6 +81,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
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
@@ -97,16 +97,67 @@ MASS_HAUL_ID = "mass_haul"
COVER_ID = "cover"
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
CROSS_STANDARD_ID = "cross_standard"
# 횡단 장 배치 판 — 단면마다 자기 그림 + 1 mm 박스(68-1). 이 판 전에 확정한 장은 옛 배치다.
CROSS_SHEET_LAYOUT = 2
# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(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 _drawing_list(
project_root: Path,
longitudinal_path: Path,
designs: dict[int | float, dict[str, Any]] | None = None,
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
) -> list[DesignDrawingItem]:
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"]
@@ -123,7 +174,7 @@ def _drawing_list(
# 횡단 장 계획이 실패해도 나머지 도면은 목록에 남긴다 — 예전에는 `cross_sections/`
# 하나가 없으면 표지·종단면도·토적도·유역도까지 함께 사라졌다(2026-09-01 지적).
try:
sheets = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays)
sheets = _cross_sheet_plan(project_root, longitudinal_path)
except (FileNotFoundError, ValueError, OSError) as exc:
logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc)
sheets = []
@@ -190,32 +241,21 @@ def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]
_write_manifest(project_root, manifest)
def _cross_sheet_plan(
project_root: Path,
longitudinal_path: Path,
designs: dict[int | float, dict[str, Any]] | None = None,
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
) -> list[dict[str, Any]]:
def _cross_sheet_plan(project_root: Path, longitudinal_path: Path) -> list[dict[str, Any]]:
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 +
설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다.
장마다 열 × 행 칸 수만큼 담는다(140-3 · 단면 크기와 무관 — 설계 · 덧그림에 장 id 가 안 흔들림).
"""
longitudinal = _read_json(longitudinal_path)
blocks: list[tuple[int, float, float]] = []
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 이름이라 이름으로 못 앎
chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))
design = (designs or {}).get(chainage)
design_line = _cross_design_line(longitudinal_path, source, design)
overlay = (overlays or {}).get(chainage) # 화면 덧그림 범위(68-3)
width, height = section_block_size(source, design_line, design=design, overlay=overlay)
blocks.append((chainage, width, height))
return plan_cross_sheets(blocks)
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(
@@ -353,7 +393,7 @@ def _read_cross_sheet(
"""횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정}, overlays는 {측점: 덧그림 범위}."""
designs = stored_designs if isinstance(stored_designs, dict) else {}
overlays = overlays or {}
plan = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays)
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("요청한 횡단 장을 찾을 수 없습니다.")