From ea5ce92725d98ce7b7c566aba3298644d50fc6c5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 30 Aug 2026 15:12:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=91=9C=EB=A5=BC=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EC=96=B4=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=EC=9D=98=20=ED=91=9C=EA=B0=80=20=EC=A7=84=EC=A7=9C=20?= =?UTF-8?q?=ED=91=9C=EA=B0=80=20=EB=90=98=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 표가 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) --- .../B07_DesignDetail_Engine_Cad.py | 39 ++ .../B07_DesignDetail_Engine_Cad_Basin.py | 102 ++--- .../B07_DesignDetail_Engine_Cad_Table.py | 295 +++++++------- .../src/commands/commands.annotate.ts | 55 ++- .../src/drawControllers/DrawController.ts | 108 +++--- .../openwebcad/src/entities/Entity.ts | 5 +- .../openwebcad/src/entities/TableEntity.ts | 361 ++++++++++++++++++ .../openwebcad/src/helpers/grips.ts | 35 +- .../import-entities-from-json.ts | 36 +- .../openwebcad/src/helpers/table-geometry.ts | 225 +++++++++++ .../openwebcad/src/ribbon/ribbon.config.ts | 6 +- B07_DesignDetail/openwebcad/src/tools.ts | 7 +- .../src/tools/annotate/leader-table-tools.ts | 59 +-- .../src/tools/annotate/table-tools.ts | 192 ++++++++++ .../src/tools/modify/modify.helpers.ts | 44 ++- 15 files changed, 1207 insertions(+), 362 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/entities/TableEntity.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/annotate/table-tools.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index ac460ad2..e43b549f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -140,6 +140,45 @@ def _text_entity( } +def table_entity( + seed: str, + origin: tuple[float, float], + column_widths: list[float], + row_heights: list[float], + cells: list[list[dict[str, Any] | None]], + layer_id: str, + color: str, + font_size: float, + text_color: str, + padding: float = 1.0, +) -> dict[str, Any]: + """표 하나를 openwebcad Table 엔티티로 직렬화한다. + + origin은 표의 좌측 상단이다. cells[r][c]는 칸 하나이고 None은 병합에 먹힌 자리다. + 칸은 {"text", "colSpan", "rowSpan", "align", "color", "fontSize", "bold", "italic"}를 갖는다. + 격자선은 담지 않는다 — 병합 자리에서 선을 끊는 규칙은 표가 스스로 안다. + """ + return { + "id": str(uuid5(_ENTITY_NS, seed)), + "type": "Table", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": { + "origin": {"x": origin[0], "y": origin[1]}, + "columnWidths": list(column_widths), + "rowHeights": list(row_heights), + "cells": cells, + "style": { + "fontSize": font_size, + "fontFamily": "sans-serif", + "textColor": text_color, + "padding": padding, + }, + }, + } + + def _layer(layer_id: str, name: str, locked: bool = False) -> dict[str, Any]: return {"id": layer_id, "name": name, "isVisible": True, "isLocked": locked} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index c1e63af0..c03ba318 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -26,10 +26,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( TABLE_VALUE_COLOR, _format, _layer, - _line_entity, _text_entity, polyline_entity, station_no_label, + table_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( compass_entities, @@ -215,88 +215,42 @@ def _info_box_entities( origin: tuple[float, float], interval_m: float, ) -> list[dict[str, Any]]: - """구역 하나의 정보표 박스(머리행 + 3행).""" - x0, y0 = origin # 박스 좌측 상단 - entities: list[dict[str, Any]] = [] - rows = len(_BOX_ROWS) + 1 - bottom = y0 - rows * _BOX_ROW_H - for index in range(rows + 1): - y = y0 - index * _BOX_ROW_H - entities.append( - _line_entity( - f"{drawing_id}:box:{number}:h:{index}", - (x0, y), - (x0 + _BOX_WIDTH, y), - TABLE_LAYER_ID, - color, - ) - ) - for index, x in enumerate((x0, x0 + _BOX_LABEL_W, x0 + _BOX_WIDTH)): - entities.append( - _line_entity( - f"{drawing_id}:box:{number}:v:{index}", - (x, y0), - (x, bottom), - TABLE_LAYER_ID, - color, - ) - ) + """구역 하나의 정보표(머리행 + 3행)를 표 객체 하나로 만든다. - head_y = y0 - _BOX_ROW_H / 2.0 + 선과 문자를 따로 두지 않는다 — 표로 두어야 나중에 DXF의 표로 나갈 수 있다. + """ station = station_no_label(_number(props.get("chainage_m")), interval_m) - entities.append( - _text_entity( - f"{drawing_id}:box:{number}:head:left", - f"({number}) {station}", - x0 + _BOX_LABEL_W / 2.0, - head_y, - TABLE_LAYER_ID, - _FONT_SIZE, - TABLE_LABEL_COLOR, - ) - ) - entities.append( - _text_entity( - f"{drawing_id}:box:{number}:head:right", - f"배수규격 {_diameter_label(props)}", - x0 + _BOX_LABEL_W + (_BOX_WIDTH - _BOX_LABEL_W) / 2.0, - head_y, - TABLE_LAYER_ID, - _FONT_SIZE, - TABLE_VALUE_COLOR, - ) - ) - values = { "area": f"{_number(props.get('area_m2')) / 10000.0:.2f} ha", "relief": f"{_format(_number(props.get('relief_m')), 1)} m", "flow": f"{_format(_number(props.get('flow_length_m')), 1)} m", } - for index, (label, key) in enumerate(_BOX_ROWS): - center_y = y0 - (index + 1.5) * _BOX_ROW_H - entities.append( - _text_entity( - f"{drawing_id}:box:{number}:label:{key}", - label, - x0 + _BOX_LABEL_W / 2.0, - center_y, - TABLE_LAYER_ID, - _FONT_SIZE, - TABLE_LABEL_COLOR, - ) + cells: list[list[dict[str, Any] | None]] = [ + [ + {"text": f"({number}) {station}", "color": TABLE_LABEL_COLOR}, + {"text": f"배수규격 {_diameter_label(props)}", "color": TABLE_VALUE_COLOR}, + ] + ] + for label, key in _BOX_ROWS: + cells.append( + [ + {"text": label, "color": TABLE_LABEL_COLOR}, + {"text": values[key], "color": TABLE_VALUE_COLOR}, + ] ) - entities.append( - _text_entity( - f"{drawing_id}:box:{number}:value:{key}", - values[key], - x0 + _BOX_LABEL_W + (_BOX_WIDTH - _BOX_LABEL_W) / 2.0, - center_y, - TABLE_LAYER_ID, - _FONT_SIZE, - TABLE_VALUE_COLOR, - ) + return [ + table_entity( + f"{drawing_id}:box:{number}", + origin, + [_BOX_LABEL_W, _BOX_WIDTH - _BOX_LABEL_W], + [_BOX_ROW_H] * (len(_BOX_ROWS) + 1), + cells, + TABLE_LAYER_ID, + color, + _FONT_SIZE, + TABLE_VALUE_COLOR, ) - return entities + ] def build_watershed_drawing( diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index a401c0cd..20844ec4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -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) diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts index 631aadf5..c738faeb 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts @@ -1,6 +1,5 @@ /** 주석 탭 명령 (조사표 5절 전 항목) */ import { toast } from 'react-toastify'; -import type { CadCommand } from './command.types'; import { setActiveRibbonTab } from '../components/ui-state'; import { Tool } from '../tools'; import { @@ -28,16 +27,24 @@ import { mleaderStyleToolStateMachine, mleaderToolStateMachine, revCloudToolStateMachine, - tableStyleToolStateMachine, - tableToolStateMachine, updateDimensions, } from '../tools/annotate/leader-table-tools'; +import { + tableColumnToolStateMachine, + tableEditToolStateMachine, + tableMergeToolStateMachine, + tableRowToolStateMachine, + tableStyleToolStateMachine, + tableToolStateMachine, + tableUnmergeToolStateMachine, +} from '../tools/annotate/table-tools'; import { findToolStateMachine, mtextToolStateMachine, textEditToolStateMachine, textToolStateMachine, } from '../tools/annotate/text-tools'; +import type { CadCommand } from './command.types'; export const TEXT_COMMANDS: CadCommand[] = [ { @@ -273,10 +280,50 @@ export const TABLE_COMMANDS: CadCommand[] = [ label: '테이블 스타일', aliases: ['TS'], glyph: '⚙', - hint: '표의 열 너비와 행 높이를 정한다', + hint: '새 표의 기본 열 너비와 행 높이를 정한다', tool: Tool.TABLESTYLE, machine: tableStyleToolStateMachine, }, + { + id: 'TABLEEDIT', + label: '칸 편집', + glyph: '✎', + hint: '표의 칸에 문자를 넣는다', + tool: Tool.TABLEEDIT, + machine: tableEditToolStateMachine, + }, + { + id: 'TABLEROW', + label: '행 넣기·지우기', + glyph: '⬒', + hint: '표의 행을 넣거나 지운다', + tool: Tool.TABLEROW, + machine: tableRowToolStateMachine, + }, + { + id: 'TABLECOL', + label: '열 넣기·지우기', + glyph: '◫', + hint: '표의 열을 넣거나 지운다', + tool: Tool.TABLECOL, + machine: tableColumnToolStateMachine, + }, + { + id: 'TABLEMERGE', + label: '칸 병합', + glyph: '⧉', + hint: '표의 여러 칸을 하나로 합친다', + tool: Tool.TABLEMERGE, + machine: tableMergeToolStateMachine, + }, + { + id: 'TABLEUNMERGE', + label: '병합 해제', + glyph: '⧅', + hint: '합친 칸을 원래대로 되돌린다', + tool: Tool.TABLEUNMERGE, + machine: tableUnmergeToolStateMachine, + }, ]; export const MARKUP_COMMANDS: CadCommand[] = [ diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/DrawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/DrawController.ts index 23696180..4193d18e 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/DrawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/DrawController.ts @@ -2,62 +2,64 @@ import { type Point, Vector } from '@flatten-js/core'; import { CANVAS_INPUT_FIELD_FONT_SIZE } from '../App.consts.ts'; export interface DrawController { - getCanvasSize(): Point; - getScreenScale(): number; - getScreenOffset(): Point; + getCanvasSize(): Point; + getScreenScale(): number; + getScreenOffset(): Point; - worldToTarget(worldCoordinate: Point): Point; - worldsToTargets(worldCoordinates: Point[]): Point[]; - targetToWorld(screenCoordinate: Point): Point; - targetsToWorlds(screenCoordinates: Point[]): Point[]; + worldToTarget(worldCoordinate: Point): Point; + worldsToTargets(worldCoordinates: Point[]): Point[]; + targetToWorld(screenCoordinate: Point): Point; + targetsToWorlds(screenCoordinates: Point[]): Point[]; - setLineStyles( - isHighlighted: boolean, - isSelected: boolean, - color: string, - lineWidth: number, - dash?: number[], - ): void; - setFillStyles(fillColor: string): void; - /** 0~1. 도면층·객체 투명도를 그릴 때 반영한다 */ - setOpacity(opacity: number): void; - clear(): void; - drawLine(startPoint: Point, endPoint: Point): void; - drawArc( - centerPoint: Point, - radius: number, - startAngle: number, - endAngle: number, - counterClockwise: boolean, - ): void; - drawText( - label: string, - basePoint: Point, - options: Partial<{ - textDirection?: Vector; - textAlign: 'left' | 'center' | 'right'; - textColor: string; - fontSize: number; - fontFamily: string; - }>, - ): void; - drawImage( - imageElement: HTMLImageElement, - xMin: number, - yMin: number, - width: number, - height: number, - angle: number, - ): void; - fillPolygon(...points: Point[]): void; + setLineStyles( + isHighlighted: boolean, + isSelected: boolean, + color: string, + lineWidth: number, + dash?: number[] + ): void; + setFillStyles(fillColor: string): void; + /** 0~1. 도면층·객체 투명도를 그릴 때 반영한다 */ + setOpacity(opacity: number): void; + clear(): void; + drawLine(startPoint: Point, endPoint: Point): void; + drawArc( + centerPoint: Point, + radius: number, + startAngle: number, + endAngle: number, + counterClockwise: boolean + ): void; + drawText( + label: string, + basePoint: Point, + options: Partial<{ + textDirection?: Vector; + textAlign: 'left' | 'center' | 'right'; + textColor: string; + fontSize: number; + fontFamily: string; + bold: boolean; + italic: boolean; + }> + ): void; + drawImage( + imageElement: HTMLImageElement, + xMin: number, + yMin: number, + width: number, + height: number, + angle: number + ): void; + fillPolygon(...points: Point[]): void; } export const DEFAULT_TEXT_OPTIONS = { - textDirection: new Vector(1, 0), - textAlign: 'center' as const, - textColor: '#FFF', - fontSize: CANVAS_INPUT_FIELD_FONT_SIZE, - fontFamily: 'sans-serif', - bold: false, - italic: false, + textDirection: new Vector(1, 0), + textAlign: 'center' as const, + textColor: '#FFF', + fontSize: CANVAS_INPUT_FIELD_FONT_SIZE, + fontFamily: 'sans-serif', + bold: false, + italic: false, }; diff --git a/B07_DesignDetail/openwebcad/src/entities/Entity.ts b/B07_DesignDetail/openwebcad/src/entities/Entity.ts index 3404c667..23dc7403 100644 --- a/B07_DesignDetail/openwebcad/src/entities/Entity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/Entity.ts @@ -4,11 +4,12 @@ import type { DrawController } from '../drawControllers/DrawController.ts'; import type { ArcJsonData } from './ArcEntity'; import type { ArrowHeadJsonData } from './ArrowHeadEntity.ts'; import type { CircleJsonData } from './CircleEntity'; +import type { HatchJsonData } from './HatchEntity'; import type { ImageJsonData } from './ImageEntity'; import type { LineEntity, LineJsonData } from './LineEntity'; import type { PointJsonData } from './PointEntity'; import type { RectangleJsonData } from './RectangleEntity'; -import type { HatchJsonData } from './HatchEntity'; +import type { TableJsonData } from './TableEntity.ts'; import type { TextJsonData } from './TextEntity.ts'; export interface Entity { @@ -63,6 +64,7 @@ export enum EntityName { Text = 'Text', PolyLine = 'PolyLine', Hatch = 'Hatch', + Table = 'Table', } export type ShapeJsonData = @@ -74,6 +76,7 @@ export type ShapeJsonData = | ImageJsonData | ArrowHeadJsonData | HatchJsonData + | TableJsonData | TextJsonData; export interface JsonEntity { diff --git a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts new file mode 100644 index 00000000..93e5ec55 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts @@ -0,0 +1,361 @@ +/** + * 표 객체 — 열별 폭·행별 높이·칸 문자·병합을 한 객체가 들고 있다. + * 선과 문자를 따로 두던 방식과 달리, 내보낼 때 진짜 표(DXF TABLE)로 낼지 선으로 낼지를 + * 그 시점에 고를 수 있다. + * + * ponytail: 회전·대칭은 지원하지 않는다 — 표는 축에 붙어 있고, 기울어진 표는 도면에서 + * 쓰지 않는다. 필요해지면 origin과 축 벡터를 들고 다니는 쪽으로 올린다. + */ +import type * as Flatten from '@flatten-js/core'; +import { Box, Point, Polygon, Segment } from '@flatten-js/core'; +import type { Shape, SnapPoint } from '../App.types'; +import { SnapPointType } from '../App.types'; +import type { DrawController } from '../drawControllers/DrawController'; +import { + type TableCell, + type TableCells, + cellRect, + cellTextAnchor, + columnEdges, + gridPoints, + normalizeCells, + rowEdges, + tableBorders, +} from '../helpers/table-geometry'; +import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts'; +import { type Entity, EntityName, type JsonEntity } from './Entity'; +import type { LineEntity } from './LineEntity.ts'; + +export interface TableStyleOptions { + /** 칸 문자 기본 크기 */ + fontSize: number; + fontFamily: string; + /** 칸 문자 기본색. 셀이 color를 따로 가지면 그쪽이 이긴다 */ + textColor: string; + /** 문자를 칸 좌우 끝에서 띄우는 거리 */ + padding: number; +} + +export const DEFAULT_TABLE_STYLE: TableStyleOptions = { + fontSize: 2.2, + fontFamily: 'Noto Sans KR', + textColor: '#ffffff', + padding: 1, +}; + +export class TableEntity implements Entity { + public id: string = crypto.randomUUID(); + public lineColor = '#fff'; + public lineWidth = 1; + public lineDash: number[] | undefined = undefined; + public layerId: string; + /** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */ + public opacity?: number; + /** GROUP으로 묶인 객체가 공유하는 식별자 */ + public groupId?: string; + + private origin: Point; + private columnWidths: number[]; + private rowHeights: number[]; + private cells: TableCells; + private style: TableStyleOptions; + + constructor( + layerId: string, + origin: Point, + columnWidths: number[], + rowHeights: number[], + cells?: TableCells, + style?: Partial + ) { + this.layerId = layerId; + this.origin = origin; + this.columnWidths = [...columnWidths]; + this.rowHeights = [...rowHeights]; + this.cells = normalizeCells(cells ?? [], rowHeights.length, columnWidths.length); + this.style = { ...DEFAULT_TABLE_STYLE, ...style }; + } + + // ── 읽기 (명령·그립이 쓴다) + public getOrigin(): Point { + return this.origin; + } + public getColumnWidths(): number[] { + return [...this.columnWidths]; + } + public getRowHeights(): number[] { + return [...this.rowHeights]; + } + public getCells(): TableCells { + return this.cells; + } + public getStyle(): TableStyleOptions { + return { ...this.style }; + } + public getCell(row: number, column: number): TableCell | null { + return this.cells[row]?.[column] ?? null; + } + + // ── 쓰기 (표 편집 명령이 쓴다) + public setCell(row: number, column: number, patch: Partial): void { + const current = this.cells[row]?.[column]; + if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다 + this.cells[row][column] = { ...current, ...patch }; + } + + public setColumnWidth(column: number, width: number): void { + if (column < 0 || column >= this.columnWidths.length) return; + this.columnWidths[column] = Math.max(1, width); + } + + public setRowHeight(row: number, height: number): void { + if (row < 0 || row >= this.rowHeights.length) return; + this.rowHeights[row] = Math.max(1, height); + } + + public insertRow(at: number, height?: number): void { + const index = Math.min(Math.max(0, at), this.rowHeights.length); + this.rowHeights.splice(index, 0, height ?? this.rowHeights[Math.max(0, index - 1)] ?? 5); + this.cells.splice( + index, + 0, + this.columnWidths.map(() => ({ text: '' }) as TableCell | null) + ); + } + + public deleteRow(at: number): void { + if (this.rowHeights.length <= 1) return; + this.rowHeights.splice(at, 1); + this.cells.splice(at, 1); + this.cells = normalizeCells(this.cells, this.rowHeights.length, this.columnWidths.length); + } + + public insertColumn(at: number, width?: number): void { + const index = Math.min(Math.max(0, at), this.columnWidths.length); + this.columnWidths.splice(index, 0, width ?? this.columnWidths[Math.max(0, index - 1)] ?? 20); + for (const row of this.cells) row.splice(index, 0, { text: '' }); + } + + public deleteColumn(at: number): void { + if (this.columnWidths.length <= 1) return; + this.columnWidths.splice(at, 1); + for (const row of this.cells) row.splice(at, 1); + this.cells = normalizeCells(this.cells, this.rowHeights.length, this.columnWidths.length); + } + + /** 앵커 칸에서 오른쪽·아래로 병합한다 */ + public mergeCells(row: number, column: number, colSpan: number, rowSpan: number): void { + const anchor = this.cells[row]?.[column]; + if (!anchor) return; + this.cells[row][column] = { + ...anchor, + colSpan: Math.max(1, colSpan), + rowSpan: Math.max(1, rowSpan), + }; + this.cells = normalizeCells(this.cells, this.rowHeights.length, this.columnWidths.length); + } + + public unmergeCells(row: number, column: number): void { + const anchor = this.cells[row]?.[column]; + if (!anchor) return; + this.cells[row][column] = { ...anchor, colSpan: 1, rowSpan: 1 }; + this.cells = normalizeCells(this.cells, this.rowHeights.length, this.columnWidths.length); + } + + // ── Entity + public draw( + drawController: DrawController, + parentHighlighted?: boolean, + parentSelected?: boolean + ): void { + drawController.setLineStyles( + parentHighlighted ?? isEntityHighlighted(this), + parentSelected ?? isEntitySelected(this), + this.lineColor, + this.lineWidth, + this.lineDash + ); + for (const border of tableBorders( + this.origin, + this.columnWidths, + this.rowHeights, + this.cells + )) { + drawController.drawLine(new Point(border.x1, border.y1), new Point(border.x2, border.y2)); + } + + for (let row = 0; row < this.rowHeights.length; row += 1) { + for (let column = 0; column < this.columnWidths.length; column += 1) { + const cell = this.cells[row]?.[column]; + if (!cell?.text) continue; + const rect = cellRect(this.origin, this.columnWidths, this.rowHeights, row, column, cell); + const anchor = cellTextAnchor(rect, cell.align, this.style.padding); + drawController.drawText(cell.text, new Point(anchor.x, anchor.y), { + textAlign: cell.align ?? 'center', + textColor: cell.color ?? this.style.textColor, + fontSize: cell.fontSize ?? this.style.fontSize, + fontFamily: this.style.fontFamily, + bold: cell.bold, + italic: cell.italic, + }); + } + } + } + + public move(x: number, y: number): void { + this.origin = new Point(this.origin.x + x, this.origin.y + y); + } + + public scale(scaleOrigin: Point, scaleFactor: number): void { + this.origin = new Point( + scaleOrigin.x + (this.origin.x - scaleOrigin.x) * scaleFactor, + scaleOrigin.y + (this.origin.y - scaleOrigin.y) * scaleFactor + ); + this.columnWidths = this.columnWidths.map((width) => width * scaleFactor); + this.rowHeights = this.rowHeights.map((height) => height * scaleFactor); + this.style = { ...this.style, fontSize: this.style.fontSize * scaleFactor }; + } + + public rotate(_rotateOrigin: Point, _angle: number): void { + // 표는 축에 붙어 있다 — 회전하지 않는다 (파일 머리 주석 참고) + } + + public mirror(_mirrorAxis: LineEntity): void { + // 표는 대칭하지 않는다 — 문자가 뒤집히면 읽을 수 없다 + } + + public clone(): TableEntity { + return new TableEntity( + getActiveLayerId(), + this.origin.clone(), + this.columnWidths, + this.rowHeights, + this.cells.map((row) => row.map((cell) => (cell ? { ...cell } : null))), + this.style + ); + } + + private borderSegments(): Segment[] { + return tableBorders(this.origin, this.columnWidths, this.rowHeights, this.cells).map( + (border) => new Segment(new Point(border.x1, border.y1), new Point(border.x2, border.y2)) + ); + } + + private outerPolygon(): Polygon { + const xs = columnEdges(this.origin.x, this.columnWidths); + const ys = rowEdges(this.origin.y, this.rowHeights); + const left = xs[0]; + const right = xs[xs.length - 1]; + const top = ys[0]; + const bottom = ys[ys.length - 1]; + return new Polygon([ + new Point(left, top), + new Point(right, top), + new Point(right, bottom), + new Point(left, bottom), + ]); + } + + public intersectsWithBox(selectionBox: Box): boolean { + return this.borderSegments().some((segment) => segment.intersect(selectionBox).length > 0); + } + + public isContainedInBox(selectionBox: Box): boolean { + return selectionBox.contains(this.getBoundingBox()); + } + + public distanceTo(shape: Shape): [number, Segment] | null { + let shortest: [number, Segment] | null = null; + for (const segment of this.borderSegments()) { + const info = segment.distanceTo(shape); + if (!shortest || info[0] < shortest[0]) shortest = info as [number, Segment]; + } + return shortest; + } + + public getBoundingBox(): Box { + const xs = columnEdges(this.origin.x, this.columnWidths); + const ys = rowEdges(this.origin.y, this.rowHeights); + return new Box(xs[0], ys[ys.length - 1], xs[xs.length - 1], ys[0]); + } + + public getShape(): Shape | null { + return this.outerPolygon(); + } + + public getSnapPoints(): SnapPoint[] { + return gridPoints(this.origin, this.columnWidths, this.rowHeights).map((point) => ({ + point: new Point(point.x, point.y), + type: SnapPointType.LineEndPoint, + })); + } + + public getIntersections(entity: Entity): Point[] { + const otherShape = entity.getShape(); + if (!otherShape) return []; + return this.borderSegments().flatMap((segment) => segment.intersect(otherShape)); + } + + public getFirstPoint(): Point | null { + return this.origin; + } + + public getSvgString(): string | null { + // SVG 내보내기는 draw()를 SvgDrawController로 다시 태우므로 여기서 만들 필요가 없다 + return null; + } + + public getType(): EntityName { + return EntityName.Table; + } + + public containsPointOnShape(point: Flatten.Point): boolean { + return this.borderSegments().some((segment) => segment.contains(point)); + } + + public async toJson(): Promise | null> { + return { + id: this.id, + type: EntityName.Table, + lineColor: this.lineColor, + lineWidth: this.lineWidth, + lineDash: this.lineDash, + layerId: this.layerId, + shapeData: { + origin: { x: this.origin.x, y: this.origin.y }, + columnWidths: [...this.columnWidths], + rowHeights: [...this.rowHeights], + cells: this.cells.map((row) => row.map((cell) => (cell ? { ...cell } : null))), + style: { ...this.style }, + }, + }; + } + + public static async fromJson(jsonEntity: JsonEntity): Promise { + if (!jsonEntity.shapeData) { + throw new Error('Invalid JSON entity of type Table: missing shapeData'); + } + const data = jsonEntity.shapeData; + const table = new TableEntity( + jsonEntity.layerId || getActiveLayerId(), + new Point(data.origin.x, data.origin.y), + data.columnWidths, + data.rowHeights, + data.cells, + data.style + ); + table.id = jsonEntity.id; + table.lineColor = jsonEntity.lineColor; + table.lineWidth = jsonEntity.lineWidth; + table.lineDash = jsonEntity.lineDash; + return table; + } +} + +export interface TableJsonData { + origin: { x: number; y: number }; + columnWidths: number[]; + rowHeights: number[]; + cells: TableCells; + style?: Partial; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/grips.ts b/B07_DesignDetail/openwebcad/src/helpers/grips.ts index 751e377e..ccfb3dda 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/grips.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/grips.ts @@ -12,14 +12,16 @@ import { LineEntity } from '../entities/LineEntity'; import { PointEntity } from '../entities/PointEntity'; import { PolyLineEntity } from '../entities/PolyLineEntity'; import { RectangleEntity } from '../entities/RectangleEntity'; +import { TableEntity } from '../entities/TableEntity'; import { TextEntity } from '../entities/TextEntity'; +import { columnEdges, rowEdges } from './table-geometry'; /** 두 점의 가운데 */ function midpoint(a: Point, b: Point): Point { return new Point((a.x + b.x) / 2, (a.y + b.y) / 2); } -export type GripKind = 'vertex' | 'midpoint' | 'center' | 'radius' | 'base'; +export type GripKind = 'vertex' | 'midpoint' | 'center' | 'radius' | 'base' | 'column' | 'row'; export interface Grip { point: Point; @@ -108,6 +110,20 @@ export function getGrips(entity: Entity): Grip[] { } return grips; } + if (entity instanceof TableEntity) { + // 좌측 상단은 표 전체를 옮기고, 열·행 경계는 폭·높이를 바꾼다 + const origin = entity.getOrigin(); + const xs = columnEdges(origin.x, entity.getColumnWidths()); + const ys = rowEdges(origin.y, entity.getRowHeights()); + const grips: Grip[] = [{ point: new Point(xs[0], ys[0]), kind: 'base', index: 0 }]; + for (let index = 1; index < xs.length; index += 1) { + grips.push({ point: new Point(xs[index], ys[0]), kind: 'column', index: index - 1 }); + } + for (let index = 1; index < ys.length; index += 1) { + grips.push({ point: new Point(xs[0], ys[index]), kind: 'row', index: index - 1 }); + } + return grips; + } if (entity instanceof TextEntity || entity instanceof PointEntity) { const point = entity.getFirstPoint(); return point ? [{ point, kind: 'base', index: 0 }] : []; @@ -155,6 +171,23 @@ export function applyGrip(entity: Entity, grip: Grip, target: Point): Entity | n const moved = vertices.map((vertex, index) => (index === grip.index ? target : vertex)); return polylineFromVertices(entity, moved); } + if (entity instanceof TableEntity) { + const copy = inherit(entity, entity.clone()); + const origin = entity.getOrigin(); + if (grip.kind === 'base') { + copy.move(target.x - origin.x, target.y - origin.y); + return copy; + } + if (grip.kind === 'column') { + // 끈 경계 왼쪽 열의 폭만 바꾼다 — 오른쪽 열들은 따라 밀린다 + const left = columnEdges(origin.x, entity.getColumnWidths())[grip.index]; + copy.setColumnWidth(grip.index, target.x - left); + return copy; + } + const top = rowEdges(origin.y, entity.getRowHeights())[grip.index]; + copy.setRowHeight(grip.index, top - target.y); + return copy; + } if (entity instanceof TextEntity || entity instanceof PointEntity) { const base = entity.getFirstPoint(); if (!base) return null; diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts index fd4519da..c121cf78 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts @@ -1,18 +1,22 @@ -import {compact} from 'es-toolkit'; -import {ArcEntity, type ArcJsonData} from '../../entities/ArcEntity'; -import {CircleEntity, type CircleJsonData} from '../../entities/CircleEntity'; -import {type Entity, EntityName, type JsonEntity} from '../../entities/Entity'; -import {HatchEntity, type HatchJsonData} from '../../entities/HatchEntity'; -import {ImageEntity, type ImageJsonData} from '../../entities/ImageEntity.ts'; -import {LineEntity, type LineJsonData} from '../../entities/LineEntity'; -import {MeasurementEntity, type MeasurementJsonData} from '../../entities/MeasurementEntity.ts'; -import {PointEntity, type PointJsonData} from '../../entities/PointEntity'; -import {PolyLineEntity, type PolyLineJsonData} from '../../entities/PolyLineEntity.ts'; -import {RectangleEntity, type RectangleJsonData} from '../../entities/RectangleEntity'; -import {TextEntity, type TextJsonData} from '../../entities/TextEntity.ts'; -import {setActiveLayerId, setEntities, setLayers} from '../../state'; -import {getNewLayer} from '../get-new-layer.ts'; -import type {JsonDrawingFileDeserialized, JsonDrawingFileSerialized,} from './export-entities-to-json'; +import { compact } from 'es-toolkit'; +import { ArcEntity, type ArcJsonData } from '../../entities/ArcEntity'; +import { CircleEntity, type CircleJsonData } from '../../entities/CircleEntity'; +import { type Entity, EntityName, type JsonEntity } from '../../entities/Entity'; +import { HatchEntity, type HatchJsonData } from '../../entities/HatchEntity'; +import { ImageEntity, type ImageJsonData } from '../../entities/ImageEntity.ts'; +import { LineEntity, type LineJsonData } from '../../entities/LineEntity'; +import { MeasurementEntity, type MeasurementJsonData } from '../../entities/MeasurementEntity.ts'; +import { PointEntity, type PointJsonData } from '../../entities/PointEntity'; +import { PolyLineEntity, type PolyLineJsonData } from '../../entities/PolyLineEntity.ts'; +import { RectangleEntity, type RectangleJsonData } from '../../entities/RectangleEntity'; +import { TableEntity, type TableJsonData } from '../../entities/TableEntity.ts'; +import { TextEntity, type TextJsonData } from '../../entities/TextEntity.ts'; +import { setActiveLayerId, setEntities, setLayers } from '../../state'; +import { getNewLayer } from '../get-new-layer.ts'; +import type { + JsonDrawingFileDeserialized, + JsonDrawingFileSerialized, +} from './export-entities-to-json'; /** * Open a file selection dialog to select *.json files @@ -63,6 +67,8 @@ export async function getEntitiesAndLayersFromJsonObject( return PolyLineEntity.fromJson(entity as JsonEntity); case EntityName.Hatch: return HatchEntity.fromJson(entity as JsonEntity); + case EntityName.Table: + return TableEntity.fromJson(entity as JsonEntity); default: throw new Error(`Invalid entity type: ${entity.type}`); diff --git a/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts b/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts new file mode 100644 index 00000000..aa74e65f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts @@ -0,0 +1,225 @@ +/** + * 표 기하 — 셀 사각형과 그려야 할 경계선을 계산한다. 상태가 없는 순수 함수라 + * 화면·SVG·내보내기가 같은 규칙을 쓴다. + * + * 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은 + * 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다. + */ + +export interface TableCell { + text: string; + /** 오른쪽으로 합칠 칸 수 (기본 1) */ + colSpan?: number; + /** 아래로 합칠 칸 수 (기본 1) */ + rowSpan?: number; + align?: 'left' | 'center' | 'right'; + /** 문자 색. 없으면 표 기본색 (라벨·값 색을 칸마다 달리 쓰려고 둔다) */ + color?: string; + fontSize?: number; + bold?: boolean; + italic?: boolean; + /** 백엔드가 값을 되읽을 때 쓰는 이름 (수량 산출표의 항목 키). 화면 동작에는 쓰지 않는다 */ + key?: string; +} + +/** null은 앞 칸의 병합에 먹힌 자리다 */ +export type TableCells = (TableCell | null)[][]; + +export interface CellRect { + left: number; + right: number; + top: number; + bottom: number; +} + +/** 열 경계 x 좌표 (길이 = 열 수 + 1) */ +export function columnEdges(originX: number, columnWidths: number[]): number[] { + const edges = [originX]; + for (const width of columnWidths) edges.push(edges[edges.length - 1] + width); + return edges; +} + +/** 행 경계 y 좌표. 표는 좌측 상단이 원점이라 아래로 내려간다 (길이 = 행 수 + 1) */ +export function rowEdges(originY: number, rowHeights: number[]): number[] { + const edges = [originY]; + for (const height of rowHeights) edges.push(edges[edges.length - 1] - height); + return edges; +} + +const span = (value: number | undefined) => Math.max(1, Math.round(value ?? 1)); + +/** + * 칸마다 그 칸을 소유한 앵커 셀의 위치를 적어 둔 표. + * 병합된 칸은 앵커와 같은 값을 갖는다 — 경계선을 그릴지 판단하는 데 쓴다. + */ +export function cellOwners(cells: TableCells, rows: number, columns: number): (string | null)[][] { + const owners: (string | null)[][] = Array.from({ length: rows }, () => + Array.from({ length: columns }, () => null as string | null) + ); + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const cell = cells[row]?.[column]; + if (!cell) continue; + const key = `${row}:${column}`; + const rowEnd = Math.min(rows, row + span(cell.rowSpan)); + const columnEnd = Math.min(columns, column + span(cell.colSpan)); + for (let r = row; r < rowEnd; r += 1) { + for (let c = column; c < columnEnd; c += 1) { + owners[r][c] = key; + } + } + } + } + return owners; +} + +/** 앵커 셀이 차지하는 사각형 (병합 반영) */ +export function cellRect( + origin: { x: number; y: number }, + columnWidths: number[], + rowHeights: number[], + row: number, + column: number, + cell: TableCell | null +): CellRect { + const xs = columnEdges(origin.x, columnWidths); + const ys = rowEdges(origin.y, rowHeights); + const columnEnd = Math.min(columnWidths.length, column + span(cell?.colSpan)); + const rowEnd = Math.min(rowHeights.length, row + span(cell?.rowSpan)); + return { + left: xs[column], + right: xs[columnEnd], + top: ys[row], + bottom: ys[rowEnd], + }; +} + +export interface BorderSegment { + x1: number; + y1: number; + x2: number; + y2: number; +} + +/** + * 그려야 할 경계선. 바깥 테두리는 항상 긋고, 안쪽 선은 양옆(위아래) 칸이 같은 병합 셀에 + * 속하면 긋지 않는다 — 병합 자리에서 선을 끊는 규칙을 표가 스스로 안다. + */ +export function tableBorders( + origin: { x: number; y: number }, + columnWidths: number[], + rowHeights: number[], + cells: TableCells +): BorderSegment[] { + const rows = rowHeights.length; + const columns = columnWidths.length; + if (!rows || !columns) return []; + const xs = columnEdges(origin.x, columnWidths); + const ys = rowEdges(origin.y, rowHeights); + const owners = cellOwners(cells, rows, columns); + const borders: BorderSegment[] = []; + + // 바깥 테두리 + borders.push({ x1: xs[0], y1: ys[0], x2: xs[columns], y2: ys[0] }); + borders.push({ x1: xs[0], y1: ys[rows], x2: xs[columns], y2: ys[rows] }); + borders.push({ x1: xs[0], y1: ys[0], x2: xs[0], y2: ys[rows] }); + borders.push({ x1: xs[columns], y1: ys[0], x2: xs[columns], y2: ys[rows] }); + + // 안쪽 세로선 + for (let column = 1; column < columns; column += 1) { + for (let row = 0; row < rows; row += 1) { + const left = owners[row][column - 1]; + const right = owners[row][column]; + if (left !== null && left === right) continue; // 병합 구간 — 선을 끊는다 + borders.push({ x1: xs[column], y1: ys[row], x2: xs[column], y2: ys[row + 1] }); + } + } + + // 안쪽 가로선 + for (let row = 1; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const above = owners[row - 1][column]; + const below = owners[row][column]; + if (above !== null && above === below) continue; + borders.push({ x1: xs[column], y1: ys[row], x2: xs[column + 1], y2: ys[row] }); + } + } + + return borders; +} + +/** 격자 교차점 — 스냅점으로 쓴다 */ +export function gridPoints( + origin: { x: number; y: number }, + columnWidths: number[], + rowHeights: number[] +): { x: number; y: number }[] { + const points: { x: number; y: number }[] = []; + for (const x of columnEdges(origin.x, columnWidths)) { + for (const y of rowEdges(origin.y, rowHeights)) points.push({ x, y }); + } + return points; +} + +/** 셀 문자를 놓을 자리. align에 따라 좌우 여백을 준다 */ +export function cellTextAnchor( + rect: CellRect, + align: TableCell['align'] = 'center', + padding = 1 +): { x: number; y: number } { + const y = (rect.top + rect.bottom) / 2; + if (align === 'left') return { x: rect.left + padding, y }; + if (align === 'right') return { x: rect.right - padding, y }; + return { x: (rect.left + rect.right) / 2, y }; +} + +/** 클릭 지점이 들어 있는 칸 (앵커 위치로 돌려준다). 표 밖이면 null */ +export function cellAt( + origin: { x: number; y: number }, + columnWidths: number[], + rowHeights: number[], + cells: TableCells, + point: { x: number; y: number } +): { row: number; column: number } | null { + const xs = columnEdges(origin.x, columnWidths); + const ys = rowEdges(origin.y, rowHeights); + if (point.x < xs[0] || point.x > xs[xs.length - 1]) return null; + if (point.y > ys[0] || point.y < ys[ys.length - 1]) return null; + + let column = columnWidths.length - 1; + for (let index = 0; index < columnWidths.length; index += 1) { + if (point.x <= xs[index + 1]) { + column = index; + break; + } + } + let row = rowHeights.length - 1; + for (let index = 0; index < rowHeights.length; index += 1) { + if (point.y >= ys[index + 1]) { + row = index; + break; + } + } + + const owner = cellOwners(cells, rowHeights.length, columnWidths.length)[row][column]; + if (!owner) return { row, column }; + const [ownerRow, ownerColumn] = owner.split(':').map(Number); + return { row: ownerRow, column: ownerColumn }; +} + +/** 모든 칸이 자기 자리를 갖도록 채운 셀 표 (행·열을 늘렸을 때 쓴다) */ +export function normalizeCells(cells: TableCells, rows: number, columns: number): TableCells { + // 병합 관계는 원본에서 먼저 읽는다 — 빈 칸을 채운 뒤에 읽으면 병합이 풀린다 + const owners = cellOwners(cells, rows, columns); + const normalized: TableCells = []; + for (let row = 0; row < rows; row += 1) { + const line: (TableCell | null)[] = []; + for (let column = 0; column < columns; column += 1) { + const owner = owners[row][column]; + const coveredByMerge = owner !== null && owner !== `${row}:${column}`; + line.push(coveredByMerge ? null : (cells[row]?.[column] ?? { text: '' })); + } + normalized.push(line); + } + return normalized; +} diff --git a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts index bf8e4b3d..487f2955 100644 --- a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts +++ b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts @@ -178,7 +178,11 @@ export const RIBBON_TABS: RibbonTab[] = [ big: ['MLEADER'], commands: ['MLEADERSTYLE', 'MLEADERALIGN'], }, - { label: '표', commands: ['TABLE', 'TABLESTYLE'] }, + { + label: '표', + commands: ['TABLE', 'TABLEEDIT'], + overflow: ['TABLESTYLE', 'TABLEROW', 'TABLECOL', 'TABLEMERGE', 'TABLEUNMERGE'], + }, { label: '표식', commands: ['REVCLOUD', 'ANNOSCALE'] }, ], }, diff --git a/B07_DesignDetail/openwebcad/src/tools.ts b/B07_DesignDetail/openwebcad/src/tools.ts index ea00c405..1f051dab 100644 --- a/B07_DesignDetail/openwebcad/src/tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools.ts @@ -114,9 +114,14 @@ export enum Tool { MLEADERALIGN = 'MLEADERALIGN', TABLE = 'TABLE', TABLESTYLE = 'TABLESTYLE', + TABLEEDIT = 'TABLEEDIT', + TABLEROW = 'TABLEROW', + TABLECOL = 'TABLECOL', + TABLEMERGE = 'TABLEMERGE', + TABLEUNMERGE = 'TABLEUNMERGE', REVCLOUD = 'REVCLOUD', ANNOSCALE = 'ANNOSCALE', COLOR = 'COLOR', LINETYPE = 'LINETYPE', - LWEIGHT = 'LWEIGHT' + LWEIGHT = 'LWEIGHT', } diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/leader-table-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/leader-table-tools.ts index d0c46cf4..923671f3 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/leader-table-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/leader-table-tools.ts @@ -4,11 +4,8 @@ import { toast } from 'react-toastify'; import { getAnnotationScale, getDimTextHeight, - getTableColumnWidth, - getTableRowHeight, setAnnotationScale, setDimStyle, - setTableStyle, } from '../../commands/dim-settings'; import type { Entity } from '../../entities/Entity'; import { EntityName } from '../../entities/Entity'; @@ -39,7 +36,10 @@ export const mleaderToolStateMachine = createSequenceTool({ const landingLength = textHeight * 2; const toRight = knee.x >= tip.x; const landingEnd = new Point(knee.x + (toRight ? landingLength : -landingLength), knee.y); - const label = annotationText(input.text(2), new Point(landingEnd.x, landingEnd.y + textHeight * 0.4)); + const label = annotationText( + input.text(2), + new Point(landingEnd.x, landingEnd.y + textHeight * 0.4) + ); const groupId = crypto.randomUUID(); const parts: Entity[] = [ lineEntity(tip, knee), @@ -121,57 +121,6 @@ export const annotationScaleToolStateMachine = createSequenceTool({ }, }); -export const tableStyleToolStateMachine = createSequenceTool({ - tool: Tool.TABLESTYLE, - helpers: false, - steps: [ - { kind: 'number', instructions: '열 너비를 입력하십시오 <40>.', defaultValue: 40 }, - { kind: 'number', instructions: '행 높이를 입력하십시오 <10>.', defaultValue: 10 }, - ], - commit: (input) => { - setTableStyle(input.number(0), input.number(1)); - toast.success(`표 스타일: 열 ${input.number(0)} · 행 ${input.number(1)}`); - }, -}); - -export const tableToolStateMachine = createSequenceTool({ - tool: Tool.TABLE, - steps: [ - { kind: 'number', instructions: '열 수를 입력하십시오 <3>.', defaultValue: 3 }, - { kind: 'number', instructions: '행 수를 입력하십시오 <3>.', defaultValue: 3 }, - { kind: 'point', instructions: '표의 좌측 상단 삽입점을 지정하십시오.' }, - ], - commit: (input) => { - const columns = Math.max(1, Math.round(input.number(0))); - const rows = Math.max(1, Math.round(input.number(1))); - const origin = input.point(2); - const width = getTableColumnWidth(); - const height = getTableRowHeight(); - const groupId = crypto.randomUUID(); - const parts: Entity[] = []; - - for (let column = 0; column <= columns; column++) { - parts.push( - lineEntity( - new Point(origin.x + column * width, origin.y), - new Point(origin.x + column * width, origin.y - rows * height) - ) - ); - } - for (let row = 0; row <= rows; row++) { - parts.push( - lineEntity( - new Point(origin.x, origin.y - row * height), - new Point(origin.x + columns * width, origin.y - row * height) - ) - ); - } - for (const part of parts) part.groupId = groupId; - addEntities(parts, true); - toast.info('표를 만들었습니다. 칸 내용은 문자(TEXT) 명령으로 채우십시오.'); - }, -}); - export const revCloudToolStateMachine = createSequenceTool({ tool: Tool.REVCLOUD, steps: [ diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/table-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/table-tools.ts new file mode 100644 index 00000000..2589ed15 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/table-tools.ts @@ -0,0 +1,192 @@ +/** + * 표 명령 — 표 객체 하나를 만들고 칸을 고친다 (조사표 5절 표 · 12절 테이블 셀). + * 셀을 고르는 방법은 이 CAD의 다른 명령과 같다: 점을 찍으면 그 자리의 칸을 집는다. + */ +import type { Point } from '@flatten-js/core'; +import { toast } from 'react-toastify'; +import { getTableColumnWidth, getTableRowHeight, setTableStyle } from '../../commands/dim-settings'; +import type { Entity } from '../../entities/Entity'; +import { TableEntity } from '../../entities/TableEntity'; +import { cellAt } from '../../helpers/table-geometry'; +import { + addEntities, + getActiveLayerId, + getActiveTextStyle, + getEntities, + setEntities, +} from '../../state'; +import { Tool } from '../../tools'; +import { createSequenceTool } from '../factories/sequence-tool'; + +/** 점이 놓인 표와 그 칸. 표를 못 찾으면 null */ +function findTableCell(point: Point): { table: TableEntity; row: number; column: number } | null { + for (const entity of getEntities()) { + if (!(entity instanceof TableEntity)) continue; + const hit = cellAt( + entity.getOrigin(), + entity.getColumnWidths(), + entity.getRowHeights(), + entity.getCells(), + point + ); + if (hit) return { table: entity, row: hit.row, column: hit.column }; + } + return null; +} + +/** 표를 고친 뒤 화면·실행취소에 반영한다 */ +function commitTableChange(table: TableEntity): void { + setEntities( + getEntities().map((entity: Entity) => (entity.id === table.id ? table : entity)), + true + ); +} + +function withTableAt( + point: Point, + apply: (hit: NonNullable>) => string | null +): void { + const hit = findTableCell(point); + if (!hit) { + toast.info('표 안의 칸을 지정하십시오.'); + return; + } + const message = apply(hit); + commitTableChange(hit.table); + if (message) toast.success(message); +} + +export const tableToolStateMachine = createSequenceTool({ + tool: Tool.TABLE, + steps: [ + { kind: 'number', instructions: '열 수를 입력하십시오 <3>.', defaultValue: 3 }, + { kind: 'number', instructions: '행 수를 입력하십시오 <3>.', defaultValue: 3 }, + { kind: 'point', instructions: '표의 좌측 상단 삽입점을 지정하십시오.' }, + ], + commit: (input) => { + const columns = Math.max(1, Math.round(input.number(0))); + const rows = Math.max(1, Math.round(input.number(1))); + const origin = input.point(2); + const table = new TableEntity( + getActiveLayerId(), + origin, + Array.from({ length: columns }, () => getTableColumnWidth()), + Array.from({ length: rows }, () => getTableRowHeight()), + undefined, + { fontSize: getActiveTextStyle().fontSize, textColor: getActiveTextStyle().textColor } + ); + addEntities([table], true); + toast.info('표를 만들었습니다. 칸 내용은 TABLEEDIT 명령으로 채우십시오.'); + }, +}); + +export const tableStyleToolStateMachine = createSequenceTool({ + tool: Tool.TABLESTYLE, + helpers: false, + steps: [ + { kind: 'number', instructions: '열 너비를 입력하십시오 <40>.', defaultValue: 40 }, + { kind: 'number', instructions: '행 높이를 입력하십시오 <10>.', defaultValue: 10 }, + ], + commit: (input) => { + setTableStyle(input.number(0), input.number(1)); + toast.success(`표 스타일: 열 ${input.number(0)} · 행 ${input.number(1)}`); + }, +}); + +export const tableEditToolStateMachine = createSequenceTool({ + tool: Tool.TABLEEDIT, + steps: [ + { kind: 'point', instructions: '내용을 채울 칸을 지정하십시오.' }, + { kind: 'text', instructions: '칸에 넣을 문자를 입력하십시오.' }, + ], + commit: (input) => { + const text = input.text(1); + withTableAt(input.point(0), (hit) => { + hit.table.setCell(hit.row, hit.column, { text }); + return `칸에 '${text}'를 넣었습니다.`; + }); + }, +}); + +export const tableRowToolStateMachine = createSequenceTool({ + tool: Tool.TABLEROW, + steps: [ + { kind: 'point', instructions: '기준이 될 칸을 지정하십시오.' }, + { + kind: 'number', + instructions: '위에 넣으려면 1, 아래에 넣으려면 2, 지우려면 0 <1>.', + defaultValue: 1, + }, + ], + commit: (input) => { + const mode = Math.round(input.number(1)); + withTableAt(input.point(0), (hit) => { + if (mode === 0) { + hit.table.deleteRow(hit.row); + return '행을 지웠습니다.'; + } + hit.table.insertRow(mode === 2 ? hit.row + 1 : hit.row); + return '행을 넣었습니다.'; + }); + }, +}); + +export const tableColumnToolStateMachine = createSequenceTool({ + tool: Tool.TABLECOL, + steps: [ + { kind: 'point', instructions: '기준이 될 칸을 지정하십시오.' }, + { + kind: 'number', + instructions: '왼쪽에 넣으려면 1, 오른쪽에 넣으려면 2, 지우려면 0 <1>.', + defaultValue: 1, + }, + ], + commit: (input) => { + const mode = Math.round(input.number(1)); + withTableAt(input.point(0), (hit) => { + if (mode === 0) { + hit.table.deleteColumn(hit.column); + return '열을 지웠습니다.'; + } + hit.table.insertColumn(mode === 2 ? hit.column + 1 : hit.column); + return '열을 넣었습니다.'; + }); + }, +}); + +export const tableMergeToolStateMachine = createSequenceTool({ + tool: Tool.TABLEMERGE, + steps: [ + { kind: 'point', instructions: '병합할 범위의 왼쪽 위 칸을 지정하십시오.' }, + { kind: 'point', instructions: '병합할 범위의 오른쪽 아래 칸을 지정하십시오.' }, + ], + commit: (input) => { + const end = findTableCell(input.point(1)); + withTableAt(input.point(0), (hit) => { + if (!end || end.table.id !== hit.table.id) { + toast.info('같은 표 안에서 두 칸을 지정하십시오.'); + return null; + } + const rowSpan = Math.abs(end.row - hit.row) + 1; + const colSpan = Math.abs(end.column - hit.column) + 1; + hit.table.mergeCells( + Math.min(hit.row, end.row), + Math.min(hit.column, end.column), + colSpan, + rowSpan + ); + return `칸 ${rowSpan}×${colSpan}을 합쳤습니다.`; + }); + }, +}); + +export const tableUnmergeToolStateMachine = createSequenceTool({ + tool: Tool.TABLEUNMERGE, + steps: [{ kind: 'point', instructions: '병합을 풀 칸을 지정하십시오.' }], + commit: (input) => { + withTableAt(input.point(0), (hit) => { + hit.table.unmergeCells(hit.row, hit.column); + return '병합을 풀었습니다.'; + }); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/modify.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/modify/modify.helpers.ts index d551b413..4344c197 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/modify.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/modify.helpers.ts @@ -2,7 +2,7 @@ * 수정 명령의 객체 조작 — 간격띄우기·연장·길이조정·분해·결합·중복정리. * 기하 계산은 helpers/geometry의 순수 함수를 쓰고, 여기서는 엔티티로 바꾼다. */ -import { Circle, Point, Segment } from '@flatten-js/core'; +import { Circle, Point, Segment, Vector } from '@flatten-js/core'; import { ArcEntity } from '../../entities/ArcEntity'; import { CircleEntity } from '../../entities/CircleEntity'; import { type Entity, EntityName } from '../../entities/Entity'; @@ -10,9 +10,12 @@ import type { HatchEntity } from '../../entities/HatchEntity'; import { LineEntity } from '../../entities/LineEntity'; import { PolyLineEntity } from '../../entities/PolyLineEntity'; import type { RectangleEntity } from '../../entities/RectangleEntity'; +import type { TableEntity } from '../../entities/TableEntity'; +import { TextEntity } from '../../entities/TextEntity'; import { dedupeConsecutive, sampleEntityPoints } from '../../helpers/geometry/sample-entity'; import { intersectLines, offsetPolylinePoints } from '../../helpers/geometry/shape-points'; import { polygonToSegments } from '../../helpers/polygon-to-segments'; +import { cellRect, cellTextAnchor, tableBorders } from '../../helpers/table-geometry'; import { getActiveLayerId } from '../../state'; /** 원본 객체의 표시 특성을 새 객체에 옮긴다 */ @@ -148,7 +151,11 @@ export function lengthenLine(entity: Entity, delta: number, nearPoint: Point): E shape.end ); } - return makeLine(entity, shape.start, new Point(shape.end.x + dx * delta, shape.end.y + dy * delta)); + return makeLine( + entity, + shape.start, + new Point(shape.end.x + dx * delta, shape.end.y + dy * delta) + ); } /** EXPLODE — 복합 객체를 구성요소로 나눈다. 나눌 게 없으면 빈 배열 */ @@ -163,6 +170,9 @@ export function explodeEntity(entity: Entity): Entity[] { makeLine(entity, segment.start, segment.end) ); } + if (entity.getType() === EntityName.Table) { + return explodeTable(entity as TableEntity); + } if (entity.getType() === EntityName.Hatch) { const boundary = makePolyLine(entity, (entity as HatchEntity).getPoints()); return boundary ? [boundary] : []; @@ -170,6 +180,36 @@ export function explodeEntity(entity: Entity): Entity[] { return []; } +/** 표를 경계선과 칸 문자로 흩는다 (EXPLODE) */ +function explodeTable(table: TableEntity): Entity[] { + const origin = table.getOrigin(); + const columnWidths = table.getColumnWidths(); + const rowHeights = table.getRowHeights(); + const style = table.getStyle(); + const parts: Entity[] = tableBorders(origin, columnWidths, rowHeights, table.getCells()).map( + (border) => makeLine(table, new Point(border.x1, border.y1), new Point(border.x2, border.y2)) + ); + for (let row = 0; row < rowHeights.length; row += 1) { + for (let column = 0; column < columnWidths.length; column += 1) { + const cell = table.getCell(row, column); + if (!cell?.text) continue; + const rect = cellRect(origin, columnWidths, rowHeights, row, column, cell); + const anchor = cellTextAnchor(rect, cell.align, style.padding); + const text = new TextEntity(table.layerId, cell.text, new Point(anchor.x, anchor.y), { + textDirection: new Vector(1, 0), + textAlign: cell.align ?? 'center', + textColor: cell.color ?? style.textColor, + fontSize: cell.fontSize ?? style.fontSize, + fontFamily: style.fontFamily, + bold: cell.bold, + italic: cell.italic, + }); + parts.push(copyStyle(table, text)); + } + } + return parts; +} + /** JOIN — 끝점이 맞닿는 객체들을 하나의 폴리선으로 잇는다 */ export function joinEntities(entities: Entity[], tolerance = 1e-3): PolyLineEntity | null { const chains = entities