"""표준도(구조물도) 도면 — **하단표를 CAD 표로 그린다**. 실무 원본(`07-구조도-소광리.xlsx`)의 탭 하나가 「위 치수조서 + 아래 수량산출서」다. 여기서 그리는 것은 **아래쪽 표**이고, 위쪽 그림은 2단계(돌쌓기 그림)에서 붙는다. ⚠ **도각을 두르지 않는다** — 2026-09-08 사용자 지시(「도각·표제란 필요 없음」). 그래서 `frame_entities` 를 부르지 않는다(도면마다 끝에서 부르는 구조라 안 부르면 끝). ⚠ **한 장에 여러 표를 세로로 쌓는다**(2026-09-09 현재). 제원 조합마다 장을 따로 내는 것은 계획평면도가 쓰는 장 나눔 본을 따라야 하고 목록·라우터 네 곳이 함께 움직여야 한다(㉢). 지금은 **값이 눈에 보이는 것**이 먼저라 한 장에 쌓고, 장 나눔은 그 다음이다. ⚠ 값을 여기서 셈하지 않는다 — `B08_Quantity_Engine_StructureSheet.build_standard_sheets` 가 낸 것을 글자로 옮길 뿐이다. 셈이 두 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5장). """ from __future__ import annotations import logging from pathlib import Path from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, _text_entity, TABLE_LABEL_COLOR, TABLE_LINE_COLOR, TABLE_VALUE_COLOR, _format, _layer, table_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure, figure_reason from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox logger = logging.getLogger(__name__) STANDARD_LAYER_ID = "standard" #: 장 하나가 도면 하나 — 계획평면도와 같은 본을 따른다(`plan_lidar` / `plan_lidar_2`). SHEET_ID_PREFIX = "standard_sheet" def sheet_drawing_id(number: int, total: int) -> str: """장이 하나면 `standard_sheet`, 여럿이면 `standard_sheet_2` 처럼 번호를 붙인다.""" return SHEET_ID_PREFIX if total <= 1 and number <= 1 else f"{SHEET_ID_PREFIX}_{number}" # 종이 밀리미터. 실무 시트가 「공종 | 산출근거 | 수량 | 단위」 넉 줄이라 그대로 간다. # ⚠ **산출근거 칸을 넓게 잡는다** — 처음 176㎜ 로 냈더니 물구멍관·버림콘크리트처럼 단서가 # 붙은 근거 문구가 칸을 넘어 수량 칸을 덮었다(2026-09-09 실화면). 표는 글자를 안 접는다. # A1 작도 영역이 780㎜ 남짓이므로 그 폭을 그대로 쓴다. # A1 작도 영역이 **739 × 499㎜** 라 그 폭을 그대로 쓴다(합 735㎜). # ⚠ 줄 높이 9㎜ 는 **네 장까지** 한 면에 들어가는 크기다(47줄 × 9 + 간격 = 465㎜). # 장이 더 늘면 세로로 넘친다 — 그때가 장 나눔(㉢)을 할 때다. _COLUMN_WIDTHS: tuple[float, ...] = (90.0, 535.0, 70.0, 40.0) _ROW_HEIGHT = 9.0 _FONT = 4.2 #: 그림이 없는 장에 까닭을 적는 글자 크기(㎜) — 표 글자보다 살짝 작게. _NOTE_FONT = 4.0 #: 표 사이 간격 — 장이 여럿일 때 서로 붙어 보이지 않게. _GAP = 14.0 #: 그림을 왼쪽 여백에서 조금 들여 놓는다 — 치수 글자가 왼쪽으로 나가기 때문. _FIGURE_INSET = 60.0 #: 그림 제목이 들어갈 윗여백. _FIGURE_TOP_PAD = 6.0 def _figure_height(sheet: dict[str, Any]) -> float: """그림이 차지할 높이(㎜) — 자리를 먼저 비워 두려고 미리 잰다.""" height_m = float(sheet.get("height_m") or 0.0) return (height_m + 0.95) * FIGURE_SCALE_MM_PER_M if height_m > 0 else 0.0 def _cell( text: str, color: str, *, align: str = "left", col_span: int = 1, bold: bool = False ) -> dict[str, Any]: cell: dict[str, Any] = {"text": text, "color": color, "align": align} if col_span > 1: cell["colSpan"] = col_span if bold: cell["bold"] = True return cell def _sheet_rows(sheet: dict[str, Any]) -> list[list[dict[str, Any] | None]]: """장 하나를 표 격자로. 제목 · 단위 · 머리 · 본문 · (막힌 사유).""" span = len(_COLUMN_WIDTHS) rows: list[list[dict[str, Any] | None]] = [] title = str(sheet.get("title") or "표준도") members = int(sheet.get("member_count") or 0) total = float(sheet.get("billing_total") or 0.0) unit = str(sheet.get("billing_unit") or "") head = f"{title} — {members}개소" if total: head += f" · 합 {_format(total)}{unit}" rows.append([_cell(head, TABLE_LABEL_COLOR, col_span=span, bold=True)] + [None] * (span - 1)) # 실무 시트가 머리 위에 「m당」을 따로 적는다 — 단위가 종류마다 달라서다. rows.append( [_cell(str(sheet.get("unit_label") or ""), TABLE_LABEL_COLOR, align="right", col_span=span)] + [None] * (span - 1) ) rows.append( [ _cell("공종", TABLE_LABEL_COLOR, align="center"), _cell("산 출 근 거", TABLE_LABEL_COLOR, align="center"), _cell("수량", TABLE_LABEL_COLOR, align="center"), _cell("단위", TABLE_LABEL_COLOR, align="center"), ] ) for row in sheet.get("rows") or []: amount = row.get("unit_amount") # ⚠ 못 낸 값은 **0 이 아니라 「-」** 로 적는다. 도면에 0 이 찍히면 「없다」로 읽힌다. text = _format(amount) if isinstance(amount, (int, float)) else "-" rows.append( [ _cell(str(row.get("name") or ""), TABLE_VALUE_COLOR), _cell(str(row.get("basis") or ""), TABLE_LABEL_COLOR), _cell(text, TABLE_VALUE_COLOR, align="right"), _cell(str(row.get("unit") or ""), TABLE_LABEL_COLOR, align="center"), ] ) # 막힌 줄의 사유 — 표 밖에 두면 도면을 넘길 때 같이 안 따라간다. for note in sheet.get("notes") or []: rows.append([_cell(f"· {note}", TABLE_LABEL_COLOR, col_span=span)] + [None] * (span - 1)) return rows def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any]) -> dict[str, Any]: """표준도 한 장 — 장 목록의 표를 위에서 아래로 쌓는다. 구조물이 없으면 **빈 표 대신 한 줄로 그 사실을 적는다** — 빈 격자만 뜨면 「만들다 만 것」인지 「구조물이 없는 것」인지 구별이 안 된다. """ x0, _y0, _x1, y1 = usable_bbox() entities: list[dict[str, Any]] = [] sheets = list(payload.get("sheets") or []) if not sheets: reason = ( "구조물이 없어 표준도에 실을 것이 없습니다." if not payload.get("structure_count") else "구조물이 모두 다른 단계에서 셈되어 표준도에 실리지 않았습니다." ) grid = [[_cell(label, TABLE_LABEL_COLOR, bold=True)], [_cell(reason, TABLE_VALUE_COLOR)]] entities.append( table_entity( f"{drawing_id}:empty", (x0, y1), [sum(_COLUMN_WIDTHS)], [_ROW_HEIGHT] * len(grid), grid, STANDARD_LAYER_ID, TABLE_LINE_COLOR, _FONT, TABLE_VALUE_COLOR, ) ) return { "format": DRAWING_FORMAT, "entities": entities, "layers": [_layer(STANDARD_LAYER_ID, "표준도")], } top = y1 for index, sheet in enumerate(sheets, start=1): # 위 그림 — 실무 탭의 「치수조서」 자리. 못 그리는 종류는 표만 선다(2단계). figure, used = build_figure( drawing_id, sheet, STANDARD_LAYER_ID, (x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)), TABLE_LINE_COLOR, TABLE_LABEL_COLOR, TABLE_VALUE_COLOR, ) if figure: entities.extend(figure) top -= used + _GAP else: # ⚠ **빈 자리를 그냥 두지 않는다** — 사용자가 「고장인가」로 읽는다(2026-09-09 # 사용자 지시). 왜 안 서는지와 **무엇을 받아야 서는지**를 표 위에 한 줄 적는다. reason = figure_reason(sheet) if reason: entities.append( _text_entity( f"{drawing_id}:sheet:{index}:nofigure", reason.replace("**", ""), x0, top - _NOTE_FONT, STANDARD_LAYER_ID, _NOTE_FONT, TABLE_LABEL_COLOR, "left", ) ) top -= _NOTE_FONT + _GAP grid = _sheet_rows(sheet) entities.append( table_entity( f"{drawing_id}:sheet:{index}", (x0, top), list(_COLUMN_WIDTHS), [_ROW_HEIGHT] * len(grid), grid, STANDARD_LAYER_ID, TABLE_LINE_COLOR, _FONT, TABLE_VALUE_COLOR, ) ) top -= len(grid) * _ROW_HEIGHT + _GAP return { "format": DRAWING_FORMAT, "entities": entities, "layers": [_layer(STANDARD_LAYER_ID, "표준도")], } def standard_payload( project_root: Path, section_modes: dict[float, str] | None = None ) -> dict[str, Any]: """프로젝트 구조물을 제원 조합으로 묶은 장 목록. 실패해도 **빈 목록**을 낸다. ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 모듈 맨 위에서 부르면 맞물린다. ⚠ 장 목록은 **B08 구조물도 창구와 한 벌**(2026-09-13 이관) — 따로 전개하면 기초잡석 두께가 갈림. """ from B08_Quantity.B08_Quantity_Router_StructureSheet import project_structure_sheets try: # ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이 # 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측). return project_structure_sheets(str(project_root), section_modes) except Exception: logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root) return {"sheets": [], "structure_count": 0} def sheet_items( project_root: Path, section_modes: dict[float, str] | None = None ) -> list[tuple[str, str]]: """좌측 목록에 설 `(도면 id, 이름)`. 장이 없으면 **빈 장 하나**를 남긴다. 구조물이 없다고 단추가 통째로 사라지면 「없어진 것」처럼 보인다 — 눌러서 사유를 읽게 한다. """ sheets = standard_payload(project_root, section_modes).get("sheets") or [] if not sheets: return [(SHEET_ID_PREFIX, "표준도")] total = len(sheets) return [ (sheet_drawing_id(index, total), f"표준도 {index}장 ({sheet.get('title') or ''})".strip()) for index, sheet in enumerate(sheets, start=1) ] def standard_drawing_for( project_root: Path, drawing_id: str, label: str, section_modes: dict[float, str] | None = None, ) -> dict[str, Any]: """프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문. ⚠ 실패해도 도면은 연다 — 구조물 정본을 못 읽었다고 화면이 비면 사용자는 「고장」으로 읽는다. 그때는 빈 표 대신 사유가 적힌 한 줄이 뜬다. ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 모듈 맨 위에서 부르면 맞물린다. """ payload = standard_payload(project_root, section_modes) sheets = payload.get("sheets") or [] # 이 도면 id 가 가리키는 장 하나만 남긴다 — 한 면에 한 장(2026-09-09, 그림이 붙으면서). total = len(sheets) picked = [ sheet for index, sheet in enumerate(sheets, start=1) if sheet_drawing_id(index, total) == drawing_id ] return build_standard_drawing(drawing_id, label, {**payload, "sheets": picked})