Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py
T

368 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)