From 16c61add02f4a6ae3132c8ab2acddde0a9fe4a9c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 19:44:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=91=9C=EC=A4=80=EB=8F=84=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=EC=97=90=20=ED=95=98=EB=8B=A8=ED=91=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=E2=80=94=20=EB=B9=88=20=EB=8F=84=EA=B0=81=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=91=9C=20=EB=84=89=20=EC=9E=A5=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「표준도」를 누르면 도각만 실린 빈 장이 뜨던 것을, 제원 조합마다 표 한 벌을 CAD 표 객체로 그려 낸다. 도각은 두르지 않음(2026-09-08 사용자 지시). 값은 여기서 셈하지 않음 — `build_standard_sheets` 가 낸 것을 글자로 옮길 뿐임. 셈이 두 벌이 되면 도면과 수량서가 갈림(CLAUDE.md 5장). 실화면 확인 (프로젝트 936be972, 구조물 5기를 심고 확인) 장 4개 · 개소 5 — ①③ 이 같은 제원이라 한 장에 2개소로 모임(장 나눔 실물 증명) 돌쌓기(찰) H=2.5 뒷길이 45㎝ 깬돌 12줄 2개소·합 20.00m 돌쌓기(메) H=2.0 뒷길이 35㎝ 야면석 10줄 1개소 옹벽 H=2.0 7줄 1개소 (관측 원단위 사유 1줄) 큰돌쌓기 H=2.5 5줄 1개소 (막힌 사유 4줄이 표에 그대로 뜸) ⚠ 만들다 잡은 것 — 표 폭을 176㎜ 로 냈더니 단서가 붙은 근거 문구(물구멍관·버림 콘크리트)가 칸을 넘어 수량 칸을 덮었음. 표는 글자를 안 접음. A1 작도 영역 739 × 499㎜ 에 맞춰 735㎜ 로 넓히고 줄 높이 9㎜ 로 잡음 — **네 장까지** 한 면에 듦(47줄 × 9 + 간격 = 465㎜). 더 늘면 장 나눔(㉢)이 필요함. ⚠ 못 낸 값은 0 이 아니라 「-」 로 적음. 도면에 0 이 찍히면 「없다」로 읽힘. ⚠ 구조물이 없으면 빈 격자 대신 사유 한 줄이 뜸 — 빈 표는 「만들다 만 것」으로 보임. 자체검증 — 회귀 573 통과 · 0 실패. 공용 브라우저에서 표준도를 눌러 실제로 열고 확대해 글자·수치를 읽어 확인함(스크린샷 `tmp/browser/shots/standard_fit.png`). Co-Authored-By: Claude Opus 5 (1M context) --- ...7_DesignDetail_Engine_Cad_StandardSheet.py | 189 ++++++++++++++++++ .../B07_DesignDetail_Router_Support.py | 7 +- 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py new file mode 100644 index 00000000..17ee4a46 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py @@ -0,0 +1,189 @@ +"""표준도(구조물도) 도면 — **하단표를 CAD 표로 그린다**. + +실무 원본(`07-구조도-소광리.xlsx`)의 탭 하나가 「위 치수조서 + 아래 수량산출서」다. +여기서 그리는 것은 **아래쪽 표**이고, 위쪽 그림은 2단계(돌쌓기 그림)에서 붙는다. + +⚠ **도각을 두르지 않는다** — 2026-09-08 사용자 지시(「도각·표제란 필요 없음」). + 그래서 `frame_entities` 를 부르지 않는다(도면마다 끝에서 부르는 구조라 안 부르면 끝). + +⚠ **한 장에 여러 표를 세로로 쌓는다**(2026-09-09 현재). 제원 조합마다 장을 따로 내는 것은 + 계획평면도가 쓰는 장 나눔 본을 따라야 하고 목록·라우터 네 곳이 함께 움직여야 한다(㉢). + 지금은 **값이 눈에 보이는 것**이 먼저라 한 장에 쌓고, 장 나눔은 그 다음이다. + +⚠ 값을 여기서 셈하지 않는다 — `_Engine_Standard_Sheet.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, + TABLE_LABEL_COLOR, + TABLE_LINE_COLOR, + TABLE_VALUE_COLOR, + _format, + _layer, + table_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox + +logger = logging.getLogger(__name__) + +STANDARD_LAYER_ID = "standard" + +# 종이 밀리미터. 실무 시트가 「공종 | 산출근거 | 수량 | 단위」 넉 줄이라 그대로 간다. +# ⚠ **산출근거 칸을 넓게 잡는다** — 처음 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 +#: 표 사이 간격 — 장이 여럿일 때 서로 붙어 보이지 않게. +_GAP = 14.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): + 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_drawing_for(project_root: Path, drawing_id: str, label: str) -> dict[str, Any]: + """프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문. + + ⚠ 실패해도 도면은 연다 — 구조물 정본을 못 읽었다고 화면이 비면 사용자는 「고장」으로 + 읽는다. 그때는 빈 표 대신 사유가 적힌 한 줄이 뜬다. + ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 + 모듈 맨 위에서 부르면 맞물린다. + """ + from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table + from B08_Quantity.B08_Quantity_Router_Material import _collect_structures + + try: + structures, names, _skipped = _collect_structures(str(project_root)) + payload = build_standard_sheets(build_unit_table(structures, names)) + except Exception: + logger.exception("B07 표준도 하단표 실패 — 사유만 실어 연다: %s", drawing_id) + payload = {"sheets": [], "structure_count": 0} + return build_standard_drawing(drawing_id, label, payload) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b2b9488c..b114350d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -47,6 +47,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( plan_cross_sheets, section_block_size, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_drawing_for from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import ( STANDARD_LABEL, build_standard_cross_drawing, @@ -444,8 +445,12 @@ def _read_drawing( table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None return kind, label, saved, True, table if drawing_id in BLANK_LABELS: - # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다. 확정 대상이 아니다. label = BLANK_LABELS[drawing_id] + if drawing_id == "blank_standard": + # 표준도 — 이제 하단표를 싣는다(2026-09-09). 도각은 두르지 않는다(사용자 지시). + drawing = standard_drawing_for(project_root, drawing_id, label) + return "blank", label, drawing, False, None + # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다. 확정 대상이 아니다. return "blank", label, build_blank_drawing(drawing_id, label), False, None if drawing_id == COVER_ID: