"""B07 횡단 수량 산출표 — 납품 양식 표 작도와 편집값 역추출. 표는 도면 좌표(종이 mm) 기준이다. 값 셀 id는 uuid5(NS, "{drawing_id}:qtable:{key}")로 정해져 있어, 사용자가 CAD에서 고친 숫자를 그대로 되읽을 수 있다. """ from typing import Any from uuid import uuid5 from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, CROSS_TABLE_LAYER_ID, TABLE_LABEL_COLOR, TABLE_LINE_COLOR, TABLE_VALUE_COLOR, _format, _line_entity, _text_entity, ) # 횡단 수량 산출표 (납품 양식). 본문 6행 × 3그룹. # 좌: 깍기(토사/암석)·측구(토사/암석)·쌓기·층따기 # 중: 면고르기(성토/절토)·지장목제거(성토/절토)·표토제거(성토/절토) # 우: 편책·성토파종·절토살포·제근·(공란)·노면다짐 _CROSS_HEADER_KEYS: tuple[tuple[str, str], ...] = ( ("지반고", "ground"), ("계획고", "planned"), ("성토고", "fill"), ("절토고", "cut"), ) # 좌측 그룹: (그룹 라벨 or None, 하위 라벨 or None, 값 키 or None) — 행 순서대로. _CROSS_LEFT_ROWS: tuple[tuple[str | None, str | None, str | None], ...] = ( ("깍기", "토사", "cut_soil"), (None, "암석", "cut_rock"), ("측구", "토사", "ditch_soil"), (None, "암석", "ditch_rock"), ("쌓기", None, "embankment"), ("층따기", None, "benching"), ) _CROSS_MIDDLE_ROWS: tuple[tuple[str | None, str, str], ...] = ( ("면고르기", "성토", "grading_fill"), (None, "절토", "grading_cut"), ("지장목제거", "성토", "tree_removal_fill"), (None, "절토", "tree_removal_cut"), ("표토제거", "성토", "topsoil_fill"), (None, "절토", "topsoil_cut"), ) _CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = ( ("편책", "fence"), ("성토파종", "fill_seeding"), ("절토살포", "cut_spraying"), ("제근", "grubbing"), (None, None), ("노면다짐", "road_compaction"), ) QUANTITY_VALUE_KEYS: tuple[str, ...] = ( "ground", "planned", "fill", "cut", "cut_soil", "cut_rock", "ditch_soil", "ditch_rock", "embankment", "benching", "grading_fill", "grading_cut", "tree_removal_fill", "tree_removal_cut", "topsoil_fill", "topsoil_cut", "fence", "fill_seeding", "cut_spraying", "grubbing", "road_compaction", ) # 표 치수는 종이 밀리미터다 (도면 좌표 = mm). _CROSS_TABLE_WIDTH = 108.0 _CROSS_TABLE_ROW_HEIGHT = 5.0 _CROSS_TABLE_FONT = 2.0 # 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백) _CROSS_COLUMN_WEIGHTS: tuple[float, ...] = (1.0, 1.7, 2.1, 1.0, 1.9, 1.4, 2.1, 1.0, 2.6, 2.1, 1.0) def _cross_column_edges(width: float, center_x: float = 0.0) -> list[float]: total = sum(_CROSS_COLUMN_WEIGHTS) left = center_x - width / 2.0 edges = [left] accumulated = 0.0 for weight in _CROSS_COLUMN_WEIGHTS: accumulated += weight edges.append(left + width * accumulated / total) return edges def _cross_table_entities( drawing_id: str, quantity_table: dict[str, float | None], table_top: float, title_label: str, center_x: float = 0.0, ) -> list[dict[str, Any]]: """횡단 수량 산출표(납품 양식)를 병합 셀 그리드+텍스트로 만든다. 표는 center_x를 가운데로 놓는다 — 한 장에 여러 단면을 배치할 때 각 단면 블록의 중심으로 옮기기 위한 것이다. """ width = _CROSS_TABLE_WIDTH row_h = _CROSS_TABLE_ROW_HEIGHT font = _CROSS_TABLE_FONT left = center_x - width / 2.0 right = center_x + width / 2.0 edges = _cross_column_edges(width, center_x) entities: list[dict[str, Any]] = [] def value_text(seed_key: str, x: float, y: float) -> dict[str, Any]: value = quantity_table.get(seed_key) return _text_entity( f"{drawing_id}:qtable:{seed_key}", _format(value) if isinstance(value, (int, float)) else "-", x, y, CROSS_TABLE_LAYER_ID, font, TABLE_VALUE_COLOR, ) def label_text(seed: str, label: str, x: float, y: float) -> dict[str, Any]: return _text_entity(seed, label, x, y, CROSS_TABLE_LAYER_ID, font, TABLE_LABEL_COLOR) def h_line(seed: str, x_from: float, x_to: float, y: float) -> None: entities.append( _line_entity(seed, (x_from, y), (x_to, y), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR) ) def v_line(seed: str, x: float, y_from: float, y_to: float) -> None: entities.append( _line_entity(seed, (x, y_from), (x, y_to), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR) ) # ── 행 y 좌표 (제목행, 헤더행, 본문 6행) y_title_top = table_top y_header_top = y_title_top - row_h y_body_top = y_header_top - row_h body_rows = len(_CROSS_LEFT_ROWS) y_bottom = y_body_top - row_h * body_rows def body_y(row_index: int) -> float: return y_body_top - row_h * row_index # ── 외곽/가로선 h_line(f"{drawing_id}:qgrid:top", left, right, y_title_top) h_line(f"{drawing_id}:qgrid:title", left, right, y_header_top) h_line(f"{drawing_id}:qgrid:header", left, right, y_body_top) h_line(f"{drawing_id}:qgrid:bottom", left, right, y_bottom) v_line(f"{drawing_id}:qgrid:vl", left, y_title_top, y_bottom) v_line(f"{drawing_id}:qgrid:vr", right, y_title_top, y_bottom) # 본문 행 사이 가로선 — 그룹 병합 셀(좌 colA, 중 colE)은 병합 지속 구간에서 끊는다. for boundary in range(1, body_rows): y = body_y(boundary) left_merged = _CROSS_LEFT_ROWS[boundary][0] is None middle_merged = _CROSS_MIDDLE_ROWS[boundary][0] is None seed = f"{drawing_id}:qgrid:b{boundary}" if left_merged: h_line(f"{seed}:l", edges[1], edges[4], y) else: h_line(f"{seed}:l", edges[0], edges[4], y) if middle_merged: h_line(f"{seed}:m", edges[5], edges[8], y) else: h_line(f"{seed}:m", edges[4], edges[8], y) h_line(f"{seed}:r", edges[8], edges[11], y) # ── 헤더행 (지반고/계획고/성토고/절토고): 4쌍 균등 분할 header_cell = width / 8.0 for pair_index, (label, key) in enumerate(_CROSS_HEADER_KEYS): x_label = left + header_cell * (pair_index * 2 + 0.5) x_value = left + header_cell * (pair_index * 2 + 1.5) y_mid = y_header_top - row_h * 0.5 entities.append(label_text(f"{drawing_id}:qlabel:h:{key}", label, x_label, y_mid)) entities.append(value_text(key, x_value, y_mid)) if pair_index > 0: v_line( f"{drawing_id}:qgrid:hv{pair_index}", left + header_cell * pair_index * 2, y_header_top, y_body_top, ) v_line( f"{drawing_id}:qgrid:hvl{pair_index}", left + header_cell * (pair_index * 2 + 1), y_header_top, y_body_top, ) # ── 제목행 (No.측점) entities.append( _text_entity( f"{drawing_id}:qtitle", title_label, left + width * 0.03, y_title_top - row_h * 0.5, CROSS_TABLE_LAYER_ID, font * 1.15, TABLE_LABEL_COLOR, align="left", ) ) # ── 본문 세로선: colA|B 경계는 그룹 라벨이 하위 라벨과 분리된 행(깍기~측구 4행)만. ab_rows = [i for i, row in enumerate(_CROSS_LEFT_ROWS) if row[1] is not None] if ab_rows: v_line( f"{drawing_id}:qgrid:vab", edges[1], body_y(min(ab_rows)), body_y(max(ab_rows) + 1), ) for edge_index in (2, 3, 4, 5, 6, 7, 8, 9, 10): v_line(f"{drawing_id}:qgrid:vc{edge_index}", edges[edge_index], y_body_top, y_bottom) # ── 본문 셀 (좌/중/우 그룹) def cell_mid(edge_from: int, edge_to: int) -> float: return (edges[edge_from] + edges[edge_to]) / 2.0 for row_index in range(body_rows): y_mid = body_y(row_index) - row_h * 0.5 group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index] if group_l is not None and sub_l is not None: # 그룹 라벨은 2행 병합 중앙 배치 y_group = body_y(row_index) - row_h # 병합 2행의 중앙 entities.append( label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 1), y_group) ) elif group_l is not None: entities.append( label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 2), y_mid) ) if sub_l is not None: entities.append( label_text(f"{drawing_id}:qlabel:ls:{row_index}", sub_l, cell_mid(1, 2), y_mid) ) if key_l is not None: entities.append(value_text(key_l, cell_mid(2, 3), y_mid)) group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index] if group_m is not None: y_group = body_y(row_index) - row_h entities.append( label_text(f"{drawing_id}:qlabel:mg:{row_index}", group_m, cell_mid(4, 5), y_group) ) entities.append( label_text(f"{drawing_id}:qlabel:ms:{row_index}", sub_m, cell_mid(5, 6), y_mid) ) entities.append(value_text(key_m, cell_mid(6, 7), y_mid)) label_r, key_r = _CROSS_RIGHT_ROWS[row_index] if label_r is not None: entities.append( label_text(f"{drawing_id}:qlabel:r:{row_index}", label_r, cell_mid(8, 9), y_mid) ) if key_r is not None: entities.append(value_text(key_r, cell_mid(9, 10), y_mid)) return entities def extract_quantity_table( drawing_id: str, drawing: dict[str, Any] ) -> dict[str, float | None] | None: """확정 도면 JSON에서 수량 산출표 Text 값을 결정적 id로 역추출한다. 값 셀 id = uuid5(NS, "{drawing_id}:qtable:{key}"). 하나도 없으면 None을 반환해 호출부가 요청 본문 quantity_table 폴백을 쓰게 한다. """ id_to_key = { str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable:{key}")): key for key in QUANTITY_VALUE_KEYS } entities = drawing.get("entities") if not isinstance(entities, list): return None table: dict[str, float | None] = {} found = False for entity in entities: if not isinstance(entity, dict): continue key = id_to_key.get(str(entity.get("id"))) if key is None: continue found = True shape = entity.get("shapeData") label = shape.get("label") if isinstance(shape, dict) else None try: table[key] = float(str(label).replace(",", "")) except (TypeError, ValueError): table[key] = None if not found: return None for key in QUANTITY_VALUE_KEYS: table.setdefault(key, None) ground = table.get("ground") planned = table.get("planned") if isinstance(ground, (int, float)) and isinstance(planned, (int, float)): table["cut"] = max(ground - planned, 0.0) table["fill"] = max(planned - ground, 0.0) return table def cross_table_width() -> float: """수량 산출표 가로 폭(mm).""" return _CROSS_TABLE_WIDTH def cross_table_height() -> float: """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행.""" return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2)