feat(B07): 평면도 여러 장 맞춤선 · 이웃 구간 흐리게 · n/N장 · 돌린 맞춤 뺌(141-4)

- 장 나눔 — 돌린 방향 맞춤 뺌(그림을 안 돌리므로 그림칸 높이 넘침 흠)
- 장 경계 측점에 노선 직각 맞춤선 + 「MATCH LINE NO.x · n장 이어짐」 · 글은 글 자리 잡기 장애물
- 이웃 구간 노선 = 도곽 안 흐린 파선(중심선 고를 때) · 도면명 「n/N장」
- 새 test_141_4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-09 15:16:02 +09:00
co-authored by Claude Opus 5.5
parent 24b45655e9
commit 0d49a4c3c1
4 changed files with 203 additions and 5 deletions
@@ -18,6 +18,9 @@
import math
from typing import Any
from shapely.geometry import LineString
from shapely.geometry import box as shapely_box
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
FRAME_LAYER_ID,
@@ -25,8 +28,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_layer,
_text_entity,
polyline_entity,
station_no_label,
station_plus_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import route_frame
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
compass_entities,
entities_bbox,
@@ -114,8 +119,8 @@ def plan_chunks(
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)
# 그림을 돌리지 않으므로 도곽 방향 그대로만 본다(141-4 — 돌린 맞춤은 그림칸을 넘쳤음)
return width <= span_w and height <= span_h
chunks: list[dict[str, Any]] = []
start = 0
@@ -134,6 +139,50 @@ def plan_chunks(
return chunks
MATCH_HALF_MM = 30.0 # 맞춤선 반길이(종이 mm · 노선 직각)
def sheet_links(
route: list[tuple[float, float, float]],
chunk: dict[str, Any],
total: int,
box: tuple[float, float, float, float],
interval_m: float,
mm: float,
neighbors: bool,
) -> dict[str, Any]:
"""여러 장 이음(141-4) — 장 경계 맞춤선 + 「MATCH LINE NO.x · n장 이어짐」 · 이웃 구간 노선.
`route` = (누가거리, x, y) 노선 전체 · 맞춤선 = 경계 측점에서 노선 직각(좌측 법선) ·
이웃 구간 = 이 장 구간 밖 노선을 도곽으로 자른 것(흐리게 그림 · `neighbors` 일 때만).
"""
start_m, end_m, number = float(chunk["start_m"]), float(chunk["end_m"]), int(chunk["number"])
half = MATCH_HALF_MM / mm
lines = []
for at, other in ((start_m, number - 1), (end_m, number + 1)):
if not 1 <= other <= total or len(route) < 2:
continue
x, y, nx, ny = route_frame(route, at)
no = station_no_label(at, interval_m).replace("No.", "NO.")
lines.append(
{
"points": [[x - nx * half, y - ny * half], [x + nx * half, y + ny * half]],
"label": f"MATCH LINE {no} · {other}장 이어짐",
}
)
out: list[list[list[float]]] = []
if neighbors:
frame = shapely_box(*box)
for part in ([p for p in route if p[0] <= start_m], [p for p in route if p[0] >= end_m]):
if len(part) < 2:
continue
clipped = LineString([(x, y) for _c, x, y in part]).intersection(frame)
for piece in getattr(clipped, "geoms", [clipped]):
if isinstance(piece, LineString) and len(piece.coords) >= 2:
out.append([[round(x, 4), round(y, 4)] for x, y in piece.coords])
return {"lines": lines, "neighbors": out}
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']}"
@@ -44,6 +44,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import (
label_entities,
place_labels,
text_rect,
text_width,
)
@@ -479,6 +480,59 @@ def _table_rect(entity: dict[str, Any]) -> tuple[float, float, float, float]:
return (x, y - sum(shape["rowHeights"]), x + sum(shape["columnWidths"]), y)
SHEET_LAYER = "b07-plan-sheet" # 여러 장 이음 — 맞춤선 · 이웃 구간 노선(141-4)
_SHEET_COLOR = "#ff9d4d"
_NEIGHBOR_COLOR = "#7d8590" # 이웃 구간 노선 — 흐리게
def _sheet_link_entities(
drawing_id: str, links: dict[str, Any], paper: Any
) -> tuple[list[dict[str, Any]], list[tuple[float, float, float, float]]]:
"""맞춤선(노선 직각 쇄선 + 「MATCH LINE …」) · 이웃 구간 노선(흐린 파선) · 글 사각 = 장애물."""
out: list[dict[str, Any]] = []
rects: list[tuple[float, float, float, float]] = []
for index, points in enumerate(links.get("neighbors") or []):
line = polyline_entity(
drawing_id,
[paper(p) for p in points],
SHEET_LAYER,
_NEIGHBOR_COLOR,
suffix=f":n:{index}",
dash=[3, 3],
)
if line:
out.append(line)
size = _LABEL_FONT + 0.5
for index, item in enumerate(links.get("lines") or []):
a, b = (paper(point) for point in item["points"])
line = polyline_entity(
drawing_id, [a, b], SHEET_LAYER, _SHEET_COLOR, suffix=f":m:{index}", width=2,
dash=[8, 3, 2, 3],
) # fmt: skip
if line:
out.append(line)
length = math.hypot(b[0] - a[0], b[1] - a[1]) or 1.0
ux, uy = (b[0] - a[0]) / length, (b[1] - a[1]) / length
# 글은 맞춤선 끝 바깥으로 선 방향 — 거꾸로 서지 않게 오른쪽(위)쪽 끝에서
end, (dx, dy) = (b, (ux, uy)) if ux > 1e-9 or (ux > -1e-9 and uy > 0) else (a, (-ux, -uy))
x, y = end[0] + dx * 2.0, end[1] + dy * 2.0
out.append(
_text_entity(
f"{drawing_id}:sheet:{index}",
item["label"],
x,
y,
SHEET_LAYER,
size,
_SHEET_COLOR,
align="left",
direction=(dx, dy),
)
)
rects.append(text_rect(item["label"], x, y, size, "left", (dx, dy)))
return out, rects
def build_plan_kind_drawing(
drawing_id: str,
label: str,
@@ -515,6 +569,11 @@ def build_plan_kind_drawing(
for item in pieces[piece].get("items") or []:
cx, cy = paper(item["center"])
obstacles.append((cx - radius, cy - radius, cx + radius, cy + radius))
links = pieces.get("sheet_links")
if links:
made, rects = _sheet_link_entities(drawing_id, links, paper)
entities.extend(made)
obstacles += rects
entities.extend(label_entities(drawing_id, place_labels(labels, obstacles)))
# 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다.
frame_box = (0.0, 0.0, (box[2] - min_x) * mm, (box[3] - min_y) * mm)
@@ -571,6 +630,7 @@ def build_plan_kind_drawing(
"entities": entities,
"layers": [
*layers,
*([_layer(SHEET_LAYER, "맞춤선 · 이웃 구간")] if links else []),
_layer("b07-plan-title", "표제"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
],
@@ -31,6 +31,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_structure_label,
plan_area_mm,
plan_chunks,
sheet_links,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
DEFAULT_KINDS,
@@ -544,10 +545,15 @@ def build_pieces(
pieces, _load_design_curves(project_root), route, box, chunk, total, mm
)
chosen = {piece: pieces[piece] for piece in wanted if piece in pieces}
if total > 1: # 여러 장 이음 — 조각 고름과 무관하게 늘(이웃 노선은 중심선 고를 때만 · 141-4)
chosen["sheet_links"] = pieces["sheet_links"] = sheet_links(
route, chunk, total, box, interval, mm, "centerline" in wanted
)
folder = _design_root(project_root) / "plan_pieces" / str(number)
for piece, data in pieces.items():
atomic_write_json(folder / f"{piece}.json", data)
return {piece: pieces[piece] for piece in wanted if piece in pieces}, box, chunk, total
return chosen, box, chunk, total
def plan_kind_source(
@@ -576,7 +582,7 @@ def plan_kind_source(
)
title = plan_title(kind["name"])
return {
"label": title if total <= 1 else f"{title} {chunk['number']}장",
"label": title if total <= 1 else f"{title} {chunk['number']}/{total}장",
"box": box,
"pieces": pieces,
"scale": kind["scale"],
@@ -610,7 +616,7 @@ def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[di
"id": f"plan_{kind['id']}"
if single
else f"plan_{kind['id']}_{chunk['number']}",
"label": title if single else f"{title} {chunk['number']}장",
"label": title if single else f"{title} {chunk['number']}/{len(sheets)}장",
"plan_kind": kind["id"],
"plan_kind_name": kind["name"],
}
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""141-4 평면도 여러 장 — 노선 따라 자르기 유지 + 맞춤선 · 이웃 구간 흐리게 · 「n/N장」 · 돌린 맞춤 뺌."""
from __future__ import annotations
import math
from pathlib import Path
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_chunk_span_m,
plan_chunks,
sheet_links,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
SHEET_LAYER,
build_plan_kind_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import plan_kind_items
# 남북으로 곧은 노선 1,200 m · 측점 20 m
STATIONS = [(float(c), 0.0, float(c)) for c in range(0, 1201, 20)]
def test_돌린_맞춤_빼기_남북_노선은_그림칸_높이로_나눔():
span_w, span_h = _chunk_span_m(1200)
assert span_h < 800 < span_w # 옛 규칙 = 돌려 보면 한 장(그림칸 높이를 넘침)
chunks = plan_chunks(STATIONS[:41], 1200) # 800 m
assert len(chunks) == 2
for chunk in chunks:
assert chunk["end_m"] - chunk["start_m"] <= span_h
def test_맞춤선_경계_측점_직각_이웃_장_번호():
chunks = plan_chunks(STATIONS, 1200)
total = len(chunks)
assert total == 3
middle = chunks[1]
box = (-300.0, middle["start_m"] - 10.0, 300.0, middle["end_m"] + 10.0)
mm = 1000.0 / 1200
links = sheet_links(STATIONS, middle, total, box, 20.0, mm, neighbors=True)
first, last = links["lines"]
start_no, end_no = int(middle["start_m"] // 20), int(middle["end_m"] // 20)
assert first["label"] == f"MATCH LINE NO.{start_no} · 1장 이어짐"
assert last["label"] == f"MATCH LINE NO.{end_no} · 3장 이어짐"
(ax, ay), (bx, by) = first["points"]
assert ay == by == middle["start_m"] # 남북 노선에 직각 = 동서
assert math.hypot(bx - ax, by - ay) * mm == 60.0 # 종이 60 mm
# 이웃 구간 노선 = 도곽 안만(앞 · 뒤 10 m)
assert len(links["neighbors"]) == 2
for line in links["neighbors"]:
assert all(box[1] <= y <= box[3] for _x, y in line)
# 첫 장 · 끝 장은 한쪽만 · 중심선 안 고르면 이웃 노선 없음
only = sheet_links(STATIONS, chunks[0], total, box, 20.0, mm, neighbors=False)
assert [item["label"].endswith("· 2장 이어짐") for item in only["lines"]] == [True]
assert only["neighbors"] == []
def test_도면에_맞춤선_층_글은_다른_글과_안_겹침():
chunks = plan_chunks(STATIONS, 1200)
middle = chunks[1]
box = (-300.0, middle["start_m"] - 10.0, 300.0, middle["end_m"] + 10.0)
mm = 1000.0 / 1200
links = sheet_links(STATIONS, middle, 3, box, 20.0, mm, neighbors=True)
at = middle["start_m"]
pieces = {
"sheet_links": links,
# 맞춤선 글 자리에 시종점 글 후보가 처음 놓이는 견본
"endpoints": {"items": [{"label": "시점 No.0", "x": 0.0, "y": at, "nx": 1.0, "ny": 0.0}]},
}
drawing = build_plan_kind_drawing("plan_x_2", "평면도(x) 2/3장", box, pieces, 1200)
sheet = [e for e in drawing["entities"] if e["layerId"] == SHEET_LAYER]
labels = sorted(e["shapeData"]["label"] for e in sheet if e["type"] == "Text")
assert labels == [line["label"] for line in links["lines"]]
assert any(layer["id"] == SHEET_LAYER for layer in drawing["layers"])
assert sum(e["type"] == "PolyLine" for e in sheet) == 4 # 맞춤선 둘 + 이웃 노선 둘
def test_목록_도면명_n_N장(tmp_path: Path):
stations = [{"chainage_m": c, "center_x": x, "center_y": y} for c, x, y in STATIONS]
items = [
i for i in plan_kind_items(tmp_path, {"stations": stations}) if i["plan_kind"] == "terrain"
]
assert [item["label"] for item in items] == [f"평면도(지형) {n}/3장" for n in (1, 2, 3)]