feat(b07): 횡단 장 = 작도 영역 열 × 행 표 격자선 · 장 안 블록 테두리 끔 · 칸 넘침 알림 (169-2)

- 장마다 칸 격자선(세로 열 + 1 · 가로 행 + 1 · 도각 층 · 인쇄 · DXF)
- 단면은 블록 틀 가운데 = 칸 가운데 그대로 · 장 안 블록 테두리 안 그림(측점 한 장은 그대로)
- 칸보다 큰 단면 = overflow_cells → CAD 가이드 붉은 틀 + 화면 알림(축척 그대로)
- 배치 판 4 — 옛 판 확정 장은 「옛 배치」 안내

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-10 08:35:39 +09:00
co-authored by Claude Opus 5.5
parent 3e5b7cf21f
commit ab92de3787
4 changed files with 186 additions and 8 deletions
@@ -496,6 +496,7 @@ def build_cross_drawing(
frame: dict[str, float] | None = None,
origin: tuple[float, float] = (0.0, 0.0),
overlay: tuple[float, float, float, float] | None = None,
border: bool = True,
) -> dict[str, Any]:
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
@@ -586,9 +587,10 @@ def build_cross_drawing(
(frame_left, frame_top),
(frame_left, frame_bottom),
]
border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR)
if border:
entities.append(border)
# 장 안 블록은 테두리 대신 장 칸 격자선(169-2) — 측점 한 장만 테두리
outline = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR)
if border and outline:
entities.append(outline)
return {
"format": DRAWING_FORMAT,
@@ -3,6 +3,7 @@
채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다
(2026-08-29 사용자 확정). 칸 = 도각 그림칸(usable_area)을 똑같이 나눈 자리 · 단면은
칸 가운데 · 작성 척도 1/100 그대로(큰 단면이 칸을 넘으면 겹침 — 사용자 배치로 고침).
칸 = 인쇄되는 표 격자선 · 블록 테두리 없음 · 칸보다 큰 단면은 `overflow_cells`(169-2).
"""
import re
@@ -19,11 +20,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
GROUND_LAYER_ID,
ROCK_LAYER_ID,
STRUCTURE_LAYER_ID,
TABLE_LINE_COLOR,
_clip_polyline,
_layer,
_section_window,
build_cross_drawing,
points_from_samples,
polyline_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
cross_note_height,
@@ -145,11 +148,37 @@ def plan_cross_sheets(
return sheets
def _grid_lines(sheet_id: str, cols: int, rows: int) -> list[dict[str, Any]]:
"""장 칸 격자선(169-2) — 세로 열 + 1 · 가로 행 + 1 · 도각 층(인쇄 · DXF 에 나감)."""
usable_w, usable_h = usable_area()
left, bottom = -usable_w / 2.0, -usable_h / 2.0
lines = [
((left + usable_w * i / cols, bottom), (left + usable_w * i / cols, -bottom), f"v{i}")
for i in range(cols + 1)
] + [
((left, bottom + usable_h * j / rows), (-left, bottom + usable_h * j / rows), f"h{j}")
for j in range(rows + 1)
]
return [
entity
for start, end, name in lines
if (
entity := polyline_entity(
sheet_id, [start, end], FRAME_LAYER_ID, TABLE_LINE_COLOR, f":grid-{name}"
)
)
]
def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]:
"""한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로)."""
entities: list[dict[str, Any]] = []
placements: list[dict[str, Any]] = []
overflow: list[list[float]] = []
slots = sheet.get("slots") or []
cols, rows = sheet.get("grid") or (4, 4)
usable_w, usable_h = usable_area()
cell_w, cell_h = usable_w / cols, usable_h / rows
for index, section in enumerate(sections):
if index >= len(slots):
@@ -164,13 +193,26 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
section.get("quantity_table"),
section.get("title", ""),
overlay=section.get("overlay"),
border=False,
)
bbox = entities_bbox(probe["entities"])
# 블록 틀(그림 + 1 mm · 안 그림) — 테두리를 끈 뒤에도 같은 자리 · 크기로 칸에 맞춤
frames = [p.get("frame") for p in probe.get("cross_placements") or []]
bbox = frames[0] if frames and frames[0] else entities_bbox(probe["entities"])
if bbox is None:
continue
min_x, min_y, max_x, max_y = bbox
center_x, center_y = slots[index]
# 2) 블록(테두리 = 자기 그림 + 1 mm) 가운데를 칸 가운데에 맞춰 다시 만든다.
# 칸보다 큰 단면 — 축척 그대로 두고 그 칸을 화면 가이드로 짚음(169-2)
if max_x - min_x > cell_w + 1e-6 or max_y - min_y > cell_h + 1e-6:
overflow.append(
[
center_x - cell_w / 2.0,
center_y - cell_h / 2.0,
center_x + cell_w / 2.0,
center_y + cell_h / 2.0,
]
)
# 2) 블록 그림 가운데를 칸 가운데에 맞춰 다시 만든다(장 안은 테두리 없음).
origin = (center_x - (min_x + max_x) / 2.0, center_y - (min_y + max_y) / 2.0)
placed = build_cross_drawing(
section["source"],
@@ -181,13 +223,14 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
section.get("title", ""),
origin=origin,
overlay=section.get("overlay"),
border=False,
)
entities.extend(placed["entities"])
placements.extend(placed.get("cross_placements") or [])
if entities:
entities.extend(_grid_lines(sheet["id"], cols, rows))
# 도각 = 고정 그림칸 — 내용 크기와 무관하게 칸 격자와 같은 자리(140-3).
usable_w, usable_h = usable_area()
entities.extend(
frame_entities(
sheet["id"],
@@ -201,6 +244,8 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
"format": DRAWING_FORMAT,
"entities": entities,
"cross_placements": placements,
# 칸보다 큰 단면의 칸 [x0, y0, x1, y1] — 라우터가 CAD 가이드 붉은 틀로 보냄(169-2)
"overflow_cells": overflow,
"layers": [
_layer(GROUND_LAYER_ID, "원지반", locked=True),
_layer(DESIGN_LAYER_ID, "계획선"),
@@ -97,8 +97,9 @@ MASS_HAUL_ID = "mass_haul"
COVER_ID = "cover"
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
CROSS_STANDARD_ID = "cross_standard"
# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(140-3). 이 판 전에 확정한 장은 옛 배치다.
CROSS_SHEET_LAYOUT = 3
# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(140-3) · 칸 격자선 · 블록 테두리 없음(169-2).
# 이 판 전에 확정한 장은 옛 배치다.
CROSS_SHEET_LAYOUT = 4
# 장마다 열 × 행 — {default: [열, 행], sheets: {장 번호: [열, 행]}}(140-3).
CROSS_LAYOUT_FILE = "cross_layout.json"
CROSS_GRID_DEFAULT = [4, 4]
@@ -0,0 +1,130 @@
# -*- coding: utf-8 -*-
"""169 도면 그림 영역 지정 · 횡단 격자 표.
못박는 것
- 169-1 그림칸 = 작도 영역 그대로(여백 없음) · 도각 편집이 영역을 싣고 · 저장(어긋나면 옛 값).
- 169-1 도면 좌표 가이드 영역 = 도각 bbox ↔ 양식 bbox 로 옮긴 영역 · 도각 아닌 것(블록 틀)이면 None.
- 169-2 횡단 장 = 칸 격자선(열 + 1 · 행 + 1 · 칸 경계) · 장 안 블록 테두리 없음 · 큰 단면 → 넘친 칸.
- 169-4 구조물 도각(외곽 좌하단 0,0) 응답에 영역.
"""
import json
from pathlib import Path
from uuid import uuid5
import pytest
from B07_DesignDetail import B07_DesignDetail_Engine_Template as engine
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_POLY_NS,
FRAME_LAYER_ID,
build_cross_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import build_cross_sheet, plan_cross_sheets
from B07_DesignDetail.B07_DesignDetail_Router import _drawing_guide
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame
ROOT = Path(__file__).resolve().parents[2]
TABLE = {"ground": 100.0, "planned": 100.0}
def _source(chainage: float, slope: float, half: int = 10) -> dict:
return {
"chainage_m": chainage,
"samples": [
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + slope * x}
for x in range(-half, half + 1)
],
}
def _sheet(sources: list[dict], layout: dict | None = None) -> tuple[dict, dict]:
sheet = plan_cross_sheets([s["chainage_m"] for s in sources], layout)[0]
sections = [
{"chainage": s["chainage_m"], "source": s, "quantity_table": TABLE, "title": ""}
for s in sources
]
return sheet, build_cross_sheet(sheet, sections)
def test_usable_area_is_drawing_area_without_margin():
engine.use_project_templates(None)
x0, y0, x1, y1 = engine.drawing_area()
assert engine.usable_area() == (x1 - x0, y1 - y0) == (770.0, 520.0)
def test_frame_edit_loads_and_saves_area(tmp_path):
engine.use_project_templates(None)
assert engine.frame_template_document()["drawing_area"] == [42.0, 47.0, 812.0, 567.0]
path = engine.save_project_template(tmp_path, [], area=[10, 20, 410, 320])
assert json.loads(path.read_text(encoding="utf-8"))["drawing_area"] == [10, 20, 410, 320]
# 어긋난 값(뒤집힘 · 글자) → 고치기 전 값을 이어 받음
for bad in ([410, 20, 10, 320], ["a", 0, 1, 1], None):
path = engine.save_project_template(tmp_path, [], area=bad)
assert json.loads(path.read_text(encoding="utf-8"))["drawing_area"] == [10, 20, 410, 320]
# 라우터가 요청 도면의 영역을 넘김
router = (ROOT / "B07_DesignDetail/B07_DesignDetail_Router_Frame.py").read_text(
encoding="utf-8"
)
assert 'request.drawing.get("drawing_area")' in router and "area=area" in router
def test_cross_sheet_grid_lines_no_block_border_and_guide_area():
engine.use_project_templates(None)
w, h = engine.usable_area()
sources = [_source(i * 20.0, 0.05, 6) for i in range(3)]
sheet, drawing = _sheet(sources, {"default": [3, 2]})
by_id = {e["id"]: e for e in drawing["entities"]}
def line(name: str) -> tuple[dict, dict]:
entity = by_id[str(uuid5(_POLY_NS, f"{sheet['id']}:{FRAME_LAYER_ID}:grid-{name}"))]
assert entity["layerId"] == FRAME_LAYER_ID
shape = entity["children"][0]["shapeData"]
return shape["startPoint"], shape["endPoint"]
# 격자선 = 세로 열 + 1 · 가로 행 + 1 · 칸 경계 자리 · 그림칸 끝에서 끝까지
for i in range(3 + 1):
a, b = line(f"v{i}")
assert a["x"] == b["x"] == pytest.approx(-w / 2 + w * i / 3)
assert (a["y"], b["y"]) == pytest.approx((-h / 2, h / 2))
for j in range(2 + 1):
a, b = line(f"h{j}")
assert a["y"] == b["y"] == pytest.approx(-h / 2 + h * j / 2)
assert (a["x"], b["x"]) == pytest.approx((-w / 2, w / 2))
assert str(uuid5(_POLY_NS, f"{sheet['id']}:{FRAME_LAYER_ID}:grid-v4")) not in by_id
# 장 안 블록 테두리 없음(측점 한 장 테두리와 같은 id 자리)
for source in sources:
seed = f"{sheet['id']}:{source['chainage_m']}:{FRAME_LAYER_ID}"
assert str(uuid5(_POLY_NS, seed)) not in by_id
# 가이드 영역(도면 좌표) = 그림칸 그대로 · 넘침 없음
guide = _drawing_guide(drawing)
assert guide["area"] == pytest.approx([-w / 2, -h / 2, w / 2, h / 2], abs=1e-3)
assert guide["marks"] == [] and drawing["overflow_cells"] == []
def test_big_section_marks_its_cell():
engine.use_project_templates(None)
w, h = engine.usable_area()
# 4 × 4 칸(192.5 × 130 mm)에 60 m 폭 단면(600 mm) — 첫 칸(좌하단)만 넘침
sources = [_source(0.0, 0.05, 30), _source(20.0, 0.05, 6)]
_sheet_, drawing = _sheet(sources, {"default": [4, 4]})
cw, ch = w / 4, h / 4
assert drawing["overflow_cells"] == [pytest.approx([-w / 2, -h / 2, -w / 2 + cw, -h / 2 + ch])]
assert _drawing_guide(drawing)["marks"] == drawing["overflow_cells"]
def test_station_drawing_border_is_not_a_frame():
# 측점 한 장 — 도각 층에 블록 테두리만 있음 → 가이드 없음(틀린 선 대신)
drawing = build_cross_drawing(_source(0.0, 0.1), "cross_00000m", None, None, TABLE, "")
assert any(e.get("layerId") == FRAME_LAYER_ID for e in drawing["entities"])
assert _drawing_guide(drawing) is None
def test_structure_frame_area_in_frame_coords():
engine.use_project_templates(None)
frame = structure_frame()
min_x, min_y, *_ = engine.entities_bbox(engine.template_entities())
x0, y0, x1, y1 = engine.drawing_area()
assert frame["area"] == pytest.approx(
[x0 - min_x, y0 - min_y, x1 - min_x, y1 - min_y], abs=1e-3
)