Files
Aislo/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts
T

254 lines
11 KiB
TypeScript

import { Point } from '@flatten-js/core';
import { type DesignMeta, HtmlEvent } from '../App.types.ts';
import { ImageEntity } from '../entities/ImageEntity.ts';
import { TextEntity } from '../entities/TextEntity.ts';
import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts';
import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts';
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts';
import {
clearDrawingDirty,
getCanvas,
getDesignMeta,
getEntities,
getFrameFields,
getLayers,
getScreenCanvasDrawController,
isDrawingDirty,
isDrawingReadOnly,
isViewOnly,
resetUndoBaseline,
setActiveLayerId,
setDesignMeta,
setEntities,
setFrameEditMode,
setHostReadOnly,
setHostSave,
setHostTitle,
setLayers,
setOriginMarker,
setViewOnly,
} from '../state.ts';
import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts';
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';
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
export const AISLO_DRAWING_LOADED_MESSAGE = 'aislo:b08:drawing-loaded';
export const AISLO_DRAWING_ERROR_MESSAGE = 'aislo:b08:drawing-error';
export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b08:drawing-changed';
export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request';
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response';
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate';
export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file';
export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save';
interface DrawingLoadMessage {
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
drawing: JsonDrawingFileSerialized;
meta?: DesignMeta | null;
/** 도각 편집으로 실은 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다. */
frameEdit?: boolean;
/** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */
frameFields?: Record<string, string>;
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 수량 패널 없이 싣는 M02 양식이 쓴다. */
recoveryScope?: string;
/** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */
readOnly?: boolean;
/** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
hostTitle?: string;
/** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡기는가. */
hostSave?: boolean;
/** 리본을 접힌 채로 여는가 — 없으면(B07) 펼침 그대로. */
ribbonCollapsed?: boolean;
/** 좌측 패널(특성 · 도면층)을 접힌 채로 여는가 — 없으면(B07) 지금 상태 그대로. */
panelsCollapsed?: boolean;
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
viewOnly?: boolean;
originMarker?: boolean;
}
interface DrawingSaveRequestMessage {
type: typeof AISLO_DRAWING_SAVE_REQUEST_MESSAGE;
}
function isDrawingLoadMessage(value: unknown): value is DrawingLoadMessage {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<DrawingLoadMessage>;
return candidate.type === AISLO_DRAWING_LOAD_MESSAGE && Boolean(candidate.drawing);
}
function notifyParent(type: string, payload: Record<string, unknown> = {}) {
if (window.parent === window) return;
window.parent.postMessage({ type, ...payload }, window.location.origin);
}
/** 수량 패널에서 이전/다음 도면으로 이동 요청 (부모가 처리). */
export function requestDrawingNavigation(direction: 'prev' | 'next') {
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
}
/** 저장 요청을 부모 페이지 [저장]에 넘긴다 — 캐드는 양식 파일을 모른다. */
export function requestHostSave() {
notifyParent(AISLO_DRAWING_HOST_SAVE_MESSAGE);
}
/**
* 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시).
* 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다.
*/
export function requestDrawingExport(fileFormat: 'dxf' | 'dwg') {
notifyParent(AISLO_DRAWING_EXPORT_MESSAGE, { fileFormat });
}
/**
* 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다
* (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서
* 화면과 저장값이 어긋났다.
*/
const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table';
/**
* 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다.
* CAD 측점 테이블 값 셀 수정 수단 — 잠금 레이어와 수량 산출표는 제외.
*/
function registerTextDoubleClickEdit() {
const canvas = getCanvas();
if (!canvas) return;
canvas.addEventListener('dblclick', (event: MouseEvent) => {
if (isViewOnly()) {
getScreenCanvasDrawController().zoomToFitScreen();
return;
}
if (isDrawingReadOnly()) {
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
return;
}
const drawController = getScreenCanvasDrawController();
const bounds = canvas.getBoundingClientRect();
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
const worldPoint = drawController.targetToWorld(screenPoint);
const lockedLayerIds = new Set(
getLayers()
.filter((layer) => layer.isLocked)
.map((layer) => layer.id)
);
let closest: TextEntity | null = null;
let closestDistance = Number.MAX_SAFE_INTEGER;
let closestOnQuantityTable = false;
for (const entity of getEntities()) {
if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue;
const distanceInfo = entity.distanceTo(worldPoint);
if (distanceInfo && distanceInfo[0] < closestDistance) {
closestDistance = distanceInfo[0];
closest = entity;
closestOnQuantityTable = entity.layerId === QUANTITY_TABLE_LAYER_ID;
}
}
if (!closest) return;
const fontSize = closest.getTextOptions().fontSize;
const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2);
if (closestDistance > tolerance) return;
if (closestOnQuantityTable) {
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
return;
}
const nextLabel = window.prompt('값 수정', closest.getLabel());
if (nextLabel === null || nextLabel === closest.getLabel()) return;
closest.setLabel(nextLabel);
setEntities([...getEntities()], true); // undo 스택 + DRAWING_CHANGED 통지
});
}
/**
* 자리표에 실제 값을 입힌다 — 저장값은 그대로 두고 **보여 주기만** 바꾼다
* (2026-09-06 사용자 지시). 편집 화면에서 어디에 무엇이 들어가는지 보이게 하는 것이 목적.
*/
function applyFramePreview(): void {
const fields = getFrameFields();
if (Object.keys(fields).length === 0) return;
const token = /^\{\{(.+)\}\}$/;
for (const entity of getEntities()) {
if (entity instanceof TextEntity) {
const match = token.exec(entity.getLabel().trim());
const value = match ? fields[match[1]] : undefined;
entity.previewLabel = value ?? null;
} else if (entity instanceof ImageEntity && entity.isPlaceholder()) {
const match = token.exec((entity.getSourceData() ?? '').trim());
const value = match ? fields[match[1]] : undefined;
if (value) entity.setPreviewImage(value);
}
}
}
/**
* B08 parent page와 CAD 앱 사이의 same-origin JSON 경계다.
* DXF/DWG 파일이나 파서 객체는 이 경계를 통과하지 않는다.
*/
export function registerAisloDrawingBridge() {
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
const message = event.data as Partial<DrawingSaveRequestMessage>;
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
try {
const drawing = JSON.parse(
await exportEntitiesAndLayersToJsonString()
) as JsonDrawingFileSerialized;
// 수량표는 앞 단계 산출값을 그대로 돌려준다 — B07에서는 고치지 않는다.
const quantityTable = getDesignMeta()?.quantityTable ?? null;
clearDrawingDirty(); // 부모가 받아 저장한다 — 미저장 경고 대상이 아니다
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing, quantityTable });
} catch (error) {
const detail = error instanceof Error ? error.message : 'Unable to serialize drawing';
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
}
return;
}
if (!isDrawingLoadMessage(event.data)) return;
try {
const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing);
setEntities(drawing.entities, false);
setLayers(drawing.layers);
setActiveLayerId(drawing.layers[0].id);
// 실은 도면이 되돌리기의 바닥 — 첫 Ctrl+Z가 백지로 가지 않게 한다.
// (미저장 표시도 여기서 내려간다 — 방금 서버 도면을 실었으니 고친 것이 없다.)
resetUndoBaseline();
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
setDesignMeta(event.data.meta ?? null);
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
setHostReadOnly(event.data.readOnly === true);
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
setHostSave(event.data.hostSave === true);
setRibbonCollapsed(event.data.ribbonCollapsed === true);
if (event.data.panelsCollapsed) setInspectorCollapsed(true);
setViewOnly(event.data.viewOnly === true);
setOriginMarker(event.data.originMarker === true);
if (event.data.frameEdit) applyFramePreview();
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
// 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중
// 상태를 이어 갈 이유도 없다.
runCommandInput('SELECT');
// 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다.
setRecoveryScope(event.data.recoveryScope ?? event.data.meta?.drawingId ?? null);
getScreenCanvasDrawController().zoomToFitScreen();
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
} catch (error) {
const detail = error instanceof Error ? error.message : 'Invalid drawing JSON';
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
}
});
// 부모는 이 통지로 **미저장 여부만** 안다. 확정을 푸는 것은 [수정] 버튼 한 곳뿐이다
// (2026-09-01 사용자 확정) — 예전에는 되돌리기·색 고르기까지 확정을 풀었다.
window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => {
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE, { dirty: isDrawingDirty() });
});
registerTextDoubleClickEdit();
registerBoxResizeDrag();
notifyParent(AISLO_DRAWING_READY_MESSAGE);
}