From 1244ce1d0501da52ce84967d8247829640675e42 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 09:40:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EB=8F=84=EB=A9=B4=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=ED=8E=B8=EC=A7=91=20=EB=B6=80=ED=92=88=20mountDraw?= =?UTF-8?q?ingTemplate=20=E2=80=94=20=EB=8F=84=EA=B0=81=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EB=AA=A8=EB=93=9C=20=C2=B7=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=ED=91=9C=20=ED=82=A4=20=C2=B7=20=EC=9E=91=EB=8F=84=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=C2=B7=20=EC=9E=90=EB=8F=99=EB=B0=B1=EC=97=85=20?= =?UTF-8?q?=EC=B9=B8=20M02=20=EC=9A=A9=20(PLAN=2010-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_MasterTemplete_Drawing.ts · .css — (칸, 문서, {onSave, readOnly, name}) → {getDoc(Promise), destroy} - cad_host load 곁값 recoveryScope · readOnly - CAD 앱 브리지 — 싣기 메시지의 recoveryScope(없으면 meta.drawingId) · readOnly(확정본과 같은 막힘) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6 --- .../src/integration/aislo-drawing-bridge.ts | 8 +- B07_DesignDetail/openwebcad/src/state.ts | 9 +- .../M02_MasterTemplete_Drawing.css | 66 ++++++ .../M02_MasterTemplete_Drawing.ts | 220 ++++++++++++++++++ ui_template/cad_host/cad_host.ts | 32 ++- 5 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Drawing.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Drawing.ts diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 9f6cd23c..3ffebc53 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -20,6 +20,7 @@ import { setDesignMeta, setEntities, setFrameEditMode, + setHostReadOnly, setLayers, } from '../state.ts'; import { toast } from 'react-toastify'; @@ -45,6 +46,10 @@ interface DrawingLoadMessage { frameEdit?: boolean; /** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */ frameFields?: Record; + /** 자동백업 칸 이름 — 없으면 meta.drawingId. 수량 패널 없이 싣는 M02 양식이 쓴다. */ + recoveryScope?: string; + /** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */ + readOnly?: boolean; } interface DrawingSaveRequestMessage { @@ -187,6 +192,7 @@ export function registerAisloDrawingBridge() { // 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영 setDesignMeta(event.data.meta ?? null); setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {}); + setHostReadOnly(event.data.readOnly === true); if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: @@ -194,7 +200,7 @@ export function registerAisloDrawingBridge() { // 상태를 이어 갈 이유도 없다. runCommandInput('SELECT'); // 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다. - setRecoveryScope(event.data.meta?.drawingId ?? null); + setRecoveryScope(event.data.recoveryScope ?? event.data.meta?.drawingId ?? null); getScreenCanvasDrawController().zoomToFitScreen(); notifyParent(AISLO_DRAWING_LOADED_MESSAGE); } catch (error) { diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index 1fcf3217..aa51cd94 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -190,6 +190,8 @@ let designMeta: DesignMeta | null = null; let frameEditMode = false; /** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */ let frameFields: Record = {}; +/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */ +let hostReadOnly = false; /** * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — @@ -267,7 +269,7 @@ export const isDrawingDirty = () => drawingDirty; * 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자 * 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다. */ -export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true; +export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true || hostReadOnly; // setters export const setCanvas = (newCanvas: HTMLCanvasElement) => { @@ -493,6 +495,11 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => { triggerReactUpdate(StateVariable.designMeta); }; /** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */ +/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */ +export const setHostReadOnly = (readOnly: boolean) => { + hostReadOnly = readOnly; + notifyWindow(HtmlEvent.UPDATE_STATE); +}; export const setFrameEditMode = (enabled: boolean, fields: Record = {}) => { frameEditMode = enabled; frameFields = enabled ? fields : {}; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css new file mode 100644 index 00000000..08a4bb11 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css @@ -0,0 +1,66 @@ +/* M02 도면 양식 편집 부품 — 위 도구 줄(작도 영역 · 자리표 · 단추) + 아래 웹캐드. */ + +.m02-drawing { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + width: 100%; + height: 100%; + min-height: 0; +} + +.m02-drawing > .cad-host { + flex: 1 1 0; +} + +.m02-drawing__toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16, 16px); +} + +.m02-drawing__title { + align-self: center; + font-size: 0.78rem; + font-weight: 600; + color: var(--color-text); +} + +.m02-drawing__area { + display: flex; + align-items: flex-end; + gap: var(--spacing-8); +} + +.m02-drawing__area .ui-field { + width: 5.5rem; +} + +.m02-drawing__fields { + display: flex; + flex: 1 1 20rem; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4, 4px); +} + +.m02-drawing__field { + padding: 2px 8px; + border: 1px solid var(--color-border); + border-radius: 1440px; + background-color: var(--color-surface); + color: var(--color-text); + font-size: 0.72rem; + cursor: pointer; +} + +.m02-drawing__field[data-source="도면"] { + color: var(--color-text-muted); +} + +.m02-drawing__actions { + display: flex; + gap: var(--spacing-8); + margin-left: auto; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts new file mode 100644 index 00000000..5c7d42ea --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -0,0 +1,220 @@ +/* ============================================================================= + * M02_MasterTemplete_Drawing.ts + * M02 도면 양식 편집 부품 — 웹캐드를 도각 편집 모드로 띄워 양식을 만들고 고친다 (PLAN 10-3). + * + * 계약(`tmp/M02_분석/6_계약.md` 화면 부품) — `mountDrawingTemplate(칸, 문서, {onSave, readOnly})` + * → `{getDoc, destroy}`. 페이지(sub1)가 메인 칸에 붙인다. + * `getDoc()` 은 CAD iframe 에서 편집본을 받아 오므로 **Promise** 다 — `await` 로 받는다. + * + * 양식 문서 = openwebcad 도면 JSON(`entities` · `layers`) + 양식 칸(`format` · `source` · + * `drawing_area`). CAD 는 양식 칸을 모르므로 돌려줄 때 원래 칸 위에 편집본을 얹는다. + * ========================================================================== */ + +import "./M02_MasterTemplete_Drawing.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; + +/** 도면 양식 문서 — 작도 영역은 [x0, y0, x1, y1] (양식 좌표 mm). */ +export interface DrawingTemplateDoc extends CadHostDrawing { + format?: number; + source?: string; + drawing_area?: [number, number, number, number]; + [key: string]: unknown; +} + +export interface DrawingTemplateOptions { + /** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */ + onSave?: (doc: DrawingTemplateDoc) => void | Promise; + /** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */ + readOnly?: boolean; + /** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */ + name?: string; +} + +export interface DrawingTemplateHandle { + getDoc: () => Promise; + destroy: () => void; +} + +/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */ +const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567]; +const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"]; + +interface DrawingField { + key: string; + source: string; + label: string; +} + +async function fetchDrawingFields(): Promise { + const response = await fetch(`${API_BASE_URL}/m02/drawing-fields`, { credentials: "include" }); + const payload = (await response.json()) as { fields?: DrawingField[]; message?: string }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload.fields ?? []; +} + +async function importDrawingFile( + file: File, +): Promise<{ drawing: DrawingTemplateDoc; entity_count: number }> { + const form = new FormData(); + form.append("file", file); + const response = await fetch(`${API_BASE_URL}/m02/drawing-import`, { + method: "POST", + credentials: "include", + body: form, + }); + const payload = (await response.json()) as { + drawing: DrawingTemplateDoc; + entity_count: number; + message?: string; + }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + +export function mountDrawingTemplate( + container: HTMLElement, + doc: DrawingTemplateDoc, + options: DrawingTemplateOptions = {}, +): DrawingTemplateHandle { + const readOnly = options.readOnly === true; + const recoveryScope = `m02:${options.name ?? "drawing"}`; + let base: DrawingTemplateDoc = doc; + + const root = document.createElement("div"); + root.className = "m02-drawing"; + const toolbar = document.createElement("div"); + toolbar.className = "m02-drawing__toolbar"; + + // 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다. + const area = document.createElement("div"); + area.className = "m02-drawing__area"; + const areaTitle = document.createElement("span"); + areaTitle.className = "m02-drawing__title"; + areaTitle.textContent = "작도 영역"; + const start = doc.drawing_area ?? DEFAULT_AREA; + const areaInputs = AREA_LABELS.map((label, index) => { + const field = createInputField({ label, type: "number", value: String(start[index]) }); + field.input.disabled = readOnly; + return field; + }); + area.append(areaTitle, ...areaInputs.map((field) => field.root)); + + const readArea = (): [number, number, number, number] | null => { + const values = areaInputs.map((field) => Number(field.input.value)); + const valid = values.every(Number.isFinite) && values[0] < values[2] && values[1] < values[3]; + areaInputs.forEach((field) => field.setError(valid ? undefined : "왼쪽<오른쪽 · 아래<위")); + return valid ? (values as [number, number, number, number]) : null; + }; + + // 자리표 키 — 누르면 `{{키}}` 를 복사한다. 도각 글자에 붙여 넣으면 그릴 때 값이 채워진다. + const fields = document.createElement("div"); + fields.className = "m02-drawing__fields"; + const fieldsTitle = document.createElement("span"); + fieldsTitle.className = "m02-drawing__title"; + fieldsTitle.textContent = "자리표"; + fields.append(fieldsTitle); + void fetchDrawingFields() + .then((list) => { + for (const field of list) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "m02-drawing__field"; + chip.dataset.source = field.source; + chip.textContent = `{{${field.key}}}`; + chip.title = `${field.label} (${field.source}) — 눌러 복사`; + chip.addEventListener("click", () => { + void navigator.clipboard + ?.writeText(`{{${field.key}}}`) + .then(() => showToast(`{{${field.key}}} 를 복사했습니다.`, "success")); + }); + fields.append(chip); + } + }) + .catch((error) => + showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"), + ); + + const cad = createCadHost({ title: "도면 양식" }); + const load = (drawing: DrawingTemplateDoc): void => { + cad.beginLoading(); + cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly }); + }; + + const actions = document.createElement("div"); + actions.className = "m02-drawing__actions"; + if (!readOnly) { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".dxf,.dwg"; + fileInput.hidden = true; + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + fileInput.value = ""; + if (!file) return; + importButton.disabled = true; + importDrawingFile(file) + .then((response) => { + // 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다. + load(response.drawing); + showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); + }) + .catch((error) => + showToast( + error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.", + "error", + ), + ) + .finally(() => (importButton.disabled = false)); + }); + const importButton = createButton({ + label: "파일 불러오기", + variant: "ghost", + onClick: () => fileInput.click(), + }); + actions.append(importButton, fileInput); + } + + const getDoc = async (): Promise => { + const { drawing } = await cad.requestSave(); + const drawingArea = readArea(); + if (!drawingArea) throw new Error("작도 영역 값이 올바르지 않습니다."); + base = { ...base, ...drawing, drawing_area: drawingArea }; + return base; + }; + + if (options.onSave && !readOnly) { + const onSave = options.onSave; + const saveButton = createButton({ + label: "저장", + variant: "filled", + onClick: () => { + saveButton.disabled = true; + getDoc() + .then((next) => onSave(next)) + .catch((error) => + showToast( + error instanceof Error ? error.message : "양식을 저장하지 못했습니다.", + "error", + ), + ) + .finally(() => (saveButton.disabled = false)); + }, + }); + actions.append(saveButton); + } + + toolbar.append(area, fields, actions); + root.append(toolbar, cad.element); + container.append(root); + load(doc); + + return { + getDoc, + destroy: () => { + cad.destroy(); + root.remove(); + }, + }; +} diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 5fde060a..9651c3e8 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -50,6 +50,14 @@ export interface CadHostOptions { onExport?: (fileFormat: "dxf" | "dwg") => void; } +/** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */ +export interface CadLoadExtra { + /** 자동백업 칸 이름 — 없으면 meta.drawingId. 칸이 겹치면 백업을 서로 덮는다. */ + recoveryScope?: string; + /** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */ + readOnly?: boolean; +} + export interface CadHost { /** iframe 과 라이선스 글을 담은 칸 — 페이지 메인 칸에 붙인다. */ element: HTMLElement; @@ -65,6 +73,7 @@ export interface CadHost { meta: M | null, frameEdit?: boolean, frameFields?: Record, + extra?: CadLoadExtra, ) => void; /** CAD 의 지금 편집본을 받는다. */ requestSave: () => Promise>; @@ -91,7 +100,13 @@ export function createCadHost } + | { + drawing: D; + meta: M | null; + frameEdit: boolean; + frameFields: Record; + extra: CadLoadExtra; + } | undefined; let resolveSave: ((payload: CadSaveResult) => void) | undefined; // ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다** @@ -142,10 +157,16 @@ export function createCadHost["load"] = (drawing, meta, frameEdit = false, frameFields = {}) => { - pendingLoad = { drawing, meta, frameEdit, frameFields }; + const load: CadHost["load"] = ( + drawing, + meta, + frameEdit = false, + frameFields = {}, + extra = {}, + ) => { + pendingLoad = { drawing, meta, frameEdit, frameFields, extra }; if (!cadReady) return; - post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields }); + post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra }); pendingLoad = undefined; }; @@ -191,7 +212,8 @@ export function createCadHost