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:
2026-08-30 15:12:19 +09:00
co-authored by Claude Opus 5
parent e599884888
commit ea5ce92725
15 changed files with 1207 additions and 362 deletions
@@ -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