"""B07 CAD 도면 조립 엔진 — B06/B05 산출물을 openwebcad 엔티티로 직렬화한다. 납품 도면 양식 복제: - 종단도: 30측점 N분할 + 하단 측점 테이블(곡선/측점/거리/추가거리/지반고/ 계획고/절토고/성토고/구배). 곡선·구배 행은 B05 profile_alignment의 종단곡선(curves)·구배(segments) 기하를 그대로 표기한다. - 횡단도: 지표/설계/구조물 레이어 분리 + 하단 수량 산출표(No.행, 지반고~ 절토고 헤더행, 깍기·측구·쌓기·층따기 / 면고르기·지장목제거·표토제거 / 편책·성토파종·절토살포·제근·노면다짐 3그룹 병합 셀 그리드). 모든 값 텍스트는 잠금 해제 레이어의 Text 엔티티라 CAD에서 직접 수정할 수 있고, id가 결정적(uuid5)이라 확정 시 도면 JSON에서 수량표 값을 역추출한다. 좌표 규약: 종단도 x=chainage_m, 횡단도 x=offset_m(+좌/-우), y=elevation_m. """ from typing import Any from uuid import UUID, uuid5 from config.config_system import DRAWING_SCALE_CROSS # 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(N-1-3). GROUND_LAYER_ID = "b08-ground" GROUND_COLOR = "#f5f7fa" DESIGN_LAYER_ID = "b08-design" DESIGN_COLOR = "#b794f6" STRUCTURE_LAYER_ID = "b08-structure" STRUCTURE_COLOR = "#f6d55c" ROCK_LAYER_ID = "b08-rock-boundary" ROCK_COLOR = "#f59e0b" FRAME_LAYER_ID = "b08-frame" LONG_TABLE_LAYER_ID = "b08-long-table" CROSS_TABLE_LAYER_ID = "b08-cross-table" TABLE_LINE_COLOR = "#8ea0b5" TABLE_LABEL_COLOR = "#e8edf4" TABLE_VALUE_COLOR = "#ffe066" # 종단도 분할 기준: 측점 30개 초과 시 30개 단위(경계 1측점 중복)로 나눈다. LONG_SPLIT_STATION_COUNT = 30 _ENTITY_NS = UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96") _POLY_NS = UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8") # 도면 직렬화 포맷 버전. 테이블·레이어 구성 변경 시 올린다 — 확정 저장본이 # 이 버전과 다르면 캐시를 버리고 원본에서 재생성한다(구양식 서빙 방지). # v3: 횡단 로컬좌표(계획고=0) 정규화 + 공통 프레임 + 암 경계선 레이어. # v4: 종단 그래프 축·회색 측점 세로선·기준선(datum) + 테이블 눈금·세로쓰기·구배 원 표기. # v5: 횡단 콘텐츠 bbox 중심 정렬(경사 드리프트 제거) + 외곽 테두리 제거. # v6: 외곽 테두리 복구 (콘텐츠 중심 정렬 유지, 노선 공통 크기 사각형). # v7: 종단도 A1 도각 템플릿 프레임 병합 (b08-frame 잠금 레이어). DRAWING_FORMAT = 7 # --------------------------------------------------------------------------- # 엔티티 직렬화 헬퍼 # --------------------------------------------------------------------------- def _line_entity( seed: str, start: tuple[float, float], end: tuple[float, float], layer_id: str, color: str, width: int = 1, ) -> dict[str, Any]: return { "id": str(uuid5(_ENTITY_NS, seed)), "type": "Line", "lineColor": color, "lineWidth": width, "layerId": layer_id, "shapeData": { "startPoint": {"x": start[0], "y": start[1]}, "endPoint": {"x": end[0], "y": end[1]}, }, } def polyline_entity( drawing_id: str, points: list[tuple[float, float]], layer_id: str, color: str, suffix: str = "", dash: list[int] | None = None, width: int = 1, ) -> dict[str, Any] | None: """점열을 openwebcad PolyLine(자식 Line 묶음)으로 직렬화한다 (점 2개 미만이면 None).""" if len(points) < 2: return None seed_base = f"{drawing_id}:{layer_id}{suffix}" children = [] for index in range(len(points) - 1): child = _line_entity( f"{seed_base}:{index}", points[index], points[index + 1], layer_id, color, width ) if dash: child["lineDash"] = dash children.append(child) poly: dict[str, Any] = { "id": str(uuid5(_POLY_NS, seed_base)), "type": "PolyLine", "lineColor": color, "lineWidth": 1, "layerId": layer_id, "shapeData": None, "children": children, } if dash: poly["lineDash"] = dash return poly def _text_entity( seed: str, label: str, x: float, y: float, layer_id: str, font_size: float, color: str, align: str = "center", direction: tuple[float, float] = (1.0, 0.0), ) -> dict[str, Any]: """direction=(0,1)이면 세로쓰기(아래→위) — 납품 종단 테이블 값 표기.""" return { "id": str(uuid5(_ENTITY_NS, seed)), "type": "Text", "lineColor": color, "lineWidth": 1, "layerId": layer_id, "shapeData": { "label": label, "basePoint": {"x": x, "y": y}, "options": { "textDirection": {"x": direction[0], "y": direction[1]}, "textAlign": align, "textColor": color, "fontSize": font_size, "fontFamily": "sans-serif", }, }, } def table_entity( seed: str, origin: tuple[float, float], column_widths: list[float], row_heights: list[float], cells: list[list[dict[str, Any] | None]], layer_id: str, color: str, font_size: float, text_color: str, padding: float = 1.0, ) -> dict[str, Any]: """표 하나를 openwebcad Table 엔티티로 직렬화한다. origin은 표의 좌측 상단이다. cells[r][c]는 칸 하나이고 None은 병합에 먹힌 자리다. 칸은 {"text", "colSpan", "rowSpan", "align", "color", "fontSize", "bold", "italic"}를 갖는다. 격자선은 담지 않는다 — 병합 자리에서 선을 끊는 규칙은 표가 스스로 안다. """ return { "id": str(uuid5(_ENTITY_NS, seed)), "type": "Table", "lineColor": color, "lineWidth": 1, "layerId": layer_id, "shapeData": { "origin": {"x": origin[0], "y": origin[1]}, "columnWidths": list(column_widths), "rowHeights": list(row_heights), "cells": cells, "style": { "fontSize": font_size, "fontFamily": "sans-serif", "textColor": text_color, "padding": padding, }, }, } def _layer(layer_id: str, name: str, locked: bool = False) -> dict[str, Any]: return {"id": layer_id, "name": name, "isVisible": True, "isLocked": locked} # --------------------------------------------------------------------------- # 공용 데이터 헬퍼 # --------------------------------------------------------------------------- def points_from_samples(samples: list[Any], x_key: str) -> list[tuple[float, float]]: """유효 샘플에서 (x, elevation) 점열을 뽑는다 (x_key: chainage_m 또는 offset_m).""" points: list[tuple[float, float]] = [] for sample in samples: if not isinstance(sample, dict) or not sample.get("valid", False): continue x = sample.get(x_key) y = sample.get("elevation_m", sample.get("z")) if isinstance(x, (int, float)) and isinstance(y, (int, float)): points.append((float(x), float(y))) return points def _design_profile_points(longitudinal: dict[str, Any]) -> list[tuple[float, float]]: profiles = longitudinal.get("design_profiles") if not isinstance(profiles, list) or not profiles: return [] points: list[tuple[float, float]] = [] for point in profiles[0].get("samples", []): if not isinstance(point, dict): continue x = point.get("chainage_m") y = point.get("elevation_m") if isinstance(x, (int, float)) and isinstance(y, (int, float)): points.append((float(x), float(y))) return points def _interpolate(points: list[tuple[float, float]], x: float) -> float | None: """정렬 점열의 선형 보간(범위 밖 끝값 클램프). 점이 없으면 None.""" if not points: return None if x <= points[0][0]: return points[0][1] if x >= points[-1][0]: return points[-1][1] for index in range(1, len(points)): x1, y1 = points[index] if x > x1: continue x0, y0 = points[index - 1] span = x1 - x0 if span <= 0: return y1 return y0 + (y1 - y0) * (x - x0) / span return points[-1][1] def _stations(longitudinal: dict[str, Any]) -> list[dict[str, Any]]: stations = longitudinal.get("stations") if not isinstance(stations, list): return [] return [ station for station in stations if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float)) ] def infer_station_interval(stations: list[dict[str, Any]]) -> float: """연속 chainage 차이의 최빈값으로 측점 간격을 추정한다 (No. 표기·칸 폭 기준).""" counts: dict[float, int] = {} chainages = sorted( float(s["chainage_m"]) for s in stations if isinstance(s, dict) and isinstance(s.get("chainage_m"), (int, float)) ) for index in range(1, len(chainages)): difference = round(chainages[index] - chainages[index - 1], 1) if difference > 0: counts[difference] = counts.get(difference, 0) + 1 if not counts: return 20.0 return max(counts.items(), key=lambda pair: (pair[1], pair[0]))[0] def station_plus_label(chainage_m: float, interval_m: float, decimals: int = 1) -> str: """납품 도면 측점 표기 — `120+ 0.0` (M.N 은 소수 2자리 `0+16.90`). 종전에는 `No.120` 이었다. 납품 도면과 표기를 맞춘다(2026-09-03 사용자 확정). """ safe = interval_m if interval_m > 0 else 1.0 number = int((chainage_m + 1e-6) // safe) remainder = chainage_m - number * safe if remainder >= safe - 0.05: number += 1 remainder = 0.0 gap = " " if decimals == 1 else "" return f"{number}+{gap}{remainder:.{decimals}f}" def station_no_label(chainage_m: float, interval_m: float) -> str: """납품 도면 측점 표기: No.n (비정규 측점은 No.n+잔여거리).""" safe = interval_m if interval_m > 0 else 1.0 number = int((chainage_m + 1e-6) // safe) remainder = chainage_m - number * safe if remainder >= safe - 0.05: number += 1 remainder = 0.0 if abs(remainder) < 0.05: return f"No.{number}" return f"No.{number}+{remainder:.1f}" def _profile_alignment(longitudinal: dict[str, Any]) -> dict[str, Any]: alignment = longitudinal.get("profile_alignment") return alignment if isinstance(alignment, dict) else {} def _format(value: float | None, decimals: int = 2) -> str: return "" if value is None else f"{value:.{decimals}f}" # --------------------------------------------------------------------------- # 횡단도: 레이어 분리 + 수량 산출표 (납품 양식) # --------------------------------------------------------------------------- def _ditch_bounds(design: dict[str, Any] | None) -> tuple[float, float] | None: """설계 dict에서 측구(구조물) 오프셋 구간 [lo, hi]를 구한다. 없으면 None.""" if not isinstance(design, dict) or not design.get("ditch_enabled"): return None ditch = design.get("ditch") edges = design.get("road_edges") side = design.get("ditch_side") if not isinstance(ditch, dict) or not isinstance(edges, dict) or side not in ("left", "right"): return None ditch_type = ditch.get("type") if ditch_type == "standard": width = ditch.get("top_width_m") elif ditch_type == "l_type": width = ditch.get("width_m") else: return None edge = edges.get(side) if not isinstance(edge, dict) or not isinstance(width, (int, float)): return None inner = edge.get("offset_m") if not isinstance(inner, (int, float)): return None outer = float(inner) + (float(width) if side == "left" else -float(width)) return (min(float(inner), outer), max(float(inner), outer)) def _cross_line_entities( drawing_id: str, design_line: list[Any] | None, design: dict[str, Any] | None, dy: float = 0.0, paper: Any = None, window: tuple[float, float] | None = None, ) -> list[dict[str, Any]]: """횡단 설계선을 설계(b07-design)/구조물(b07-structure, 측구 구간)로 분리한다. paper가 오면 (실좌표 m) -> (종이 mm) 변환을 거쳐 좌표를 만들고, window가 오면 그 가로 범위로 자른다. dy는 paper 안에서 처리하므로 여기서 빼지 않는다. """ points: list[tuple[float, float]] = [] for point in design_line if isinstance(design_line, list) else []: if not isinstance(point, dict): continue x = point.get("offset_m") y = point.get("elevation_m") if isinstance(x, (int, float)) and isinstance(y, (int, float)): points.append((float(x), float(y) if paper else float(y) - dy)) if len(points) < 2: return [] if window is not None: points = _clip_polyline(points, window[0], window[1]) if len(points) < 2: return [] def place(part: list[tuple[float, float]]) -> list[tuple[float, float]]: return [paper(p) for p in part] if paper else part bounds = _ditch_bounds(design) entities: list[dict[str, Any]] = [] if bounds is None: design_poly = polyline_entity(drawing_id, place(points), DESIGN_LAYER_ID, DESIGN_COLOR) return [design_poly] if design_poly else [] lo, hi = bounds tolerance = 1e-6 before = [p for p in points if p[0] <= lo + tolerance] ditch_part = [p for p in points if lo - tolerance <= p[0] <= hi + tolerance] after = [p for p in points if p[0] >= hi - tolerance] for suffix, part in ((":a", before), (":b", after)): poly = polyline_entity(drawing_id, place(part), DESIGN_LAYER_ID, DESIGN_COLOR, suffix) if poly: entities.append(poly) structure = polyline_entity(drawing_id, place(ditch_part), STRUCTURE_LAYER_ID, STRUCTURE_COLOR) if structure: entities.append(structure) return entities # 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/100 -> 10.0). CROSS_MM = 1000.0 / DRAWING_SCALE_CROSS # 단면을 그리는 가로 범위: 설계선(절·성토)·구조물이 원지반과 갈라지는 구간 + 여유(m). CROSS_SECTION_MARGIN_M = 1.5 # 원지반과 "만난다"고 볼 표고 차이(m) — 이보다 작으면 붙어 있는 것으로 본다. _TOUCH_TOLERANCE_M = 0.02 def _section_window( ground: list[tuple[float, float]], design: list[tuple[float, float]], margin_m: float = CROSS_SECTION_MARGIN_M, ) -> tuple[float, float]: """설계선이 원지반에서 갈라졌다가 다시 만나는 가로 범위(+여유)를 구한다. B06 설계선은 절·성토 구간 바깥에서 원지반을 그대로 따라가므로, 표고가 갈라지는 구간이 곧 "그려야 할 단면"이다 (2026-08-29 사용자 기준). """ left = right = None for x, y in design: ground_y = _interpolate(ground, x) if ground_y is None or abs(y - ground_y) <= _TOUCH_TOLERANCE_M: continue left = x if left is None else min(left, x) right = x if right is None else max(right, x) if left is None or right is None: xs = [x for x, _y in ground] or [-6.0, 6.0] left, right = min(xs), max(xs) return (left - margin_m, right + margin_m) def _clip_polyline( points: list[tuple[float, float]], x0: float, x1: float ) -> list[tuple[float, float]]: """폴리라인을 [x0, x1]로 자른다 — 경계는 선형보간으로 새 점을 만든다.""" if not points: return [] clipped: list[tuple[float, float]] = [] for index, (x, y) in enumerate(points): if index > 0: px, py = points[index - 1] for edge in (x0, x1): if (px < edge < x) or (x < edge < px): span = x - px ratio = (edge - px) / span if span else 0.0 clipped.append((edge, py + (y - py) * ratio)) if x0 <= x <= x1: clipped.append((x, y)) return clipped def build_cross_drawing( source: dict[str, Any], drawing_id: str, design_line: list[Any] | None, design: dict[str, Any] | None, quantity_table: dict[str, float | None] | None, title_label: str, design_elevation_m: float | None = None, frame: dict[str, float] | None = None, origin: tuple[float, float] = (0.0, 0.0), cell_frame: tuple[float, float, float, float] | None = None, ) -> dict[str, Any]: """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. 좌표는 종이 밀리미터(1/100 — 실거리 1 m = 10 mm)이고, 그리는 가로 범위는 설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의 bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. cell_frame(왼쪽, 아래, 오른쪽, 위 — 종이 mm)을 주면 테두리를 그 칸에 맞춰 그린다. 장 배치에서 한 장 안의 칸을 같은 크기로 통일할 때 쓴다(2026-09-04 사용자 확정 — 축척 1/100은 그대로, 칸만 통일). """ ox, oy = origin raw_ground = points_from_samples(source.get("samples", []), "offset_m") raw_design: list[tuple[float, float]] = [] for point in design_line if isinstance(design_line, list) else []: if not isinstance(point, dict): continue x = point.get("offset_m") y = point.get("elevation_m") if isinstance(x, (int, float)) and isinstance(y, (int, float)): raw_design.append((float(x), float(y))) rock_offset = design.get("rock_boundary_offset_m") if isinstance(design, dict) else None # 표 모듈은 이 모듈의 직렬화 헬퍼를 쓰므로 순환을 피해 함수 안에서 들여온다. from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( _cross_table_entities, cross_table_height, cross_table_width, ) # 그릴 가로 범위: 설계선이 원지반과 갈라지는 구간 + 여유. x0, x1 = _section_window(raw_ground, raw_design) ground_clipped = _clip_polyline(raw_ground, x0, x1) # 세로 정렬: 이 범위 안의 선(지표+설계+암 경계) bbox 중심을 0으로. all_ys = [y for _x, y in ground_clipped] + [y for x, y in raw_design if x0 <= x <= x1] if isinstance(rock_offset, (int, float)): all_ys.extend(y + float(rock_offset) for _x, y in ground_clipped) dy = (min(all_ys) + max(all_ys)) / 2.0 if all_ys else 0.0 own_half_height = (max(all_ys) - min(all_ys)) / 2.0 if all_ys else 5.0 def paper(point: tuple[float, float]) -> tuple[float, float]: return (point[0] * CROSS_MM + ox, (point[1] - dy) * CROSS_MM + oy) entities: list[dict[str, Any]] = [] ground = polyline_entity( drawing_id, [paper(p) for p in ground_clipped], GROUND_LAYER_ID, GROUND_COLOR ) if ground: entities.append(ground) entities.extend(_cross_line_entities(drawing_id, design_line, design, dy, paper, (x0, x1))) # 암 경계선: 지반선 복사 + 오프셋(음수=하향). 암 지반 지정 측점에만 존재. if isinstance(rock_offset, (int, float)) and ground_clipped: rock_points = [paper((x, y + float(rock_offset))) for x, y in ground_clipped] rock = polyline_entity(drawing_id, rock_points, ROCK_LAYER_ID, ROCK_COLOR, dash=[6, 4]) if rock: entities.append(rock) # 표는 단면 중심 아래에 붙인다 (좌표는 mm). 그리는 창이 좌우 비대칭이면 # 단면 중심이 원점과 어긋나므로 표도 같은 중심을 쓴다. center_x = ox + (x0 + x1) / 2.0 * CROSS_MM half_height = own_half_height * CROSS_MM + 5.0 table_bottom = oy - half_height if quantity_table is not None: table_top = oy - half_height - 8.0 entities.extend( _cross_table_entities(drawing_id, quantity_table, table_top, title_label, center_x) ) table_bottom = table_top - cross_table_height() # 외곽 테두리: 단면 범위와 표를 함께 감싼다. 칸 크기를 받았으면 그 칸에 맞춘다. if cell_frame is not None: frame_left, frame_bottom, frame_right, frame_top = cell_frame else: frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0) frame_left, frame_right = center_x - frame_x, center_x + frame_x frame_top = oy + half_height + 4.0 frame_bottom = table_bottom - 4.0 corners = [ (frame_left, frame_bottom), (frame_right, frame_bottom), (frame_right, frame_top), (frame_left, frame_top), (frame_left, frame_bottom), ] border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR) if border: entities.append(border) return { "format": DRAWING_FORMAT, "entities": entities, # 실좌표(m) -> 종이(mm) 변환값. 프론트가 B06 산식으로 만든 구조물을 같은 자리에 # 얹는 데 쓴다 — x_mm = offset*mm_per_m + ox, y_mm = (elev - dy)*mm_per_m + oy. "cross_placements": [ { "chainage_m": float(source.get("chainage_m", 0.0)), "ox": ox, "oy": oy, "dy": dy, "mm_per_m": CROSS_MM, "x0": x0, "x1": x1, # 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울 # 서버 설계선을 이 안에서만 골라내는 데 쓴다. "frame": [frame_left, frame_bottom, frame_right, frame_top], } ], "layers": [ _layer(GROUND_LAYER_ID, "원지반", locked=True), _layer(DESIGN_LAYER_ID, "계획선"), _layer(STRUCTURE_LAYER_ID, "구조물"), _layer(ROCK_LAYER_ID, "암 경계선"), _layer(CROSS_TABLE_LAYER_ID, "수량 산출표"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], }