From c426fa2ad6a62d5882d08b668094fb5778de9e53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:43:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07=20CAD):=20=ED=91=9C=20=EB=86=93?= =?UTF-8?q?=EA=B8=B0=20=EB=8F=84=EA=B5=AC=20=C2=B7=20=ED=91=9C=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20place-table=20=C2=B7=20update-table=20(41-?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 tools/annotate/place-table-tool.ts — PLACETABLE: 부모가 보낸 표를 커서에 붙여 미리보기 · 누르면 그 자리(왼쪽 위) · Esc 그만 · 놓기 = 되돌리기 한 걸음 - 새 integration/aislo-table-bridge.ts — place-table{entity, link} = style.연결 단 표로 놓기 시작 · update-table{link, cells} = 연결 같은 표 칸만 갈기(자리 · 크기 그대로) → table-updated{link, count} · 확정 · 보기 전용이면 안 함 - TableEntity style.연결 · replaceCells · bridge 갈래 한 줄 · 명령 PLACETABLE - 시험 aislo-table-bridge.test.ts(놓기 · 연결 남음 · 칸 갈기 · 넘침 · 없는 연결) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../src/commands/commands.annotate.ts | 9 ++ .../openwebcad/src/entities/TableEntity.ts | 7 ++ .../src/integration/aislo-drawing-bridge.ts | 2 + .../integration/aislo-table-bridge.test.ts | 114 ++++++++++++++++++ .../src/integration/aislo-table-bridge.ts | 80 ++++++++++++ B07_DesignDetail/openwebcad/src/tools.ts | 1 + .../src/tools/annotate/place-table-tool.ts | 49 ++++++++ 7 files changed, 262 insertions(+) create mode 100644 B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts index c738faebc..e2583de55 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts @@ -29,6 +29,7 @@ import { revCloudToolStateMachine, updateDimensions, } from '../tools/annotate/leader-table-tools'; +import { placeTableToolStateMachine } from '../tools/annotate/place-table-tool'; import { tableColumnToolStateMachine, tableEditToolStateMachine, @@ -324,6 +325,14 @@ export const TABLE_COMMANDS: CadCommand[] = [ tool: Tool.TABLEUNMERGE, machine: tableUnmergeToolStateMachine, }, + { + id: 'PLACETABLE', + label: '표 놓기', + glyph: '▦', + hint: '부모 화면이 보낸 표(면적산출 범위)를 누른 자리에 놓는다', + tool: Tool.PLACETABLE, + machine: placeTableToolStateMachine, + }, ]; export const MARKUP_COMMANDS: CadCommand[] = [ diff --git a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts index 93e5ec55a..5662184dd 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts @@ -34,6 +34,8 @@ export interface TableStyleOptions { textColor: string; /** 문자를 칸 좌우 끝에서 띄우는 거리 */ padding: number; + /** 면적산출에서 보낸 표의 연결 id(M02 41장) — 옮기기 · 복사(clone)에도 남아 [업데이트]가 이 표를 찾는다 */ + 연결?: string; } export const DEFAULT_TABLE_STYLE: TableStyleOptions = { @@ -97,6 +99,11 @@ export class TableEntity implements Entity { } // ── 쓰기 (표 편집 명령이 쓴다) + /** 칸 내용만 통째로 갈아 끼운다 — 자리 · 열 너비 · 행 높이는 그대로(넘치는 칸은 버리고 모자라면 빈 칸) */ + public replaceCells(cells: TableCells): void { + this.cells = normalizeCells(cells, this.rowHeights.length, this.columnWidths.length); + } + public setCell(row: number, column: number, patch: Partial): void { const current = this.cells[row]?.[column]; if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다 diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 29f4bea06..47ecd4f5b 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -33,6 +33,7 @@ import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; import { registerBoxResizeDrag } from './box-resize-drag.ts'; +import { handleTableMessage } from './aislo-table-bridge.ts'; export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; @@ -190,6 +191,7 @@ function applyFramePreview(): void { export function registerAisloDrawingBridge() { window.addEventListener('message', async (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== window.parent) return; + if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장) const message = event.data as Partial; if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) { try { diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts new file mode 100644 index 000000000..4057df8d7 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts @@ -0,0 +1,114 @@ +import { Point } from '@flatten-js/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TableEntity } from '../entities/TableEntity.ts'; +import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts'; +import { getPendingTable, setPendingTable, tableAt } from '../tools/annotate/place-table-tool.ts'; +import { + AISLO_PLACE_TABLE_MESSAGE, + AISLO_TABLE_UPDATED_MESSAGE, + AISLO_UPDATE_TABLE_MESSAGE, + handleTableMessage, + updateLinkedTables, +} from './aislo-table-bridge.ts'; + +const run = vi.hoisted(() => vi.fn()); +vi.mock('../commands/run-command.ts', () => ({ runCommandInput: run })); + +const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' }; + +function table(link?: string): TableEntity { + return new TableEntity( + layer.id, + new Point(10, 20), + [30, 15], + [5, 5], + [ + [{ text: '항목', colSpan: 2, key: 'S1!A1' }, null], + [ + { text: '면적', key: 'S1!A2' }, + { text: '12.5', align: 'right', key: 'S1!B2' }, + ], + ], + { fontSize: 2.5, ...(link ? { 연결: link } : {}) } + ); +} + +beforeEach(() => { + setLayers([layer]); + setEntities([]); + setPendingTable(null); + run.mockClear(); +}); + +describe('표 놓기(PLACETABLE)', () => { + it('미리보기 · 놓을 표는 커서 자리(왼쪽 위)에 같은 칸 · 크기 · 연결로', () => { + const placed = tableAt(table('T1'), new Point(100, 200), layer.id); + expect([placed.getOrigin().x, placed.getOrigin().y]).toEqual([100, 200]); + expect(placed.getColumnWidths()).toEqual([30, 15]); + expect(placed.getCell(1, 1)?.text).toBe('12.5'); + expect(placed.getStyle().연결).toBe('T1'); + }); + + it('place-table 메시지 = 연결을 단 표를 맡기고 PLACETABLE 을 켬', async () => { + const notify = vi.fn(); + const handled = await handleTableMessage( + { type: AISLO_PLACE_TABLE_MESSAGE, entity: await table().toJson(), link: 'T7' }, + notify + ); + expect(handled).toBe(true); + expect(run).toHaveBeenCalledWith('PLACETABLE'); + expect(getPendingTable()?.getStyle().연결).toBe('T7'); + expect(getPendingTable()?.getCell(0, 0)?.colSpan).toBe(2); + expect(notify).not.toHaveBeenCalled(); + }); + + it('옮기기 · 복사(clone) · JSON 왕복에도 style.연결이 남음', async () => { + const linked = table('T1'); + expect(linked.clone().getStyle().연결).toBe('T1'); + const json = await linked.toJson(); + if (!json) throw new Error('표 JSON 없음'); + const back = await TableEntity.fromJson(json); + expect(back.getStyle().연결).toBe('T1'); + }); +}); + +describe('표 칸 갈기(update-table)', () => { + it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => { + const linked = table('T1'); + const other = table('T2'); + setEntities([linked, other]); + const count = updateLinkedTables('T1', [ + [{ text: '항목(새)', colSpan: 2 }, null], + [{ text: '면적' }, { text: '99.0', align: 'right' }], + ]); + expect(count).toBe(1); + expect(linked.getCell(1, 1)?.text).toBe('99.0'); + expect(linked.getCell(0, 1)).toBeNull(); // 병합 그대로 + expect(other.getCell(1, 1)?.text).toBe('12.5'); + expect([linked.getOrigin().x, linked.getColumnWidths()]).toEqual([10, [30, 15]]); + expect(isDrawingDirty()).toBe(true); + }); + + it('넘치는 칸은 버리고 모자란 칸은 빈 칸', () => { + const linked = table('T1'); + setEntities([linked]); + updateLinkedTables('T1', [[{ text: 'a' }, { text: 'b' }, { text: '넘침' }]]); + expect(linked.getCells().map((row) => row.map((cell) => cell?.text ?? null))).toEqual([ + ['a', 'b'], + ['', ''], + ]); + }); + + it('update-table 메시지 = table-updated{link, count} · 없는 연결은 0 · 도면 그대로', async () => { + setEntities([table('T1')]); + const before = getEntities(); + const notify = vi.fn(); + await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T9', cells: [] }, notify); + expect(notify).toHaveBeenCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T9', count: 0 }); + expect(getEntities()).toBe(before); + }); + + it('표 메시지가 아니면 false', async () => { + expect(await handleTableMessage({ type: 'aislo:b08:save-request' }, vi.fn())).toBe(false); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts new file mode 100644 index 000000000..6ac5c13d1 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts @@ -0,0 +1,80 @@ +/** + * 부모 화면 ↔ CAD 표 메시지(M02 41장 · 면적산출 범위 → 도면 표). 짝 = `ui_template/cad_host/cad_host.ts`. + * place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작 + * update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음) + * → 부모에 table-updated{link, count} + * 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함. + */ +import { toast } from 'react-toastify'; +import { runCommandInput } from '../commands/run-command.ts'; +import type { JsonEntity } from '../entities/Entity.ts'; +import { TableEntity } from '../entities/TableEntity.ts'; +import type { TableCells } from '../helpers/table-geometry.ts'; +import { getEntities, isDrawingReadOnly, isViewOnly, setEntities } from '../state.ts'; +import { setPendingTable } from '../tools/annotate/place-table-tool.ts'; + +export const AISLO_PLACE_TABLE_MESSAGE = 'aislo:b08:place-table'; +export const AISLO_UPDATE_TABLE_MESSAGE = 'aislo:b08:update-table'; +export const AISLO_TABLE_UPDATED_MESSAGE = 'aislo:b08:table-updated'; + +/** 두 메시지를 한 꼴로 받음 — place = entity · link · update = link · cells */ +interface TableMessage { + type?: string; + entity?: JsonEntity; + link?: string; + cells?: TableCells; +} + +/** 이 표가 그 연결의 것인가 */ +export const isLinkedTable = (entity: unknown, link: string): entity is TableEntity => + entity instanceof TableEntity && entity.getStyle().연결 === link; + +/** 연결 표마다 칸을 갈아 끼움 — 바꾼 표 수(0 이면 도면을 건드리지 않음) */ +export function updateLinkedTables(link: string, cells: TableCells): number { + const tables = getEntities().filter((entity) => isLinkedTable(entity, link)); + for (const table of tables) table.replaceCells(cells); + if (tables.length) setEntities([...getEntities()], true); + return tables.length; +} + +/** 표 JSON 을 놓기 도구에 맡기고 도구를 켬 */ +export async function startPlaceTable(entity: JsonEntity, link: string): Promise { + const table = await TableEntity.fromJson(entity as Parameters[0]); + const style = table.getStyle(); + setPendingTable( + new TableEntity( + table.layerId, + table.getOrigin(), + table.getColumnWidths(), + table.getRowHeights(), + table.getCells(), + { ...style, 연결: link } + ) + ); + runCommandInput('PLACETABLE'); +} + +const blocked = (): boolean => { + if (!isDrawingReadOnly() && !isViewOnly()) return false; + toast.info('보기 전용 도면 — 표를 넣거나 고치려면 [수정]을 먼저 누르세요.'); + return true; +}; + +/** 표 메시지면 처리하고 true — 아니면 false(다른 메시지 갈래로) */ +export async function handleTableMessage( + data: unknown, + notify: (type: string, payload: Record) => void +): Promise { + const message = data as TableMessage | null; + if (!message || typeof message !== 'object') return false; + if (message.type === AISLO_PLACE_TABLE_MESSAGE && message.entity && message.link) { + if (!blocked()) await startPlaceTable(message.entity, message.link); + return true; + } + if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) { + const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells); + notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count }); + return true; + } + return false; +} diff --git a/B07_DesignDetail/openwebcad/src/tools.ts b/B07_DesignDetail/openwebcad/src/tools.ts index 1f051dab2..941badd93 100644 --- a/B07_DesignDetail/openwebcad/src/tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools.ts @@ -113,6 +113,7 @@ export enum Tool { MLEADERSTYLE = 'MLEADERSTYLE', MLEADERALIGN = 'MLEADERALIGN', TABLE = 'TABLE', + PLACETABLE = 'PLACETABLE', TABLESTYLE = 'TABLESTYLE', TABLEEDIT = 'TABLEEDIT', TABLEROW = 'TABLEROW', diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts new file mode 100644 index 000000000..ce5331048 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts @@ -0,0 +1,49 @@ +/** + * 표 놓기(PLACETABLE · M02 41장) — 부모 화면이 보낸 표(면적산출 범위)를 커서에 붙여 따라오게 + * 그리고, 누르면 그 자리(표 왼쪽 위)에 놓는다. Esc = 그만 · 놓기는 되돌리기 한 걸음. + * 놓을 표는 브리지(`integration/aislo-table-bridge.ts`)가 `setPendingTable` 로 맡긴 뒤 이 명령을 켠다. + */ +import type { Point } from '@flatten-js/core'; +import { TableEntity } from '../../entities/TableEntity'; +import { addEntities, getActiveLayerId } from '../../state'; +import { Tool } from '../../tools'; +import { createSequenceTool } from '../factories/sequence-tool'; + +let pending: TableEntity | null = null; + +/** 다음 PLACETABLE 이 놓을 표 — 놓으면 비워진다 */ +export function setPendingTable(table: TableEntity | null): void { + pending = table; +} + +export function getPendingTable(): TableEntity | null { + return pending; +} + +/** `table` 과 같은 꼴(칸 · 크기 · style.연결)을 `at`(왼쪽 위)에 둔 새 표 */ +export function tableAt(table: TableEntity, at: Point, layerId: string): TableEntity { + const placed = new TableEntity( + layerId, + at.clone(), + table.getColumnWidths(), + table.getRowHeights(), + table.getCells().map((row) => row.map((cell) => (cell ? { ...cell } : null))), + table.getStyle() + ); + placed.lineColor = table.lineColor; + placed.lineWidth = table.lineWidth; + return placed; +} + +export const placeTableToolStateMachine = createSequenceTool({ + tool: Tool.PLACETABLE, + steps: [{ kind: 'point', instructions: '표를 놓을 자리(왼쪽 위)를 누르십시오 · Esc 그만.' }], + helpers: true, + restart: false, + preview: (input) => (pending ? [tableAt(pending, input.cursor, getActiveLayerId())] : []), + commit: (input) => { + if (!pending) return; + addEntities([tableAt(pending, input.point(0), getActiveLayerId())], true); + pending = null; + }, +});