Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
|
||||
readOnly?: boolean;
|
||||
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DrawingTemplateHandle {
|
||||
getDoc: () => Promise<DrawingTemplateDoc>;
|
||||
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<DrawingField[]> {
|
||||
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<DrawingTemplateDoc>({ 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<DrawingTemplateDoc> => {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""M02 도면 양식 서버 길 — 외부 도각 파일 불러오기 · 자리표 키 목록 (PLAN 10-3).
|
||||
|
||||
양식을 읽고 쓰는 길은 `M02_MasterTemplete_Router.py`(시스템 층)·`_Router_Layers.py`(층)가 맡는다.
|
||||
여기는 도면 양식 편집 화면만 쓰는 두 길 — B07 의 불러오기 엔진 · 표제란 값을 그대로 다시 쓴다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
frame_document,
|
||||
validate_template_entities,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
|
||||
# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다.
|
||||
_IMPORT_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
# 자리표 `{{키}}` 로 쓸 수 있는 키 — 도면을 그릴 때 채워지는 것만 둔다.
|
||||
# 프로젝트 값은 B07 `title_block_fields` 가 · 도면 값은 도면을 그리는 엔진이 채운다.
|
||||
DRAWING_FIELDS: tuple[tuple[str, str, str], ...] = (
|
||||
("공사명", "프로젝트", "프로젝트 이름"),
|
||||
("위치", "프로젝트", "사업 위치"),
|
||||
("시행청", "프로젝트", "발주 기관"),
|
||||
("연도기번", "프로젝트", "프로젝트 번호"),
|
||||
("사업량", "프로젝트", "사업량"),
|
||||
("설계일자", "프로젝트", "설계 일자"),
|
||||
("용역회사", "프로젝트", "회사 이름"),
|
||||
("설계자", "프로젝트", "설계자 이름"),
|
||||
("과업책임자", "프로젝트", "과업책임자 이름"),
|
||||
("분야별책임자", "프로젝트", "분야별책임자 이름"),
|
||||
("회사로고", "프로젝트", "회사 로고 그림"),
|
||||
("설계자서명", "프로젝트", "설계자 서명 그림"),
|
||||
("과업책임자서명", "프로젝트", "과업책임자 서명 그림"),
|
||||
("분야별책임자서명", "프로젝트", "분야별책임자 서명 그림"),
|
||||
("도면명", "도면", "도면 이름"),
|
||||
("도면번호", "도면", "도면 목록 순번"),
|
||||
("축척_A1", "도면", "A1 축척 분모"),
|
||||
("축척_A3", "도면", "A3 축척 분모"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/drawing-import")
|
||||
async def import_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
|
||||
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다 — 저장하지 않는다."""
|
||||
try:
|
||||
data = await file.read()
|
||||
if len(data) > _IMPORT_MAX_BYTES:
|
||||
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
|
||||
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
|
||||
validate_template_entities(entities)
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"drawing": frame_document(entities),
|
||||
"entity_count": len(entities),
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("M02 도각 불러오기 실패: %s", file.filename)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도각 파일을 읽지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drawing-fields")
|
||||
async def get_drawing_fields(project_id: UUID | None = None) -> JSONResponse:
|
||||
"""자리표 키 목록. project_id 를 주면 그 프로젝트의 표제란 값도 함께(미리보기용)."""
|
||||
try:
|
||||
values = await title_block_fields(project_id) if project_id else {}
|
||||
except Exception:
|
||||
logger.exception("M02 자리표 값 조회 실패: project_id=%s", project_id)
|
||||
values = {}
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"fields": [
|
||||
{"key": key, "source": source, "label": label}
|
||||
for key, source, label in DRAWING_FIELDS
|
||||
],
|
||||
"values": values,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user