Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1
This commit is contained in:
@@ -4,6 +4,7 @@ import { clearRecovery, restoreRecovery } from '../helpers/autosave';
|
||||
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
|
||||
import { requestDrawingExport, requestHostSave } from '../integration/aislo-drawing-bridge';
|
||||
import { runSendSelectionTable } from '../integration/aislo-table-export';
|
||||
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
|
||||
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
|
||||
import { isHostSave, redo, undo } from '../state';
|
||||
@@ -87,6 +88,14 @@ export const FILE_COMMANDS: CadCommand[] = [
|
||||
return 'PNG 내보내기';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'TOAREA',
|
||||
label: '면적산출로 보내기',
|
||||
glyph: '▦',
|
||||
hint: '고른 범위(표 · 선 표 · 글)를 면적산출 새 시트로 한 번 옮긴다',
|
||||
needsSelection: true,
|
||||
run: runSendSelectionTable,
|
||||
},
|
||||
{
|
||||
id: 'UNDO',
|
||||
label: '실행 취소',
|
||||
|
||||
@@ -89,6 +89,7 @@ const VIEW_ONLY_COMMAND_IDS: ReadonlySet<string> = new Set([
|
||||
'EXPORTPNG',
|
||||
'COPYCLIP',
|
||||
'COPYBASE',
|
||||
'TOAREA', // 고른 범위 → 면적산출 표(M02 83장)
|
||||
]);
|
||||
|
||||
/** 확정한 도면에서 이 명령을 실행해도 되는가. */
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* 고른 범위 → 표 자료(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);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* CAD → 부모 화면 표 보내기(M02 83장 · 도면 → 면적산출 한 번 옮기기 · 연동 아님). 짝 = `M02_MasterTemplete_AreaDrawing.ts`.
|
||||
* export-table-request 부모 [면적산출로 보내기] 단추 → 고른 범위를 표 자료로
|
||||
* export-table{table} 표 자료(`helpers/table-from-drawing.ts` 꼴) → 부모가 새 시트로
|
||||
* CAD 명령 TOAREA(「면적산출로 보내기」)도 같은 길. 보기 전용 도면에서도 됨(도면을 안 바꿈).
|
||||
* 고른 것이 없거나 옮길 글이 없으면 부모에 안 보내고 안내만.
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import type { DrawingEntityJson } from '../helpers/table-from-drawing.ts';
|
||||
import { type DrawingTable, tableFromDrawing } from '../helpers/table-from-drawing.ts';
|
||||
import { getSelectedEntities } from '../state.ts';
|
||||
|
||||
export const AISLO_EXPORT_TABLE_REQUEST_MESSAGE = 'aislo:b08:export-table-request';
|
||||
export const AISLO_EXPORT_TABLE_MESSAGE = 'aislo:b08:export-table';
|
||||
|
||||
const TEXT = {
|
||||
empty: '면적산출로 보낼 범위를 먼저 고르세요(누름 · 끌기).',
|
||||
noText: '고른 범위에 글이 없어 표로 옮길 것이 없습니다.',
|
||||
standalone: '면적산출 화면에서 연 도면에서만 보낼 수 있습니다.',
|
||||
};
|
||||
|
||||
type Notify = (type: string, payload: Record<string, unknown>) => void;
|
||||
|
||||
function notifyParent(type: string, payload: Record<string, unknown>): void {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage({ type, ...payload }, window.location.origin);
|
||||
}
|
||||
|
||||
/** 고른 범위 → 표 자료 · 고른 것이 없으면 'empty' · 글이 없으면 'no-text' */
|
||||
export async function selectionTable(): Promise<DrawingTable | 'empty' | 'no-text'> {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) return 'empty';
|
||||
const json = (await Promise.all(selected.map((entity) => entity.toJson()))).filter(
|
||||
(entity): entity is NonNullable<typeof entity> => Boolean(entity)
|
||||
);
|
||||
return tableFromDrawing(json as DrawingEntityJson[]) ?? 'no-text';
|
||||
}
|
||||
|
||||
/** 고른 범위를 표로 만들어 부모에 보냄 — 보냈으면 true */
|
||||
export async function sendSelectionTable(notify: Notify = notifyParent): Promise<boolean> {
|
||||
const result = await selectionTable();
|
||||
if (result === 'empty') toast.info(TEXT.empty);
|
||||
else if (result === 'no-text') toast.info(TEXT.noText);
|
||||
else {
|
||||
notify(AISLO_EXPORT_TABLE_MESSAGE, { table: result });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** CAD 명령 TOAREA — 부모 없이 띄운 CAD(단독)면 안내만 */
|
||||
export function runSendSelectionTable(): string {
|
||||
if (window.parent === window) {
|
||||
toast.info(TEXT.standalone);
|
||||
return '부모 화면 없음';
|
||||
}
|
||||
void sendSelectionTable();
|
||||
return '면적산출로 보내기';
|
||||
}
|
||||
|
||||
/** 부모 요청 메시지면 처리하고 true */
|
||||
export async function handleTableExportMessage(
|
||||
data: unknown,
|
||||
notify: Notify = notifyParent
|
||||
): Promise<boolean> {
|
||||
const message = data as { type?: string } | null;
|
||||
if (
|
||||
!message ||
|
||||
typeof message !== 'object' ||
|
||||
message.type !== AISLO_EXPORT_TABLE_REQUEST_MESSAGE
|
||||
)
|
||||
return false;
|
||||
await sendSelectionTable(notify);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 부모 [면적산출로 보내기] 요청을 받음 — 같은 출처 · 부모 창만 */
|
||||
export function registerAisloTableExport(): void {
|
||||
window.addEventListener('message', (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||
void handleTableExportMessage(event.data);
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { queryEntitiesNearPoint } from './helpers/spatial-index';
|
||||
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
|
||||
import { InputController } from './inputController/input-controller.ts';
|
||||
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
|
||||
import { registerAisloTableExport } from './integration/aislo-table-export.ts';
|
||||
import { registerDxfClipPaste } from './integration/dxf-clip-paste.ts';
|
||||
import {
|
||||
getActiveToolActor,
|
||||
@@ -155,6 +156,7 @@ function initApplication() {
|
||||
setLayers(layers);
|
||||
setActiveLayerId(layers[0].id);
|
||||
registerAisloDrawingBridge();
|
||||
registerAisloTableExport(); // 고른 범위 → 면적산출 표(M02 83장)
|
||||
registerDxfClipPaste();
|
||||
registerCadDebugHook();
|
||||
registerAutoSave();
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* CAD → 부모 표 보내기(`integration/aislo-table-export.ts` · M02 83-2) 검증.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad table-export
|
||||
* (시험 파일은 바깥 라이브러리를 직접 부르지 않음 — 엔티티는 fromJson 으로 만듦)
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getCommandById,
|
||||
isViewOnlyCommand,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/commands/registry.ts";
|
||||
import type { JsonEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity.ts";
|
||||
import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity.ts";
|
||||
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts";
|
||||
import {
|
||||
AISLO_EXPORT_TABLE_MESSAGE,
|
||||
AISLO_EXPORT_TABLE_REQUEST_MESSAGE,
|
||||
handleTableExportMessage,
|
||||
selectionTable,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts";
|
||||
import {
|
||||
getEntities,
|
||||
setEntities,
|
||||
setLayers,
|
||||
setSelectedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state.ts";
|
||||
|
||||
const layer = { id: "layer-1", isLocked: false, isVisible: true, name: "작업" };
|
||||
|
||||
const line = (x1: number, y1: number, x2: number, y2: number) =>
|
||||
LineEntity.fromJson({
|
||||
id: crypto.randomUUID(),
|
||||
type: "Line",
|
||||
lineColor: "#fff",
|
||||
lineWidth: 1,
|
||||
layerId: layer.id,
|
||||
shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } },
|
||||
} as unknown as JsonEntity<never>);
|
||||
const text = (label: string, x: number, y: number) =>
|
||||
TextEntity.fromJson({
|
||||
id: crypto.randomUUID(),
|
||||
type: "Text",
|
||||
lineColor: "#fff",
|
||||
lineWidth: 1,
|
||||
layerId: layer.id,
|
||||
shapeData: {
|
||||
label,
|
||||
basePoint: { x, y },
|
||||
options: {
|
||||
textDirection: { x: 1, y: 0 },
|
||||
textAlign: "left",
|
||||
textColor: "#fff",
|
||||
fontSize: 2.5,
|
||||
fontFamily: "Noto Sans KR",
|
||||
},
|
||||
},
|
||||
} as unknown as JsonEntity<never>);
|
||||
|
||||
/** 2×2 선 표 + 글 넷 + 표 밖 선 하나(고르지 않음) */
|
||||
async function drawing() {
|
||||
const parts = await Promise.all([
|
||||
line(0, 20, 40, 20),
|
||||
line(0, 10, 40, 10),
|
||||
line(0, 0, 40, 0),
|
||||
line(0, 0, 0, 20),
|
||||
line(20, 0, 20, 20),
|
||||
line(40, 0, 40, 20),
|
||||
text("항목", 2, 14),
|
||||
text("수량", 22, 14),
|
||||
text("흙깎기", 2, 4),
|
||||
text("1,500", 22, 4),
|
||||
]);
|
||||
const outside = await line(100, 100, 200, 100);
|
||||
setEntities([...parts, outside]);
|
||||
return { parts, outside };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setLayers([layer]);
|
||||
setEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
});
|
||||
|
||||
describe("고른 범위 → 표 자료", () => {
|
||||
it("고른 선 + 글만 표로 · 숫자 글은 숫자", async () => {
|
||||
const { parts } = await drawing();
|
||||
setSelectedEntityIds(parts.map((entity) => entity.id));
|
||||
const table = await selectionTable();
|
||||
expect(table).toMatchObject({
|
||||
kind: "grid",
|
||||
rows: [
|
||||
["항목", "수량"],
|
||||
["흙깎기", 1500],
|
||||
],
|
||||
colWidths: [20, 20],
|
||||
});
|
||||
});
|
||||
|
||||
it("고른 것 없음 = empty · 글 없는 선 = no-text", async () => {
|
||||
const { outside } = await drawing();
|
||||
expect(await selectionTable()).toBe("empty");
|
||||
setSelectedEntityIds([outside.id]);
|
||||
expect(await selectionTable()).toBe("no-text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("부모 요청 메시지", () => {
|
||||
it("export-table-request = 고른 범위를 export-table{table} 로 회신 · 도면은 그대로", async () => {
|
||||
const { parts } = await drawing();
|
||||
setSelectedEntityIds(parts.map((entity) => entity.id));
|
||||
const before = getEntities();
|
||||
const notify = vi.fn();
|
||||
expect(
|
||||
await handleTableExportMessage({ type: AISLO_EXPORT_TABLE_REQUEST_MESSAGE }, notify),
|
||||
).toBe(true);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
const [type, payload] = notify.mock.calls[0];
|
||||
expect(type).toBe(AISLO_EXPORT_TABLE_MESSAGE);
|
||||
expect(payload.table.rows[1]).toEqual(["흙깎기", 1500]);
|
||||
expect(getEntities()).toBe(before);
|
||||
});
|
||||
|
||||
it("고른 것이 없으면 회신 안 함 · 다른 메시지는 false", async () => {
|
||||
await drawing();
|
||||
const notify = vi.fn();
|
||||
expect(
|
||||
await handleTableExportMessage({ type: AISLO_EXPORT_TABLE_REQUEST_MESSAGE }, notify),
|
||||
).toBe(true);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(await handleTableExportMessage({ type: "aislo:b08:save-request" }, notify)).toBe(false);
|
||||
expect(await handleTableExportMessage(null, notify)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CAD 명령 TOAREA", () => {
|
||||
it("고른 것이 있어야 하고 · 확정(보기 전용) 도면에서도 됨", () => {
|
||||
const command = getCommandById("TOAREA");
|
||||
expect(command?.label).toBe("면적산출로 보내기");
|
||||
expect(command?.needsSelection).toBe(true);
|
||||
expect(command && isViewOnlyCommand(command)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 고른 범위 → 표 자료(`helpers/table-from-drawing.ts` · M02 83-1) 검증.
|
||||
* 실행: node --experimental-strip-types resources/tester/cad/table-from-drawing.test.ts
|
||||
* 견본 셋 = 표 엔티티 · 선 표(병합 · PolyLine · 끊긴 선) · 글만 + 빈 범위 · 글 없는 선 · 회전된 표.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
cellValue,
|
||||
tableFromDrawing,
|
||||
type DrawingEntityJson,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts";
|
||||
|
||||
const line = (x1: number, y1: number, x2: number, y2: number): DrawingEntityJson => ({
|
||||
type: "Line",
|
||||
shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } },
|
||||
});
|
||||
const text = (label: string, x: number, y: number, fontSize = 2.5): DrawingEntityJson => ({
|
||||
type: "Text",
|
||||
shapeData: { label, basePoint: { x, y }, options: { fontSize, textAlign: "left" } },
|
||||
});
|
||||
|
||||
// 숫자 글
|
||||
assert.equal(cellValue("1,500"), 1500, "천 단위 쉼표");
|
||||
assert.equal(cellValue(" -2.75 "), -2.75, "음수 소수");
|
||||
assert.equal(cellValue("12,34"), "12,34", "쉼표 자리 틀리면 글");
|
||||
assert.equal(cellValue("D600"), "D600", "글 그대로");
|
||||
assert.equal(cellValue("1,234,567.5"), 1234567.5);
|
||||
|
||||
// ① 표 엔티티 — 칸 그대로 · 병합 · 폭 · 높이
|
||||
const tableEntity: DrawingEntityJson = {
|
||||
type: "Table",
|
||||
shapeData: {
|
||||
origin: { x: 0, y: 100 },
|
||||
columnWidths: [30, 20, 25],
|
||||
rowHeights: [8, 6],
|
||||
cells: [
|
||||
[{ text: "구분", rowSpan: 2 }, { text: "수량", colSpan: 2 }, null],
|
||||
[null, { text: "1,500" }, { text: "m2" }],
|
||||
],
|
||||
},
|
||||
};
|
||||
const t1 = tableFromDrawing([tableEntity, text("밖 글", 500, 500)]);
|
||||
assert.ok(t1);
|
||||
assert.equal(t1.kind, "table");
|
||||
assert.deepEqual(t1.rows, [
|
||||
["구분", "수량", ""],
|
||||
["", 1500, "m2"],
|
||||
]);
|
||||
assert.deepEqual(t1.merges, [
|
||||
{ row: 0, col: 0, rowSpan: 2, colSpan: 1 },
|
||||
{ row: 0, col: 1, rowSpan: 1, colSpan: 2 },
|
||||
]);
|
||||
assert.deepEqual(t1.colWidths, [30, 20, 25]);
|
||||
assert.deepEqual(t1.rowHeights, [8, 6]);
|
||||
|
||||
// ② 선 표 — 3열 × 3행 · 맨 위 줄 가운데 세로선 빠짐(병합) · 바깥 테두리는 PolyLine · 가로선 한 줄은 끊겨 둘
|
||||
// x 경계 0 · 40 · 70 · 100 / y 경계 60 · 50 · 40 · 30
|
||||
const outline: DrawingEntityJson = {
|
||||
type: "PolyLine",
|
||||
shapeData: null,
|
||||
children: [
|
||||
line(0, 60, 100, 60),
|
||||
line(100, 60, 100, 30),
|
||||
line(100, 30, 0, 30),
|
||||
line(0, 30, 0, 60),
|
||||
],
|
||||
};
|
||||
const gridEntities: DrawingEntityJson[] = [
|
||||
outline,
|
||||
line(0, 50, 55, 50), // 끊긴 가로선 둘(겹침 5)
|
||||
line(50, 50.05, 100, 50.05), // 살짝 어긋남(허용오차 안)
|
||||
line(0, 40, 100, 40),
|
||||
line(40, 60, 40, 30), // 세로선 — 맨 위 줄도 지남
|
||||
line(70, 50, 70, 30), // 맨 위 줄엔 없음 → (0,1)~(0,2) 병합
|
||||
text("구분", 2, 53),
|
||||
text("수량", 45, 53),
|
||||
text("흙깎기", 2, 43),
|
||||
text("1,500", 42, 43),
|
||||
text("m3", 72, 43),
|
||||
text("돌쌓기", 2, 33),
|
||||
text("2.5", 42, 33),
|
||||
text("m2", 72, 33),
|
||||
text("(전면)", 2, 31), // 같은 칸 두 줄 → 「돌쌓기 (전면)」
|
||||
];
|
||||
const t2 = tableFromDrawing(gridEntities);
|
||||
assert.ok(t2);
|
||||
assert.equal(t2.kind, "grid");
|
||||
assert.deepEqual(t2.rows, [
|
||||
["구분", "수량", ""],
|
||||
["흙깎기", 1500, "m3"],
|
||||
["돌쌓기 (전면)", 2.5, "m2"],
|
||||
]);
|
||||
assert.deepEqual(t2.merges, [{ row: 0, col: 1, rowSpan: 1, colSpan: 2 }]);
|
||||
assert.deepEqual(
|
||||
t2.colWidths.map((w) => Math.round(w)),
|
||||
[40, 30, 30],
|
||||
);
|
||||
assert.deepEqual(
|
||||
t2.rowHeights.map((h) => Math.round(h)),
|
||||
[10, 10, 10],
|
||||
);
|
||||
|
||||
// 사각형 엔티티 칸 · 짧은 눈금 선은 경계로 안 침
|
||||
const rectGrid = tableFromDrawing([
|
||||
{
|
||||
type: "Rectangle",
|
||||
shapeData: {
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 20, y: 0 },
|
||||
{ x: 20, y: 10 },
|
||||
{ x: 0, y: 10 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "Rectangle",
|
||||
shapeData: {
|
||||
points: [
|
||||
{ x: 20, y: 0 },
|
||||
{ x: 50, y: 0 },
|
||||
{ x: 50, y: 10 },
|
||||
{ x: 20, y: 10 },
|
||||
],
|
||||
},
|
||||
},
|
||||
line(35, 0, 35, 1), // 눈금
|
||||
text("가", 2, 4),
|
||||
text("나", 22, 4),
|
||||
]);
|
||||
assert.ok(rectGrid);
|
||||
assert.equal(rectGrid.kind, "grid");
|
||||
assert.deepEqual(rectGrid.rows, [["가", "나"]]);
|
||||
assert.deepEqual(rectGrid.colWidths, [20, 30]);
|
||||
|
||||
// ③ 글만 — 줄(y 묶음) · 칸(x 차례) · 모자란 칸은 빈 칸
|
||||
const t3 = tableFromDrawing([
|
||||
text("합계", 0, 10),
|
||||
text("3,000", 40, 10.3),
|
||||
text("비고", 80, 9.8),
|
||||
text("소계", 0, 0),
|
||||
text("1,000", 40, 0),
|
||||
]);
|
||||
assert.ok(t3);
|
||||
assert.equal(t3.kind, "text");
|
||||
assert.deepEqual(t3.rows, [
|
||||
["합계", 3000, "비고"],
|
||||
["소계", 1000, ""],
|
||||
]);
|
||||
assert.deepEqual(t3.merges, []);
|
||||
|
||||
// 빈 범위 · 글 없는 선 = null
|
||||
assert.equal(tableFromDrawing([]), null, "빈 범위");
|
||||
assert.equal(
|
||||
tableFromDrawing([line(0, 0, 10, 0), line(0, 5, 10, 5), line(0, 0, 0, 5), line(10, 0, 10, 5)]),
|
||||
null,
|
||||
"글 없는 선",
|
||||
);
|
||||
|
||||
// 회전된 표(30°) — 축 선이 없어 글 자리 폴백
|
||||
const rot = (x: number, y: number): [number, number] => {
|
||||
const a = Math.PI / 6;
|
||||
return [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)];
|
||||
};
|
||||
const rline = (x1: number, y1: number, x2: number, y2: number) =>
|
||||
line(...rot(x1, y1), ...rot(x2, y2));
|
||||
const t4 = tableFromDrawing([
|
||||
rline(0, 0, 40, 0),
|
||||
rline(0, 10, 40, 10),
|
||||
rline(0, 0, 0, 10),
|
||||
rline(40, 0, 40, 10),
|
||||
rline(20, 0, 20, 10),
|
||||
text("A", ...rot(2, 4)),
|
||||
text("B", ...rot(22, 4)),
|
||||
]);
|
||||
assert.ok(t4);
|
||||
assert.equal(t4.kind, "text");
|
||||
assert.equal(t4.rows.flat().filter(Boolean).length, 2, "글 둘 다 살아 있음");
|
||||
|
||||
// 격자 밖 글이 반 넘으면 폴백(표가 아닌 범위)
|
||||
const t5 = tableFromDrawing([
|
||||
line(0, 0, 10, 0),
|
||||
line(0, 10, 10, 10),
|
||||
line(0, 0, 0, 10),
|
||||
line(10, 0, 10, 10),
|
||||
text("안", 2, 4),
|
||||
text("밖1", 50, 50),
|
||||
text("밖2", 80, 50),
|
||||
]);
|
||||
assert.equal(t5?.kind, "text");
|
||||
|
||||
console.log("table-from-drawing: 통과");
|
||||
Reference in New Issue
Block a user