Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_FramePlaceholders.ts
T
eomsangdonandClaude Opus 5 784d6df801 feat(B07): 도각 외부 파일 불러오기(DXF/DWG)와 자리표 배치 도구
- DXF -> 도각 JSON 변환 엔진 신설 (ezdxf) — 선·폴리선·원/호/타원/스플라인 평탄화·글자·점 변환, 블록·치수는 분해, 해치 등 미지원 요소는 제외
- DWG 는 ODA File Converter(.env ODA_CONVERTER_PATH) 경유 변환, 미설치 시 DXF 저장 안내로 폴백
- POST /{project_id}/frame-template/import 신설 — 파일을 편집 화면용 도면으로 반환(저장은 기존 [완료] 경로 유지), 20MB·2만 도형 상한
- 도각 편집 띠에 「파일 불러오기」 추가, 자리표 팔레트 신설 (글자 14종·그림 4종을 도면 중앙에 배치 후 이동)
- CAD 텍스트 선택 시 회색 점선 외곽선 표시 (출력물에는 미포함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 12:41:22 +09:00

197 lines
6.2 KiB
TypeScript

/**
* B07 도각 자리표 배치 — 프로그램 값이 들어갈 자리를 사용자가 직접 놓는다 (2026-09-06 사용자 확정).
*
* 값을 알아맞히는 규칙은 만들지 않는다. 여기서 놓은 `{{키}}` 토큰을 출력 때 기존 치환
* 엔진(`_fill_placeholders`)이 그대로 채운다. 자리표를 안 놓은 값은 빈칸으로 남는다.
*
* 놓는 방식 — 단추를 누르면 도면 한가운데에 자리표가 서고, 그 뒤 캐드의 이동·크기 도구로
* 자리를 잡는다. 캐드 안쪽 코드는 건드리지 않는다(도면을 통째로 다시 싣는 방식).
*/
import { createButton, showToast } from "@ui/ui_template_elements";
import type { CadDrawing } from "./B07_DesignDetail_Api_Fetch";
/** 글자 자리표 — 출력 때 표제란 값으로 바뀐다. */
const TEXT_TOKENS: readonly string[] = [
"도면명",
"도면번호",
"공사명",
"위치",
"시행청",
"용역회사",
"연도기번",
"사업량",
"과업책임자",
"분야별책임자",
"설계자",
"설계일자",
"축척_A1",
"축척_A3",
];
/** 그림 자리표 — 회사 로고와 사람 서명. 값이 없으면 그림째 빠진다. */
const IMAGE_TOKENS: readonly string[] = [
"회사로고",
"과업책임자서명",
"분야별책임자서명",
"설계자서명",
];
const TEXT_SIZE_MM = 5;
const IMAGE_WIDTH_MM = 32;
const IMAGE_HEIGHT_MM = 16;
/** 겹쳐 놓지 않도록 하나 놓을 때마다 이만큼 내려 찍는다. */
const STACK_STEP_MM = 8;
interface Options {
/** CAD에서 현재 편집본을 받아온다. */
requestCadDrawing: () => Promise<CadDrawing>;
/** CAD에 도면을 다시 싣는다. */
sendLoad: (drawing: CadDrawing, meta: null) => void;
}
interface Point {
x: number;
y: number;
}
/** 엔티티 목록의 한가운데 — 자리표를 처음 놓는 자리. 좌표가 없으면 원점. */
function centerOf(entities: Record<string, unknown>[]): Point {
const xs: number[] = [];
const ys: number[] = [];
const visit = (entity: Record<string, unknown>): void => {
const shape = (entity.shapeData ?? {}) as Record<string, unknown>;
for (const key of ["startPoint", "endPoint", "basePoint", "point"]) {
const value = shape[key] as Point | undefined;
if (value && typeof value.x === "number" && typeof value.y === "number") {
xs.push(value.x);
ys.push(value.y);
}
}
for (const vertex of (shape.points as Point[] | undefined) ?? []) {
if (vertex && typeof vertex.x === "number") {
xs.push(vertex.x);
ys.push(vertex.y);
}
}
for (const child of (entity.children as Record<string, unknown>[] | undefined) ?? []) {
visit(child);
}
};
for (const entity of entities) visit(entity);
if (xs.length === 0) return { x: 0, y: 0 };
const mid = (values: number[]): number => (Math.min(...values) + Math.max(...values)) / 2;
return { x: mid(xs), y: mid(ys) };
}
function layerIdOf(drawing: CadDrawing): string {
return drawing.layers[0]?.id ?? "0";
}
function textEntity(token: string, at: Point, layerId: string): Record<string, unknown> {
return {
id: crypto.randomUUID(),
type: "Text",
lineColor: "#f5f7fa",
lineWidth: 1,
layerId,
shapeData: {
label: `{{${token}}}`,
basePoint: { x: at.x, y: at.y },
options: {
textDirection: { x: 1, y: 0 },
textAlign: "center",
textColor: "#f5f7fa",
fontSize: TEXT_SIZE_MM,
fontFamily: "sans-serif",
},
},
};
}
function imageEntity(token: string, at: Point, layerId: string): Record<string, unknown> {
const halfWidth = IMAGE_WIDTH_MM / 2;
const halfHeight = IMAGE_HEIGHT_MM / 2;
return {
id: crypto.randomUUID(),
type: "Image",
lineColor: "#f5f7fa",
lineWidth: 1,
layerId,
shapeData: {
points: [
{ x: at.x - halfWidth, y: at.y - halfHeight },
{ x: at.x + halfWidth, y: at.y - halfHeight },
{ x: at.x + halfWidth, y: at.y + halfHeight },
{ x: at.x - halfWidth, y: at.y + halfHeight },
],
imageData: `{{${token}}}`,
},
};
}
export interface PlaceholderPalette {
/** 도각 편집 띠 아래에 붙는 자리표 목록 (평소엔 숨김). */
root: HTMLElement;
setVisible: (visible: boolean) => void;
}
export function createPlaceholderPalette(options: Options): PlaceholderPalette {
const root = document.createElement("div");
root.className = "b07-frame-tokens";
root.hidden = true;
const hint = document.createElement("span");
hint.className = "b07-frame-tokens__hint";
hint.textContent = "자리표 놓기 — 누르면 도면 가운데에 서고, 끌어서 자리를 잡음";
root.append(hint);
let placed = 0;
const place = async (token: string, kind: "text" | "image"): Promise<void> => {
try {
const drawing = await options.requestCadDrawing();
const center = centerOf(drawing.entities);
const at = { x: center.x, y: center.y - placed * STACK_STEP_MM };
const layerId = layerIdOf(drawing);
const entity =
kind === "text" ? textEntity(token, at, layerId) : imageEntity(token, at, layerId);
options.sendLoad({ ...drawing, entities: [...drawing.entities, entity] }, null);
placed += 1;
showToast(`「${token}」 자리표를 놓았습니다. 끌어서 자리를 잡으십시오.`, "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "자리표를 놓지 못했습니다.", "error");
}
};
const group = (label: string, tokens: readonly string[], kind: "text" | "image"): void => {
const box = document.createElement("div");
box.className = "b07-frame-tokens__group";
const title = document.createElement("span");
title.className = "b07-frame-tokens__title";
title.textContent = label;
box.append(title);
for (const token of tokens) {
box.append(
createButton({
label: token,
variant: "ghost",
onClick: () => void place(token, kind),
}),
);
}
root.append(box);
};
group("글자", TEXT_TOKENS, "text");
group("그림", IMAGE_TOKENS, "image");
return {
root,
setVisible: (visible: boolean) => {
root.hidden = !visible;
if (!visible) placed = 0;
},
};
}