빈 도각이던 계획평면도(지형·노선배치도·배치도)를 실제 도면으로 만듦. - 배경 공용화: 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>
389 lines
14 KiB
Python
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),
|
|
],
|
|
}
|