"""B07 종단도 CAD 조립 — 30측점 N분할 + 그래프 축·격자 + 하단 측점 테이블. 납품 양식: - 그래프 영역: 좌측 Y축(표고 눈금·라벨), 기준선(X축, 최저 표고에서 5m 이상 여유), 측점별 회색 세로선. - 테이블 영역: 측점 세로선 없이 가로 구분선 위 눈금(틱)으로 측점 표현, 값 텍스트는 세로쓰기. 행: 곡선/측점/거리/추가거리/지반고/계획고/절토고/ 성토고/구배. - 곡선행: 종단곡선 BVC·EVC 세로틱 + 수평선 + R/L 표기(브래킷형). - 구배행: 구간 사선(상향/하향) + 가로 "구배% L=길이" + 구배 변화점에 원(내부 세로쓰기 계획고), 노선 시·종점은 반원. """ import math from typing import Any from uuid import uuid5 from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, DESIGN_COLOR, DESIGN_LAYER_ID, DRAWING_FORMAT, FRAME_LAYER_ID, GROUND_COLOR, GROUND_LAYER_ID, LONG_SPLIT_STATION_COUNT, LONG_TABLE_LAYER_ID, TABLE_LABEL_COLOR, TABLE_LINE_COLOR, TABLE_VALUE_COLOR, _design_profile_points, _format, _interpolate, _layer, _line_entity, _profile_alignment, _stations, _text_entity, infer_station_interval, points_from_samples, polyline_entity, station_no_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, frame_entities, scale_fields, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import DRAWING_SCALE_LONG_H, DRAWING_SCALE_LONG_V # 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/1,000 -> 1.0, 1/200 -> 5.0). MM_H = 1000.0 / DRAWING_SCALE_LONG_H MM_V = 1000.0 / DRAWING_SCALE_LONG_V # 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외). LONG_GRID_LAYER_ID = "b08-long-grid" GRID_COLOR = "#5b6572" _VERTICAL = (0.0, 1.0) # 세로쓰기(아래→위) # 테이블 행: (키, 헤더 라벨, 행 높이 m). 값 세로쓰기 행은 높게 잡는다. _ROW_TALL = 9.0 _LONG_TABLE_ROWS: tuple[tuple[str, str, float], ...] = ( ("curve", "곡선", 5.0), ("station", "측점", 7.0), ("distance", "거리", 4.5), ("cum_distance", "추가거리", _ROW_TALL), ("ground", "지반고", _ROW_TALL), ("design", "계획고", _ROW_TALL), ("cut", "절토고", _ROW_TALL), ("fill", "성토고", _ROW_TALL), ("grade", "구배", 10.0), ) _FONT_SIZE = 2.2 _TICK_LEN = 0.8 # 측점 눈금 길이 def _circle_entity( seed: str, center: tuple[float, float], radius: float, layer_id: str, color: str ) -> dict[str, Any]: return { "id": str(uuid5(_ENTITY_NS, seed)), "type": "Circle", "lineColor": color, "lineWidth": 1, "layerId": layer_id, "shapeData": {"center": {"x": center[0], "y": center[1]}, "radius": radius}, } def _arc_entity( seed: str, center: tuple[float, float], radius: float, start_angle: float, end_angle: float, layer_id: str, color: str, ) -> dict[str, Any]: return { "id": str(uuid5(_ENTITY_NS, seed)), "type": "Arc", "lineColor": color, "lineWidth": 1, "layerId": layer_id, "shapeData": { "center": {"x": center[0], "y": center[1]}, "radius": radius, "startAngle": start_angle, "endAngle": end_angle, "counterClockwise": True, }, } def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]: """측점 30개 단위 분할 목록. 각 항목: {id, label, start_m, end_m}. 30개 이하면 단일 도면(id="longitudinal", 기존 manifest 호환). 초과 시 경계 측점 1개를 중복시켜 도면 간 선이 이어지게 한다(납품 도면 관례). """ stations = sorted(_stations(longitudinal), key=lambda s: float(s["chainage_m"])) if not stations: return [{"id": "longitudinal", "label": "종단도", "start_m": None, "end_m": None}] if len(stations) <= LONG_SPLIT_STATION_COUNT: return [ { "id": "longitudinal", "label": "종단도", "start_m": float(stations[0]["chainage_m"]), "end_m": float(stations[-1]["chainage_m"]), } ] chunks: list[dict[str, Any]] = [] stride = LONG_SPLIT_STATION_COUNT - 1 # 경계 1측점 중복 index = 0 number = 1 while index < len(stations) - 1: chunk_stations = stations[index : index + LONG_SPLIT_STATION_COUNT] chunks.append( { "id": f"longitudinal_{number}", "label": f"종단도({number})", "start_m": float(chunk_stations[0]["chainage_m"]), "end_m": float(chunk_stations[-1]["chainage_m"]), } ) index += stride number += 1 return chunks def _long_table_values( stations: list[dict[str, Any]], all_stations: list[dict[str, Any]], ground_points: list[tuple[float, float]], longitudinal: dict[str, Any], interval_m: float, ) -> list[dict[str, str]]: """청크 측점별 테이블 셀 문자열. 거리는 노선 전체 기준 직전 측점과의 차.""" chainage_all = sorted(float(s["chainage_m"]) for s in all_stations) rows: list[dict[str, str]] = [] for station in stations: chainage = float(station["chainage_m"]) ground = station.get("center_z") ground = ( float(ground) if isinstance(ground, (int, float)) else _interpolate(ground_points, chainage) ) design = design_elevation_from_longitudinal(longitudinal, chainage) cut = max(ground - design, 0.0) if ground is not None and design is not None else None fill = max(design - ground, 0.0) if ground is not None and design is not None else None position = chainage_all.index(chainage) if chainage in chainage_all else -1 distance = chainage - chainage_all[position - 1] if position > 0 else None rows.append( { "station": station_no_label(chainage, interval_m), "distance": _format(distance, 1), "cum_distance": _format(chainage, 1), "ground": _format(ground), "design": _format(design), "cut": _format(cut) if cut else "", "fill": _format(fill) if fill else "", } ) return rows def _graph_grid_entities( drawing_id: str, chainages: list[float], x0: float, x1: float, datum_m: float, top_m: float, ) -> list[dict[str, Any]]: """그래프 영역: 기준선(X축)·Y축(표고 눈금/라벨)·측점 회색 세로선. x는 종이 mm, datum_m·top_m은 실제 표고(m)로 받는다 — 눈금 라벨이 표고라서 m으로 돌고 좌표만 MM_V를 곱한다. """ # 기준선을 종이 y=0으로 두고 표고 차이만 MM_V로 올린다. datum_y = 0.0 top_y = (top_m - datum_m) * MM_V entities: list[dict[str, Any]] = [ # 기준선(X축) _line_entity( f"{drawing_id}:lgx", (x0, datum_y), (x1, datum_y), LONG_GRID_LAYER_ID, GRID_COLOR ), # Y축 _line_entity( f"{drawing_id}:lgy", (x0, datum_y), (x0, top_y), LONG_GRID_LAYER_ID, GRID_COLOR ), ] # Y축 표고 눈금·라벨 (5m 간격, 가로쓰기) level_m = datum_m tick_index = 0 while level_m <= top_m + 1e-6: level = (level_m - datum_m) * MM_V entities.append( _line_entity( f"{drawing_id}:lgyt{tick_index}", (x0 - 1.0, level), (x0, level), LONG_GRID_LAYER_ID, GRID_COLOR, ) ) entities.append( _text_entity( f"{drawing_id}:lgyl{tick_index}", _format(level_m), x0 - 1.6, level, LONG_GRID_LAYER_ID, _FONT_SIZE, TABLE_LABEL_COLOR, align="right", ) ) level_m += 5.0 tick_index += 1 # 측점별 회색 세로선 (기준선 → 그래프 상단) for index, x in enumerate(chainages): entities.append( _line_entity( f"{drawing_id}:lgv{index}", (x, datum_y), (x, top_y), LONG_GRID_LAYER_ID, GRID_COLOR, ) ) return entities def _curve_row_entities( drawing_id: str, longitudinal: dict[str, Any], x0: float, x1: float, y_top: float, row_height: float, ) -> list[dict[str, Any]]: """곡선행: 종단곡선(BVC~EVC) 세로틱+수평선+R/L 표기 (브래킷형).""" entities: list[dict[str, Any]] = [] curves = _profile_alignment(longitudinal).get("curves") if not isinstance(curves, list): return entities y_line = y_top - row_height * 0.62 y_text = y_top - row_height * 0.30 tick = row_height * 0.24 for index, curve in enumerate(curves): if not isinstance(curve, dict) or curve.get("omitted"): continue bvc = curve.get("bvc_m") evc = curve.get("evc_m") if not isinstance(bvc, (int, float)) or not isinstance(evc, (int, float)): continue start = max(float(bvc) * MM_H, x0) end = min(float(evc) * MM_H, x1) if end <= start: continue seed = f"{drawing_id}:lcurve:{index}" entities.append( _line_entity( f"{seed}:l", (start, y_line), (end, y_line), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR ) ) entities.append( _line_entity( f"{seed}:t0", (start, y_line - tick), (start, y_line + tick), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR, ) ) entities.append( _line_entity( f"{seed}:t1", (end, y_line - tick), (end, y_line + tick), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR, ) ) r_m = curve.get("r_m") l_m = curve.get("l_m") parts = [] if isinstance(r_m, (int, float)): parts.append(f"R={r_m:g}") if isinstance(l_m, (int, float)): parts.append(f"L={l_m:g}") if parts: entities.append( _text_entity( f"{seed}:txt", " ".join(parts), (start + end) / 2.0, y_text, LONG_TABLE_LAYER_ID, _FONT_SIZE * 0.9, TABLE_VALUE_COLOR, ) ) return entities def _grade_break_points( segments: list[Any], x0: float, x1: float, route_start: float, route_end: float ) -> list[tuple[float, bool]]: """구배 변화점 목록 [(chainage, 노선 시·종점 여부)] — 청크 범위 내만.""" boundaries: set[float] = set() for segment in segments: if not isinstance(segment, dict): continue for key in ("from_m", "to_m"): value = segment.get(key) if isinstance(value, (int, float)) and x0 - 1e-6 <= float(value) <= x1 + 1e-6: boundaries.add(round(float(value), 4)) return [ (chainage, abs(chainage - route_start) < 1e-3 or abs(chainage - route_end) < 1e-3) for chainage in sorted(boundaries) ] def _grade_row_entities( drawing_id: str, longitudinal: dict[str, Any], stations: list[dict[str, Any]], x0: float, x1: float, y_top: float, row_height: float, route_start: float, route_end: float, ) -> list[dict[str, Any]]: """구배행: 구간 사선 + 가로 구배 표기 + 변화점 원(내부 세로쓰기 계획고). 노선 시·종점은 반원(상반원)으로 표기한다. profile_alignment.segments가 없으면(구 폴백 계획선) 측점 간 계획고 차이 텍스트만 표기한다. """ entities: list[dict[str, Any]] = [] y_mid = y_top - row_height * 0.5 y_high = y_top - row_height * 0.18 y_low = y_top - row_height * 0.82 radius = row_height * 0.40 segments = _profile_alignment(longitudinal).get("segments") if not (isinstance(segments, list) and segments): # 폴백: 측점 간 계획고 차이 기반 구배 텍스트 for index in range(len(stations) - 1): c0 = float(stations[index]["chainage_m"]) c1 = float(stations[index + 1]["chainage_m"]) d0 = design_elevation_from_longitudinal(longitudinal, c0) d1 = design_elevation_from_longitudinal(longitudinal, c1) span = c1 - c0 if d0 is None or d1 is None or span <= 0: continue entities.append( _text_entity( f"{drawing_id}:lgrade:fb{index}", f"{(d1 - d0) / span * 100:.2f}%", (c0 + c1) / 2.0, y_mid, LONG_TABLE_LAYER_ID, _FONT_SIZE * 0.9, TABLE_VALUE_COLOR, ) ) return entities # 구간 사선 + 가로 구배/거리 표기 (원 반경만큼 안쪽으로 클리핑) for index, segment in enumerate(segments): if not isinstance(segment, dict): continue from_m = segment.get("from_m") to_m = segment.get("to_m") grade = segment.get("grade_percent") if not isinstance(from_m, (int, float)) or not isinstance(to_m, (int, float)): continue start = max(float(from_m) * MM_H, x0) + radius end = min(float(to_m) * MM_H, x1) - radius if end <= start: continue seed = f"{drawing_id}:lgrade:{index}" rising = isinstance(grade, (int, float)) and grade >= 0 entities.append( _line_entity( f"{seed}:d", (start, y_low if rising else y_high), (end, y_high if rising else y_low), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR, ) ) if isinstance(grade, (int, float)): length = segment.get("length_m") length_text = f" L={length:g}" if isinstance(length, (int, float)) else "" entities.append( _text_entity( f"{seed}:txt", f"{grade:.2f}%{length_text}", (start + end) / 2.0, y_high + 0.2 if rising else y_low - 0.2, LONG_TABLE_LAYER_ID, _FONT_SIZE * 0.85, TABLE_VALUE_COLOR, ) ) # 구배 변화점: 원(정원) 또는 노선 시·종점 반원 + 내부 세로쓰기 계획고 for index, (chainage, is_route_end) in enumerate( _grade_break_points(segments, x0 / MM_H, x1 / MM_H, route_start, route_end) ): # chainage는 월드 거리 — 계획고 조회에 그대로 쓰고, 좌표에는 MM_H를 곱한다. x_paper = chainage * MM_H seed = f"{drawing_id}:lgradebp:{index}" if is_route_end: entities.append( _arc_entity( seed, (x_paper, y_mid), radius, 0.0, math.pi, LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR, ) ) else: entities.append( _circle_entity( seed, (x_paper, y_mid), radius, LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR ) ) elevation = design_elevation_from_longitudinal(longitudinal, chainage) if elevation is not None: entities.append( _text_entity( f"{seed}:txt", _format(elevation), x_paper, y_mid, LONG_TABLE_LAYER_ID, _FONT_SIZE * 0.8, TABLE_VALUE_COLOR, direction=_VERTICAL, ) ) return entities def _long_table_entities( drawing_id: str, stations: list[dict[str, Any]], values: list[dict[str, str]], longitudinal: dict[str, Any], table_top: float, interval_m: float, route_start: float, route_end: float, ) -> list[dict[str, Any]]: """종단 테이블: 가로 구분선 + 측점 눈금(세로선 없음) + 세로쓰기 값.""" chainages = [float(s["chainage_m"]) * MM_H for s in stations] header_width = max(interval_m * MM_H, 12.0) left = chainages[0] - header_width right = chainages[-1] entities: list[dict[str, Any]] = [] # 행 y 경계 (가변 행 높이) boundaries = [table_top] for _key, _header, height in _LONG_TABLE_ROWS: boundaries.append(boundaries[-1] - height) bottom = boundaries[-1] # 가로 구분선 + 측점 눈금(구분선 아래 짧은 틱) for row_index, y in enumerate(boundaries): entities.append( _line_entity( f"{drawing_id}:ltgrid:h{row_index}", (left, y), (right, y), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR, ) ) if row_index < len(boundaries) - 1: for column_index, x in enumerate(chainages): entities.append( _line_entity( f"{drawing_id}:lttick:{row_index}:{column_index}", (x, y), (x, y - _TICK_LEN), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR, ) ) # 외곽·헤더 구분 세로선 (측점 세로선은 없음) for seed, x in (("v-left", left), ("v-header", chainages[0]), ("v-right", right)): entities.append( _line_entity( f"{drawing_id}:ltgrid:{seed}", (x, table_top), (x, bottom), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR, ) ) for row_index, (key, header, height) in enumerate(_LONG_TABLE_ROWS): y_top = boundaries[row_index] y_mid = y_top - height * 0.5 entities.append( _text_entity( f"{drawing_id}:ltable:header:{key}", header, left + header_width / 2.0, y_mid, LONG_TABLE_LAYER_ID, _FONT_SIZE, TABLE_LABEL_COLOR, ) ) if key == "curve": entities.extend( _curve_row_entities(drawing_id, longitudinal, chainages[0], right, y_top, height) ) continue if key == "grade": entities.extend( _grade_row_entities( drawing_id, longitudinal, stations, chainages[0], right, y_top, height, route_start, route_end, ) ) continue for value_index, row_values in enumerate(values): label = row_values.get(key, "") if not label: continue if key == "distance": # 거리는 직전 측점과의 구간 중앙, 가로쓰기 (청크 첫 측점 제외) if value_index == 0: continue entities.append( _text_entity( f"{drawing_id}:ltable:{key}:{value_index}", label, (chainages[value_index - 1] + chainages[value_index]) / 2.0, y_mid, LONG_TABLE_LAYER_ID, _FONT_SIZE * 0.9, TABLE_VALUE_COLOR, ) ) continue # 측점·추가거리·지반고·계획고·절토고·성토고: 측점 위치 세로쓰기 entities.append( _text_entity( f"{drawing_id}:ltable:{key}:{value_index}", label, chainages[value_index], y_mid, LONG_TABLE_LAYER_ID, _FONT_SIZE, TABLE_VALUE_COLOR, direction=_VERTICAL, ) ) return entities def build_longitudinal_drawing( longitudinal: dict[str, Any], drawing_id: str, chunk: dict[str, Any] ) -> dict[str, Any]: """종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다.""" start_m = chunk.get("start_m") end_m = chunk.get("end_m") def in_range(x: float) -> bool: if start_m is None or end_m is None: return True return start_m - 1e-6 <= x <= end_m + 1e-6 ground_all = points_from_samples(longitudinal.get("samples", []), "chainage_m") ground_points = [point for point in ground_all if in_range(point[0])] design_points = [point for point in _design_profile_points(longitudinal) if in_range(point[0])] all_stations = _stations(longitudinal) stations = sorted( (s for s in all_stations if in_range(float(s["chainage_m"]))), key=lambda s: float(s["chainage_m"]), ) interval_m = infer_station_interval(all_stations) # 좌표는 종이 mm — 거리에 MM_H, 표고는 기준선(datum) 기준으로 MM_V를 곱한다. elevations = [y for _x, y in [*ground_points, *design_points]] min_e = min(elevations) if elevations else 0.0 max_e = max(elevations) if elevations else 10.0 # 기준선(datum): 최저 표고에서 5m 이상 여유를 두고 5m 단위로 내림 → 종이 y=0. datum_m = math.floor((min_e - 5.0) / 5.0) * 5.0 top_m = max_e + 3.0 def paper(point: tuple[float, float]) -> tuple[float, float]: return (point[0] * MM_H, (point[1] - datum_m) * MM_V) entities: list[dict[str, Any]] = [] ground = polyline_entity( drawing_id, [paper(p) for p in ground_points], GROUND_LAYER_ID, GROUND_COLOR ) if ground: entities.append(ground) design = polyline_entity( drawing_id, [paper(p) for p in design_points], DESIGN_LAYER_ID, DESIGN_COLOR ) if design: entities.append(design) if stations: chainages = [float(s["chainage_m"]) * MM_H for s in stations] entities.extend( _graph_grid_entities(drawing_id, chainages, chainages[0], chainages[-1], datum_m, top_m) ) values = _long_table_values(stations, all_stations, ground_all, longitudinal, interval_m) all_chainages = sorted(float(s["chainage_m"]) for s in all_stations) table_top = -3.0 # 그래프-테이블 영역 분리 간격(mm, 기준선 y=0 아래) entities.extend( _long_table_entities( drawing_id, stations, values, longitudinal, table_top, interval_m, all_chainages[0], all_chainages[-1], ) ) # A1 도각: 콘텐츠가 이미 종이 mm라 도각도 실치수(1:1)로 두고 위치만 맞춘다. bbox = entities_bbox(entities) if bbox: entities.extend( frame_entities( drawing_id, bbox, fit=False, fields={ "도면명": "종단면도", **scale_fields(("H", DRAWING_SCALE_LONG_H), ("V", DRAWING_SCALE_LONG_V)), }, ) ) return { "format": DRAWING_FORMAT, "entities": entities, "layers": [ _layer(GROUND_LAYER_ID, "원지반", locked=True), _layer(DESIGN_LAYER_ID, "계획선"), _layer(LONG_GRID_LAYER_ID, "그래프 격자", locked=True), _layer(LONG_TABLE_LAYER_ID, "측점표"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], }