Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""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,
|
||
_text_entity,
|
||
table_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"),
|
||
# 사토장(유용토운반작업장) — **「쌓기」와 갈라 세운다**(2026-09-09 확정 ㉠).
|
||
# ⚠ 여기는 원래 **빈 줄**이었고, 실무 원본 횡단도에는 그 자리가 **아예 없다**
|
||
# (2026-09-09 400dpi 실측). 즉 **납품 양식을 늘리는 것이 아니라 안 쓰던 칸을 쓰는 것**이다.
|
||
# ⚠ 사토장이 없는 측점에는 값이 안 실려 종전처럼 빈칸으로 남는다.
|
||
("사토장", "spoil_fill"),
|
||
("노면다짐", "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",
|
||
# 사토장 — 「쌓기(embankment)」와 **다른 칸**이다. 합치지 않는다(확정 ㉠).
|
||
"spoil_fill",
|
||
"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 _union_edges(*edge_lists: list[float], tol: float = 1e-6) -> list[float]:
|
||
"""여러 격자의 열 경계를 하나로 합친다 (같은 자리는 한 번만)."""
|
||
merged: list[float] = []
|
||
for edges in edge_lists:
|
||
for x in edges:
|
||
if not any(abs(x - m) <= tol for m in merged):
|
||
merged.append(x)
|
||
merged.sort()
|
||
return merged
|
||
|
||
|
||
def _span(edges: list[float], x_from: float, x_to: float) -> tuple[int, int]:
|
||
"""합친 격자에서 [x_from, x_to] 구간이 차지하는 (시작 열, 열 수)."""
|
||
start = min(range(len(edges)), key=lambda k: abs(edges[k] - x_from))
|
||
stop = min(range(len(edges)), key=lambda k: abs(edges[k] - x_to))
|
||
return start, max(1, stop - start)
|
||
|
||
|
||
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]]:
|
||
"""횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다.
|
||
|
||
머리행은 폭을 8등분하고 본문은 11열 가중치를 쓴다 — 격자가 서로 다르므로 두 경계를
|
||
합친 열로 표를 만들고 병합으로 원래 칸을 되살린다. 병합 자리에서 선을 끊는 일은
|
||
표가 스스로 하므로, 예전처럼 가로선을 손으로 끊지 않는다.
|
||
"""
|
||
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
|
||
body_edges = _cross_column_edges(width, center_x)
|
||
header_cell = width / 8.0
|
||
header_edges = [left + header_cell * index for index in range(9)]
|
||
edges = _union_edges(body_edges, header_edges)
|
||
column_widths = [edges[i + 1] - edges[i] for i in range(len(edges) - 1)]
|
||
|
||
body_rows = len(_CROSS_LEFT_ROWS)
|
||
rows = body_rows + 2 # 제목행 + 머리행 + 본문
|
||
cells: list[list[dict[str, Any] | None]] = [[None] * len(column_widths) for _ in range(rows)]
|
||
|
||
def put(
|
||
row: int,
|
||
x_from: float,
|
||
x_to: float,
|
||
text: str,
|
||
color: str,
|
||
*,
|
||
align: str = "center",
|
||
font_size: float | None = None,
|
||
row_span: int = 1,
|
||
key: str | None = None,
|
||
) -> None:
|
||
column, col_span = _span(edges, x_from, x_to)
|
||
cell: dict[str, Any] = {"text": text, "color": color, "align": align}
|
||
if col_span > 1:
|
||
cell["colSpan"] = col_span
|
||
if row_span > 1:
|
||
cell["rowSpan"] = row_span
|
||
if font_size is not None:
|
||
cell["fontSize"] = font_size
|
||
if key is not None:
|
||
# 사용자가 CAD에서 고친 값을 되읽을 때 쓰는 이름 (extract_quantity_table)
|
||
cell["key"] = key
|
||
cells[row][column] = cell
|
||
|
||
def value_of(key: str) -> str:
|
||
value = quantity_table.get(key)
|
||
return _format(value) if isinstance(value, (int, float)) else "-"
|
||
|
||
# ── 제목행 (No.측점) — 전체 병합
|
||
put(0, left, right, title_label, TABLE_LABEL_COLOR, align="left", font_size=font * 1.15)
|
||
|
||
# ── 머리행 (지반고/계획고/성토고/절토고) — 폭 8등분
|
||
for pair_index, (label, key) in enumerate(_CROSS_HEADER_KEYS):
|
||
x0 = left + header_cell * pair_index * 2
|
||
put(1, x0, x0 + header_cell, label, TABLE_LABEL_COLOR)
|
||
put(1, x0 + header_cell, x0 + header_cell * 2, value_of(key), TABLE_VALUE_COLOR, key=key)
|
||
|
||
# ── 본문 6행 (좌/중/우 그룹)
|
||
for row_index in range(body_rows):
|
||
row = row_index + 2
|
||
group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index]
|
||
if group_l is not None and sub_l is not None:
|
||
# 그룹 라벨은 아래 행까지 두 행 병합
|
||
put(row, body_edges[0], body_edges[1], group_l, TABLE_LABEL_COLOR, row_span=2)
|
||
elif group_l is not None:
|
||
# 하위 라벨이 없으면 그룹 열과 하위 열을 합친다
|
||
put(row, body_edges[0], body_edges[2], group_l, TABLE_LABEL_COLOR)
|
||
if sub_l is not None:
|
||
put(row, body_edges[1], body_edges[2], sub_l, TABLE_LABEL_COLOR)
|
||
if key_l is not None:
|
||
put(row, body_edges[2], body_edges[3], value_of(key_l), TABLE_VALUE_COLOR, key=key_l)
|
||
|
||
group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index]
|
||
if group_m is not None:
|
||
put(row, body_edges[4], body_edges[5], group_m, TABLE_LABEL_COLOR, row_span=2)
|
||
put(row, body_edges[5], body_edges[6], sub_m, TABLE_LABEL_COLOR)
|
||
put(row, body_edges[6], body_edges[7], value_of(key_m), TABLE_VALUE_COLOR, key=key_m)
|
||
|
||
label_r, key_r = _CROSS_RIGHT_ROWS[row_index]
|
||
if label_r is not None:
|
||
put(row, body_edges[8], body_edges[9], label_r, TABLE_LABEL_COLOR)
|
||
if key_r is not None:
|
||
put(row, body_edges[9], body_edges[10], value_of(key_r), TABLE_VALUE_COLOR, key=key_r)
|
||
|
||
return [
|
||
table_entity(
|
||
f"{drawing_id}:qtable",
|
||
(left, table_top),
|
||
column_widths,
|
||
[row_h] * rows,
|
||
cells,
|
||
CROSS_TABLE_LAYER_ID,
|
||
TABLE_LINE_COLOR,
|
||
font,
|
||
TABLE_VALUE_COLOR,
|
||
)
|
||
]
|
||
|
||
|
||
def _table_values_from_cells(
|
||
entities: list[Any], drawing_id: str | None = None
|
||
) -> dict[str, float | None]:
|
||
"""표 객체의 칸에서 값을 읽는다. 칸의 key가 어느 수량인지 알려 준다.
|
||
|
||
**한 도면에 표가 여럿이면 그 측점 것만 읽는다**(장은 측점 4~6개를 담는다).
|
||
표 엔티티 id 는 `uuid5(f"{도면id}:qtable")` 이라 측점을 되짚을 수 있다. 이 걸름이
|
||
없던 동안 장 확정이 **모든 측점에 마지막 표 값을 넣었다**(2026-09-03 실측: 4개 측점이
|
||
전부 지반고 837.21 — 도면에는 840.87·840.33·836.45·837.21 로 제대로 그려져 있었다).
|
||
id 로 못 찾으면(옛 도면) 종전처럼 전부 훑는다.
|
||
"""
|
||
wanted = str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable")) if drawing_id else None
|
||
if wanted is not None and not any(
|
||
isinstance(entity, dict) and entity.get("id") == wanted for entity in entities
|
||
):
|
||
wanted = None
|
||
table: dict[str, float | None] = {}
|
||
for entity in entities:
|
||
if not isinstance(entity, dict) or entity.get("type") != "Table":
|
||
continue
|
||
if wanted is not None and entity.get("id") != wanted:
|
||
continue
|
||
shape = entity.get("shapeData")
|
||
rows = shape.get("cells") if isinstance(shape, dict) else None
|
||
if not isinstance(rows, list):
|
||
continue
|
||
for row in rows:
|
||
if not isinstance(row, list):
|
||
continue
|
||
for cell in row:
|
||
if not isinstance(cell, dict):
|
||
continue
|
||
key = cell.get("key")
|
||
if key not in QUANTITY_VALUE_KEYS:
|
||
continue
|
||
try:
|
||
table[str(key)] = float(str(cell.get("text")).replace(",", ""))
|
||
except (TypeError, ValueError):
|
||
table[str(key)] = None
|
||
return table
|
||
|
||
|
||
def _table_values_from_texts(drawing_id: str, entities: list[Any]) -> dict[str, float | None]:
|
||
"""표를 객체로 바꾸기 전에 저장된 도면 — 값 Text의 결정적 id로 읽는다."""
|
||
id_to_key = {
|
||
str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable:{key}")): key for key in QUANTITY_VALUE_KEYS
|
||
}
|
||
table: dict[str, float | None] = {}
|
||
for entity in entities:
|
||
if not isinstance(entity, dict):
|
||
continue
|
||
key = id_to_key.get(str(entity.get("id")))
|
||
if key is None:
|
||
continue
|
||
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
|
||
return table
|
||
|
||
|
||
def extract_quantity_table(
|
||
drawing_id: str, drawing: dict[str, Any]
|
||
) -> dict[str, float | None] | None:
|
||
"""확정 도면 JSON에서 수량 산출표 값을 역추출한다.
|
||
|
||
표 객체의 칸에 실린 key를 먼저 읽고, 없으면 옛 방식(값 Text의 결정적 id)으로 읽는다.
|
||
둘 다 없으면 None을 반환해 호출부가 요청 본문 quantity_table 폴백을 쓰게 한다.
|
||
"""
|
||
entities = drawing.get("entities")
|
||
if not isinstance(entities, list):
|
||
return None
|
||
table = _table_values_from_cells(entities, drawing_id)
|
||
if not table:
|
||
table = _table_values_from_texts(drawing_id, entities)
|
||
if not table:
|
||
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
|
||
|
||
|
||
# 표 위 글(150-3) — B06 [상세] 글(`design.structure_note`)을 줄마다 표 왼쪽 맞춤으로 찍음.
|
||
_CROSS_NOTE_LINE_HEIGHT = 3.0
|
||
|
||
|
||
def cross_note_lines(design: dict[str, Any] | None) -> list[str]:
|
||
"""설계의 표 위 글 → 줄 목록(앞뒤 빈 줄 · 줄 끝 공백 지움 · 없으면 빈 목록)."""
|
||
note = design.get("structure_note") if isinstance(design, dict) else None
|
||
if not isinstance(note, str):
|
||
return []
|
||
return [line.rstrip() for line in note.strip().splitlines()]
|
||
|
||
|
||
def cross_note_height(lines: list[str]) -> float:
|
||
"""표 위 글 높이(mm) — 줄 수 × 줄 높이."""
|
||
return _CROSS_NOTE_LINE_HEIGHT * len(lines)
|
||
|
||
|
||
def _cross_note_entities(
|
||
drawing_id: str, lines: list[str], top: float, center_x: float = 0.0
|
||
) -> list[dict[str, Any]]:
|
||
"""표 위 글 줄마다 Text — 표 왼쪽 끝 맞춤 · 위에서 아래로(id `{도면}:note:{n}`)."""
|
||
left = center_x - _CROSS_TABLE_WIDTH / 2.0
|
||
return [
|
||
_text_entity(
|
||
f"{drawing_id}:note:{index}",
|
||
line,
|
||
left,
|
||
top - _CROSS_NOTE_LINE_HEIGHT * (index + 0.5),
|
||
CROSS_TABLE_LAYER_ID,
|
||
_CROSS_TABLE_FONT,
|
||
TABLE_LABEL_COLOR,
|
||
align="left",
|
||
)
|
||
for index, line in enumerate(lines)
|
||
if line
|
||
]
|
||
|
||
|
||
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)
|