Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py
eomsangdonandClaude Opus 5 6dd0dec93a feat(B07): 계획평면도 3종 — 수치등고선 배경 위 노선·측점·구조물
빈 도각이던 계획평면도(지형·노선배치도·배치도)를 실제 도면으로 만듦.

- 배경 공용화: map_background() 한 창구로 도엽 등고선·세류선 읽기·좌표 환산·
  도곽 절취를 모음. 유역도와 계획평면도가 같은 것을 부르고, 환산 결과는
  파일 mtime 을 키로 캐시(_metric_lines_cached).
- 축척 1/1,200 고정(DRAWING_SCALE_PLAN) — 지식DB 「설계제원_총괄」 측량·도면
  기준. 횡단면도와 같은 원칙으로 줄이지 않고 안 들어가면 장을 나눔(plan_chunks,
  종단 측점 기준·경계 측점 1개 중복).
- 세 장이 같은 배경·같은 도곽 배치를 쓰고 주제만 다름. 측점 눈금은 종단 측점
  좌표로 찍고, 구조물은 pipe_points.json 정본을 읽어 마름모+이름으로 표기.
- 도면 목록·단건 조회에 kind="plan" 추가. 화면 목록은 id 접두어로 묶어 장이
  나뉘어도 한 그룹으로 보임.

검증(용화_LAS): 콘텐츠 734.3x489.2 mm ≤ A1 작도영역 739.2x499.2, 노선 실거리
630.1x214.2 m → 종이 525.12x178.46 mm(실측 0.83333 mm/m = 1/1,200 일치),
배경 등고선 180줄이 세 장 동일, 유역도 8.5초 → 계획평면도 1.3초(캐시 적중,
파일 재읽기 없음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:38:43 +09:00

389 lines
14 KiB
Python

"""B07 계획평면도 CAD 조립 — 수치등고선 배경 위에 노선·측점·구조물을 얹는다.
세 장이 같은 배경·같은 축척을 쓰고 주제만 다르다(2026-09-04 사용자 지시).
- 계획평면도(지형) : 등고선·세류선만
- 계획평면도(노선배치도): 배경 + 계획노선 + 측점
- 계획평면도(배치도) : 배경 + 계획노선 + 구조물 배치
배경 자료는 유역도와 **같은 창구**(`B07_DesignDetail_Router_Support_Basin.map_background`)
에서 온다 — 도엽 GeoJSON 읽기·좌표 환산은 한 번뿐이고 여러 도면이 그 결과를 나눠 쓴다.
축척은 지식DB 「설계제원_총괄」 측량·도면 기준 **1/1,200 고정**이다. 횡단면도와 같은
원칙으로, 한 장에 안 들어가면 축척을 줄이지 않고 **장을 나눈다**.
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm).
"""
import math
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
FRAME_LAYER_ID,
TABLE_LABEL_COLOR,
_layer,
_text_entity,
polyline_entity,
station_plus_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
compass_entities,
entities_bbox,
frame_entities,
scale_fields,
usable_area,
)
from config.config_system import DRAWING_SCALE_PLAN
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/1,200 -> 0.8333).
MM = 1000.0 / DRAWING_SCALE_PLAN
CONTOUR_LAYER_ID = "b07-plan-contour"
CONTOUR_COLOR = "#6b7684"
STREAM_LAYER_ID = "b07-plan-stream"
STREAM_COLOR = "#4d9dff"
ROUTE_LAYER_ID = "b07-plan-route"
ROUTE_COLOR = "#ffe066"
STATION_LAYER_ID = "b07-plan-station"
STATION_COLOR = "#ff9d4d"
STRUCTURE_LAYER_ID = "b07-plan-structure"
STRUCTURE_COLOR = "#ff4d4d"
TITLE_LAYER_ID = "b07-plan-title"
_ROUTE_WIDTH = 3
_TITLE_FONT_SIZE = 7.0
_FONT_SIZE = 2.2
_STATION_FONT_SIZE = 2.0
_STATION_TICK_MM = 2.5 # 측점 눈금 반길이(종이 mm)
_STRUCTURE_SIZE_MM = 3.0 # 구조물 기호 반크기(종이 mm)
_STRUCTURE_FONT_SIZE = 2.2
_TITLE_BAND = 22.0 # 제목·척도가 차지하는 위쪽 띠(mm)
_COMPASS_SIZE = 26.0
_COMPASS_MARGIN = 12.0
# 세 장의 주제 (id 접두어, 도면명, 노선·측점·구조물을 그리는지).
PLAN_KINDS: tuple[tuple[str, str, bool, bool, bool], ...] = (
("plan_terrain", "계획평면도(지형)", False, False, False),
("plan_route", "계획평면도(노선배치도)", True, True, False),
("plan_layout", "계획평면도(배치도)", True, False, True),
)
PLAN_KIND_LABELS: dict[str, str] = {kind: label for kind, label, *_rest in PLAN_KINDS}
# 구조물 종류별 표기 — pipe_points.json 의 facility 값 기준.
_FACILITY_LABELS: dict[str, str] = {
"ford_bridge": "세월교",
"box_culvert": "BOX암거",
"bridge": "교량",
}
def plan_area_mm() -> tuple[float, float]:
"""지형 배경이 차지할 수 있는 크기(mm) — A1 작도영역에서 방위표 칸과 제목 띠를 뺀다.
라우터는 이 크기를 축척으로 되돌려 등고선·세류선 절취 범위를 잡는다(정의처 한 곳).
"""
width, height = usable_area()
return (width - (_COMPASS_MARGIN + _COMPASS_SIZE), height - _TITLE_BAND)
def _chunk_span_m() -> tuple[float, float]:
"""한 장이 담을 수 있는 실거리(m) — 도곽 지형 영역을 축척으로 되돌린 크기."""
area_w, area_h = plan_area_mm()
return (area_w * DRAWING_SCALE_PLAN / 1000.0, area_h * DRAWING_SCALE_PLAN / 1000.0)
def plan_chunks(stations: list[tuple[float, float, float]]) -> list[dict[str, Any]]:
"""노선을 한 장에 들어가는 구간으로 나눈다. 각 항목: {number, start_m, end_m}.
입력은 종단 측점의 (누가거리 m, x, y)다 — **도면 목록과 도면 생성이 같은 자료**를
보아야 장수가 어긋나지 않는다(종단도 분할과 같은 방식).
축척 1/1,200 은 고정이므로 한 장에 안 들어가면 **노선을 따라 장을 나눈다**
(2026-09-04 — 횡단면도와 같은 원칙). 경계 측점 1개를 중복시켜 장 사이에서 노선이
끊겨 보이지 않게 한다(납품 도면 관례).
"""
ordered = sorted(stations, key=lambda item: item[0])
if len(ordered) < 2:
span = (ordered[0][0] if ordered else 0.0, ordered[0][0] if ordered else 0.0)
return [{"number": 1, "start_m": span[0], "end_m": span[1]}]
span_w, span_h = _chunk_span_m()
def fits(part: list[tuple[float, float, float]]) -> bool:
width = max(x for _c, x, _y in part) - min(x for _c, x, _y in part)
height = max(y for _c, _x, y in part) - min(y for _c, _x, y in part)
# 가로로 길든 세로로 길든 도곽에만 들어가면 된다 — 두 방향 다 본다.
return (width <= span_w and height <= span_h) or (width <= span_h and height <= span_w)
chunks: list[dict[str, Any]] = []
start = 0
while start < len(ordered) - 1:
end = start + 1
while end + 1 < len(ordered) and fits(ordered[start : end + 2]):
end += 1
chunks.append(
{
"number": len(chunks) + 1,
"start_m": float(ordered[start][0]),
"end_m": float(ordered[end][0]),
}
)
start = end # 경계 측점 1개 중복
return chunks
def plan_drawing_id(kind: str, chunk: dict[str, Any], total: int) -> str:
"""장이 하나면 접두어 그대로, 여럿이면 `plan_route_2` 처럼 번호를 붙인다."""
return kind if total <= 1 else f"{kind}_{chunk['number']}"
def plan_drawing_label(kind: str, chunk: dict[str, Any], total: int) -> str:
label = PLAN_KIND_LABELS.get(kind, kind)
return label if total <= 1 else f"{label} {chunk['number']}장"
def _structure_label(structure: dict[str, Any]) -> str:
"""구조물 표기 — 세월교·BOX암거는 이름, 배수관은 관경(mm)."""
facility = structure.get("facility")
if isinstance(facility, str) and facility in _FACILITY_LABELS:
return _FACILITY_LABELS[facility]
options = structure.get("options")
diameter = options.get("pipe_diameter_mm") if isinstance(options, dict) else None
return f"D{int(diameter)}" if isinstance(diameter, (int, float)) else "배수시설"
def _station_entities(
drawing_id: str,
stations: list[tuple[float, float, float]],
interval_m: float,
paper: Any,
) -> list[dict[str, Any]]:
"""측점 눈금과 이름(No.n+00)을 노선 위에 직각으로 세운다.
입력은 **종단 측점**(누가거리 m, x, y)이다 — 노선 정점은 수백 개라 전부 찍으면
뭉개지고, 정점의 누가거리는 측점 간격의 배수가 아니라 걸러지지도 않는다
(2026-09-04 실측: 눈금이 2개만 찍혔음).
"""
entities: list[dict[str, Any]] = []
for index, (chainage, x, y) in enumerate(stations):
point = (x, y)
before = stations[max(index - 1, 0)]
after = stations[min(index + 1, len(stations) - 1)]
dx, dy = after[1] - before[1], after[2] - before[2]
length = math.hypot(dx, dy) or 1.0
# 노선 진행 방향의 법선 — 눈금을 노선과 직각으로 세운다.
nx, ny = -dy / length, dx / length
cx, cy = paper(point)
tick = polyline_entity(
drawing_id,
[
(cx - nx * _STATION_TICK_MM, cy - ny * _STATION_TICK_MM),
(cx + nx * _STATION_TICK_MM, cy + ny * _STATION_TICK_MM),
],
STATION_LAYER_ID,
STATION_COLOR,
suffix=f":tick:{index}",
)
if tick:
entities.append(tick)
entities.append(
_text_entity(
f"{drawing_id}:station:{index}",
station_plus_label(chainage, interval_m),
cx + nx * (_STATION_TICK_MM + 1.5),
cy + ny * (_STATION_TICK_MM + 1.5),
STATION_LAYER_ID,
_STATION_FONT_SIZE,
STATION_COLOR,
)
)
return entities
def _structure_entities(
drawing_id: str,
structures: list[dict[str, Any]],
paper: Any,
box: tuple[float, float, float, float],
) -> list[dict[str, Any]]:
"""구조물 위치를 마름모 기호 + 이름으로 찍는다. 이 장의 범위 밖은 건너뛴다."""
entities: list[dict[str, Any]] = []
min_x, min_y, max_x, max_y = box
for index, structure in enumerate(structures):
x, y = structure.get("x"), structure.get("y")
if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
continue
if not (min_x <= x <= max_x and min_y <= y <= max_y):
continue
cx, cy = paper((float(x), float(y)))
size = _STRUCTURE_SIZE_MM
marker = polyline_entity(
drawing_id,
[
(cx, cy + size),
(cx + size, cy),
(cx, cy - size),
(cx - size, cy),
(cx, cy + size),
],
STRUCTURE_LAYER_ID,
STRUCTURE_COLOR,
suffix=f":structure:{index}",
)
if marker:
entities.append(marker)
entities.append(
_text_entity(
f"{drawing_id}:structure:label:{index}",
_structure_label(structure),
cx + size + 1.0,
cy,
STRUCTURE_LAYER_ID,
_STRUCTURE_FONT_SIZE,
STRUCTURE_COLOR,
)
)
return entities
def build_plan_drawing(
kind: str,
drawing_id: str,
label: str,
route_xy: list[tuple[float, float]],
stations: list[tuple[float, float, float]],
contours: list[list[tuple[float, float]]],
streams: list[list[tuple[float, float]]],
structures: list[dict[str, Any]],
interval_m: float = 20.0,
) -> dict[str, Any]:
"""계획평면도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다.
`kind` 가 세 장 중 무엇을 그릴지 정한다(`PLAN_KINDS`). 배경은 세 장이 같다.
"""
with_route, with_station, with_structure = next(
((r, s, t) for name, _label, r, s, t in PLAN_KINDS if name == kind),
(True, False, False),
)
# 도곽 배치는 세 장이 같아야 한다 — 노선을 그리지 않는 지형도도 노선을 범위에 넣는다.
everything = [
*route_xy,
*(point for line in contours for point in line),
*(point for line in streams for point in line),
]
if not everything:
raise FileNotFoundError(
"계획평면도에 그릴 좌표가 없습니다. B04 전처리에서 수치지형도 도엽을 먼저 받으세요."
)
min_x = min(x for x, _y in everything)
min_y = min(y for _x, y in everything)
max_x = max(x for x, _y in everything)
max_y = max(y for _x, y in everything)
def paper(point: tuple[float, float]) -> tuple[float, float]:
return ((point[0] - min_x) * MM, (point[1] - min_y) * MM)
entities: list[dict[str, Any]] = []
for index, line in enumerate(contours):
contour = polyline_entity(
drawing_id,
[paper(point) for point in line],
CONTOUR_LAYER_ID,
CONTOUR_COLOR,
suffix=f":contour:{index}",
)
if contour:
entities.append(contour)
for index, line in enumerate(streams):
stream = polyline_entity(
drawing_id,
[paper(point) for point in line],
STREAM_LAYER_ID,
STREAM_COLOR,
suffix=f":stream:{index}",
)
if stream:
entities.append(stream)
map_bbox = entities_bbox(entities)
# 노선·측점·구조물은 배경 위에 얹는다 — 아래에 깔리면 등고선에 묻힌다.
if with_route:
route = polyline_entity(
drawing_id,
[paper(point) for point in route_xy],
ROUTE_LAYER_ID,
ROUTE_COLOR,
width=_ROUTE_WIDTH,
)
if route:
entities.append(route)
if with_station and stations:
entities.extend(_station_entities(drawing_id, stations, interval_m, paper))
if with_structure:
entities.extend(
_structure_entities(drawing_id, structures, paper, (min_x, min_y, max_x, max_y))
)
# 방위표는 지형 오른쪽 칸 맨 위에 둔다(유역도와 같은 자리).
if map_bbox:
entities.extend(
compass_entities(
drawing_id,
(
map_bbox[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0,
map_bbox[3] - _COMPASS_SIZE / 2.0,
),
_COMPASS_SIZE,
)
)
bbox = entities_bbox(entities)
if bbox:
min_bx, _min_by, max_bx, max_by = bbox
entities.append(
_text_entity(
f"{drawing_id}:title",
label,
(min_bx + max_bx) / 2.0,
max_by + 12.0,
TITLE_LAYER_ID,
_TITLE_FONT_SIZE,
TABLE_LABEL_COLOR,
)
)
entities.append(
_text_entity(
f"{drawing_id}:scale",
f"S = 1/{DRAWING_SCALE_PLAN:,}",
max_bx,
max_by + 5.0,
TITLE_LAYER_ID,
_FONT_SIZE,
TABLE_LABEL_COLOR,
align="right",
)
)
entities.extend(
frame_entities(
drawing_id,
entities_bbox(entities) or bbox,
fit=False,
fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))},
)
)
return {
"format": DRAWING_FORMAT,
"entities": entities,
"layers": [
_layer(CONTOUR_LAYER_ID, "등고선", locked=True),
_layer(STREAM_LAYER_ID, "계류", locked=True),
_layer(ROUTE_LAYER_ID, "계획노선"),
_layer(STATION_LAYER_ID, "측점"),
_layer(STRUCTURE_LAYER_ID, "구조물"),
_layer(TITLE_LAYER_ID, "표제"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
],
}