feat(B07): 표를 객체로 만들어 도면의 표가 진짜 표가 되게 한다
표가 DXF의 표로 나가야 한다(사용자 확정). 지금까지 도면의 표는 선과 문자 뭉치라 내보낼 때 고를 수 있는 길이 하나뿐이었다. - TableEntity: 열별 폭·행별 높이·칸 문자·병합을 한 객체가 들고 있다. 격자선은 담지 않고 병합 자리에서 선을 끊는 규칙을 표가 스스로 안다(helpers/table-geometry.ts). 회전·대칭은 지원하지 않는다 — 표는 축에 붙어 있다. - 명령: TABLE을 표 객체 생성으로 다시 쓰고 TABLEEDIT(칸 문자)·TABLEROW·TABLECOL· TABLEMERGE·TABLEUNMERGE를 더했다. EXPLODE는 표를 선과 문자로 흩는다. - 그립: 좌측 상단으로 표를 옮기고, 열·행 경계로 폭·높이를 바꾼다. - 백엔드: 유역 정보표와 횡단 수량 산출표를 표 객체로 낸다. 횡단표는 머리행이 폭 8등분, 본문이 11열 가중치로 격자가 서로 달라 두 경계를 합친 18열로 만들고 병합으로 원래 칸을 되살렸다 — 손으로 하던 가로선 끊기가 사라졌다. - 수량 역추출: 값 Text의 결정적 id로 읽던 것을 칸에 실은 key로 읽도록 옮겼다. 이미 저장된 도면을 위해 옛 방식을 폴백으로 남겼다. 토적도·종단표는 값이 칸이 아니라 측점 위치에 놓이는 성격이라 이관하지 않았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
TABLE_LINE_COLOR,
|
||||
TABLE_VALUE_COLOR,
|
||||
_format,
|
||||
_line_entity,
|
||||
_text_entity,
|
||||
table_entity,
|
||||
)
|
||||
|
||||
# 횡단 수량 산출표 (납품 양식). 본문 6행 × 3그룹.
|
||||
@@ -98,6 +97,24 @@ def _cross_column_edges(width: float, center_x: float = 0.0) -> list[float]:
|
||||
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],
|
||||
@@ -105,201 +122,169 @@ def _cross_table_entities(
|
||||
title_label: str,
|
||||
center_x: float = 0.0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""횡단 수량 산출표(납품 양식)를 병합 셀 그리드+텍스트로 만든다.
|
||||
"""횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다.
|
||||
|
||||
표는 center_x를 가운데로 놓는다 — 한 장에 여러 단면을 배치할 때 각 단면
|
||||
블록의 중심으로 옮기기 위한 것이다.
|
||||
머리행은 폭을 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
|
||||
edges = _cross_column_edges(width, center_x)
|
||||
entities: list[dict[str, Any]] = []
|
||||
|
||||
def value_text(seed_key: str, x: float, y: float) -> dict[str, Any]:
|
||||
value = quantity_table.get(seed_key)
|
||||
return _text_entity(
|
||||
f"{drawing_id}:qtable:{seed_key}",
|
||||
_format(value) if isinstance(value, (int, float)) else "-",
|
||||
x,
|
||||
y,
|
||||
CROSS_TABLE_LAYER_ID,
|
||||
font,
|
||||
TABLE_VALUE_COLOR,
|
||||
)
|
||||
|
||||
def label_text(seed: str, label: str, x: float, y: float) -> dict[str, Any]:
|
||||
return _text_entity(seed, label, x, y, CROSS_TABLE_LAYER_ID, font, TABLE_LABEL_COLOR)
|
||||
|
||||
def h_line(seed: str, x_from: float, x_to: float, y: float) -> None:
|
||||
entities.append(
|
||||
_line_entity(seed, (x_from, y), (x_to, y), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR)
|
||||
)
|
||||
|
||||
def v_line(seed: str, x: float, y_from: float, y_to: float) -> None:
|
||||
entities.append(
|
||||
_line_entity(seed, (x, y_from), (x, y_to), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR)
|
||||
)
|
||||
|
||||
# ── 행 y 좌표 (제목행, 헤더행, 본문 6행)
|
||||
y_title_top = table_top
|
||||
y_header_top = y_title_top - row_h
|
||||
y_body_top = y_header_top - row_h
|
||||
body_rows = len(_CROSS_LEFT_ROWS)
|
||||
y_bottom = y_body_top - row_h * body_rows
|
||||
|
||||
def body_y(row_index: int) -> float:
|
||||
return y_body_top - row_h * row_index
|
||||
|
||||
# ── 외곽/가로선
|
||||
h_line(f"{drawing_id}:qgrid:top", left, right, y_title_top)
|
||||
h_line(f"{drawing_id}:qgrid:title", left, right, y_header_top)
|
||||
h_line(f"{drawing_id}:qgrid:header", left, right, y_body_top)
|
||||
h_line(f"{drawing_id}:qgrid:bottom", left, right, y_bottom)
|
||||
v_line(f"{drawing_id}:qgrid:vl", left, y_title_top, y_bottom)
|
||||
v_line(f"{drawing_id}:qgrid:vr", right, y_title_top, y_bottom)
|
||||
|
||||
# 본문 행 사이 가로선 — 그룹 병합 셀(좌 colA, 중 colE)은 병합 지속 구간에서 끊는다.
|
||||
for boundary in range(1, body_rows):
|
||||
y = body_y(boundary)
|
||||
left_merged = _CROSS_LEFT_ROWS[boundary][0] is None
|
||||
middle_merged = _CROSS_MIDDLE_ROWS[boundary][0] is None
|
||||
seed = f"{drawing_id}:qgrid:b{boundary}"
|
||||
if left_merged:
|
||||
h_line(f"{seed}:l", edges[1], edges[4], y)
|
||||
else:
|
||||
h_line(f"{seed}:l", edges[0], edges[4], y)
|
||||
if middle_merged:
|
||||
h_line(f"{seed}:m", edges[5], edges[8], y)
|
||||
else:
|
||||
h_line(f"{seed}:m", edges[4], edges[8], y)
|
||||
h_line(f"{seed}:r", edges[8], edges[11], y)
|
||||
|
||||
# ── 헤더행 (지반고/계획고/성토고/절토고): 4쌍 균등 분할
|
||||
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):
|
||||
x_label = left + header_cell * (pair_index * 2 + 0.5)
|
||||
x_value = left + header_cell * (pair_index * 2 + 1.5)
|
||||
y_mid = y_header_top - row_h * 0.5
|
||||
entities.append(label_text(f"{drawing_id}:qlabel:h:{key}", label, x_label, y_mid))
|
||||
entities.append(value_text(key, x_value, y_mid))
|
||||
if pair_index > 0:
|
||||
v_line(
|
||||
f"{drawing_id}:qgrid:hv{pair_index}",
|
||||
left + header_cell * pair_index * 2,
|
||||
y_header_top,
|
||||
y_body_top,
|
||||
)
|
||||
v_line(
|
||||
f"{drawing_id}:qgrid:hvl{pair_index}",
|
||||
left + header_cell * (pair_index * 2 + 1),
|
||||
y_header_top,
|
||||
y_body_top,
|
||||
)
|
||||
|
||||
# ── 제목행 (No.측점)
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:qtitle",
|
||||
title_label,
|
||||
left + width * 0.03,
|
||||
y_title_top - row_h * 0.5,
|
||||
CROSS_TABLE_LAYER_ID,
|
||||
font * 1.15,
|
||||
TABLE_LABEL_COLOR,
|
||||
align="left",
|
||||
)
|
||||
)
|
||||
|
||||
# ── 본문 세로선: colA|B 경계는 그룹 라벨이 하위 라벨과 분리된 행(깍기~측구 4행)만.
|
||||
ab_rows = [i for i, row in enumerate(_CROSS_LEFT_ROWS) if row[1] is not None]
|
||||
if ab_rows:
|
||||
v_line(
|
||||
f"{drawing_id}:qgrid:vab",
|
||||
edges[1],
|
||||
body_y(min(ab_rows)),
|
||||
body_y(max(ab_rows) + 1),
|
||||
)
|
||||
for edge_index in (2, 3, 4, 5, 6, 7, 8, 9, 10):
|
||||
v_line(f"{drawing_id}:qgrid:vc{edge_index}", edges[edge_index], y_body_top, y_bottom)
|
||||
|
||||
# ── 본문 셀 (좌/중/우 그룹)
|
||||
def cell_mid(edge_from: int, edge_to: int) -> float:
|
||||
return (edges[edge_from] + edges[edge_to]) / 2.0
|
||||
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):
|
||||
y_mid = body_y(row_index) - row_h * 0.5
|
||||
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:
|
||||
# 그룹 라벨은 2행 병합 중앙 배치
|
||||
y_group = body_y(row_index) - row_h # 병합 2행의 중앙
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 1), y_group)
|
||||
)
|
||||
# 그룹 라벨은 아래 행까지 두 행 병합
|
||||
put(row, body_edges[0], body_edges[1], group_l, TABLE_LABEL_COLOR, row_span=2)
|
||||
elif group_l is not None:
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 2), y_mid)
|
||||
)
|
||||
# 하위 라벨이 없으면 그룹 열과 하위 열을 합친다
|
||||
put(row, body_edges[0], body_edges[2], group_l, TABLE_LABEL_COLOR)
|
||||
if sub_l is not None:
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:ls:{row_index}", sub_l, cell_mid(1, 2), y_mid)
|
||||
)
|
||||
put(row, body_edges[1], body_edges[2], sub_l, TABLE_LABEL_COLOR)
|
||||
if key_l is not None:
|
||||
entities.append(value_text(key_l, cell_mid(2, 3), y_mid))
|
||||
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:
|
||||
y_group = body_y(row_index) - row_h
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:mg:{row_index}", group_m, cell_mid(4, 5), y_group)
|
||||
)
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:ms:{row_index}", sub_m, cell_mid(5, 6), y_mid)
|
||||
)
|
||||
entities.append(value_text(key_m, cell_mid(6, 7), y_mid))
|
||||
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:
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:r:{row_index}", label_r, cell_mid(8, 9), y_mid)
|
||||
)
|
||||
put(row, body_edges[8], body_edges[9], label_r, TABLE_LABEL_COLOR)
|
||||
if key_r is not None:
|
||||
entities.append(value_text(key_r, cell_mid(9, 10), y_mid))
|
||||
return entities
|
||||
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 extract_quantity_table(
|
||||
drawing_id: str, drawing: dict[str, Any]
|
||||
) -> dict[str, float | None] | None:
|
||||
"""확정 도면 JSON에서 수량 산출표 Text 값을 결정적 id로 역추출한다.
|
||||
def _table_values_from_cells(entities: list[Any]) -> dict[str, float | None]:
|
||||
"""표 객체의 칸에서 값을 읽는다. 칸의 key가 어느 수량인지 알려 준다."""
|
||||
table: dict[str, float | None] = {}
|
||||
for entity in entities:
|
||||
if not isinstance(entity, dict) or entity.get("type") != "Table":
|
||||
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
|
||||
|
||||
값 셀 id = uuid5(NS, "{drawing_id}:qtable:{key}"). 하나도 없으면 None을
|
||||
반환해 호출부가 요청 본문 quantity_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
|
||||
}
|
||||
entities = drawing.get("entities")
|
||||
if not isinstance(entities, list):
|
||||
return None
|
||||
table: dict[str, float | None] = {}
|
||||
found = False
|
||||
for entity in entities:
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
key = id_to_key.get(str(entity.get("id")))
|
||||
if key is None:
|
||||
continue
|
||||
found = True
|
||||
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
|
||||
if not found:
|
||||
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)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user