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