실무 탭이 「위 치수조서 + 아래 수량산출서」인데 1단계에서 아래 표가 섰고 이번이 위 그림.
그림이 붙으면서 한 면에 넉 장을 쌓을 수 없게 돼 **장 나눔(㉢)도 같이** 했음.
장 나눔 — 계획평면도가 쓰는 본 그대로
`standard_sheet` / `standard_sheet_2` … 제원 조합 하나가 한 장.
좌측 목록은 `idPrefix` 묶음(계획평면도와 같은 짜임)이고 kind 는 새로 `standard`.
구조물이 없어도 한 장은 남김 — 단추가 사라지면 「없어진 것」처럼 보이고 사유를 읽을
자리도 없어짐.
그림 — `B07_DesignDetail_Engine_Cad_StandardFigure.py`
⚠ **기울기를 값으로 받음. 0.3 을 그림에 박지 않았음.** `face_slope_ratio` 가 오면 그
값으로 기울고, 비어 있으면 교본 7-3 기본 0.3 으로 그리되 **「자동 판정 전」이라고 그림에
적음**. 자동 판정이 붙는 대로 그림이 따라감.
⚠ 치수는 수량이 쓰는 그 상수(`STONE_MASONRY`)를 직접 읽음 — 여기서 다시 적으면 그림과
표가 갈림. 상부 0.45+0.10H · 하부 0.45+0.40H · 터파기 폭 평균두께+0.2 · 되메우기 0.2.
ⓘ 그 상수는 **뒷면이 수직인 1:0.3 벽**을 뜻함(하부−상부 = 0.30H = 전면이 기우는 양).
기울기가 0.3 이 아니면 그 차이만큼 뒷면이 기욺 — 상수에서 따라 나오는 결과라 그대로 그림.
⚠ 치수가 없는 것은 그리되 치수를 안 적음 — 막자갈은 「입적 − 몸통 − 고임돌」이라 폭·높이로
정의된 도형이 아님. 자리만 보이고 값은 표를 가리킴.
죽은 길 걷어냄 — `BLANK_DRAWINGS` 가 「표준도」 하나뿐이었는데 그것이 내용 있는 장이 되어
빈 도각 경로가 통째로 안 쓰이게 됨. 상수·목록 루프·분기·import 를 지움(700줄 제한도 걸렸음).
자체검증 — 회귀 573 통과 · 0 실패, `tsc --noEmit` 0.
⚠ 병합 뒤 깨진 시험 둘을 고침(내 폴더 `tmp/tests`, 랩탑 메인 `11394d40` 의 여파)
· `stone_supply='채집'` 기본값을 확정 ② 근거와 함께 허용 목록에 등록
· 바닥막이 「모든 옵션이 b05」 단정 → 조달이 detail 로 들어와 B05 셋만 보게 좁힘
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
258 lines
11 KiB
Python
258 lines
11 KiB
Python
"""표준도(구조물도) 도면 — **하단표를 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_Cad_StandardFigure import (
|
|
SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure
|
|
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
|
|
#: 표 사이 간격 — 장이 여럿일 때 서로 붙어 보이지 않게.
|
|
_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
|
|
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) -> 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))
|
|
return build_standard_sheets(build_unit_table(structures, names))
|
|
except Exception:
|
|
logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root)
|
|
return {"sheets": [], "structure_count": 0}
|
|
|
|
|
|
def sheet_items(project_root: Path) -> list[tuple[str, str]]:
|
|
"""좌측 목록에 설 `(도면 id, 이름)`. 장이 없으면 **빈 장 하나**를 남긴다.
|
|
|
|
구조물이 없다고 단추가 통째로 사라지면 「없어진 것」처럼 보인다 — 눌러서 사유를 읽게 한다.
|
|
"""
|
|
sheets = standard_payload(project_root).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) -> dict[str, Any]:
|
|
"""프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문.
|
|
|
|
⚠ 실패해도 도면은 연다 — 구조물 정본을 못 읽었다고 화면이 비면 사용자는 「고장」으로
|
|
읽는다. 그때는 빈 표 대신 사유가 적힌 한 줄이 뜬다.
|
|
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어
|
|
모듈 맨 위에서 부르면 맞물린다.
|
|
"""
|
|
payload = standard_payload(project_root)
|
|
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})
|