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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<TShapeJsonData = ShapeJsonData> {
|
||||
|
||||
@@ -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<TableStyleOptions>
|
||||
) {
|
||||
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<TableCell>): 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<JsonEntity<TableJsonData> | 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<TableJsonData>): Promise<TableEntity> {
|
||||
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<TableStyleOptions>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+21
-15
@@ -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<PolyLineJsonData>);
|
||||
case EntityName.Hatch:
|
||||
return HatchEntity.fromJson(entity as JsonEntity<HatchJsonData>);
|
||||
case EntityName.Table:
|
||||
return TableEntity.fromJson(entity as JsonEntity<TableJsonData>);
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid entity type: ${entity.type}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<ReturnType<typeof findTableCell>>) => 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 '병합을 풀었습니다.';
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user