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"]