Files
Aislo/B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts
T

382 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 고른 범위 → 표 자료(M02 83장 · 도면 → 면적산출 한 번 옮기기). 상태 없는 순수 함수 — 엔티티 JSON(`toJson` 꼴)만 읽는다.
* ① 표 엔티티(Table) 칸 그대로(병합 = colSpan · rowSpan · 폭 · 높이)
* ② 선 + 글 격자 인식 — 가로 · 세로 선 경계를 허용오차로 묶고(PolyLine 자식 · 사각형 변 풀어 씀 ·
* 끊긴 · 겹친 선 합침) 안쪽 선 빠진 곳 = 병합 · 글 자리가 든 칸에 글
* ③ 둘 다 아님 글 자리로 줄(y 묶음) · 칸(x 차례) 폴백
* 「1,500」 같은 글은 숫자로. 선이 축에서 기운 것(회전된 표)은 ③ 으로 떨어진다.
* 칸 기하는 `table-geometry.ts`(cellOwners) 와 같은 규칙 — 서버 짝 판정은 `Engine_Frame_Export.table_parts`.
* 시험: `resources/tester/cad/table-from-drawing.test.ts`(node --experimental-strip-types).
*/
import { cellOwners, type TableCells } from './table-geometry.ts';
export type DrawingCellValue = string | number;
export interface DrawingTableMerge {
row: number;
col: number;
rowSpan: number;
colSpan: number;
}
export interface DrawingTable {
/** table = 표 엔티티 · grid = 선 격자 · text = 글 자리 폴백 */
kind: 'table' | 'grid' | 'text';
rows: DrawingCellValue[][];
merges: DrawingTableMerge[];
/** 도면 단위(mm) — 폴백(text)은 빈 배열(기본 폭) */
colWidths: number[];
rowHeights: number[];
}
/** 엔티티 JSON 중 읽는 칸만 */
export interface DrawingEntityJson {
type: string;
// biome-ignore lint/suspicious/noExplicitAny: 엔티티 종류마다 꼴이 달라 읽는 자리에서 가림
shapeData?: any;
children?: DrawingEntityJson[] | null;
}
interface Seg {
x1: number;
y1: number;
x2: number;
y2: number;
}
interface Label {
text: string;
x: number;
y: number;
size: number;
}
interface Interval {
at: number;
from: number;
to: number;
}
const NUMBER = /^[+\-−]?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$|^[+\-−]?\.\d+$/;
/** 「1,500」 · 「-2.5」 → 숫자 · 그 밖은 글 그대로(앞뒤 공백만 뗌) */
export function cellValue(text: string): DrawingCellValue {
const trimmed = text.trim();
if (!NUMBER.test(trimmed)) return trimmed;
const value = Number(trimmed.replace(/,/g, '').replace('−', '-'));
return Number.isFinite(value) ? value : trimmed;
}
const span = (value: number | undefined): number => Math.max(1, Math.round(value ?? 1));
/** ① 표 엔티티 → 표 자료 */
function fromTableEntity(data: {
columnWidths: number[];
rowHeights: number[];
cells: TableCells;
}): DrawingTable {
const rowCount = data.rowHeights.length;
const colCount = data.columnWidths.length;
const rows: DrawingCellValue[][] = [];
const merges: DrawingTableMerge[] = [];
for (let row = 0; row < rowCount; row += 1) {
const line: DrawingCellValue[] = [];
for (let col = 0; col < colCount; col += 1) {
const cell = data.cells[row]?.[col];
line.push(cell ? cellValue(cell.text ?? '') : '');
if (!cell) continue;
const rowSpan = Math.min(span(cell.rowSpan), rowCount - row);
const colSpan = Math.min(span(cell.colSpan), colCount - col);
if (rowSpan > 1 || colSpan > 1) merges.push({ row, col, rowSpan, colSpan });
}
rows.push(line);
}
return {
kind: 'table',
rows,
merges,
colWidths: [...data.columnWidths],
rowHeights: [...data.rowHeights],
};
}
/** 선 · 글 모으기 — PolyLine 자식 · 사각형 변을 풀어 씀 */
function collect(entities: DrawingEntityJson[], segs: Seg[], labels: Label[]): void {
for (const entity of entities) {
const data = entity.shapeData;
if (entity.type === 'Line' && data?.startPoint && data?.endPoint) {
segs.push({
x1: data.startPoint.x,
y1: data.startPoint.y,
x2: data.endPoint.x,
y2: data.endPoint.y,
});
} else if (entity.type === 'Rectangle' && Array.isArray(data?.points)) {
const points = data.points as { x: number; y: number }[];
for (let index = 0; index < points.length; index += 1) {
const a = points[index];
const b = points[(index + 1) % points.length];
segs.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
}
} else if (entity.type === 'PolyLine' && entity.children) {
collect(entity.children, segs, labels);
} else if (entity.type === 'Text' && data?.basePoint && String(data.label ?? '').trim()) {
labels.push(labelOf(data));
}
}
}
/** 글이 놓인 자리 — 자리표 칸이면 basePoint 가 가운데 · 아니면 기준선에서 글 높이 반쯤 위 */
function labelOf(data: {
label: string;
basePoint: { x: number; y: number };
options?: { fontSize?: number; boxWidth?: number; boxHeight?: number };
}): Label {
const size = data.options?.fontSize ?? 2.2;
const boxed = Boolean(data.options?.boxWidth && data.options?.boxHeight);
return {
text: data.label,
x: data.basePoint.x,
y: boxed ? data.basePoint.y : data.basePoint.y + size * 0.35,
size,
};
}
/** 값들을 허용오차 안에서 묶어 대표값(평균) 목록 — 오름차순 */
function cluster(values: number[], tol: number): number[] {
const sorted = [...values].sort((a, b) => a - b);
const groups: number[][] = [];
for (const value of sorted) {
const last = groups[groups.length - 1];
if (last && value - last[last.length - 1] <= tol) last.push(value);
else groups.push([value]);
}
return groups.map((group) => group.reduce((sum, value) => sum + value, 0) / group.length);
}
const nearest = (edges: number[], value: number, tol: number): number => {
let best = -1;
let bestGap = tol;
for (let index = 0; index < edges.length; index += 1) {
const gap = Math.abs(edges[index] - value);
if (gap <= bestGap) {
best = index;
bestGap = gap;
}
}
return best;
};
/** 한 경계 위 선 구간들이 [from, to] 를 거의(90%) 덮는가 — 끊긴 · 겹친 선은 합쳐 셈 */
function covers(intervals: Interval[], from: number, to: number, tol: number): boolean {
const parts = intervals
.map((part) => [Math.max(part.from, from), Math.min(part.to, to)] as const)
.filter(([a, b]) => b > a)
.sort((p, q) => p[0] - q[0]);
let covered = 0;
let reach = from;
for (const [a, b] of parts) {
const start = Math.max(a, reach);
if (b > start) covered += b - start;
reach = Math.max(reach, b);
}
return covered >= (to - from) * 0.9 - tol;
}
/** ② 선 + 글 격자 — 경계가 2×2 안 되거나 글이 반 넘게 격자 밖이면 null */
function fromGrid(segs: Seg[], labels: Label[]): DrawingTable | null {
const box = { x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity };
for (const s of segs) {
box.x0 = Math.min(box.x0, s.x1, s.x2);
box.x1 = Math.max(box.x1, s.x1, s.x2);
box.y0 = Math.min(box.y0, s.y1, s.y2);
box.y1 = Math.max(box.y1, s.y1, s.y2);
}
const extent = Math.max(box.x1 - box.x0, box.y1 - box.y0, 1e-9);
const tol = extent * 0.003;
const horizontal: Seg[] = [];
const vertical: Seg[] = [];
for (const s of segs) {
const dx = Math.abs(s.x2 - s.x1);
const dy = Math.abs(s.y2 - s.y1);
if (Math.max(dx, dy) <= tol) continue; // 점 같은 선
if (dy <= dx * 0.01) horizontal.push(s);
else if (dx <= dy * 0.01) vertical.push(s);
}
let colEdges = cluster(
vertical.map((s) => (s.x1 + s.x2) / 2),
tol
);
let rowEdges = cluster(
horizontal.map((s) => (s.y1 + s.y2) / 2),
tol
).reverse(); // 위 → 아래
const lineOf = (list: Seg[], edges: number[], vertical: boolean): Interval[][] => {
const byEdge: Interval[][] = edges.map(() => []);
for (const s of list) {
const at = vertical ? (s.x1 + s.x2) / 2 : (s.y1 + s.y2) / 2;
const index = nearest(edges, at, tol);
if (index < 0) continue;
const [a, b] = vertical ? [s.y1, s.y2] : [s.x1, s.x2];
byEdge[index].push({ at, from: Math.min(a, b), to: Math.max(a, b) });
}
return byEdge;
};
// 칸 경계를 하나도 덮지 못하는 짧은 선(눈금 · 지시선)의 경계는 뺀다 — 두 번이면 자리 잡음
for (let pass = 0; pass < 2; pass += 1) {
if (colEdges.length < 2 || rowEdges.length < 2) return null;
const vLines = lineOf(vertical, colEdges, true);
const hLines = lineOf(horizontal, rowEdges, false);
const keepCol = colEdges.map((_, i) =>
rowEdges.slice(1).some((y, j) => covers(vLines[i], y + tol, rowEdges[j] - tol, tol))
);
const keepRow = rowEdges.map((_, j) =>
colEdges.slice(1).some((x, i) => covers(hLines[j], colEdges[i] + tol, x - tol, tol))
);
colEdges = colEdges.filter((_, i) => keepCol[i]);
rowEdges = rowEdges.filter((_, j) => keepRow[j]);
}
if (colEdges.length < 2 || rowEdges.length < 2) return null;
const colCount = colEdges.length - 1;
const rowCount = rowEdges.length - 1;
const vLines = lineOf(vertical, colEdges, true);
const hLines = lineOf(horizontal, rowEdges, false);
// 안쪽 선 빠진 곳 = 같은 묶음(union-find)
const parent = Array.from({ length: rowCount * colCount }, (_, i) => i);
const find = (i: number): number => {
while (parent[i] !== i) {
parent[i] = parent[parent[i]];
i = parent[i];
}
return i;
};
const join = (a: number, b: number): void => {
parent[find(a)] = find(b);
};
for (let r = 0; r < rowCount; r += 1) {
for (let c = 1; c < colCount; c += 1) {
if (!covers(vLines[c], rowEdges[r + 1] + tol, rowEdges[r] - tol, tol))
join(r * colCount + c - 1, r * colCount + c);
}
}
for (let r = 1; r < rowCount; r += 1) {
for (let c = 0; c < colCount; c += 1) {
if (!covers(hLines[r], colEdges[c] + tol, colEdges[c + 1] - tol, tol))
join((r - 1) * colCount + c, r * colCount + c);
}
}
// 묶음이 꽉 찬 사각형이면 병합 — 아니면(ㄱ 자 등) 칸 따로
const groups = new Map<number, number[]>();
for (let i = 0; i < rowCount * colCount; i += 1) {
const root = find(i);
const members = groups.get(root);
if (members) members.push(i);
else groups.set(root, [i]);
}
const cells: TableCells = Array.from({ length: rowCount }, () =>
Array.from({ length: colCount }, () => ({ text: '' }))
);
for (const members of groups.values()) {
if (members.length < 2) continue;
const rs = members.map((i) => Math.floor(i / colCount));
const cs = members.map((i) => i % colCount);
const [r0, r1, c0, c1] = [Math.min(...rs), Math.max(...rs), Math.min(...cs), Math.max(...cs)];
if ((r1 - r0 + 1) * (c1 - c0 + 1) !== members.length) continue;
cells[r0][c0] = { text: '', rowSpan: r1 - r0 + 1, colSpan: c1 - c0 + 1 };
for (let r = r0; r <= r1; r += 1)
for (let c = c0; c <= c1; c += 1) if (r !== r0 || c !== c0) cells[r][c] = null;
}
// 글 → 칸(병합이면 앵커) · 한 칸 여러 글은 위 → 아래 · 왼 → 오른 차례로 띄어 이음
const owners = cellOwners(cells, rowCount, colCount);
const texts = new Map<string, Label[]>();
let outside = 0;
for (const label of labels) {
const c = colEdges.findIndex(
(x, i) => i < colCount && label.x >= x && label.x <= colEdges[i + 1]
);
const r = rowEdges.findIndex(
(y, j) => j < rowCount && label.y <= y && label.y >= rowEdges[j + 1]
);
if (c < 0 || r < 0) {
outside += 1;
continue;
}
const key = owners[r][c] ?? `${r}:${c}`;
const inCell = texts.get(key);
if (inCell) inCell.push(label);
else texts.set(key, [label]);
}
if (!labels.length || outside * 2 > labels.length) return null;
const rows: DrawingCellValue[][] = cells.map((line) => line.map(() => ''));
const merges: DrawingTableMerge[] = [];
cells.forEach((line, r) =>
line.forEach((cell, c) => {
if (!cell) return;
if ((cell.rowSpan ?? 1) > 1 || (cell.colSpan ?? 1) > 1)
merges.push({ row: r, col: c, rowSpan: cell.rowSpan ?? 1, colSpan: cell.colSpan ?? 1 });
const inCell = texts.get(`${r}:${c}`) ?? [];
const sorted = sortReading(inCell);
if (sorted.length) rows[r][c] = cellValue(sorted.map((label) => label.text.trim()).join(' '));
})
);
return {
kind: 'grid',
rows,
merges,
colWidths: colEdges.slice(1).map((x, i) => x - colEdges[i]),
rowHeights: rowEdges.slice(1).map((y, j) => rowEdges[j] - y),
};
}
/** 읽는 차례 — 줄(y, 글 높이 반 안이면 같은 줄) 위 → 아래 · 줄 안 왼 → 오른 */
function textLines(labels: Label[]): Label[][] {
const sorted = [...labels].sort((a, b) => b.y - a.y);
const lines: { y: number; size: number; items: Label[] }[] = [];
for (const label of sorted) {
const last = lines[lines.length - 1];
if (last && last.y - label.y <= Math.max(last.size, label.size) * 0.5) last.items.push(label);
else lines.push({ y: label.y, size: label.size, items: [label] });
}
return lines.map((line) => line.items.sort((a, b) => a.x - b.x));
}
const sortReading = (labels: Label[]): Label[] => textLines(labels).flat();
/** ③ 글 자리 폴백 — 줄마다 x 차례로 칸 */
function fromText(labels: Label[]): DrawingTable {
const lines = textLines(labels);
const width = Math.max(...lines.map((line) => line.length));
return {
kind: 'text',
rows: lines.map((line) => {
const values: DrawingCellValue[] = line.map((label) => cellValue(label.text));
while (values.length < width) values.push('');
return values;
}),
merges: [],
colWidths: [],
rowHeights: [],
};
}
/**
* 고른 엔티티 JSON → 표 자료. 표 엔티티가 있으면 칸이 가장 많은 표 하나 · 없으면 선 격자 · 그래도 아니면 글 자리.
* 표로 옮길 글이 하나도 없으면(빈 범위 · 글 없는 선) null.
*/
export function tableFromDrawing(entities: DrawingEntityJson[]): DrawingTable | null {
const tables = entities.filter(
(entity) => entity.type === 'Table' && entity.shapeData?.columnWidths?.length
);
if (tables.length) {
const size = (entity: DrawingEntityJson) =>
entity.shapeData.columnWidths.length * entity.shapeData.rowHeights.length;
const biggest = tables.reduce((best, entity) => (size(entity) > size(best) ? entity : best));
return fromTableEntity(biggest.shapeData);
}
const segs: Seg[] = [];
const labels: Label[] = [];
collect(entities, segs, labels);
if (!labels.length) return null;
return (segs.length ? fromGrid(segs, labels) : null) ?? fromText(labels);
}