feat(B07): 종단면도를 작성 척도(1/1,000 x 1/200)로 고정

도면에 척도 개념이 없어 도각을 콘텐츠 크기에 맞춰 늘렸다 줄였다. 사용자가 정한
작성 척도로 그림 크기가 결정되도록 좌표를 종이 밀리미터로 옮긴다.

- config_system에 DRAWING_SCALE_* 상수 추가 (종단 1/1,000 x 1/200, 횡단 1/100, A1)
- 종단 좌표를 종이 mm로: 거리 x1.0, 표고는 기준선 기준 x5.0 (세로 5배 과장)
- 기준선(datum)을 종이 y=0으로 정규화, Y축 눈금 라벨은 실제 표고 유지
- frame_entities(fit=False) 추가 — 도각을 실치수 1:1로 두고 위치만 맞춘다.
  콘텐츠가 작도 영역(693x468mm)을 넘으면 경고 로그
- 실측: 도각 840x594mm(A1 실치수), 콘텐츠 370x213mm (노선 350m = 350mm)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 18:25:21 +09:00
co-authored by Claude Fable 5
parent c24e556146
commit e40dc97b09
3 changed files with 95 additions and 34 deletions
@@ -46,6 +46,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
frame_entities,
)
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
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/1,000 -> 1.0, 1/200 -> 5.0).
MM_H = 1000.0 / DRAWING_SCALE_LONG_H
MM_V = 1000.0 / DRAWING_SCALE_LONG_V
# 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외).
LONG_GRID_LAYER_ID = "b08-long-grid"
@@ -187,10 +192,17 @@ def _graph_grid_entities(
chainages: list[float],
x0: float,
x1: float,
datum_y: float,
top_y: float,
datum_m: float,
top_m: float,
) -> list[dict[str, Any]]:
"""그래프 영역: 기준선(X축)·Y축(표고 눈금/라벨)·측점 회색 세로선."""
"""그래프 영역: 기준선(X축)·Y축(표고 눈금/라벨)·측점 회색 세로선.
x는 종이 mm, datum_m·top_m은 실제 표고(m)로 받는다 — 눈금 라벨이 표고라서
m으로 돌고 좌표만 MM_V를 곱한다.
"""
# 기준선을 종이 y=0으로 두고 표고 차이만 MM_V로 올린다.
datum_y = 0.0
top_y = (top_m - datum_m) * MM_V
entities: list[dict[str, Any]] = [
# 기준선(X축)
_line_entity(
@@ -202,9 +214,10 @@ def _graph_grid_entities(
),
]
# Y축 표고 눈금·라벨 (5m 간격, 가로쓰기)
level = datum_y
level_m = datum_m
tick_index = 0
while level <= top_y + 1e-6:
while level_m <= top_m + 1e-6:
level = (level_m - datum_m) * MM_V
entities.append(
_line_entity(
f"{drawing_id}:lgyt{tick_index}",
@@ -217,7 +230,7 @@ def _graph_grid_entities(
entities.append(
_text_entity(
f"{drawing_id}:lgyl{tick_index}",
_format(level),
_format(level_m),
x0 - 1.6,
level,
LONG_GRID_LAYER_ID,
@@ -226,7 +239,7 @@ def _graph_grid_entities(
align="right",
)
)
level += 5.0
level_m += 5.0
tick_index += 1
# 측점별 회색 세로선 (기준선 → 그래프 상단)
for index, x in enumerate(chainages):
@@ -265,8 +278,8 @@ def _curve_row_entities(
evc = curve.get("evc_m")
if not isinstance(bvc, (int, float)) or not isinstance(evc, (int, float)):
continue
start = max(float(bvc), x0)
end = min(float(evc), x1)
start = max(float(bvc) * MM_H, x0)
end = min(float(evc) * MM_H, x1)
if end <= start:
continue
seed = f"{drawing_id}:lcurve:{index}"
@@ -387,8 +400,8 @@ def _grade_row_entities(
grade = segment.get("grade_percent")
if not isinstance(from_m, (int, float)) or not isinstance(to_m, (int, float)):
continue
start = max(float(from_m), x0) + radius
end = min(float(to_m), x1) - radius
start = max(float(from_m) * MM_H, x0) + radius
end = min(float(to_m) * MM_H, x1) - radius
if end <= start:
continue
seed = f"{drawing_id}:lgrade:{index}"
@@ -419,14 +432,16 @@ def _grade_row_entities(
# 구배 변화점: 원(정원) 또는 노선 시·종점 반원 + 내부 세로쓰기 계획고
for index, (chainage, is_route_end) in enumerate(
_grade_break_points(segments, x0, x1, route_start, route_end)
_grade_break_points(segments, x0 / MM_H, x1 / MM_H, route_start, route_end)
):
# chainage는 월드 거리 — 계획고 조회에 그대로 쓰고, 좌표에는 MM_H를 곱한다.
x_paper = chainage * MM_H
seed = f"{drawing_id}:lgradebp:{index}"
if is_route_end:
entities.append(
_arc_entity(
seed,
(chainage, y_mid),
(x_paper, y_mid),
radius,
0.0,
math.pi,
@@ -437,7 +452,7 @@ def _grade_row_entities(
else:
entities.append(
_circle_entity(
seed, (chainage, y_mid), radius, LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR
seed, (x_paper, y_mid), radius, LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR
)
)
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
@@ -446,7 +461,7 @@ def _grade_row_entities(
_text_entity(
f"{seed}:txt",
_format(elevation),
chainage,
x_paper,
y_mid,
LONG_TABLE_LAYER_ID,
_FONT_SIZE * 0.8,
@@ -468,8 +483,8 @@ def _long_table_entities(
route_end: float,
) -> list[dict[str, Any]]:
"""종단 테이블: 가로 구분선 + 측점 눈금(세로선 없음) + 세로쓰기 값."""
chainages = [float(s["chainage_m"]) for s in stations]
header_width = max(interval_m, 12.0)
chainages = [float(s["chainage_m"]) * MM_H for s in stations]
header_width = max(interval_m * MM_H, 12.0)
left = chainages[0] - header_width
right = chainages[-1]
entities: list[dict[str, Any]] = []
@@ -606,29 +621,38 @@ def build_longitudinal_drawing(
)
interval_m = infer_station_interval(all_stations)
# 좌표는 종이 mm — 거리에 MM_H, 표고는 기준선(datum) 기준으로 MM_V를 곱한다.
elevations = [y for _x, y in [*ground_points, *design_points]]
min_e = min(elevations) if elevations else 0.0
max_e = max(elevations) if elevations else 10.0
# 기준선(datum): 최저 표고에서 5m 이상 여유를 두고 5m 단위로 내림 → 종이 y=0.
datum_m = math.floor((min_e - 5.0) / 5.0) * 5.0
top_m = max_e + 3.0
def paper(point: tuple[float, float]) -> tuple[float, float]:
return (point[0] * MM_H, (point[1] - datum_m) * MM_V)
entities: list[dict[str, Any]] = []
ground = polyline_entity(drawing_id, ground_points, GROUND_LAYER_ID, GROUND_COLOR)
ground = polyline_entity(
drawing_id, [paper(p) for p in ground_points], GROUND_LAYER_ID, GROUND_COLOR
)
if ground:
entities.append(ground)
design = polyline_entity(drawing_id, design_points, DESIGN_LAYER_ID, DESIGN_COLOR)
design = polyline_entity(
drawing_id, [paper(p) for p in design_points], DESIGN_LAYER_ID, DESIGN_COLOR
)
if design:
entities.append(design)
if stations:
chainages = [float(s["chainage_m"]) for s in stations]
elevations = [y for _x, y in [*ground_points, *design_points]]
min_e = min(elevations) if elevations else 0.0
max_e = max(elevations) if elevations else 10.0
# 기준선(datum): 최저 표고에서 5m 이상 여유를 두고 5m 단위로 내림.
datum_y = math.floor((min_e - 5.0) / 5.0) * 5.0
top_y = max_e + 3.0
chainages = [float(s["chainage_m"]) * MM_H for s in stations]
entities.extend(
_graph_grid_entities(drawing_id, chainages, chainages[0], chainages[-1], datum_y, top_y)
_graph_grid_entities(drawing_id, chainages, chainages[0], chainages[-1], datum_m, top_m)
)
values = _long_table_values(stations, all_stations, ground_all, longitudinal, interval_m)
all_chainages = sorted(float(s["chainage_m"]) for s in all_stations)
table_top = datum_y - 3.0 # 그래프-테이블 영역 분리 간격
table_top = -3.0 # 그래프-테이블 영역 분리 간격(mm, 기준선 y=0 아래)
entities.extend(
_long_table_entities(
drawing_id,
@@ -642,10 +666,10 @@ def build_longitudinal_drawing(
)
)
# A1 도각 프레임: 콘텐츠 bbox를 감싸도록 배치 (잠금 레이어, 좌표는 콘텐츠 불변).
# A1 도각: 콘텐츠가 이미 종이 mm라 도각도 실치수(1:1)로 두고 위치만 맞춘다.
bbox = entities_bbox(entities)
if bbox:
entities.extend(frame_entities(drawing_id, bbox))
entities.extend(frame_entities(drawing_id, bbox, fit=False))
return {
"format": DRAWING_FORMAT,
@@ -10,6 +10,7 @@ A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표
"""
import json
import logging
from functools import lru_cache
from pathlib import Path
from typing import Any
@@ -22,6 +23,8 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing"
logger = logging.getLogger(__name__)
A1_TEMPLATE = "00_template_A1"
# A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다.
_A1_INNER = (42.0, 47.0, 812.0, 567.0)
@@ -94,12 +97,27 @@ def _transform_entity(
return out
def usable_area() -> tuple[float, float]:
"""A1 내부 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도."""
ix0, iy0, ix1, iy1 = _A1_INNER
return (
(ix1 - ix0) * (1.0 - 2.0 * _CONTENT_MARGIN),
(iy1 - iy0) * (1.0 - 2.0 * _CONTENT_MARGIN),
)
def frame_entities(
drawing_id: str,
content_bbox: tuple[float, float, float, float],
template_name: str = A1_TEMPLATE,
fit: bool = True,
) -> list[dict[str, Any]]:
"""콘텐츠 bbox를 감싸는 도각 프레임 엔티티 목록(잠금 레이어). 템플릿 없으면 빈 목록."""
"""콘텐츠 bbox를 감싸는 도각 프레임 엔티티 목록(잠금 레이어). 템플릿 없으면 빈 목록.
fit=False면 도각을 **실치수(1:1)** 로 두고 위치만 맞춘다 — 콘텐츠가 이미 종이
밀리미터로 그려진 척도 고정 도면(종단·횡단)용. fit=True는 척도가 없는 도면을
도각에 맞춰 늘리던 기존 동작이다.
"""
template = _load_template(template_name)
if not template:
return []
@@ -108,9 +126,17 @@ def frame_entities(
content_h = max(max_y - min_y, 1e-6)
ix0, iy0, ix1, iy1 = _A1_INNER
usable_w = (ix1 - ix0) * (1.0 - 2.0 * _CONTENT_MARGIN)
usable_h = (iy1 - iy0) * (1.0 - 2.0 * _CONTENT_MARGIN)
scale = max(content_w / usable_w, content_h / usable_h)
usable_w, usable_h = usable_area()
scale = max(content_w / usable_w, content_h / usable_h) if fit else 1.0
if not fit and (content_w > usable_w or content_h > usable_h):
logger.warning(
"도면 콘텐츠가 A1 작도 영역을 넘습니다: %s (%.0fx%.0f mm > %.0fx%.0f mm)",
drawing_id,
content_w,
content_h,
usable_w,
usable_h,
)
# 콘텐츠 중심 = 내부 작도 영역 중심이 되도록 이동량 산출.
dx = (min_x + max_x) / 2.0 - (ix0 + ix1) / 2.0 * scale
+11
View File
@@ -745,6 +745,17 @@ VWORLD_LOGIN_PW = os.getenv("VWORLD_LOGIN_PW", "")
# 선택: 수동 발급 세션 쿠키 (예: "JSESSIONID=..; SSCSID=.."). 비어 있으면 자동 로그인 사용.
VWORLD_SESSION_COOKIE = os.getenv("VWORLD_SESSION_COOKIE", "")
# ─────────────────────────────────────────────────────────────────────────
# B07 도면 작성 척도 (2026-08-29 사용자 확정)
# 종단면도: 가로 1/1,000 · 세로 1/200 (세로 5배 과장)
# 횡단면도: 가로·세로 1/100
# 도면 좌표는 종이 밀리미터다. 실거리 1 m가 종이에서 몇 mm인지 = 1000 / 축척분모.
# ─────────────────────────────────────────────────────────────────────────
DRAWING_SCALE_LONG_H = 1000 # 종단면도 가로 축척 분모
DRAWING_SCALE_LONG_V = 200 # 종단면도 세로 축척 분모
DRAWING_SCALE_CROSS = 100 # 횡단면도 가로·세로 축척 분모
DRAWING_SHEET = "A1" # 용지 규격 (840x594 mm 도각 템플릿)
# 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관)
LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log")
RESOURCE_LOG_PATH = os.path.join(LOG_BASE_DIR, "system_resources.log")