From d26b991fb3eaca1b7ee9c24206ba4a8203519e34 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:06:52 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(B07):=20=ED=8F=89=EB=A9=B4=EB=8F=84=20?= =?UTF-8?q?=EA=B8=80=20=EC=9E=90=EB=A6=AC=20=EC=9E=A1=EA=B8=B0=20=E2=80=94?= =?UTF-8?q?=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20=C2=B7=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EC=9E=90=EB=A6=AC=20=C2=B7=20=EC=A7=80=EC=8B=9C?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EB=B9=84=ED=83=88=20=EA=B8=B0=ED=95=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC(141-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Engine_Cad_PlanLabels — 시종점 > 측점 NO > IP > R > 배수 > 대피소 차례로 후보 중 첫 빈 자리 · 장애물 IP 표 · 필지 상자 · 먼 자리엔 지시선 - 조각 글은 후보째 모아 build_plan_kind_drawing 이 자리 정한 뒤 그림 · R 실거리 4 m 대신 종이 mm 후보 - 비탈 · 노선 따라가기 기하 = 새 Engine_Cad_PlanSlope(PlanKinds 700줄 넘어 분리 · import 길 그대로) - sub6 초안 반영 · 검증_sub8 실측 겹친 글 쌍 지형 12 → 0 · 새 test_141 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 327 ++++++++---------- .../B07_DesignDetail_Engine_Cad_PlanLabels.py | 121 +++++++ .../B07_DesignDetail_Engine_Cad_PlanSlope.py | 182 ++++++++++ resources/tester/test_141_b07_plan_labels.py | 139 ++++++++ 4 files changed, 577 insertions(+), 192 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py create mode 100644 resources/tester/test_141_b07_plan_labels.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 878f9515d..3c628cfc3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -10,7 +10,6 @@ """ import math -from bisect import bisect_left from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( @@ -22,7 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( polyline_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_MARGIN, _COMPASS_SIZE, @@ -36,6 +35,20 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( STREAM_COLOR, STRUCTURE_COLOR, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import ( + label_entities, + place_labels, + text_width, +) + +# 비탈 · 노선 따라가기 기하는 700줄을 넘어 떼어냄 — 호출부 import 경로는 그대로. +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: F401 + TICK_STEP_M, + route_frame, + side_is_cut, + slope_end, + slope_pieces, +) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( compass_entities, entities_bbox, @@ -130,7 +143,6 @@ _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = { "sgg": (3, [14, 5, 3, 5]), } PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값 -TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) _LABEL_FONT = 2.5 _MAJOR_FONT = 4.5 @@ -142,178 +154,6 @@ def layer_id(piece: str) -> str: return f"b07-plan-{piece}" -# ── 노선 따라가기 ───────────────────────────────────── - - -def route_frame( - route: list[tuple[float, float, float]], chainage: float -) -> tuple[float, float, float, float]: - """노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny).""" - chainages = [item[0] for item in route] - index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1) - (c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index] - dx, dy = x1 - x0, y1 - y0 - length = math.hypot(dx, dy) or 1.0 - ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0 - return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length) - - -def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float: - chainages = [record["chainage_m"] for record in records] - index = bisect_left(chainages, chainage) - if index <= 0: - return records[0][key] - if index >= len(records): - return records[-1][key] - before, after = records[index - 1], records[index] - span = after["chainage_m"] - before["chainage_m"] - ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0 - return before[key] + (after[key] - before[key]) * ratio - - -def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: - return sorted( - (float(item["offset_m"]), float(item["elevation_m"])) - for item in samples - if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) - ) - - -def _ground_at(ground: list[tuple[float, float]], offset: float) -> float: - offsets = [x for x, _z in ground] - index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) - (x0, z0), (x1, z1) = ground[index - 1], ground[index] - return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) - - -def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool: - """노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토).""" - ground = _ground(samples) - return len(ground) >= 2 and _ground_at(ground, edge) > elevation - - -def slope_end( - design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str -) -> float: - """노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝. - - 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. - 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. - """ - ground = _ground(samples) - points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line) - outward = ( - [p for p in points if p[0] > edge] - if side == "left" - else [p for p in points[::-1] if p[0] < edge] - ) - if not ground: - return outward[-1][0] if outward else edge - end, differs = edge, False - for offset, elevation in outward: - if abs(elevation - _ground_at(ground, offset)) <= 1e-3: - return offset if differs else end - differs, end = True, offset - return end - - -def _split_runs( - points: list[list[float]], flags: list[bool] -) -> tuple[list[list[list[float]]], list[list[list[float]]]]: - """한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다.""" - runs: dict[bool, list[list[list[float]]]] = {True: [], False: []} - current: list[list[float]] = [] - previous: bool | None = None - for point, flag in zip(points, flags): - if flag != previous: - if current: - current.append(point) - current = [point] - runs[flag].append(current) - previous = flag - else: - current.append(point) - return ( - [run for run in runs[True] if len(run) >= 2], - [run for run in runs[False] if len(run) >= 2], - ) - - -def slope_pieces( - route: list[tuple[float, float, float]], - records: list[dict[str, Any]], - start_m: float, - end_m: float, -) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: - """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. - - `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, - left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 - 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 - 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. - """ - records = sorted( - ( - { - **record, - **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, - } - for record in records - ), - key=lambda record: record["chainage_m"], - ) - if len(route) < 2 or not records: - return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} - keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") - by_chainage = {round(record["chainage_m"], 3): record for record in records} - samples = { - round(record["chainage_m"], 3) - for record in records - if start_m <= record["chainage_m"] <= end_m - } - step = start_m - while step <= end_m: - samples.add(round(step, 3)) - step += TICK_STEP_M - lines: dict[str, list[list[float]]] = {key: [] for key in keys} - cut: dict[str, list[bool]] = {"left": [], "right": []} - ticks: list[list[list[float]]] = [] - for index, chainage in enumerate(sorted(samples)): - record = by_chainage.get(chainage) - if record and all(k in record for k in ("x", "y", "nx", "ny")): - x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] - else: - x, y, nx, ny = route_frame(route, chainage) - offsets = {key: _interp(records, chainage, key) for key in keys} - points = { - key: [round(x + nx * value, 4), round(y + ny * value, 4)] - for key, value in offsets.items() - } - for key in keys: - lines[key].append(points[key]) - for side in cut: - cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) - # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). - for side in ("left", "right"): - edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] - if abs(end - edge) < 0.05: - continue - reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 - ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) - cut_lines: list[list[list[float]]] = [] - fill_lines: list[list[list[float]]] = [] - for side in cut: - cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) - cut_lines += cut_runs - fill_lines += fill_runs - return ( - {"lines": [lines["left_edge_m"], lines["right_edge_m"]]}, - {"lines": fill_lines}, - {"lines": cut_lines}, - {"ticks": ticks}, - ) - - def curve_pieces( curves: list[dict[str, Any]], route: list[tuple[float, float, float]], @@ -338,7 +178,7 @@ def curve_pieces( "end": list(end[:2]), } ) - # R 라벨은 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽)으로 조금 띄운다. + # R 라벨 후보는 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽) · 바깥으로(종이 mm · 141-1). middle = min( ((x, y) for _c, x, y in route), key=lambda point: math.hypot(point[0] - apex[0], point[1] - apex[1]), @@ -349,7 +189,8 @@ def curve_pieces( radii.append( { "label": f"R={round(float(radius), 1):g}", - "at": [middle[0] + dx / length * 4.0, middle[1] + dy / length * 4.0], + "middle": list(middle), + "dir": [dx / length, dy / length], } ) return {"items": ips}, {"items": radii} @@ -371,11 +212,30 @@ def _reading(nx: float, ny: float) -> tuple[float, float]: return (tx, ty) if tx > 0 or (abs(tx) < 1e-9 and ty > 0) else (-tx, -ty) +def _side_spots(cx: float, cy: float, size: float) -> list[dict[str, Any]]: + """기호 곁 글 후보 — 오른쪽(옛 자리) → 왼 · 위 · 아래 → 멀리 오른 · 왼(지시선).""" + return [ + {"x": cx + size + 1.0, "y": cy, "align": "left"}, + {"x": cx - size - 1.0, "y": cy, "align": "right"}, + {"x": cx, "y": cy + size + 2.5}, + {"x": cx, "y": cy - size - 2.5}, + {"x": cx + size + 8.0, "y": cy + 4.0, "align": "left", "far": True}, + {"x": cx - size - 8.0, "y": cy - 4.0, "align": "right", "far": True}, + ] + + def _piece_entities( - drawing_id: str, piece: str, data: dict[str, Any], paper: Any, mm: float = MM + drawing_id: str, + piece: str, + data: dict[str, Any], + paper: Any, + mm: float = MM, + labels: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: + """조각의 선 · 기호 엔티티. 글은 후보 자리째 `labels` 에 모음(자리는 `place_labels` · 141-1).""" layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR) out: list[dict[str, Any]] = [] + labels = labels if labels is not None else [] def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None: entity = polyline_entity( @@ -390,11 +250,24 @@ def _piece_entities( if entity: out.append(entity) - def text(seed: str, label: str, at: tuple[float, float], size: float, **kw: Any) -> None: - out.append( - _text_entity( - f"{drawing_id}:{piece}:{seed}", label, at[0], at[1], layer, size, color, **kw - ) + def text( + seed: str, + label: str, + size: float, + spots: list[dict[str, Any]], + anchor: tuple[float, float] | None = None, + ) -> None: + labels.append( + { + "piece": piece, + "seed": f"{drawing_id}:{piece}:{seed}", + "label": label, + "size": size, + "layer": layer, + "color": color, + "anchor": anchor, + "candidates": spots, + } ) if piece == "lidar": @@ -434,10 +307,39 @@ def _piece_entities( ) if out_circle: out.append(out_circle) - text(f"{index}", item["name"], (apex[0], apex[1] - _CIRCLE_MM - 3.0), _LABEL_FONT) + ax, ay, reach = apex[0], apex[1], _CIRCLE_MM + 3.0 + text( + f"{index}", + item["name"], + _LABEL_FONT, + [ + {"x": ax, "y": ay - reach}, + {"x": ax, "y": ay + reach}, + {"x": ax - _CIRCLE_MM - 1.0, "y": ay, "align": "right"}, + {"x": ax + _CIRCLE_MM + 1.0, "y": ay, "align": "left"}, + {"x": ax, "y": ay - reach - 5.0, "far": True}, + {"x": ax, "y": ay + reach + 5.0, "far": True}, + ], + apex, + ) elif piece == "radius": for index, item in enumerate(data.get("items") or []): - text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT) + if "middle" not in item: # 옛 조각 파일 — 자리 하나 + at = paper(item["at"]) + text(f"{index}", item["label"], _LABEL_FONT, [{"x": at[0], "y": at[1]}]) + continue + mx, my = paper(item["middle"]) + dx, dy = item["dir"] + text( + f"{index}", + item["label"], + _LABEL_FONT, + [ + {"x": mx + dx * d, "y": my + dy * d, "far": abs(d) > 6.0} + for d in (3.0, 6.0, -3.0, -6.0, 10.0, -10.0) + ], + (mx, my), + ) elif piece == "ip_table": out.extend(ip_table_entities(drawing_id, data)) elif piece == "station": @@ -457,13 +359,25 @@ def _piece_entities( ) if circle: out.append(circle) - gap = _MAJOR_FONT * 1.8 + gap, (tx, ty) = _MAJOR_FONT * 1.8, _reading(nx, ny) + shift = text_width(item["label"], _MAJOR_FONT) / 2.0 + 2.0 text( f"{index}", item["label"], - (cx + nx * gap, cy + ny * gap), _MAJOR_FONT, - direction=_reading(nx, ny), + [ + { + "x": cx + nx * g + tx * t, + "y": cy + ny * g + ty * t, + "direction": (tx, ty), + } + for g, t in ((gap, 0.0), (-gap, 0.0), (gap, shift), (gap, -shift)) + ] + + [ + {"x": cx + nx * g, "y": cy + ny * g, "direction": (tx, ty), "far": True} + for g in (gap * 1.8, -gap * 1.8) + ], + (cx, cy), ) elif piece == "endpoints": for index, item in enumerate(data.get("items") or []): @@ -478,7 +392,16 @@ def _piece_entities( width=2, ) cx, cy = paper((item["x"], item["y"])) - text(f"{index}", item["label"], (cx + nx * 9.0, cy + ny * 9.0), _LABEL_FONT + 0.5) + text( + f"{index}", + item["label"], + _LABEL_FONT + 0.5, + [ + {"x": cx + nx * g, "y": cy + ny * g, "far": abs(g) > 9.0} + for g in (9.0, -9.0, 15.0, -15.0) + ], + (cx, cy), + ) elif piece == "refuge": size = 2.0 # 네모 기호 반크기(종이 mm) for index, item in enumerate(data.get("items") or []): @@ -486,7 +409,7 @@ def _piece_entities( corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)] line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}") cx, cy = paper((x, y)) - text(f"{index}", item["label"], (cx + size + 1.0, cy), _LABEL_FONT, align="left") + text(f"{index}", item["label"], _LABEL_FONT, _side_spots(cx, cy, size), (cx, cy)) elif piece == "drainage": size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm) for index, item in enumerate(data.get("items") or []): @@ -502,7 +425,7 @@ def _piece_entities( if diamond: out.append(diamond) text( - f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left" + f"{index}", item["label"], _STRUCTURE_FONT_SIZE, _side_spots(cx, cy, size), (cx, cy) ) elif piece in _BOUNDARY_STYLE: width, dash = _BOUNDARY_STYLE[piece] @@ -526,6 +449,12 @@ def _piece_entities( return out +def _table_rect(entity: dict[str, Any]) -> tuple[float, float, float, float]: + shape = entity["shapeData"] + x, y = shape["origin"]["x"], shape["origin"]["y"] + return (x, y - sum(shape["rowHeights"]), x + sum(shape["columnWidths"]), y) + + def build_plan_kind_drawing( drawing_id: str, label: str, @@ -541,9 +470,23 @@ def build_plan_kind_drawing( return ((point[0] - min_x) * mm, (point[1] - min_y) * mm) entities: list[dict[str, Any]] = [] + labels: list[dict[str, Any]] = [] + obstacles: list[tuple[float, float, float, float]] = [] for piece in PIECE_IDS: if piece in pieces: - entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper, mm)) + made = _piece_entities(drawing_id, piece, pieces[piece], paper, mm, labels) + entities.extend(made) + # 글이 비켜 갈 자리 — IP 표 · 필지 상자(141-1). 제목은 내용 위에 따로 서서 안 겹침. + if piece == "ip_table": + obstacles += [_table_rect(e) for e in made if e.get("type") == "Table"] + elif piece == "parcel_info": + for item in pieces[piece].get("items") or []: + width, height = balloon_size(item["lines"], PARCEL_FONT) + cx, cy = paper(item["center"]) + obstacles.append( + (cx - width / 2, cy - height / 2, cx + width / 2, cy + height / 2) + ) + 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) entities.extend( diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py new file mode 100644 index 000000000..2caf7bb57 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py @@ -0,0 +1,121 @@ +"""B07 평면도 글 자리 잡기(141-1) — 글끼리 · 장애물(IP 표 · 필지 상자)과 안 겹치게. + +조각(`_piece_entities`)은 글을 바로 그리지 않고 후보 자리째 모은다. 여기서 우선순위 차례로 +후보 중 첫 빈 자리를 고르고(못 찾으면 첫 후보) 멀리 놓인 글엔 지시선을 단다. +우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소. 서버 단독 계산(CLAUDE.md 5장 ③). + +좌표 = 종이 mm. 글 사각 = 글 크기 × (한글 1.0 · 그 밖 0.6) 폭 · 글 크기 높이(세로 가운데 기준 — +CAD 가 textBaseline middle 로 그림) · 방향이 있으면 돌린 사각의 바깥 상자. +""" + +import math +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _text_entity, polyline_entity + +PRIORITY = ("endpoints", "station", "ip", "radius", "drainage", "refuge") +_GAP_MM = 0.6 # 글 사이 틈(종이 mm) + +Rect = tuple[float, float, float, float] + + +def text_width(label: str, size: float) -> float: + return sum(1.0 if ord(char) > 0x2E7F else 0.6 for char in label) * size + + +def text_rect( + label: str, + x: float, + y: float, + size: float, + align: str = "center", + direction: tuple[float, float] = (1.0, 0.0), +) -> Rect: + width = text_width(label, size) + left = {"left": 0.0, "right": -width}.get(align, -width / 2.0) + dx, dy = direction + length = math.hypot(dx, dy) or 1.0 + dx, dy = dx / length, dy / length + corners = [ + (x + cx * dx - cy * dy, y + cx * dy + cy * dx) + for cx in (left, left + width) + for cy in (-size / 2.0, size / 2.0) + ] + xs, ys = [p[0] for p in corners], [p[1] for p in corners] + return min(xs), min(ys), max(xs), max(ys) + + +def overlaps(a: Rect, b: Rect, gap: float = 0.0) -> bool: + return a[0] < b[2] + gap and b[0] < a[2] + gap and a[1] < b[3] + gap and b[1] < a[3] + gap + + +def _rect(item: dict[str, Any], spot: dict[str, Any]) -> Rect: + return text_rect( + item["label"], + spot["x"], + spot["y"], + item["size"], + spot.get("align", "center"), + spot.get("direction", (1.0, 0.0)), + ) + + +def place_labels(labels: list[dict[str, Any]], obstacles: list[Rect]) -> list[dict[str, Any]]: + """우선순위 차례로 후보 중 첫 빈 자리 — 글마다 고른 자리(`spot`) · 사각(`rect`)을 붙여 냄. + + labels = [{piece, seed, label, size, layer, color, anchor?, candidates: [{x, y, align?, + direction?, far?}]}] — 첫 후보 = 옛 자리. + """ + taken = list(obstacles) + placed = [] + rank = {piece: index for index, piece in enumerate(PRIORITY)} + for item in sorted(labels, key=lambda label: rank.get(label["piece"], len(PRIORITY))): + rects = [_rect(item, spot) for spot in item["candidates"]] + # ponytail: 후보가 모두 막히면 첫 후보 그대로(겹침 남음) — 더 필요하면 후보 고리를 늘림 + index = next( + ( + i + for i, rect in enumerate(rects) + if not any(overlaps(rect, t, _GAP_MM) for t in taken) + ), + 0, + ) + taken.append(rects[index]) + placed.append({**item, "spot": item["candidates"][index], "rect": rects[index]}) + return placed + + +def _nearest_on_rect(point: tuple[float, float], rect: Rect) -> tuple[float, float]: + return (min(max(point[0], rect[0]), rect[2]), min(max(point[1], rect[1]), rect[3])) + + +def label_entities(drawing_id: str, placed: list[dict[str, Any]]) -> list[dict[str, Any]]: + """자리 잡은 글 + 멀리 놓인 글(후보 far)의 지시선.""" + out: list[dict[str, Any]] = [] + for item in placed: + spot = item["spot"] + out.append( + _text_entity( + item["seed"], + item["label"], + spot["x"], + spot["y"], + item["layer"], + item["size"], + item["color"], + align=spot.get("align", "center"), + direction=spot.get("direction", (1.0, 0.0)), + ) + ) + anchor = item.get("anchor") + if spot.get("far") and anchor: + leader = polyline_entity( + drawing_id, + [anchor, _nearest_on_rect(anchor, item["rect"])], + item["layer"], + item["color"], + suffix=f":leader:{item['seed']}", + ) + if leader: + out.append(leader) + return out diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py new file mode 100644 index 000000000..279372493 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -0,0 +1,182 @@ +"""B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장). + +`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. +""" + +import math +from bisect import bisect_left +from typing import Any + +TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 + + +# ── 노선 따라가기 ───────────────────────────────────── + + +def route_frame( + route: list[tuple[float, float, float]], chainage: float +) -> tuple[float, float, float, float]: + """노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny).""" + chainages = [item[0] for item in route] + index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1) + (c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index] + dx, dy = x1 - x0, y1 - y0 + length = math.hypot(dx, dy) or 1.0 + ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0 + return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length) + + +def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float: + chainages = [record["chainage_m"] for record in records] + index = bisect_left(chainages, chainage) + if index <= 0: + return records[0][key] + if index >= len(records): + return records[-1][key] + before, after = records[index - 1], records[index] + span = after["chainage_m"] - before["chainage_m"] + ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0 + return before[key] + (after[key] - before[key]) * ratio + + +def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: + return sorted( + (float(item["offset_m"]), float(item["elevation_m"])) + for item in samples + if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) + ) + + +def _ground_at(ground: list[tuple[float, float]], offset: float) -> float: + offsets = [x for x, _z in ground] + index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) + (x0, z0), (x1, z1) = ground[index - 1], ground[index] + return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) + + +def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool: + """노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토).""" + ground = _ground(samples) + return len(ground) >= 2 and _ground_at(ground, edge) > elevation + + +def slope_end( + design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str +) -> float: + """노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝. + + 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. + 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. + """ + ground = _ground(samples) + points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line) + outward = ( + [p for p in points if p[0] > edge] + if side == "left" + else [p for p in points[::-1] if p[0] < edge] + ) + if not ground: + return outward[-1][0] if outward else edge + end, differs = edge, False + for offset, elevation in outward: + if abs(elevation - _ground_at(ground, offset)) <= 1e-3: + return offset if differs else end + differs, end = True, offset + return end + + +def _split_runs( + points: list[list[float]], flags: list[bool] +) -> tuple[list[list[list[float]]], list[list[list[float]]]]: + """한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다.""" + runs: dict[bool, list[list[list[float]]]] = {True: [], False: []} + current: list[list[float]] = [] + previous: bool | None = None + for point, flag in zip(points, flags): + if flag != previous: + if current: + current.append(point) + current = [point] + runs[flag].append(current) + previous = flag + else: + current.append(point) + return ( + [run for run in runs[True] if len(run) >= 2], + [run for run in runs[False] if len(run) >= 2], + ) + + +def slope_pieces( + route: list[tuple[float, float, float]], + records: list[dict[str, Any]], + start_m: float, + end_m: float, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: + """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. + + `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, + left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 + 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 + 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. + """ + records = sorted( + ( + { + **record, + **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, + } + for record in records + ), + key=lambda record: record["chainage_m"], + ) + if len(route) < 2 or not records: + return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} + keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") + by_chainage = {round(record["chainage_m"], 3): record for record in records} + samples = { + round(record["chainage_m"], 3) + for record in records + if start_m <= record["chainage_m"] <= end_m + } + step = start_m + while step <= end_m: + samples.add(round(step, 3)) + step += TICK_STEP_M + lines: dict[str, list[list[float]]] = {key: [] for key in keys} + cut: dict[str, list[bool]] = {"left": [], "right": []} + ticks: list[list[list[float]]] = [] + for index, chainage in enumerate(sorted(samples)): + record = by_chainage.get(chainage) + if record and all(k in record for k in ("x", "y", "nx", "ny")): + x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] + else: + x, y, nx, ny = route_frame(route, chainage) + offsets = {key: _interp(records, chainage, key) for key in keys} + points = { + key: [round(x + nx * value, 4), round(y + ny * value, 4)] + for key, value in offsets.items() + } + for key in keys: + lines[key].append(points[key]) + for side in cut: + cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) + # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). + for side in ("left", "right"): + edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] + if abs(end - edge) < 0.05: + continue + reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 + ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) + cut_lines: list[list[list[float]]] = [] + fill_lines: list[list[list[float]]] = [] + for side in cut: + cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) + cut_lines += cut_runs + fill_lines += fill_runs + return ( + {"lines": [lines["left_edge_m"], lines["right_edge_m"]]}, + {"lines": fill_lines}, + {"lines": cut_lines}, + {"ticks": ticks}, + ) diff --git a/resources/tester/test_141_b07_plan_labels.py b/resources/tester/test_141_b07_plan_labels.py new file mode 100644 index 000000000..47c52df3b --- /dev/null +++ b/resources/tester/test_141_b07_plan_labels.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +"""141-1 B07 평면도 글자 겹침 피하기. + +못박는 것 + - 우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소 — 앞 글이 첫 후보(옛 자리)를 가짐. + - 후보 중 첫 빈 자리 · 모두 막히면 첫 후보 그대로(글은 남음). + - 장애물(IP 표 · 필지 상자) 비켜 감. + - 가까운 IP · R · 배수 둘 · NO.0 + 시점 한 장 → 글 사각 겹침 0 · 글 모두 남음. + - 멀리 놓인 글(후보 far)엔 지시선. +""" + +from itertools import combinations + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import ( + overlaps, + place_labels, + text_rect, +) + + +def _label(piece: str, label: str, spots: list[dict], anchor=None) -> dict: + return { + "piece": piece, + "seed": f"s:{piece}:{label}", + "label": label, + "size": 2.5, + "layer": "l", + "color": "#fff", + "anchor": anchor, + "candidates": spots, + } + + +def test_priority_keeps_first_spot_and_next_moves() -> None: + spot = [{"x": 0.0, "y": 0.0}, {"x": 0.0, "y": 10.0}] + placed = place_labels([_label("refuge", "대피소", spot), _label("endpoints", "시점", spot)], []) + by = {item["piece"]: item["spot"] for item in placed} + assert by["endpoints"] == spot[0] # 시종점이 먼저 — 옛 자리 + assert by["refuge"] == spot[1] + + +def test_all_blocked_falls_back_to_first() -> None: + spot = [{"x": 0.0, "y": 0.0}] + placed = place_labels([_label("ip", "IP1", spot), _label("radius", "R=12", spot)], []) + assert [item["spot"] for item in placed] == [spot[0], spot[0]] # 글은 남음 + + +def test_obstacle_is_avoided() -> None: + spots = [{"x": 0.0, "y": 0.0}, {"x": 30.0, "y": 0.0}] + placed = place_labels([_label("ip", "IP1", spots)], [(-5.0, -5.0, 5.0, 5.0)]) + assert placed[0]["spot"] == spots[1] + + +def _texts(drawing: dict) -> list[dict]: + return [ + e + for e in drawing["entities"] + if e["type"] == "Text" + and e["layerId"].startswith("b07-plan-") + and e["layerId"] != "b07-plan-title" + ] + + +def _rect(entity: dict) -> tuple: + shape = entity["shapeData"] + opts = shape["options"] + direction = (opts["textDirection"]["x"], opts["textDirection"]["y"]) + return text_rect( + shape["label"], + shape["basePoint"]["x"], + shape["basePoint"]["y"], + opts["fontSize"], + opts["textAlign"], + direction, + ) + + +def test_crowded_sheet_has_no_overlapping_labels() -> None: + box = (0.0, 0.0, 300.0, 200.0) + pieces = { + "ip": { + "items": [ + { + "name": "IP1", + "apex": [100.0, 100.0], + "start": [90.0, 100.0], + "end": [100.0, 110.0], + }, + { + "name": "IP2", + "apex": [103.0, 101.0], + "start": [95.0, 101.0], + "end": [103.0, 111.0], + }, + ] + }, + "radius": { + "items": [ + {"label": "R=12", "middle": [100.0, 98.0], "dir": [0.0, -1.0]}, + {"label": "R=15", "middle": [103.0, 99.0], "dir": [0.0, -1.0]}, + ] + }, + "drainage": { + "items": [ + {"x": 101.0, "y": 96.0, "label": "D800 L=8.0"}, + {"x": 102.0, "y": 95.0, "label": "D600 L=6.0"}, + ] + }, + "station": { + "items": [ + {"x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0, "label": "NO.0", "major": True}, + ] + }, + "endpoints": { + "items": [{"label": "시점 NO.0", "x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0}] + }, + } + drawing = build_plan_kind_drawing("plan_x", "시험", box, pieces) + texts = _texts(drawing) + labels = sorted(e["shapeData"]["label"] for e in texts) + assert labels == sorted( + ["IP1", "IP2", "R=12", "R=15", "D800 L=8.0", "D600 L=6.0", "NO.0", "시점 NO.0"] + ) + pairs = [ + (a["shapeData"]["label"], b["shapeData"]["label"]) + for a, b in combinations(texts, 2) + if overlaps(_rect(a), _rect(b)) + ] + assert pairs == [] + + +def test_far_spot_draws_leader() -> None: + near = [{"x": 0.0, "y": 0.0}, {"x": 40.0, "y": 0.0, "far": True}] + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities + + placed = place_labels([_label("ip", "IP1", near, (0.0, 0.0))], [(-3.0, -3.0, 3.0, 3.0)]) + entities = label_entities("plan_x", placed) + assert [e["type"] for e in entities] == ["Text", "PolyLine"] From f05d77d642b78972d5e9bfe257ba7a30e4750d5f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:10:02 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(B07):=20=ED=8F=89=EB=A9=B4=EB=8F=84=20?= =?UTF-8?q?=EC=B6=95=EC=B2=99=20=EC=97=AC=EB=8D=9F=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=83=81=EC=88=98=20=EC=B6=95=EC=B2=99?= =?UTF-8?q?=ED=99=94=20=C2=B7=20=EB=A7=89=ED=9E=8C=20=EA=B8=80=20=EB=A8=BC?= =?UTF-8?q?=20=EC=9E=90=EB=A6=AC(141-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 축척 = 1/500 · 600 · 1,000 · 1,200(기본) · 2,500 · 3,000 · 5,000 · 6,000(지식DB 근거) - 비탈 눈금 간격 · 대피소 띄움 · 필지 버퍼 · 틈 = 1/1,200 실거리 × 축척 비(종이 거리 같게 · 1/6,000 눈금 10 m) - 글 후보가 다 막히면 기호에서 2 · 3 배 먼 자리 + 지시선 — 검증_sub8 1/6,000 겹친 쌍 55 → 4 - 시험 test_119 · test_141 늘림 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B07_DesignDetail_Engine_Cad_PlanLabels.py | 21 +++++++++++++--- .../B07_DesignDetail_Engine_Cad_PlanSlope.py | 14 +++++++++-- ...B07_DesignDetail_Router_Support_Landuse.py | 11 ++++++--- .../B07_DesignDetail_Router_Support_Plan.py | 22 +++++++++++------ config/config_system.py | 6 +++-- resources/tester/test_119_b07_plan_scale.py | 24 +++++++++++++++++-- resources/tester/test_141_b07_plan_labels.py | 15 ++++++++++++ 7 files changed, 94 insertions(+), 19 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py index 2caf7bb57..53b368cf5 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py @@ -15,6 +15,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _text_entity, polyline_ PRIORITY = ("endpoints", "station", "ip", "radius", "drainage", "refuge") _GAP_MM = 0.6 # 글 사이 틈(종이 mm) +_PUSH = (2.0, 3.0) # 먼 고리 — 후보 거리의 배수 Rect = tuple[float, float, float, float] @@ -70,8 +71,9 @@ def place_labels(labels: list[dict[str, Any]], obstacles: list[Rect]) -> list[di placed = [] rank = {piece: index for index, piece in enumerate(PRIORITY)} for item in sorted(labels, key=lambda label: rank.get(label["piece"], len(PRIORITY))): - rects = [_rect(item, spot) for spot in item["candidates"]] - # ponytail: 후보가 모두 막히면 첫 후보 그대로(겹침 남음) — 더 필요하면 후보 고리를 늘림 + spots = [*item["candidates"], *_pushed(item)] + rects = [_rect(item, spot) for spot in spots] + # ponytail: 먼 고리까지 모두 막히면 첫 후보 그대로(겹침 남음 · 작은 축척에 글이 몰린 장) index = next( ( i @@ -81,10 +83,23 @@ def place_labels(labels: list[dict[str, Any]], obstacles: list[Rect]) -> list[di 0, ) taken.append(rects[index]) - placed.append({**item, "spot": item["candidates"][index], "rect": rects[index]}) + placed.append({**item, "spot": spots[index], "rect": rects[index]}) return placed +def _pushed(item: dict[str, Any]) -> list[dict[str, Any]]: + """후보가 다 막혔을 때 — 기호에서 후보 쪽으로 2 · 3 배 먼 자리(지시선 · 141-2 작은 축척).""" + anchor = item.get("anchor") + if not anchor: + return [] + ax, ay = anchor + return [ + {**spot, "x": ax + (spot["x"] - ax) * k, "y": ay + (spot["y"] - ay) * k, "far": True} + for k in _PUSH + for spot in item["candidates"] + ] + + def _nearest_on_rect(point: tuple[float, float], rect: Rect) -> tuple[float, float]: return (min(max(point[0], rect[0]), rect[2]), min(max(point[1], rect[1]), rect[3])) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index 279372493..3ef15e745 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -7,7 +7,16 @@ import math from bisect import bisect_left from typing import Any -TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 +from config.config_system import DRAWING_SCALE_PLAN + +#: 비탈 눈금 간격(1/1,200 실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 · 다른 축척은 종이 간격이 +#: 같게(1/6,000 = 10 m · 141-2) +TICK_STEP_M = 2.0 + + +def tick_step_m(scale: int = DRAWING_SCALE_PLAN) -> float: + """비탈 눈금 간격(실거리 m) — 종이 간격(1/1,200 의 2 m) × 축척.""" + return TICK_STEP_M * scale / DRAWING_SCALE_PLAN # ── 노선 따라가기 ───────────────────────────────────── @@ -112,6 +121,7 @@ def slope_pieces( records: list[dict[str, Any]], start_m: float, end_m: float, + step_m: float = TICK_STEP_M, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. @@ -142,7 +152,7 @@ def slope_pieces( step = start_m while step <= end_m: samples.add(round(step, 3)) - step += TICK_STEP_M + step += step_m lines: dict[str, list[list[float]]] = {key: [] for key in keys} cut: dict[str, list[bool]] = {"left": [], "right": []} ticks: list[list[list[float]]] = [] diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py index 16463c373..0811853e7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py @@ -31,6 +31,7 @@ PARCEL_FILE = "연속지적도_bounds.geojson" EMD_FILE = "행정구역_읍면동_bounds.geojson" SGG_FILE = "행정구역_시군구_bounds.geojson" +# 실거리는 1/1,200 값 — 다른 축척은 종이 거리가 같게 늘이고 줄임(`mm` · 141-2) ROUTE_CLEAR_M = 5.0 # 라벨이 비켜 갈 노선 버퍼(중심선에서 m) _GAP_M = 2.0 # 지시선 끝과 상자 사이 틈(m) _RINGS = 5 # 후보 자리 고리 수 @@ -131,7 +132,11 @@ def place_labels( mm: float = MM, ) -> list[dict[str, Any]]: """필지마다 라벨 [{lines, anchor, center}] — 큰 필지부터 자리를 잡는다.""" - clear = LineString(route_xy).buffer(ROUTE_CLEAR_M) if len(route_xy) >= 2 else Polygon() + stretch = MM / mm # 1/1,200 = 1 + gap = _GAP_M * stretch + clear = ( + LineString(route_xy).buffer(ROUTE_CLEAR_M * stretch) if len(route_xy) >= 2 else Polygon() + ) shapely.prepare(clear) no_outline = not any(parcel["lines"] for parcel in parcels) placed: list[tuple[float, float, float, float]] = [] @@ -149,8 +154,8 @@ def place_labels( chosen = None for ring in range(1, _RINGS + 1): for sx, sy in _DIRECTIONS: - cx = ax + sx * (half_w + _GAP_M) * ring - cy = ay + sy * (half_h + _GAP_M) * ring + cx = ax + sx * (half_w + gap) * ring + cy = ay + sy * (half_h + gap) * ring rect = (cx - half_w, cy - half_h, cx + half_w, cy + half_h) inside = ( rect[0] >= box[0] diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 97410d0a3..ef3be073d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -42,6 +42,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( slope_end, slope_pieces, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import tick_step_m from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _chunk_route, _hillshade_for_box, @@ -77,7 +78,7 @@ _NAME_MAX = 40 WATERSHED_LAYOUT = "watershed" _LAYOUTS = ("plan", WATERSHED_LAYOUT) _REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"} -_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄우는 거리(좌측) +_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 기호를 중심선에서 띄우는 거리(좌측 · 1/1,200 실거리) def plan_title(name: str) -> str: @@ -391,9 +392,16 @@ def slope_records( def _refuge_items( - project_root: Path, route: list[tuple[float, float, float]], start_m: float, end_m: float + project_root: Path, + route: list[tuple[float, float, float]], + start_m: float, + end_m: float, + scale: int = DRAWING_SCALE_PLAN, ) -> dict[str, Any]: - """대피소 · 사토장 = 구조물 정본의 그 종류 + 사용자가 정한 자리. 한 자리에 둘 다 가능.""" + """대피소 · 사토장 = 구조물 정본의 그 종류 + 사용자가 정한 자리. 한 자리에 둘 다 가능. + + 띄움은 종이 거리가 같게 축척 따라(141-2).""" + offset = _REFUGE_OFFSET_M * scale / DRAWING_SCALE_PLAN spots: dict[float, set[str]] = {} _revision, structures = load_structures(str(project_root)) for structure in structures: @@ -420,8 +428,8 @@ def _refuge_items( { "chainage_m": chainage, "label": " · ".join(name for name in ("대피소", "사토장") if name in labels), - "x": x + nx * _REFUGE_OFFSET_M, - "y": y + ny * _REFUGE_OFFSET_M, + "x": x + nx * offset, + "y": y + ny * offset, } ) return {"items": items} @@ -464,7 +472,7 @@ def build_pieces( pieces["fill_outline"], pieces["cut_outline"], pieces["slope_ticks"], - ) = slope_pieces(route, records, start_m, end_m) + ) = slope_pieces(route, records, start_m, end_m, tick_step_m(scale)) if {"ip", "radius"} & set(wanted): pieces["ip"], pieces["radius"] = curve_pieces(_load_design_curves(project_root), route, box) if "station" in wanted: @@ -502,7 +510,7 @@ def build_pieces( items.append({"label": label, "x": x, "y": y, "nx": nx, "ny": ny}) pieces["endpoints"] = {"items": items} if "refuge" in wanted: - pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m) + pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m, scale) if "drainage" in wanted: min_x, min_y, max_x, max_y = box pieces["drainage"] = { diff --git a/config/config_system.py b/config/config_system.py index 24de90716..ed3852578 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -185,8 +185,10 @@ DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/ # 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200. # 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**. DRAWING_SCALE_PLAN = 1200 -# 평면도 종류마다 고를 수 있는 축척 분모 — 1/1,200 기본(법정) · 1/1,000 · 1/600(용지도). -DRAWING_SCALE_PLAN_CHOICES = (600, 1000, 1200) +# 평면도 종류마다 고를 수 있는 축척 분모 — 1/1,200 기본(법정) · 지식DB 근거 있는 것만(141-2): +# 1/500 · 1/2,500(측량 품셈) · 1/600(용지도) · 1/1,000(임도 설계) · 1/3,000 · 1/6,000(측량원도) · +# 1/5,000(지형도) +DRAWING_SCALE_PLAN_CHOICES = (500, 600, 1000, 1200, 2500, 3000, 5000, 6000) # 표준 횡단면도 — 상세도라 지식DB에 지정 축척이 없다. 본 그림 1/50, 측구 부분확대도 # 1/10 (2026-09-04). 노폭 4 m 기준 본 그림이 A1 작도영역에 여유 있게 든다. DRAWING_SCALE_CROSS_STANDARD = 50 diff --git a/resources/tester/test_119_b07_plan_scale.py b/resources/tester/test_119_b07_plan_scale.py index 9e448c718..c8a6fd096 100644 --- a/resources/tester/test_119_b07_plan_scale.py +++ b/resources/tester/test_119_b07_plan_scale.py @@ -38,7 +38,9 @@ _STATIONS = [ def test_scale_choices_add_update_and_old_files(tmp_path: Path) -> None: root = tmp_path / "project" state = _state(root) - assert state["scales"] == [600, 1000, 1200] and state["default_scale"] == 1200 + # 141-2 — 지식DB 근거 있는 여덟 + assert state["scales"] == [500, 600, 1000, 1200, 2500, 3000, 5000, 6000] + assert state["default_scale"] == 1200 assert all(k["scale"] == (6000 if k["layout"] == "watershed" else 1200) for k in state["kinds"]) added = add_kind(root, "용지 600", ["contour"], "plan", 600) assert added["scale"] == 600 @@ -49,7 +51,7 @@ def test_scale_choices_add_update_and_old_files(tmp_path: Path) -> None: update_kind(root, added["id"], "용지 1000", ["contour"]) # 축척 없이 = 지금 값 assert next(k for k in list_kinds(root) if k["id"] == added["id"])["scale"] == 1000 with pytest.raises(ValueError): - add_kind(root, "이상한 축척", ["contour"], "plan", 500) + add_kind(root, "이상한 축척", ["contour"], "plan", 700) with pytest.raises(ValueError): update_kind(root, added["id"], "용지 1000", ["contour"], 777) watershed = add_kind(root, "유역 둘", [], "watershed", 600) # 유역도 양식은 고정 @@ -171,3 +173,21 @@ def test_ip_table_clip_and_parcel_label_follow_scale() -> None: (half,) = label_rects(items, 1000.0 / 600) assert half[2] - half[0] == pytest.approx((big[2] - big[0]) / 2) assert half[3] - half[1] == pytest.approx((big[3] - big[1]) / 2) + + +def test_실거리_상수는_종이_거리가_같게_141_2() -> None: + """비탈 눈금 · 대피소 띄움 · 필지 버퍼 = 1/1,200 실거리 × 축척 비 — 1/6,000 눈금 간격 10 m.""" + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import slope_pieces, tick_step_m + + assert tick_step_m(1200) == 2.0 and tick_step_m(6000) == 10.0 and tick_step_m(500) < 1.0 + route = [(0.0, 0.0, 0.0), (200.0, 200.0, 0.0)] + records = [ + {"chainage_m": c, "left_edge_m": 2.0, "left_end_m": 6.0, "right_edge_m": -2.0, + "right_end_m": -6.0} + for c in (0.0, 200.0) + ] # fmt: skip + for scale in (1200, 6000): + ticks = slope_pieces(route, records, 0.0, 200.0, tick_step_m(scale))[3]["ticks"] + left = sorted(t[0][0] for t in ticks if t[0][1] > 0) + gaps_mm = [(b - a) * 1000.0 / scale for a, b in zip(left, left[1:])] + assert min(gaps_mm) == pytest.approx(2000.0 / 1200.0) # 1.67 mm — 검은 띠 없음 diff --git a/resources/tester/test_141_b07_plan_labels.py b/resources/tester/test_141_b07_plan_labels.py index 47c52df3b..6e9c426f1 100644 --- a/resources/tester/test_141_b07_plan_labels.py +++ b/resources/tester/test_141_b07_plan_labels.py @@ -137,3 +137,18 @@ def test_far_spot_draws_leader() -> None: placed = place_labels([_label("ip", "IP1", near, (0.0, 0.0))], [(-3.0, -3.0, 3.0, 3.0)]) entities = label_entities("plan_x", placed) assert [e["type"] for e in entities] == ["Text", "PolyLine"] + + +def test_blocked_spots_push_farther_with_leader() -> None: + """141-2 — 후보가 다 막히면 기호에서 2 · 3 배 먼 자리(지시선) · 작은 축척에 몰린 장.""" + anchor = (0.0, 0.0) + spot = [{"x": 0.0, "y": -4.0}] + placed = place_labels( + [_label("ip", "IP1", spot, anchor), _label("radius", "R=12", spot, anchor)], [] + ) + assert placed[0]["spot"] == spot[0] + assert placed[1]["spot"] == {"x": 0.0, "y": -8.0, "far": True} + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities + + entities = label_entities("d", placed) + assert [e["type"] for e in entities] == ["Text", "Text", "PolyLine"] From 24b45655e99417b60018ddbc113e47535743ee89 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:12:34 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(B07):=20=ED=8F=89=EB=A9=B4=EB=8F=84=20?= =?UTF-8?q?=EC=A1=B0=EA=B0=81=20=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=C2=B7=20=EC=9C=A0=EC=97=AD=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=E2=80=94=20=EC=9E=A5=20=EB=B2=94=EC=9C=84=EB=A1=9C?= =?UTF-8?q?=20=EC=9E=90=EB=A6=84(141-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조각 둘 더함 — 「배수유역 경계」(유역도 해치 + 테두리) · 「유역 번호」(유역도 배지) · 어느 평면도 종류에나 체크로 - 세부유역 읽기를 metric_basins 로 떼어 유역도 · 평면도 공용 · 장 범위로 잘라 번호는 유역도와 같게 - 번호 배지는 글 자리 잡기의 장애물 · 새 test_141_3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B07_DesignDetail_Engine_Cad_Basin.py | 20 +++-- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 29 ++++++++ .../B07_DesignDetail_Router_Support_Basin.py | 73 +++++++++++++++---- .../B07_DesignDetail_Router_Support_Plan.py | 3 + resources/tester/test_141_3_b07_plan_basin.py | 69 ++++++++++++++++++ 5 files changed, 172 insertions(+), 22 deletions(-) create mode 100644 resources/tester/test_141_3_b07_plan_basin.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index 3ce69d183..d1d53ed05 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -159,7 +159,11 @@ def _diameter_label(props: dict[str, Any]) -> str: def _hatch_entity( - drawing_id: str, number: int, ring: list[tuple[float, float]], color: str + drawing_id: str, + number: int, + ring: list[tuple[float, float]], + color: str, + layer: str = BASIN_LAYER_ID, ) -> dict[str, Any]: """유역 안쪽 격자 해칭. openwebcad Hatch 엔티티라 CAD에서 무늬·간격을 그대로 편집한다.""" return { @@ -167,7 +171,7 @@ def _hatch_entity( "type": "Hatch", "lineColor": color, "lineWidth": 1, - "layerId": BASIN_LAYER_ID, + "layerId": layer, "shapeData": { "points": [{"x": x, "y": y} for x, y in ring], "options": { @@ -181,7 +185,11 @@ def _hatch_entity( def _number_badge_entities( - drawing_id: str, number: int, center: tuple[float, float], color: str + drawing_id: str, + number: int, + center: tuple[float, float], + color: str, + layer: str = BASIN_LAYER_ID, ) -> list[dict[str, Any]]: """유역 번호를 동그라미 배지로 그린다 — 회색 원판 + 유역색 테두리 + 검은 숫자. @@ -202,7 +210,7 @@ def _number_badge_entities( "type": "Hatch", "lineColor": _BADGE_FILL, "lineWidth": 1, - "layerId": BASIN_LAYER_ID, + "layerId": layer, "shapeData": { "points": [{"x": x, "y": y} for x, y in disc], "options": {"style": "solid", "color": _BADGE_FILL, "spacing": 1, "angle": 0}, @@ -213,7 +221,7 @@ def _number_badge_entities( "type": "Circle", "lineColor": color, "lineWidth": 1, - "layerId": BASIN_LAYER_ID, + "layerId": layer, "shapeData": {"center": {"x": cx, "y": cy}, "radius": _BADGE_RADIUS_MM}, }, _text_entity( @@ -221,7 +229,7 @@ def _number_badge_entities( str(number), cx, cy, - BASIN_LAYER_ID, + layer, _NUMBER_FONT_SIZE, _BADGE_TEXT_COLOR, ), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 3c628cfc3..2f2339c3a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -20,6 +20,12 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, polyline_entity, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import ( + _BADGE_RADIUS_MM, + BASIN_COLORS, + _hatch_entity, + _number_badge_entities, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( @@ -62,6 +68,9 @@ PIECES: tuple[tuple[str, str, bool], ...] = ( ("lidar", "라이다 음영", True), ("contour", "등고선", True), ("stream", "계류", True), + # 배수유역(141-3) — 유역도 해치 · 번호 배지를 어느 평면도에나(장 범위로 자름) + ("basin", "배수유역 경계", False), + ("basin_no", "유역 번호", False), ("slope_ticks", "비탈 눈금", False), ("road_edge", "노견선", False), ("fill_outline", "성토 외곽선", False), @@ -446,6 +455,21 @@ def _piece_entities( font=PARCEL_FONT, ) ) + elif piece == "basin": + for index, item in enumerate(data.get("items") or []): + ring = [paper(point) for point in item["ring"]] + tint = BASIN_COLORS[(int(item["number"]) - 1) % len(BASIN_COLORS)] + seed = f"{drawing_id}:plan:{index}" + out.append(_hatch_entity(seed, int(item["number"]), ring, tint, layer)) + outline = polyline_entity(drawing_id, ring, layer, tint, suffix=f":{index}") + if outline: + out.append(outline) + elif piece == "basin_no": + for index, item in enumerate(data.get("items") or []): + number = int(item["number"]) + tint = BASIN_COLORS[(number - 1) % len(BASIN_COLORS)] + seed = f"{drawing_id}:plan:{index}" + out.extend(_number_badge_entities(seed, number, paper(item["center"]), tint, layer)) return out @@ -486,6 +510,11 @@ def build_plan_kind_drawing( obstacles.append( (cx - width / 2, cy - height / 2, cx + width / 2, cy + height / 2) ) + elif piece == "basin_no": # 유역 번호 배지(141-3) + radius = _BADGE_RADIUS_MM + for item in pieces[piece].get("items") or []: + cx, cy = paper(item["center"]) + obstacles.append((cx - radius, cy - radius, cx + radius, cy + radius)) 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) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index 4419556fd..bbcea06eb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -13,6 +13,8 @@ from typing import Any import numpy as np from pyproj import Transformer +from shapely.geometry import Polygon +from shapely.geometry import box as shapely_box from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm @@ -499,6 +501,30 @@ def watershed_source(context: Any) -> dict[str, Any]: 좌표계 기록 이전 저장본은 노선 CSV의 EPSG 라벨로 쓰였고, 그 라벨로 되돌려야 원래 미터 좌표가 나온다(2026-09-01). """ + route_xy = [(vertex.x, vertex.y) for vertex in context.vertices] + basins = metric_basins(context) + + # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 + # (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다). + # 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다. + background = map_background( + Path(context.project_root), + context.crs, + DRAWING_SCALE_BASIN, + map_area_mm(), + [*route_xy, *(point for basin in basins for point in basin["ring"])], + ) + + return { + "route_xy": route_xy, + "basins": basins, + "contours": background["contours"], + "streams": background["streams"], + } + + +def metric_basins(context: Any) -> list[dict[str, Any]]: + """세부유역 [{ring, props}] — 사업지 m(유역도 · 평면도 유역 조각 141-3 공용).""" basins_payload = _geojson_payload(detail_basins_path(context.stored_path)) to_basin_metric = Transformer.from_crs( "EPSG:4326", _basins_crs(context, basins_payload), always_xy=True @@ -533,21 +559,36 @@ def watershed_source(context: Any) -> dict[str, Any]: _BASIN_MAX_DISTANCE_M / 1000.0, dropped, ) + return basins - # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 - # (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다). - # 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다. - background = map_background( - Path(context.project_root), - context.crs, - DRAWING_SCALE_BASIN, - map_area_mm(), - [*route_xy, *(point for basin in basins for point in basin["ring"])], - ) - return { - "route_xy": route_xy, - "basins": basins, - "contours": background["contours"], - "streams": background["streams"], - } +def plan_basin_pieces( + context: Any, box: tuple[float, float, float, float] +) -> dict[str, dict[str, Any]]: + """평면도 조각 「배수유역 경계」 · 「유역 번호」 — 세부유역을 장 범위로 자름(141-3). + + 번호 = 유역도와 같은 번호(`index` · 없으면 차례) · 자리 = 잘린 조각 안 대표점. + 유역 저장본이 없으면 빈 조각(배수 분석 전). + """ + try: + basins = metric_basins(context) + except (FileNotFoundError, OSError, ValueError): + basins = [] + frame = shapely_box(*box) + rings: list[dict[str, Any]] = [] + numbers: list[dict[str, Any]] = [] + for order, basin in enumerate(basins): + number = int((basin.get("props") or {}).get("index") or order + 1) + try: + part = Polygon(basin["ring"]).buffer(0).intersection(frame) + except (ValueError, TypeError): + continue + if part.is_empty or part.area <= 0: + continue + for polygon in getattr(part, "geoms", [part]): + if isinstance(polygon, Polygon) and polygon.area > 0: + ring = [[round(x, 4), round(y, 4)] for x, y in polygon.exterior.coords] + rings.append({"number": number, "ring": ring}) + point = part.representative_point() + numbers.append({"number": number, "center": [round(point.x, 4), round(point.y, 4)]}) + return {"basin": {"items": rings}, "basin_no": {"items": numbers}} diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index ef3be073d..cb6dc7534 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -49,6 +49,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _plan_structures, _sheet_box, map_background, + plan_basin_pieces, plan_stations, watershed_source, ) @@ -523,6 +524,8 @@ def build_pieces( and min_y <= item["y"] <= max_y ] } + if {"basin", "basin_no"} & set(wanted): + pieces.update(plan_basin_pieces(context, box)) # 141-3 if "lidar" in wanted: try: image, image_box = _hillshade_for_box(context, box) diff --git a/resources/tester/test_141_3_b07_plan_basin.py b/resources/tester/test_141_3_b07_plan_basin.py new file mode 100644 index 000000000..205b9b400 --- /dev/null +++ b/resources/tester/test_141_3_b07_plan_basin.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""141-3 평면도 조각 「배수유역 경계」 · 「유역 번호」 — 세부유역을 장 범위로 잘라 유역도 해치 · 배지로. + +견본: 유역 1 = 장 안 정사각 · 유역 2 = 장 경계에 걸침(잘림) · 유역 3 = 장 밖(빠짐). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from B07_DesignDetail import B07_DesignDetail_Router_Support_Basin as basin +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( + PIECE_IDS, + build_plan_kind_drawing, + layer_id, +) +from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import PIECES + +BOX = (0.0, 0.0, 100.0, 100.0) +BASINS = [ + {"ring": [(10, 10), (40, 10), (40, 40), (10, 40), (10, 10)], "props": {"index": 1}}, + {"ring": [(80, 50), (120, 50), (120, 90), (80, 90), (80, 50)], "props": {"index": 2}}, + {"ring": [(300, 300), (320, 300), (320, 320), (300, 300)], "props": {"index": 3}}, +] + + +def _pieces(monkeypatch) -> dict: + monkeypatch.setattr(basin, "metric_basins", lambda _context: BASINS) + return basin.plan_basin_pieces(SimpleNamespace(), BOX) + + +def test_장_범위로_자르고_번호는_유역도와_같다(monkeypatch): + pieces = _pieces(monkeypatch) + rings = pieces["basin"]["items"] + assert [item["number"] for item in rings] == [1, 2] # 장 밖 3 은 빠짐 + clipped = rings[1]["ring"] + assert max(x for x, _y in clipped) == 100.0 # 장 경계에서 잘림 + numbers = {item["number"]: item["center"] for item in pieces["basin_no"]["items"]} + assert set(numbers) == {1, 2} + x, y = numbers[2] + assert 80 <= x <= 100 and 50 <= y <= 90 # 잘린 조각 안 + + +def test_유역_저장본_없으면_빈_조각(monkeypatch): + def missing(_context): + raise FileNotFoundError + + monkeypatch.setattr(basin, "metric_basins", missing) + assert basin.plan_basin_pieces(SimpleNamespace(), BOX) == { + "basin": {"items": []}, + "basin_no": {"items": []}, + } + + +def test_어느_평면도에나_조각_체크(monkeypatch): + assert {"basin", "basin_no"} <= set(PIECE_IDS) + assert ("basin", "배수유역 경계", False) in PIECES and ( + "basin_no", + "유역 번호", + False, + ) in PIECES + drawing = build_plan_kind_drawing("plan_x", "평면도(x)", BOX, _pieces(monkeypatch), 1200) + hatches = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin")] + assert [e["type"] for e in hatches].count("Hatch") == 2 + badges = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin_no")] + assert sorted(e["shapeData"]["label"] for e in badges if e["type"] == "Text") == ["1", "2"] + assert len({e["id"] for e in drawing["entities"]}) == len(drawing["entities"]) + names = {layer["id"]: layer["name"] for layer in drawing["layers"]} + assert names[layer_id("basin")] == "배수유역 경계" From 0d49a4c3c13827f910db8f09da73ac048979aed1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:16:02 +0900 Subject: [PATCH 4/5] =?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)] From dbc9a32b7263e8845848bea62c36fefeedea7af3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:20:56 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(B08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=97=B4=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20=EC=A3=BC=EC=9D=B8=EB=B3=84=20=EA=B0=92=20?= =?UTF-8?q?owners(145-3=20=EC=9D=B4=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 열 owners = {사본 주인 id: 그 주인이 낸 값} — 주인 id = linked_snapshots 키(pipe@측점 · 구조물 id · {id}__{그룹}) - 칸을 낸 잇기 사본이 먼저 · 없으면 시설 본체 · owners 합 = 열 계 · 기존 키 · 화면 그대로 - 새 test_145_3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Router_Summary.py | 39 ++++++++++++++-- .../tester/test_145_3_b08_summary_owners.py | 46 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_145_3_b08_summary_owners.py diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 4e840da35..416c2c161 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -22,6 +22,9 @@ 열 id 접두 `pipe:` = 이 집계표(횡단배수) 이름공간 · 같은 측점 박스 + 관은 종류로 갈라 셈. 머리 글 = M02 정의 이름(`구조물정의.json` types.name · 종류.{종류}.이름 — 사용자가 M02 에서 고침) · 열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리). +열마다 `owners` = {사본 주인 id: 그 주인이 낸 값}(145-3) — 주인 id = `linked_snapshots` 키 +(`pipe@측점` · 구조물 id · `{id}__{그룹}`) · 칸을 낸 잇기 사본이 먼저 · 없으면 시설 본체 · +합 = 열 계. 설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용. `pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈 (짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122). @@ -111,6 +114,21 @@ def _combo(item: Any) -> str | None: return f"{item.get('layer')}|{item.get('owner') or ''}|{item['key']}" +class _Copy(str): + """잇기 조합 id + 그 사본 주인 id(`linked_snapshots` 키 · `{구조물 id}__{그룹}`) — 145-3.""" + + copy: str + + +def _linked(combo: str | None, copy: str) -> str | None: + """잇기 조합에 사본 주인 id 를 붙임 — 본체 조합(그냥 글)은 시설 id 가 주인.""" + if combo is None: + return None + out = _Copy(combo) + out.copy = copy + return out + + def one_pipe_length(project_root: str | Path) -> float | None: """1본 길이(m) — 프로젝트 층 구조물집계표 변수만(시스템 층으로 안 채움 · 143-2) · 빈칸 None.""" found = layers.read_template(layers.project_dir(project_root), "table", TABLE_NAME) @@ -193,14 +211,19 @@ class _Sheet: return cid = f"pipe:{key}" column = self.columns.setdefault( - cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort} + cid, + {"id": cid, "group": group, "label": label, "combos": [], "owners": {}, "_sort": sort}, ) for combo in combos: if combo and combo not in column["combos"]: - column["combos"].append(combo) + column["combos"].append(str(combo)) row = self.rows.setdefault(round(chainage * 100), {"chainage_m": chainage, "values": {}}) if value is not None: # 열은 세움 — 놓였는데 값이 빈칸인 것이 보여야 함 row["values"][cid] = round((row["values"].get(cid) or 0.0) + value, 3) + # 사본 주인별 값(145-3 · 설계내역서가 대가 호표에 이음) — 잇기 사본이 먼저 · 없으면 본체 + owner = next((c.copy for c in combos if isinstance(c, _Copy)), self.owner) + owners = column["owners"] + owners[owner] = round(owners.get(owner, 0.0) + value, 3) def result(self) -> dict[str, Any]: columns = sorted(self.columns.values(), key=lambda column: column["_sort"]) @@ -472,7 +495,11 @@ def _drain( return # 필수 컨테이너 잇기 — 정의 그룹에 없는 것(세월교)은 저장된 잇기에서 required = {"box_culvert": BOX_LINKS, "ford_bridge": FORD_LINKS}.get(type_id, PAVE_LINKS) - combos = {part: links.get(link) or _combo(group.get(link)) for part, link in required.items()} + combos = { + part: links.get(link) + or _linked(_combo(group.get(link)), f"{sheet.owner}{GROUP_JOIN}{link}") + for part, link in required.items() + } if type_id == "ford_pavement": _pavement(sheet, at, base, combos, names) return @@ -527,7 +554,7 @@ def pipe_summary( # 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1) combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None if link: - entry["links"][link] = combo + entry["links"][link] = _linked(combo, item["복사본"]) else: entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None} @@ -552,7 +579,9 @@ def pipe_summary( } _drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length) body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"] - body = links.get(body_link) or _combo(group.get(body_link)) + body = links.get(body_link) or _linked( + _combo(group.get(body_link)), f"{owner}{GROUP_JOIN}{body_link}" + ) counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음) no_box += type_id == "box_culvert" and counted and not box_length if type_id == "ford_bridge": diff --git a/resources/tester/test_145_3_b08_summary_owners.py b/resources/tester/test_145_3_b08_summary_owners.py new file mode 100644 index 000000000..6e09df059 --- /dev/null +++ b/resources/tester/test_145_3_b08_summary_owners.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""145-3 이음 — B08 구조물 집계표 열마다 `owners` = {사본 주인 id: 그 주인이 낸 값}. + +주인 id = `linked_snapshots` 키(`pipe@측점` · 구조물 id · `{id}__{그룹}`) · 열 owners 합 = 열 계 · +칸을 낸 잇기 사본이 먼저(없으면 시설 본체). +""" + +from __future__ import annotations + +import pytest +from test_120_b08_drain_summary import out # noqa: F401 — 픽스처(관 밖 횡단배수 견본) +from test_143_1_b08_linked_snapshots import _run as _run_143 +from test_143_1_b08_linked_snapshots import root # noqa: F401 — 픽스처(실제 사본 폴더) + +from M02_MasterTemplete import M02_Item_Snapshot as snapshots + + +def _check_sums(result: dict, keys: set[str]) -> None: + for column in result["columns"]: + owners = column["owners"] + assert set(owners) <= keys, column["id"] + assert sum(owners.values()) == pytest.approx(result["total"].get(column["id"], 0.0)) + + +def test_owners_합_열_계_주인은_사본_키(out): # noqa: F811 + cols = {c["id"]: c for c in out["columns"]} + _check_sums(out, set(out["linked"])) + # 본체 칸 = 본체 잇기 사본(gboxbody) · 날개벽 = 유입 잇기 · 흙막이 = 유출 잇기 + assert cols["pipe:box|body|2.0×2.0"]["owners"] == {"pipe@300.0__gboxbody": 9.6} + assert cols["pipe:box_culvert|wing|inlet"]["owners"] == {"pipe@300.0__gboxin": 1.0} + assert cols["pipe:revet|돌쌓기(메)|2.0"]["owners"] == {"pipe@300.0__gboxout": 10.0} + # 세월교 월류 폭 = 저장된 잇기(gford) · 관 공은 본체(사본 키 = 관 지점 id) + assert cols["pipe:ford|1000|2|width"]["owners"] == {"pipe@400.0__gford": 12.0} + assert cols["pipe:len|800"]["owners"] == {"pipe@300.0": 18.0} + # B05 구조물 = 구조물 id · 그룹 잇기 = {id}__{그룹} + assert cols["pipe:open_ditch"]["owners"] == {"s-ditch": 1.0} + assert cols["pipe:cross_underdrain"]["owners"] == {"s-ud__gcrossud": 1.0} + + +def test_실제_사본_폴더_키와_같다(root): # noqa: F811 + result = _run_143(root) + linked = set(snapshots.linked_snapshots(root)) + _check_sums(result, linked) + cols = {c["id"]: c for c in result["columns"]} + assert cols["pipe:len|800"]["owners"] == {"pipe@100.0": 16.0} + assert cols["pipe:len|1000"]["owners"] == {"pipe@500.0__gford": 18.0}