From 0d49a4c3c13827f910db8f09da73ac048979aed1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:16:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=8F=89=EB=A9=B4=EB=8F=84=20?= =?UTF-8?q?=EC=97=AC=EB=9F=AC=20=EC=9E=A5=20=EB=A7=9E=EC=B6=A4=EC=84=A0=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EC=9B=83=20=EA=B5=AC=EA=B0=84=20=ED=9D=90?= =?UTF-8?q?=EB=A6=AC=EA=B2=8C=20=C2=B7=20n/N=EC=9E=A5=20=C2=B7=20=EB=8F=8C?= =?UTF-8?q?=EB=A6=B0=20=EB=A7=9E=EC=B6=A4=20=EB=BA=8C(141-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장 나눔 — 돌린 방향 맞춤 뺌(그림을 안 돌리므로 그림칸 높이 넘침 흠) - 장 경계 측점에 노선 직각 맞춤선 + 「MATCH LINE NO.x · n장 이어짐」 · 글은 글 자리 잡기 장애물 - 이웃 구간 노선 = 도곽 안 흐린 파선(중심선 고를 때) · 도면명 「n/N장」 - 새 test_141_4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B07_DesignDetail_Engine_Cad_Plan.py | 53 +++++++++++- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 60 ++++++++++++++ .../B07_DesignDetail_Router_Support_Plan.py | 12 ++- .../tester/test_141_4_b07_plan_sheets.py | 83 +++++++++++++++++++ 4 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_141_4_b07_plan_sheets.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py index 51c6690eb..1ca70fbb9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py @@ -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']}" diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 2f2339c3a..e3058d46a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -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), ], diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index cb6dc7534..073f7bc0b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -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"], } diff --git a/resources/tester/test_141_4_b07_plan_sheets.py b/resources/tester/test_141_4_b07_plan_sheets.py new file mode 100644 index 000000000..d9b87dbe9 --- /dev/null +++ b/resources/tester/test_141_4_b07_plan_sheets.py @@ -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)]