diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 93daca79..04b121aa 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -443,6 +443,7 @@ def build_cross_drawing( 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 수량 산출표로 만든다. @@ -450,6 +451,10 @@ def build_cross_drawing( 설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의 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") @@ -511,16 +516,20 @@ def build_cross_drawing( ) table_bottom = table_top - cross_table_height() - # 외곽 테두리: 단면 범위와 표를 함께 감싼다. - frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0) - frame_top = oy + half_height + 4.0 - frame_bottom = table_bottom - 4.0 + # 외곽 테두리: 단면 범위와 표를 함께 감싼다. 칸 크기를 받았으면 그 칸에 맞춘다. + 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 = [ - (center_x - frame_x, frame_bottom), - (center_x + frame_x, frame_bottom), - (center_x + frame_x, frame_top), - (center_x - frame_x, frame_top), - (center_x - frame_x, frame_bottom), + (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: @@ -542,12 +551,7 @@ def build_cross_drawing( "x1": x1, # 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울 # 서버 설계선을 이 안에서만 골라내는 데 쓴다. - "frame": [ - center_x - frame_x, - frame_bottom, - center_x + frame_x, - frame_top, - ], + "frame": [frame_left, frame_bottom, frame_right, frame_top], } ], "layers": [ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 43a1e52d..cce9992e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -84,72 +84,85 @@ def section_block_size( return (width, top + below) -def _pack( - blocks: list[tuple[int, float, float]], start: int, rows: int -) -> tuple[int, list[float], list[float]]: - """blocks[start:]를 rows행 **열 우선**으로 담아 (담은 개수, 열폭, 행높이)를 낸다. - - 열폭은 그 열에 든 블록의 최대폭, 행높이는 그 행에 든 블록의 최대높이다 — 칸을 - 전체 최대치로 통일하지 않으면서 행·열은 맞춘다(2026-08-30 사용자 확정). - """ +def _grid_for(group: list[tuple[int, float, float]]) -> tuple[float, float, int, int]: + """한 장에 담을 블록 묶음의 (칸폭, 칸높이, 열수, 행수) — 칸은 그 장 최대 블록 기준.""" usable_w, usable_h = usable_area() - col_widths: list[float] = [] - row_heights: list[float] = [0.0] * rows - count = 0 - for index, (_chainage, width, height) in enumerate(blocks[start:]): - column, row = divmod(index, rows) - current = col_widths[column] if column < len(col_widths) else 0.0 - new_col = max(current, width + _BLOCK_GAP_MM) - new_row = max(row_heights[row], height + _BLOCK_GAP_MM) - if sum(col_widths[:column]) + new_col > usable_w: - break - if sum(row_heights) - row_heights[row] + new_row > usable_h: - break - if column < len(col_widths): - col_widths[column] = new_col - else: - col_widths.append(new_col) - row_heights[row] = new_row - count = index + 1 - return count, col_widths, row_heights + 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 _slots(col_widths: list[float], row_heights: list[float], count: int) -> list[list[float]]: +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-30 사용자: 표는 행·열을 맞춘다). + 칸이 모두 같은 크기이므로 격자 좌표만 계산하면 된다(2026-08-29 사용자: 채우는 + 순서는 좌하단부터 열 우선). """ usable_w, usable_h = usable_area() - rows = len(row_heights) slots: list[list[float]] = [] for index in range(count): column, row = divmod(index, rows) - x = -usable_w / 2.0 + sum(col_widths[:column]) + col_widths[column] / 2.0 - y = -usable_h / 2.0 + sum(row_heights[:row]) - slots.append([x, y]) + 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 장으로 나눈다. - 블록 크기를 먼저 재서 **가장 많이 담기는 행 수**를 고르고, 그 행·열 격자에 - 담는다(2026-08-30 사용자 지시 — 전체 최대치 통일은 여백이 너무 많았다). + 한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다. + 작성 척도는 1/100 고정 — 안 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB + 「설계제원_총괄」 측량·도면 기준). 장에 담기는 측점 수는 세트마다 다르다 + (2026-09-04 사용자 확정). """ sheets: list[dict[str, Any]] = [] start = 0 - while start < len(blocks): - best: tuple[int, list[float], list[float]] = (0, [], []) - for rows in range(1, len(blocks) - start + 1): - packed = _pack(blocks, start, rows) - if packed[0] > best[0]: - best = packed - count, col_widths, row_heights = best - if count == 0: # 한 칸도 못 담을 만큼 큰 블록 — 그래도 한 장에 하나는 놓는다. - count, col_widths, row_heights = 1, [blocks[start][1]], [blocks[start][2]] + for count in _sheet_breaks(blocks): group = blocks[start : start + count] - number = len(sheets) + 1 + cell_w, cell_h, _columns, rows = _grid_for(group) chainages = [chainage for chainage, _w, _h in group] sheets.append( { @@ -157,10 +170,12 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, # 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을 # 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적). "id": f"cross_s{chainages[0]:05d}m", - "number": number, + "number": len(sheets) + 1, "chainages": chainages, - "rows": len(row_heights), - "slots": _slots(col_widths, row_heights, count), + "rows": max(rows, 1), + "cell_width": cell_w, + "cell_height": cell_h, + "slots": _slots(cell_w, cell_h, max(rows, 1), count), } ) start += count @@ -172,6 +187,8 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> 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): @@ -197,6 +214,16 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> 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, @@ -205,6 +232,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> section.get("quantity_table"), section.get("title", ""), origin=origin, + cell_frame=cell_frame, ) entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or [])