feat(B07): 종단도 장마다 우측 상단 평면 위치도 · 종단 ⚙ 「평면도」 척도 칸 (521-2 · 요구 340-2)

- 새 Engine_Cad_LongPlan — 작도 영역 우상단 5 mm 안쪽 150×100 mm 칸 · 자동 = 전체 노선 맞춤(분모 500 올림) · 척도 정함 = 이 장 중심 확대 · 밖 노선 흐린 파선 · 칸 밖 잘림 · 이 장 굵게 · 시 · 종점 측점 · 방위표 · 「평면도 S=1/n」 · 「n/N 장」 · 좌표 없으면 안 그림
- 장 나눔 — 그래프가 칸을 덮으면 측점 줄여 장 더 나눔 · 장 목록에 number · total
- 축척 — longitudinal.plan 기본 자동 · 범위 1/500~1/50,000 · ⚙ 칸 = 서버 기본값 열쇠 그대로(셋째 칸 + 자동)
- 칸 앞 확정 종단 저장본 — 목록 받을 때 종단만 한 번 모두 풂(DRAWING_FORMAT 그대로)
- 시험 — 칸 자리 · 안 겹침 · 장마다 강조 · 척도 확대 · 좌표 없음 · 한 번 풂 · plan 범위 · ⚙ 평면 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 08:01:02 +09:00
co-authored by Claude Opus 5.5
parent b522a52622
commit df0e64ffe2
12 changed files with 472 additions and 37 deletions
@@ -0,0 +1,224 @@
"""B07 종단도 우측 상단 평면 위치도(521-2 · 요구 340-2) — 장마다 작도 영역 우상단 칸 하나.
칸 = 작도 영역 우상단 5 mm 안쪽 150×100 mm(작도 영역 좌하단 = 그림 좌하단 · `frame_entities`
bottom_left). 척도 = 종단 축척 `plan` 칸(None = 자동). 방위표만 도각 층.
- 자동: 전체 노선을 칸에 맞춤(분모 500 단위 올림) · 전체 노선 가는 선 · 이 장 구간 굵게.
- 척도 정함: 이 장 중심 확대 · 이 장 굵게 · 밖 노선 흐린 파선 · 칸 밖은 잘림.
칸 내용 = 노선 · 이 장 시 · 종점 측점 글 · 방위표 · 「평면도 S=1/n」 · 「n/N 장」.
좌표 = 종단 측점 center_x · center_y — 없으면 칸 안 그림. 서버 단독(CLAUDE.md 5장 ③).
"""
import math
from collections.abc import Iterator
from typing import Any
from shapely.geometry import LineString, Point
from shapely.geometry import box as shapely_box
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DESIGN_COLOR,
TABLE_LABEL_COLOR,
TABLE_LINE_COLOR,
_stations,
_text_entity,
infer_station_interval,
polyline_entity,
station_no_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import text_rect
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
compass_entities,
drawing_scale,
usable_area,
)
LONG_PLAN_LAYER_ID = "b07-long-plan"
BOX_W, BOX_H = 150.0, 100.0 # 칸 크기(종이 mm)
_INSET = 5.0 # 작도 영역 테두리에서 안쪽
_MAP_PAD = (4.0, 10.0, 4.0, 4.0) # 칸 안 노선 자리 여백(좌 · 하 · 우 · 상) — 아래는 글 줄
_AUTO_STEP = 500 # 자동 분모 올림 단위
_FONT = 2.5
_COMPASS = 12.0
_ROUTE_COLOR = "#7d8590" # 전체 · 밖 노선 — 흐리게
def plan_box(content_bbox: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
"""칸 (x0, y0, x1, y1) — 그림 좌표(작도 영역 좌하단 = 그림 좌하단)."""
usable_w, usable_h = usable_area()
x1 = content_bbox[0] + usable_w - _INSET
y1 = content_bbox[1] + usable_h - _INSET
return (x1 - BOX_W, y1 - BOX_H, x1, y1)
def _plan_points(longitudinal: dict[str, Any]) -> list[tuple[float, float, float]]:
"""종단 측점의 (누가거리 m, x, y) — `Router_Support_Basin.plan_stations` 와 같은 셈."""
points: list[tuple[float, float, float]] = []
for station in _stations(longitudinal):
x, y = station.get("center_x"), station.get("center_y")
if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (x, y)):
points.append((float(station["chainage_m"]), float(x), float(y)))
return sorted(points)
def _shapes(entities: list[dict[str, Any]]) -> Iterator[Any]:
"""엔티티 → shapely 도형(선 · 글 사각 · 원) — 칸 겹침 판정용."""
for entity in entities:
yield from _shapes(entity.get("children") or [])
shape = entity.get("shapeData") or {}
if "startPoint" in shape:
a, b = shape["startPoint"], shape["endPoint"]
yield LineString([(a["x"], a["y"]), (b["x"], b["y"])])
elif "basePoint" in shape:
base, options = shape["basePoint"], shape.get("options") or {}
direction = options.get("textDirection") or {}
yield shapely_box(
*text_rect(
str(shape.get("label") or ""),
base["x"],
base["y"],
float(options.get("fontSize", _FONT)),
str(options.get("textAlign", "center")),
(float(direction.get("x", 1.0)), float(direction.get("y", 0.0))),
)
)
elif "center" in shape:
center = shape["center"]
yield Point(center["x"], center["y"]).buffer(float(shape.get("radius", 0.0)))
def plan_box_overlaps(
longitudinal: dict[str, Any],
entities: list[dict[str, Any]],
content_bbox: tuple[float, float, float, float] | None,
) -> bool:
"""그래프 · 측점표가 칸을 덮는가 — 칸을 안 그리는 종단(좌표 없음)은 늘 거짓."""
if content_bbox is None or len(_plan_points(longitudinal)) < 2:
return False
x0, y0, x1, y1 = plan_box(content_bbox)
if content_bbox[2] < x0 or content_bbox[3] < y0:
return False
region = shapely_box(x0, y0, x1, y1)
return any(region.intersects(shape) for shape in _shapes(entities))
def _auto_denominator(points: list[tuple[float, float, float]], map_w: float, map_h: float) -> int:
width = max(p[1] for p in points) - min(p[1] for p in points)
height = max(p[2] for p in points) - min(p[2] for p in points)
need = max(width / map_w, height / map_h) * 1000.0
return max(_AUTO_STEP, math.ceil(need / _AUTO_STEP) * _AUTO_STEP)
def _clipped(
drawing_id: str,
points: list[tuple[float, float]],
region: Any,
suffix: str,
color: str,
dash: list[int] | None = None,
width: int = 1,
) -> list[dict[str, Any]]:
"""종이 점열을 칸 노선 자리로 잘라 PolyLine 들로."""
if len(points) < 2:
return []
clipped = LineString(points).intersection(region)
parts = getattr(clipped, "geoms", [clipped])
out = []
for index, part in enumerate(parts):
if isinstance(part, LineString) and not part.is_empty:
line = polyline_entity(
drawing_id,
list(part.coords),
LONG_PLAN_LAYER_ID,
color,
suffix=f"{suffix}:{index}",
dash=dash,
width=width,
)
if line:
out.append(line)
return out
def long_plan_entities(
longitudinal: dict[str, Any],
drawing_id: str,
chunk: dict[str, Any],
content_bbox: tuple[float, float, float, float] | None,
) -> list[dict[str, Any]]:
"""이 장의 평면 위치도 칸 — 좌표 없거나 그림이 비면 빈 목록."""
points = _plan_points(longitudinal)
if content_bbox is None or len(points) < 2:
return []
start_m, end_m = chunk.get("start_m"), chunk.get("end_m")
if start_m is None or end_m is None:
start_m, end_m = points[0][0], points[-1][0]
inside = [p for p in points if start_m - 1e-6 <= p[0] <= end_m + 1e-6]
x0, y0, x1, y1 = plan_box(content_bbox)
left, bottom, right, top = _MAP_PAD
map_box = (x0 + left, y0 + bottom, x1 - right, y1 - top)
map_w, map_h = map_box[2] - map_box[0], map_box[3] - map_box[1]
fixed = drawing_scale("longitudinal").get("plan")
denominator = fixed or _auto_denominator(points, map_w, map_h)
focus = inside if fixed and inside else points
center_x = (min(p[1] for p in focus) + max(p[1] for p in focus)) / 2.0
center_y = (min(p[2] for p in focus) + max(p[2] for p in focus)) / 2.0
mm = 1000.0 / denominator
box_cx, box_cy = (map_box[0] + map_box[2]) / 2.0, (map_box[1] + map_box[3]) / 2.0
def paper(x: float, y: float) -> tuple[float, float]:
return (box_cx + (x - center_x) * mm, box_cy + (y - center_y) * mm)
region = shapely_box(*map_box)
border = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
entities = [polyline_entity(drawing_id, border, LONG_PLAN_LAYER_ID, TABLE_LINE_COLOR, ":box")]
entities += _clipped(
drawing_id,
[paper(x, y) for _c, x, y in points],
region,
":all",
_ROUTE_COLOR,
dash=[3, 3] if fixed else None,
)
entities += _clipped(
drawing_id, [paper(x, y) for _c, x, y in inside], region, ":this", DESIGN_COLOR, width=3
)
interval_m = infer_station_interval(_stations(longitudinal))
for name, (chainage, x, y) in (("start", inside[0]), ("end", inside[-1])) if inside else ():
px, py = paper(x, y)
if region.contains(Point(px, py)):
entities.append(
_text_entity(
f"{drawing_id}:lplan:{name}",
station_no_label(chainage, interval_m),
px,
py + _FONT,
LONG_PLAN_LAYER_ID,
_FONT,
TABLE_LABEL_COLOR,
)
)
number, total = int(chunk.get("number") or 1), int(chunk.get("total") or 1)
for name, label, x, align in (
("scale", f"평면도 S=1/{denominator:,}", x0 + left, "left"),
("page", f"{number}/{total} 장", x1 - right, "right"),
):
entities.append(
_text_entity(
f"{drawing_id}:lplan:{name}",
label,
x,
y0 + bottom / 2.0,
LONG_PLAN_LAYER_ID,
_FONT,
TABLE_LABEL_COLOR,
align=align,
)
)
# 방위표 = 다른 평면 도면처럼 도각 층(잠금)
compass = compass_entities(
f"{drawing_id}:lplan",
(x0 + left + _COMPASS / 2.0, y1 - top - _COMPASS / 2.0),
_COMPASS,
)
return [entity for entity in entities if entity] + compass