fix(B07): 종단도 장마다 그림 좌하단을 작도영역 좌하단에 맞춤 · 넘치면 장 더 나눔

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-06 17:44:51 +09:00
co-authored by Claude Opus 5.5
parent 91428fb0a0
commit 0fb1af7e1d
4 changed files with 147 additions and 64 deletions
@@ -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]],
@@ -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)),
@@ -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)
+59
View File
@@ -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