"""B07 토적도(유토곡선) CAD 조립 — 확정 종단의 유토곡선 산출물을 도면으로 옮긴다. 납품 양식(2026-08-30 사용자 제시 도면): - 제목 「유 토 곡 선」, 우측 상단 척도 표기(H=1:2,000 / V=1:50,000). - 좌측 세로축(빨강) + 5,000㎥ 눈금·라벨, 누가토량 0 기준선. - 유토곡선(자홍) + 블록 평형선(흰색) + 띠 경계현(노랑). - 띠마다 balloon(장비별 육각/타원/사각) — `장비 / Q= / L= / EA= / RR= / BR=`. (납품 도면은 장비명 뒤에 숫자가 붙지만 정체 미확인이라 적지 않는다.) - 사토·토취 balloon — `사토 번호 / Q= / M.N= / EA= / RR= / BR=`. - 하단 2행 테이블: 누가토량(세로쓰기) / 측점(No. 표기). 계산은 하지 않는다. 값은 B06 확정 시 저장한 `longitudinal_sections.data.mass_haul` (정의처 `common_util_mass_haul.massHaulPayload`)을 그대로 읽어 좌표만 종이 mm로 바꾼다 — 곡선 보간·토량 배분 로직을 파이썬에 복제하지 않는다. 좌표 규약: x = 누가거리(m) x MM_H, y = 누가토량(㎥) / 종이 1mm당 토량. """ import math from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, FRAME_LAYER_ID, TABLE_LABEL_COLOR, TABLE_LINE_COLOR, TABLE_VALUE_COLOR, _format, _layer, _line_entity, _stations, _text_entity, infer_station_interval, polyline_entity, station_no_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, frame_entities from config.config_system import ( DRAWING_SCALE_MASSHAUL_H, DRAWING_SCALE_MASSHAUL_V_M3_MM, ) # 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/2,000 -> 0.5). MM_H = 1000.0 / DRAWING_SCALE_MASSHAUL_H # 토량 1㎥가 종이에서 차지하는 mm (1 mm = 50㎥ -> 0.02). MM_V = 1.0 / DRAWING_SCALE_MASSHAUL_V_M3_MM CURVE_LAYER_ID = "b08-masshaul-curve" CURVE_COLOR = "#ff66ff" AXIS_LAYER_ID = "b08-masshaul-axis" AXIS_COLOR = "#ff4d4d" BAND_LAYER_ID = "b08-masshaul-band" BAND_COLOR = "#ffe066" BALANCE_COLOR = "#e8edf4" TABLE_LAYER_ID = "b08-masshaul-table" # 세로축 눈금 간격(㎥) — 5,000㎥ = 종이 100 mm. AXIS_TICK_M3 = 5000.0 _AXIS_TICK_LEN = 1.2 _FONT_SIZE = 2.2 _TITLE_FONT_SIZE = 7.0 _VERTICAL = (0.0, 1.0) # balloon 치수(mm) — 납품 도면 balloon은 6줄 세로 표기다. _BALLOON_LINE_H = 3.0 _BALLOON_PAD_X = 2.0 _BALLOON_PAD_Y = 1.5 _BALLOON_CHAR_W = 1.35 # 글자 1개가 차지하는 가로 폭 추정(mm) _BALLOON_GAP = 10.0 # 곡선과 balloon 사이 최소 틈(mm) _BALLOON_STAGGER = 3 # 겹침 회피용 층 수 # 장비 키 → 도면 표기(실무 수량산출 용어: 무대·도자·덤프). EQUIPMENT_LABEL = { "free_haul": "무대", "dozer": "도자", "dump_truck": "덤프", } # 하단 테이블 행: (키, 헤더 라벨, 행 높이 mm). _TABLE_ROWS: tuple[tuple[str, str, float], ...] = ( ("cumulative", "누가토량", 12.0), ("station", "측점", 7.0), ) _TABLE_LABEL_WIDTH = 16.0 # 좌측 행 이름 칸 폭(mm) _TABLE_TOP_GAP = 6.0 # 그래프 최저점과 테이블 사이 간격(mm) def _number(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback def _curve_points(mass_haul: dict[str, Any]) -> list[tuple[float, float]]: """유토곡선 점열 (누가거리 m, 누가토량 ㎥). 측점 사이는 직선이다(2026-08-02 확정).""" points = mass_haul.get("points") if not isinstance(points, list): return [] return [ (_number(point.get("chainage_m")), _number(point.get("cumulative_volume_m3"))) for point in points if isinstance(point, dict) and isinstance(point.get("chainage_m"), (int, float)) ] def _paper(x_m: float, volume_m3: float) -> tuple[float, float]: return (x_m * MM_H, volume_m3 * MM_V) def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float) -> float: """구간 안 곡선의 최고 누가토량(㎥). 구간에 점이 없으면 양 끝 값을 쓴다.""" inside = [v for x, v in curve if from_m - 1e-6 <= x <= to_m + 1e-6] if inside: return max(inside) return max((v for _x, v in curve), default=0.0) def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> list[dict[str, Any]]: """좌측 세로축(빨강)·눈금·라벨 + 누가토량 0 기준선.""" entities: list[dict[str, Any]] = [] top = _paper(0.0, max_v)[1] bottom = _paper(0.0, min_v)[1] entities.append( _line_entity(f"{drawing_id}:axis:v", (x0, bottom), (x0, top), AXIS_LAYER_ID, AXIS_COLOR) ) start = int(min_v // AXIS_TICK_M3) * AXIS_TICK_M3 value = start while value <= max_v + 1e-6: y = _paper(0.0, value)[1] entities.append( _line_entity( f"{drawing_id}:axis:tick:{value:.0f}", (x0 - _AXIS_TICK_LEN, y), (x0, y), AXIS_LAYER_ID, AXIS_COLOR, ) ) entities.append( _text_entity( f"{drawing_id}:axis:label:{value:.0f}", _format(value, 2), x0 - _AXIS_TICK_LEN - 0.8, y, AXIS_LAYER_ID, _FONT_SIZE, AXIS_COLOR, align="right", ) ) value += AXIS_TICK_M3 return entities def _balloon_entities( drawing_id: str, seed: str, lines: list[str], anchor: tuple[float, float], center: tuple[float, float], shape: str, ) -> list[dict[str, Any]]: """balloon 도형 + 지시선 + 6줄 문자. shape = hexagon | ellipse | rect.""" width = max(len(line) for line in lines) * _BALLOON_CHAR_W + 2 * _BALLOON_PAD_X height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y cx, cy = center half_w, half_h = width / 2.0, height / 2.0 if shape == "hexagon": notch = min(2.5, half_w / 2.0) outline = [ (cx - half_w, cy), (cx - half_w + notch, cy + half_h), (cx + half_w - notch, cy + half_h), (cx + half_w, cy), (cx + half_w - notch, cy - half_h), (cx - half_w + notch, cy - half_h), (cx - half_w, cy), ] elif shape == "ellipse": # 타원은 폴리선 근사(16각) — openwebcad Ellipse 없이도 같은 인상이 난다. outline = [ ( cx + half_w * math.cos(2 * math.pi * i / 16), cy + half_h * math.sin(2 * math.pi * i / 16), ) for i in range(17) ] else: outline = [ (cx - half_w, cy - half_h), (cx - half_w, cy + half_h), (cx + half_w, cy + half_h), (cx + half_w, cy - half_h), (cx - half_w, cy - half_h), ] entities: list[dict[str, Any]] = [] shape_entity = polyline_entity( drawing_id, outline, BAND_LAYER_ID, BAND_COLOR, suffix=f":balloon:{seed}" ) if shape_entity: entities.append(shape_entity) # 지시선: balloon 아래(또는 위) 가장자리 → 띠 현 중앙. edge_y = cy - half_h if anchor[1] < cy else cy + half_h entities.append( _line_entity( f"{drawing_id}:balloon:leader:{seed}", (cx, edge_y), anchor, BAND_LAYER_ID, BAND_COLOR, ) ) first_y = cy + half_h - _BALLOON_PAD_Y - _BALLOON_LINE_H * 0.75 for index, line in enumerate(lines): entities.append( _text_entity( f"{drawing_id}:balloon:text:{seed}:{index}", line, cx, first_y - index * _BALLOON_LINE_H, BAND_LAYER_ID, _FONT_SIZE, BAND_COLOR, ) ) return entities def _band_entities( drawing_id: str, plan: dict[str, Any], curve: list[tuple[float, float]] ) -> list[dict[str, Any]]: """블록 평형선·띠 경계현·띠 balloon.""" entities: list[dict[str, Any]] = [] blocks = plan.get("blocks") if not isinstance(blocks, list): return entities slot = 0 for block in blocks: if not isinstance(block, dict): continue from_m = _number(block.get("from_m")) to_m = _number(block.get("to_m")) base_m3 = _number(block.get("base_m3")) entities.append( _line_entity( f"{drawing_id}:block:{block.get('index')}", _paper(from_m, base_m3), _paper(to_m, base_m3), BAND_LAYER_ID, BALANCE_COLOR, ) ) for band in block.get("bands") or []: if not isinstance(band, dict): continue index = band.get("index") level_base = _number(band.get("level_base_m3"), base_m3) level_apex = _number(band.get("level_apex_m3"), level_base) boundary_from = _number(band.get("boundary_from_m"), from_m) boundary_to = _number(band.get("boundary_to_m"), to_m) haul_from = _number(band.get("haul_from_m"), boundary_from) haul_to = _number(band.get("haul_to_m"), boundary_to) entities.append( _line_entity( f"{drawing_id}:band:boundary:{index}", _paper(boundary_from, level_base), _paper(boundary_to, level_base), BAND_LAYER_ID, BAND_COLOR, ) ) mid_level = (level_base + level_apex) / 2.0 entities.append( _line_entity( f"{drawing_id}:band:haul:{index}", _paper(haul_from, mid_level), _paper(haul_to, mid_level), BAND_LAYER_ID, BAND_COLOR, ) ) equipment = str(band.get("equipment") or "") label = EQUIPMENT_LABEL.get(equipment, "운반") # 납품 도면은 장비명 뒤에 숫자가 붙지만(도자 11 등) 그 숫자의 정체가 확인되지 # 않아 적지 않는다 (2026-08-30 사용자 확정 — 확인되면 그때 붙인다). lines = [ label, f"Q={_format(_number(band.get('volume_m3')))}M3", f"L={_format(_number(band.get('haul_distance_m')))}M", f"EA={_format(_number(band.get('ea_m3')))}M3", f"RR={_format(_number(band.get('rr_m3')))}M3", f"BR={_format(_number(band.get('br_m3')))}M3", ] anchor = _paper((haul_from + haul_to) / 2.0, mid_level) top_m3 = _curve_top_at(curve, boundary_from, boundary_to) height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y center_y = ( _paper(0.0, top_m3)[1] + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER) ) entities.extend( _balloon_entities( drawing_id, f"band:{index}", lines, anchor, (anchor[0], center_y), {"free_haul": "hexagon", "dozer": "ellipse"}.get(equipment, "rect"), ) ) slot += 1 return entities def _residual_entities( drawing_id: str, plan: dict[str, Any], curve: list[tuple[float, float]], interval_m: float, ) -> list[dict[str, Any]]: """사토·토취 balloon — 운반거리 대신 발생 측점(M.N)을 적는다.""" entities: list[dict[str, Any]] = [] residuals = plan.get("residuals") if not isinstance(residuals, list): return entities bottom_m3 = min((v for _x, v in curve), default=0.0) for slot, residual in enumerate(residuals): if not isinstance(residual, dict): continue index = residual.get("index", slot + 1) kind = "사토" if residual.get("kind") == "spoil" else "토취" from_m = _number(residual.get("from_m")) to_m = _number(residual.get("to_m"), from_m) level = _number(residual.get("level_from_m3")) station = station_no_label(from_m, interval_m).removeprefix("No.") lines = [ f"{kind} {index}", f"Q={_format(_number(residual.get('volume_m3')))}M3", f"M.N={station}", f"EA={_format(_number(residual.get('ea_m3')))}M3", f"RR={_format(_number(residual.get('rr_m3')))}M3", f"BR={_format(_number(residual.get('br_m3')))}M3", ] anchor = _paper((from_m + to_m) / 2.0, level) height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y center_y = ( _paper(0.0, bottom_m3)[1] - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER) ) entities.extend( _balloon_entities( drawing_id, f"residual:{index}", lines, anchor, (anchor[0], center_y), "rect" ) ) return entities def _table_entities( drawing_id: str, curve: list[tuple[float, float]], stations: list[dict[str, Any]], interval_m: float, top_y: float, ) -> list[dict[str, Any]]: """하단 2행 테이블(누가토량 / 측점). 값은 세로쓰기, 측점은 No. 표기.""" entities: list[dict[str, Any]] = [] if not curve: return entities cumulative = {round(x, 3): v for x, v in curve} xs = [x for x, _v in curve] left = min(xs) * MM_H - _TABLE_LABEL_WIDTH right = max(xs) * MM_H y = top_y boundaries = [y] for _key, _label, height in _TABLE_ROWS: y -= height boundaries.append(y) for index, line_y in enumerate(boundaries): entities.append( _line_entity( f"{drawing_id}:table:h:{index}", (left, line_y), (right, line_y), TABLE_LAYER_ID, TABLE_LINE_COLOR, ) ) for index, x in enumerate((left, left + _TABLE_LABEL_WIDTH, right)): entities.append( _line_entity( f"{drawing_id}:table:v:{index}", (x, boundaries[0]), (x, boundaries[-1]), TABLE_LAYER_ID, TABLE_LINE_COLOR, ) ) for row_index, (key, label, height) in enumerate(_TABLE_ROWS): row_top = boundaries[row_index] row_bottom = boundaries[row_index + 1] center_y = (row_top + row_bottom) / 2.0 entities.append( _text_entity( f"{drawing_id}:table:name:{key}", label, left + _TABLE_LABEL_WIDTH / 2.0, center_y, TABLE_LAYER_ID, _FONT_SIZE, TABLE_LABEL_COLOR, ) ) for station in stations: chainage = _number(station.get("chainage_m")) x = chainage * MM_H if x < left + _TABLE_LABEL_WIDTH or x > right: continue if key == "station": text = station_no_label(chainage, interval_m) y_text = center_y direction = _VERTICAL else: value = cumulative.get(round(chainage, 3)) if value is None: continue text = _format(value) y_text = row_bottom + 0.8 direction = _VERTICAL entities.append( _text_entity( f"{drawing_id}:table:{key}:{chainage:.2f}", text, x, y_text, TABLE_LAYER_ID, _FONT_SIZE, TABLE_VALUE_COLOR, align="left" if direction == _VERTICAL else "center", direction=direction, ) ) return entities def build_mass_haul_drawing( longitudinal: dict[str, Any], mass_haul: dict[str, Any], drawing_id: str ) -> dict[str, Any]: """확정 종단의 유토곡선 산출물을 토적도 한 장으로 만든다.""" curve = _curve_points(mass_haul) if len(curve) < 2: raise FileNotFoundError("확정 종단에 유토곡선 산출물이 없습니다. B06에서 확정하세요.") all_stations = _stations(longitudinal) interval_m = infer_station_interval(all_stations) volumes = [v for _x, v in curve] min_v, max_v = min(volumes), max(volumes) entities: list[dict[str, Any]] = [] x0 = min(x for x, _v in curve) * MM_H entities.extend(_axis_entities(drawing_id, x0, min(min_v, 0.0), max(max_v, 0.0))) entities.append( _line_entity( f"{drawing_id}:axis:zero", (x0, 0.0), (max(x for x, _v in curve) * MM_H, 0.0), AXIS_LAYER_ID, AXIS_COLOR, ) ) curve_entity = polyline_entity( drawing_id, [_paper(x, v) for x, v in curve], CURVE_LAYER_ID, CURVE_COLOR ) if curve_entity: entities.append(curve_entity) plan = mass_haul.get("haul_plan") if isinstance(plan, dict): entities.extend(_band_entities(drawing_id, plan, curve)) entities.extend(_residual_entities(drawing_id, plan, curve, interval_m)) # 테이블은 그래프·balloon 어느 것보다도 아래에 둔다 — 사토·토취 balloon이 곡선 밑에 # 깔리므로 그래프 최저점만 보고 자리를 잡으면 표와 겹친다(2026-08-30 화면 실측). graph_bottom = min(_paper(0.0, min_v)[1], 0.0) drawn = entities_bbox(entities) if drawn: graph_bottom = min(graph_bottom, drawn[1]) entities.extend( _table_entities(drawing_id, curve, all_stations, interval_m, graph_bottom - _TABLE_TOP_GAP) ) bbox = entities_bbox(entities) if bbox: min_x, _min_y, max_x, max_y = bbox entities.append( _text_entity( f"{drawing_id}:title", "유 토 곡 선", (min_x + max_x) / 2.0, max_y + 12.0, AXIS_LAYER_ID, _TITLE_FONT_SIZE, TABLE_LABEL_COLOR, ) ) scale_text = ( f"SCALE H=1:{DRAWING_SCALE_MASSHAUL_H:,} " f"V=1:{int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000):,}" ) entities.append( _text_entity( f"{drawing_id}:scale", scale_text, max_x, max_y + 6.0, AXIS_LAYER_ID, _FONT_SIZE, TABLE_LABEL_COLOR, align="right", ) ) entities.extend( frame_entities( drawing_id, entities_bbox(entities) or bbox, fit=False, fields={"도면명": "토적도"} ) ) return { "format": DRAWING_FORMAT, "entities": entities, "layers": [ _layer(CURVE_LAYER_ID, "유토곡선"), _layer(AXIS_LAYER_ID, "그래프 축", locked=True), _layer(BAND_LAYER_ID, "운반 구간"), _layer(TABLE_LAYER_ID, "측점표"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], }