From 0fb1af7e1d018bd0701fb53446ba0c5ade58d064 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 17:44:51 +0900 Subject: [PATCH] =?UTF-8?q?fix(B07):=20=EC=A2=85=EB=8B=A8=EB=8F=84=20?= =?UTF-8?q?=EC=9E=A5=EB=A7=88=EB=8B=A4=20=EA=B7=B8=EB=A6=BC=20=EC=A2=8C?= =?UTF-8?q?=ED=95=98=EB=8B=A8=EC=9D=84=20=EC=9E=91=EB=8F=84=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EC=A2=8C=ED=95=98=EB=8B=A8=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=C2=B7=20=EB=84=98=EC=B9=98=EB=A9=B4=20=EC=9E=A5=20?= =?UTF-8?q?=EB=8D=94=20=EB=82=98=EB=88=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frame_entities bottom_left 선택 — 종단도만 좌하단 맞춤(나머지 중심 맞춤 그대로) - longitudinal_chunks: 30측점 묶음이 A1 작도영역을 넘으면 측점을 줄여 장을 더 나눔 · 축척 H 1/1,000 · V 1/200 고정 - 원·호 엔티티 헬퍼를 Engine_Cad.py 로 옮김(Long.py 700줄 아래) - 검증_sub4 종단 3장 그림 좌하단(도각 기준) 180,132 · 180,193 · 363,220 → 모두 47.05,52.04 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B07_DesignDetail_Engine_Cad.py | 38 ++++++ .../B07_DesignDetail_Engine_Cad_Long.py | 108 ++++++++---------- .../B07_DesignDetail_Engine_Template.py | 6 +- resources/tester/test_b07_long_sheet.py | 59 ++++++++++ 4 files changed, 147 insertions(+), 64 deletions(-) create mode 100644 resources/tester/test_b07_long_sheet.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 294db5abe..735e8371c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -75,6 +75,44 @@ def _line_entity( } +def _circle_entity( + seed: str, center: tuple[float, float], radius: float, layer_id: str, color: str +) -> dict[str, Any]: + return { + "id": str(uuid5(_ENTITY_NS, seed)), + "type": "Circle", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": {"center": {"x": center[0], "y": center[1]}, "radius": radius}, + } + + +def _arc_entity( + seed: str, + center: tuple[float, float], + radius: float, + start_angle: float, + end_angle: float, + layer_id: str, + color: str, +) -> dict[str, Any]: + return { + "id": str(uuid5(_ENTITY_NS, seed)), + "type": "Arc", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": { + "center": {"x": center[0], "y": center[1]}, + "radius": radius, + "startAngle": start_angle, + "endAngle": end_angle, + "counterClockwise": True, + }, + } + + def polyline_entity( drawing_id: str, points: list[tuple[float, float]], diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py index b1c76fa1f..4557d4b7f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py @@ -13,10 +13,8 @@ import math from typing import Any -from uuid import uuid5 from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( - _ENTITY_NS, DESIGN_COLOR, DESIGN_LAYER_ID, DRAWING_FORMAT, @@ -28,6 +26,8 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( TABLE_LABEL_COLOR, TABLE_LINE_COLOR, TABLE_VALUE_COLOR, + _arc_entity, + _circle_entity, _design_profile_points, _format, _interpolate, @@ -45,6 +45,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, frame_entities, scale_fields, + usable_area, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import DRAWING_SCALE_LONG_H, DRAWING_SCALE_LONG_V @@ -76,79 +77,50 @@ _FONT_SIZE = 2.2 _TICK_LEN = 0.8 # 측점 눈금 길이 -def _circle_entity( - seed: str, center: tuple[float, float], radius: float, layer_id: str, color: str -) -> dict[str, Any]: +def _span(stations: list[dict[str, Any]], index: int, take: int) -> dict[str, float]: return { - "id": str(uuid5(_ENTITY_NS, seed)), - "type": "Circle", - "lineColor": color, - "lineWidth": 1, - "layerId": layer_id, - "shapeData": {"center": {"x": center[0], "y": center[1]}, "radius": radius}, + "start_m": float(stations[index]["chainage_m"]), + "end_m": float(stations[index + take - 1]["chainage_m"]), } -def _arc_entity( - seed: str, - center: tuple[float, float], - radius: float, - start_angle: float, - end_angle: float, - layer_id: str, - color: str, -) -> dict[str, Any]: - return { - "id": str(uuid5(_ENTITY_NS, seed)), - "type": "Arc", - "lineColor": color, - "lineWidth": 1, - "layerId": layer_id, - "shapeData": { - "center": {"x": center[0], "y": center[1]}, - "radius": radius, - "startAngle": start_angle, - "endAngle": end_angle, - "counterClockwise": True, - }, - } +def _fits_sheet(longitudinal: dict[str, Any], span: dict[str, float]) -> bool: + """이 구간을 고정 축척으로 그렸을 때 A1 작도 영역에 드는가.""" + bbox = entities_bbox(_long_content(longitudinal, "probe", span)) + if bbox is None: + return True + usable_w, usable_h = usable_area() + return bbox[2] - bbox[0] <= usable_w + 1e-6 and bbox[3] - bbox[1] <= usable_h + 1e-6 def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]: """측점 30개 단위 분할 목록. 각 항목: {id, label, start_m, end_m}. - 30개 이하면 단일 도면(id="longitudinal", 기존 manifest 호환). 초과 시 + 한 장이면 단일 도면(id="longitudinal", 기존 manifest 호환). 여러 장이면 경계 측점 1개를 중복시켜 도면 간 선이 이어지게 한다(납품 도면 관례). + 축척(H 1/1,000 · V 1/200)은 모든 장이 같다 — 작도 영역을 넘으면 측점을 줄여 + 장을 더 나눌 뿐 줄이지 않는다. """ stations = sorted(_stations(longitudinal), key=lambda s: float(s["chainage_m"])) if not stations: return [{"id": "longitudinal", "label": "종단도", "start_m": None, "end_m": None}] - if len(stations) <= LONG_SPLIT_STATION_COUNT: - return [ - { - "id": "longitudinal", - "label": "종단도", - "start_m": float(stations[0]["chainage_m"]), - "end_m": float(stations[-1]["chainage_m"]), - } - ] - chunks: list[dict[str, Any]] = [] - stride = LONG_SPLIT_STATION_COUNT - 1 # 경계 1측점 중복 + spans: list[dict[str, float]] = [] index = 0 - number = 1 - while index < len(stations) - 1: - chunk_stations = stations[index : index + LONG_SPLIT_STATION_COUNT] - chunks.append( - { - "id": f"longitudinal_{number}", - "label": f"종단도({number})", - "start_m": float(chunk_stations[0]["chainage_m"]), - "end_m": float(chunk_stations[-1]["chainage_m"]), - } - ) - index += stride - number += 1 - return chunks + while True: + take = min(LONG_SPLIT_STATION_COUNT, len(stations) - index) + # ponytail: 한 측점씩 줄이며 다시 잰다 — 넘치는 장에서만 돎 + while take > 2 and not _fits_sheet(longitudinal, _span(stations, index, take)): + take -= 1 + spans.append(_span(stations, index, take)) + if index + take >= len(stations): + break + index += take - 1 # 경계 1측점 중복 + if len(spans) == 1: + return [{"id": "longitudinal", "label": "종단도", **spans[0]}] + return [ + {"id": f"longitudinal_{number}", "label": f"종단도({number})", **span} + for number, span in enumerate(spans, start=1) + ] def _long_table_values( @@ -600,10 +572,10 @@ def _long_table_entities( return entities -def build_longitudinal_drawing( +def _long_content( longitudinal: dict[str, Any], drawing_id: str, chunk: dict[str, Any] -) -> dict[str, Any]: - """종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다.""" +) -> list[dict[str, Any]]: + """종단도 청크 하나의 지반선+계획선+그래프 축·격자+측점 테이블(도각 제외).""" start_m = chunk.get("start_m") end_m = chunk.get("end_m") @@ -667,7 +639,16 @@ def build_longitudinal_drawing( ) ) + return entities + + +def build_longitudinal_drawing( + longitudinal: dict[str, Any], drawing_id: str, chunk: dict[str, Any] +) -> dict[str, Any]: + """종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다.""" + entities = _long_content(longitudinal, drawing_id, chunk) # A1 도각: 콘텐츠가 이미 종이 mm라 도각도 실치수(1:1)로 두고 위치만 맞춘다. + # 그림 좌하단 = 작도영역 좌하단 — 장마다 같은 자리에서 시작한다. bbox = entities_bbox(entities) if bbox: entities.extend( @@ -675,6 +656,7 @@ def build_longitudinal_drawing( drawing_id, bbox, fit=False, + bottom_left=True, fields={ "도면명": "종단면도", **scale_fields(("H", DRAWING_SCALE_LONG_H), ("V", DRAWING_SCALE_LONG_V)), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 07e264f8a..447f5037f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -481,12 +481,14 @@ def frame_entities( template_name: str = A1_TEMPLATE, fit: bool = True, fields: dict[str, str] | None = None, + bottom_left: bool = False, ) -> list[dict[str, Any]]: """콘텐츠 bbox를 감싸는 도각 프레임 엔티티 목록(잠금 레이어). 템플릿 없으면 빈 목록. fit=False면 도각을 **실치수(1:1)** 로 두고 위치만 맞춘다 — 콘텐츠가 이미 종이 밀리미터로 그려진 척도 고정 도면(종단·횡단)용. fit=True는 척도가 없는 도면을 - 도각에 맞춰 늘리던 기존 동작이다. + 도각에 맞춰 늘리던 기존 동작이다. bottom_left=True면 콘텐츠 좌하단을 작도영역 + 좌하단에 맞춘다(기본은 중심 맞춤). """ template = _load_template(template_name) if not template: @@ -513,6 +515,8 @@ def frame_entities( # 콘텐츠 중심 = 내부 작도 영역 중심이 되도록 이동량 산출. dx = (min_x + max_x) / 2.0 - (ix0 + ix1) / 2.0 * scale dy = (min_y + max_y) / 2.0 - (iy0 + iy1) / 2.0 * scale + if bottom_left: + dx, dy = min_x - ix0 * scale, min_y - iy0 * scale placed = [ _transform_entity(entity, f"{drawing_id}:frame:{index}", scale, dx, dy) diff --git a/resources/tester/test_b07_long_sheet.py b/resources/tester/test_b07_long_sheet.py new file mode 100644 index 000000000..85002c341 --- /dev/null +++ b/resources/tester/test_b07_long_sheet.py @@ -0,0 +1,59 @@ +"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3).""" + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( + build_longitudinal_drawing, + longitudinal_chunks, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area + + +def _longitudinal(count: int, interval: float, slope: float) -> dict: + xs = [index * interval for index in range(count)] + return { + "samples": [{"valid": True, "chainage_m": x, "elevation_m": 100.0 + slope * x} for x in xs], + "stations": [{"chainage_m": x} for x in xs], + "design_profiles": [ + {"samples": [{"chainage_m": x, "elevation_m": 100.5 + slope * x} for x in xs]} + ], + } + + +def _sheets(longitudinal: dict) -> list[tuple[tuple, tuple]]: + result = [] + for chunk in longitudinal_chunks(longitudinal): + entities = build_longitudinal_drawing(longitudinal, chunk["id"], chunk)["entities"] + content = entities_bbox([e for e in entities if e.get("layerId") != FRAME_LAYER_ID]) + frame = entities_bbox([e for e in entities if e.get("layerId") == FRAME_LAYER_ID]) + result.append((content, frame)) + return result + + +@pytest.mark.parametrize( + ("count", "interval", "slope"), + [(66, 20.0, 0.05), (40, 40.0, 0.0), (40, 20.0, 0.25)], +) +def test_every_sheet_fits_and_starts_at_same_corner(count, interval, slope) -> None: + longitudinal = _longitudinal(count, interval, slope) + sheets = _sheets(longitudinal) + usable_w, usable_h = usable_area() + corners = set() + for content, frame in sheets: + assert content[2] - content[0] <= usable_w + 1e-6 + assert content[3] - content[1] <= usable_h + 1e-6 + corners.add((round(content[0] - frame[0], 3), round(content[1] - frame[1], 3))) + assert len(corners) == 1 + chunks = longitudinal_chunks(longitudinal) + # 이어지는 장은 경계 측점 하나를 나눠 갖고 끝까지 덮는다. + assert chunks[0]["start_m"] == 0.0 + assert chunks[-1]["end_m"] == (count - 1) * interval + for before, after in zip(chunks, chunks[1:]): + assert before["end_m"] == after["start_m"] + + +def test_wide_interval_splits_more() -> None: + # 40 m 간격 30측점 = 1,160 mm > 작도영역 폭 → 30개보다 적게 담는다 + chunks = longitudinal_chunks(_longitudinal(40, 40.0, 0.0)) + assert len(chunks) > 2