"""B07 횡단면도 장 배치 — A1 한 장에 들어가는 만큼 단면을 담는다. 채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다 (2026-08-29 사용자 확정). 한 장에 몇 개가 들어가는지는 단면 블록의 크기가 정하며, 블록 크기는 작성 척도(1/100)와 "설계선이 원지반과 갈라지는 구간"이 정한다 — 즉 종이가 허용하는 만큼만 담고 남으면 다음 장으로 넘긴다. """ import re from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( CROSS_MM, CROSS_TABLE_LAYER_ID, DESIGN_LAYER_ID, DRAWING_FORMAT, FRAME_LAYER_ID, GROUND_LAYER_ID, ROCK_LAYER_ID, STRUCTURE_LAYER_ID, _clip_polyline, _layer, _section_window, build_cross_drawing, points_from_samples, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( cross_table_height, cross_table_width, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, frame_entities, scale_fields, usable_area, ) from config.config_system import DRAWING_SCALE_CROSS # 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m). # 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다. CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?") # 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. # 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거). _BLOCK_GAP_MM = 0.0 _BORDER_PAD_MM = 4.0 def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]: 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))) return points def section_block_size( source: dict[str, Any], design_line: list[Any] | None, with_table: bool = True ) -> tuple[float, float]: """단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다. build_cross_drawing의 테두리 계산과 같은 규칙을 쓴다: 가로는 그리는 범위와 수량표 중 넓은 쪽, 세로는 단면 높이 + 수량표. """ ground = points_from_samples(source.get("samples", []), "offset_m") design = _design_points(design_line) x0, x1 = _section_window(ground, design) ground_clipped = _clip_polyline(ground, x0, x1) ys = [y for _x, y in ground_clipped] + [y for x, y in design if x0 <= x <= x1] half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM + 5.0 width = 2.0 * max( (x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM, cross_table_width() / 2.0 + _BORDER_PAD_MM, ) # 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유 top = half_height + _BORDER_PAD_MM below = half_height + _BORDER_PAD_MM if with_table: below += 8.0 + cross_table_height() return (width, top + below) def _grid_for(group: list[tuple[int, float, float]]) -> tuple[float, float, int, int]: """한 장에 담을 블록 묶음의 (칸폭, 칸높이, 열수, 행수) — 칸은 그 장 최대 블록 기준.""" usable_w, usable_h = usable_area() cell_w = max(width for _c, width, _h in group) + _BLOCK_GAP_MM cell_h = max(height for _c, _w, height in group) + _BLOCK_GAP_MM return cell_w, cell_h, int(usable_w // cell_w), int(usable_h // cell_h) def _max_take(blocks: list[tuple[int, float, float]], start: int) -> int: """blocks[start:] 를 한 장에 담을 수 있는 최대 개수(칸 통일 기준).""" limit = 0 for take in range(1, len(blocks) - start + 1): _cw, _ch, columns, rows = _grid_for(blocks[start : start + take]) if columns * rows < take: break limit = take return limit def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]: """장 경계를 **전체 최소 장수**가 되도록 고른다 (측점 순서는 유지). 앞에서부터 최대한 채우면 바로 뒤에 큰 단면이 오는 순간 칸이 그 단면 크기로 튀어 그 장이 통째로 비었다(2026-09-04 실측: 6칸짜리 장에 1개만 배치). 단면 크기는 측점마다 원지반 기울기로 달라지므로, 경계를 뒤에서부터 훑어 최소 장수 조합을 고른다 — 큰 단면은 자기 장에 몰리고 비슷한 크기끼리 한 장에 모인다. 같은 장수면 **앞 장을 더 많이 채우는 쪽**을 고른다(뒷장에 여백을 몰아 준다). """ total = len(blocks) best_sheets = [0] * (total + 1) best_take = [0] * (total + 1) for start in range(total - 1, -1, -1): limit = max(_max_take(blocks, start), 1) choice = (total + 1, 0) for take in range(1, limit + 1): candidate = (best_sheets[start + take] + 1, -take) if candidate < choice: choice = candidate best_sheets[start], best_take[start] = choice[0], -choice[1] breaks: list[int] = [] start = 0 while start < total: breaks.append(best_take[start]) start += best_take[start] return breaks def _slots(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[float]]: """칸의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열. 칸이 모두 같은 크기이므로 격자 좌표만 계산하면 된다(2026-08-29 사용자: 채우는 순서는 좌하단부터 열 우선). """ usable_w, usable_h = usable_area() slots: list[list[float]] = [] for index in range(count): column, row = divmod(index, rows) slots.append( [ -usable_w / 2.0 + column * cell_w + cell_w / 2.0, -usable_h / 2.0 + row * cell_h, ] ) return slots def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]: """(측점, 폭, 높이) 목록을 A1 장으로 나눈다. 한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다. 작성 척도는 1/100 고정 — 안 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준). 장에 담기는 측점 수는 세트마다 다르다 (2026-09-04 사용자 확정). """ sheets: list[dict[str, Any]] = [] start = 0 for count in _sheet_breaks(blocks): group = blocks[start : start + count] cell_w, cell_h, _columns, rows = _grid_for(group) chainages = [chainage for chainage, _w, _h in group] sheets.append( { # id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가 # 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을 # 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적). "id": f"cross_s{chainages[0]:05d}m", "number": len(sheets) + 1, "chainages": chainages, "rows": max(rows, 1), "cell_width": cell_w, "cell_height": cell_h, "slots": _slots(cell_w, cell_h, max(rows, 1), count), } ) start += count return sheets def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]: """한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로).""" entities: list[dict[str, Any]] = [] placements: list[dict[str, Any]] = [] slots = sheet.get("slots") or [] cell_w = float(sheet.get("cell_width") or 0.0) cell_h = float(sheet.get("cell_height") or 0.0) for index, section in enumerate(sections): if index >= len(slots): break seed_id = f"{sheet['id']}:{section['chainage']}" # 1) 원점(0,0)에 한 번 만들어 블록이 원점 대비 어디에 놓이는지 잰다. probe = build_cross_drawing( section["source"], seed_id, section.get("design_line"), section.get("design"), section.get("quantity_table"), section.get("title", ""), ) bbox = entities_bbox(probe["entities"]) if bbox is None: continue min_x, min_y, max_x, max_y = bbox center_x, bottom_y = slots[index] # 2) 가로는 칸 가운데, 세로는 칸 아래 변에 맞춰 원점을 옮겨 다시 만든다. # 아래를 맞춰야 같은 행의 수량표가 한 줄로 선다. origin = ( center_x - (min_x + max_x) / 2.0, bottom_y + _BLOCK_GAP_MM / 2.0 - min_y, ) # 3) 테두리는 칸 크기로 통일한다 — 단면 크기와 무관하게 한 장 안에서 같은 크기. cell_frame = None if cell_w > 0.0 and cell_h > 0.0: half = (cell_w - _BLOCK_GAP_MM) / 2.0 cell_frame = ( center_x - half, bottom_y + _BLOCK_GAP_MM / 2.0, center_x + half, bottom_y + cell_h - _BLOCK_GAP_MM / 2.0, ) placed = build_cross_drawing( section["source"], seed_id, section.get("design_line"), section.get("design"), section.get("quantity_table"), section.get("title", ""), origin=origin, cell_frame=cell_frame, ) entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) bbox = entities_bbox(entities) if bbox: entities.extend( frame_entities( sheet["id"], bbox, fit=False, fields={"도면명": "횡단면도", **scale_fields(("", DRAWING_SCALE_CROSS))}, ) ) return { "format": DRAWING_FORMAT, "entities": entities, "cross_placements": placements, "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), ], }