From ab92de37870320c122a669816aaebf7998fd6665 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 08:35:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(b07):=20=ED=9A=A1=EB=8B=A8=20=EC=9E=A5=20?= =?UTF-8?q?=3D=20=EC=9E=91=EB=8F=84=20=EC=98=81=EC=97=AD=20=EC=97=B4=20?= =?UTF-8?q?=C3=97=20=ED=96=89=20=ED=91=9C=20=EA=B2=A9=EC=9E=90=EC=84=A0=20?= =?UTF-8?q?=C2=B7=20=EC=9E=A5=20=EC=95=88=20=EB=B8=94=EB=A1=9D=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20=EB=81=94=20=C2=B7=20=EC=B9=B8=20=EB=84=98?= =?UTF-8?q?=EC=B9=A8=20=EC=95=8C=EB=A6=BC=20(169-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장마다 칸 격자선(세로 열 + 1 · 가로 행 + 1 · 도각 층 · 인쇄 · DXF) - 단면은 블록 틀 가운데 = 칸 가운데 그대로 · 장 안 블록 테두리 안 그림(측점 한 장은 그대로) - 칸보다 큰 단면 = overflow_cells → CAD 가이드 붉은 틀 + 화면 알림(축척 그대로) - 배치 판 4 — 옛 판 확정 장은 「옛 배치」 안내 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_Engine_Cad.py | 8 +- .../B07_DesignDetail_Engine_Cad_Sheet.py | 51 ++++++- .../B07_DesignDetail_Router_Support.py | 5 +- .../tester/test_169_drawing_area_guide.py | 130 ++++++++++++++++++ 4 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 resources/tester/test_169_drawing_area_guide.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index e8f696edb..707014f23 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -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, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 3758fbd04..709527a3d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -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, "계획선"), diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index ddf43b53b..6c8243cff 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -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] diff --git a/resources/tester/test_169_drawing_area_guide.py b/resources/tester/test_169_drawing_area_guide.py new file mode 100644 index 000000000..1e2e28ffe --- /dev/null +++ b/resources/tester/test_169_drawing_area_guide.py @@ -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 + )