Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py

253 lines
11 KiB
Python

"""표준도(구조물도) 도면 — **하단표를 CAD 표로 그린다**.
실무 원본(`07-구조도-소광리.xlsx`)의 탭 하나가 「위 치수조서 + 아래 수량산출서」다.
여기서 그리는 것은 **아래쪽 표**이고, 위쪽 그림은 2단계(돌쌓기 그림)에서 붙는다.
⚠ **도각을 두르지 않는다** — 2026-09-08 사용자 지시(「도각·표제란 필요 없음」).
그래서 `frame_entities` 를 부르지 않는다(도면마다 끝에서 부르는 구조라 안 부르면 끝).
⚠ **한 장에 여러 표를 세로로 쌓는다**(2026-09-09 현재). 제원 조합마다 장을 따로 내는 것은
계획평면도가 쓰는 장 나눔 본을 따라야 하고 목록·라우터 네 곳이 함께 움직여야 한다(㉢).
지금은 **값이 눈에 보이는 것**이 먼저라 한 장에 쌓고, 장 나눔은 그 다음이다.
⚠ 값을 여기서 셈하지 않는다 — `B08_Quantity_Engine_StructureSheet.build_standard_sheets` 가 낸 것을
글자로 옮길 뿐이다. 셈이 두 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5장).
⚠ **2026-09-13 B07 도면 목록에서 빠짐**(사용자 확정 ③ — 구조물도는 B08 탭, PLAN 3장 ④).
목록·라우터 배선은 걷어냈고 표·그림 조립만 남김 — B08 구조물도 상단 그림 일감이 다시 쓸 자리.
"""
from __future__ import annotations
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,
_text_entity,
polyline_entity,
table_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
from B08_Quantity.B08_Quantity_Engine_StructureFigure import (
SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M,
)
from B08_Quantity.B08_Quantity_Engine_StructureFigure import build_figure as figure_shapes
from B08_Quantity.B08_Quantity_Engine_StructureFigure import figure_reason
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
#: 그림이 없는 장에 까닭을 적는 글자 크기(㎜) — 표 글자보다 살짝 작게.
_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 build_figure(
drawing_id: str, sheet: dict[str, Any], origin: tuple[float, float]
) -> tuple[list[dict[str, Any]], float]:
"""B08 그림 모양 목록(m)을 CAD 엔티티(종이 ㎜)로. `(엔티티, 높이 ㎜)` — 못 그리면 `([], 0)`."""
shapes = figure_shapes(sheet) or []
ox, oy = origin
scale = FIGURE_SCALE_MM_PER_M
entities: list[dict[str, Any]] = []
for index, shape in enumerate(shapes):
x, y = (shape.get("at") or [0.0, 0.0])[:2]
if shape["kind"] == "text":
entities.append(
_text_entity(
f"{drawing_id}:fig:{index}",
shape["text"],
ox + x * scale,
oy + y * scale,
STANDARD_LAYER_ID,
shape["size"] * scale,
TABLE_LABEL_COLOR,
shape["align"],
)
)
continue
poly = polyline_entity(
f"{drawing_id}:fig:{index}",
[(ox + px * scale, oy + py * scale) for px, py in shape["points"]],
STANDARD_LAYER_ID,
TABLE_LINE_COLOR if shape["role"] == "wall" else TABLE_VALUE_COLOR,
dash=[4, 3] if shape["dash"] else None,
width=2 if shape["role"] == "wall" else 1,
)
if poly is not None:
entities.append(poly)
ys = [point[1] for shape in shapes for point in shape.get("points") or [shape["at"]]]
return entities, ((max(ys) - min(ys)) * scale if ys 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,
(x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)),
)
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, "표준도")],
}