Files
Aislo/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts
T
eomsangdonandClaude Opus 5.5 cc63e49ab3 feat(B07): DXF 내보내기 표 = 선 + 글 — 칸 정렬 · 병합 자리 끊김 · B07 수량표도 나옴 (41-2)
- Frame_Export: Table 갈래 table_parts(경계선 · 글 자리) · 정렬 MIDDLE_LEFT · CENTER · RIGHT · 높이 = 칸 → 표 글자 크기 · 전에는 Table 이 조용히 빠짐
- 짝 = openwebcad table-geometry.ts(두 파일 머리) · 거울 시험 test_b07_frame_export_table.py + helper_b07_table_geometry.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 22:48:34 +09:00

229 lines
7.8 KiB
TypeScript

/**
* 표 기하 — 셀 사각형과 그려야 할 경계선을 계산한다. 상태가 없는 순수 함수라
* 화면·SVG·내보내기가 같은 규칙을 쓴다.
*
* 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은
* 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다.
*
* 짝(CLAUDE.md 5장 ①): 서버 DXF 내보내기 `B07_DesignDetail_Engine_Frame_Export.table_parts`
* (`tableBorders` · `cellRect` · `cellTextAnchor`) — 거울 시험 `test_b07_frame_export_table.py`.
*/
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;
}