diff --git a/README.md b/README.md
index 4c162dc..f662f55 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# img-infinite-canvas
-Moteva 风格的 AI 设计工作台复刻 MVP:主页 Prompt 创建项目,项目页提供无限画布、节点拖拽、缩放平移、右侧设计对话、历史回放、图片资产上传、项目保存与再生成。
+Moteva 风格的 AI 设计工作台复刻 MVP:主页 Prompt 创建项目,项目页提供无限画布、节点拖拽、缩放平移、右侧设计对话、历史回放、图片与 DWG/DXF 图纸导入、项目保存与再生成。
## Stack
@@ -31,6 +31,10 @@ pnpm dev
Open `http://localhost:5173`. Next.js rewrites `/api` to `http://localhost:8888` in development. `pnpm build` runs `next build` and `vite build`, producing gzip assets in `frontend/dist`.
+## CAD Import
+
+The canvas asset picker and drag-and-drop surface accept DWG and DXF files up to 24 MB. Conversion runs locally in a browser worker, uploads a sanitized derived SVG, and leaves the source file unchanged. DXF supports ASCII and binary drawings; DWG supports AutoCAD R14 through AutoCAD 2018 format families.
+
## Cache Config
Default in-memory cache:
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
index 9edff1c..c4b7818 100644
--- a/frontend/next-env.d.ts
+++ b/frontend/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/frontend/package.json b/frontend/package.json
index 027f8d6..df575ed 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -4,13 +4,15 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "next dev -H 0.0.0.0 -p 5173",
+ "dev": "next dev --webpack -H 0.0.0.0 -p 5173",
"dev:vite": "vite --host 0.0.0.0",
- "build": "tsc -b && next build && vite build",
+ "build": "tsc -b && next build --webpack && vite build",
+ "test": "node --experimental-strip-types --test tests/*.test.mjs",
"start": "next start -H 0.0.0.0 -p 3000",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
+ "@node-projects/acad-ts": "2.3.0",
"@radix-ui/react-context-menu": "^2.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.19",
"@radix-ui/react-popover": "^1.1.18",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 0831f1c..97584e2 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -5,6 +5,9 @@ settings:
excludeLinksFromLockfile: false
dependencies:
+ '@node-projects/acad-ts':
+ specifier: 2.3.0
+ version: 2.3.0
'@radix-ui/react-context-menu':
specifier: ^2.3.2
version: 2.3.2(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.7)(react@19.2.7)
@@ -439,6 +442,10 @@ packages:
dev: false
optional: true
+ /@node-projects/acad-ts@2.3.0:
+ resolution: {integrity: sha512-qeEHH6apsBZPWVXx489ju0Qln/YyhIffREic+alksgj5F4KsaD+GAE96si1P8RNFbJbZQOSG85bo0749MpzCDA==}
+ dev: false
+
/@oxc-project/types@0.138.0:
resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==}
dev: true
diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json
index a6118ac..ddf1a62 100644
--- a/frontend/src/i18n/locales/en-US.json
+++ b/frontend/src/i18n/locales/en-US.json
@@ -432,7 +432,15 @@
"canvasCursor": "Canvas status",
"imageNode": "Image node",
"uploadImageNode": "Upload image node",
+ "uploadCanvasAsset": "Upload image or CAD file",
"imageUploadSuccess": "Image uploaded",
+ "cadImportConverting": "Converting CAD drawing",
+ "cadImportSuccess": "Imported {count} CAD drawing(s)",
+ "cadImportTooLarge": "CAD files must be 24 MB or smaller.",
+ "cadImportTimeout": "CAD conversion timed out. Simplify the drawing and try again.",
+ "cadImportEmpty": "The CAD drawing has no visible model-space content.",
+ "cadImportUnsupported": "This browser cannot convert the selected CAD file.",
+ "cadImportFailed": "CAD conversion failed. Check that the file is complete and uses a supported version.",
"uploadingImage": "Uploading",
"extractingText": "Extract text",
"textNode": "Text node",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 018f344..e3d97ce 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -432,7 +432,15 @@
"canvasCursor": "画布状态",
"imageNode": "图片节点",
"uploadImageNode": "上传图片节点",
+ "uploadCanvasAsset": "上传图片或 CAD 文件",
"imageUploadSuccess": "图片上传成功",
+ "cadImportConverting": "正在转换 CAD 图纸",
+ "cadImportSuccess": "已导入 {count} 个 CAD 图纸",
+ "cadImportTooLarge": "CAD 文件不能超过 24 MB。",
+ "cadImportTimeout": "CAD 图纸转换超时,请尝试精简图纸后重试。",
+ "cadImportEmpty": "CAD 图纸中没有可显示的模型空间内容。",
+ "cadImportUnsupported": "当前浏览器无法转换此 CAD 文件。",
+ "cadImportFailed": "CAD 图纸转换失败,请确认文件完整且版本受支持。",
"uploadingImage": "上传中",
"extractingText": "提取文字",
"textNode": "文本节点",
diff --git a/frontend/src/ui/lib/cadConversion.ts b/frontend/src/ui/lib/cadConversion.ts
new file mode 100644
index 0000000..513a43a
--- /dev/null
+++ b/frontend/src/ui/lib/cadConversion.ts
@@ -0,0 +1,380 @@
+import * as Acad from "@node-projects/acad-ts";
+import type {
+ BlockRecord as CadBlockRecord,
+ CadDocument,
+ Color as CadColor,
+ Entity,
+ Insert as CadInsert,
+ UnitsType as CadUnitsType
+} from "@node-projects/acad-ts";
+
+const { Color, Dimension, DwgReader, DxfReader, Insert, LayerFlags, SvgConfiguration, SvgConverter, SvgXmlWriter, UnitsType } = Acad;
+
+export type CadFormat = "dwg" | "dxf";
+
+type AffineMatrix = {
+ a: number;
+ b: number;
+ c: number;
+ d: number;
+ e: number;
+ f: number;
+};
+
+type CadBounds = {
+ minX: number;
+ minY: number;
+ maxX: number;
+ maxY: number;
+};
+
+class EntityFragmentWriter extends SvgXmlWriter {
+ writeFragment(entity: Entity) {
+ this.writeEntity(entity);
+ return this.getOutput();
+ }
+}
+
+class CadEntitySvgWriter {
+ private fragmentIndex = 0;
+ private readonly configuration: InstanceType;
+ private readonly units: CadUnitsType;
+
+ constructor(configuration: InstanceType, units: CadUnitsType) {
+ this.configuration = configuration;
+ this.units = units;
+ }
+
+ writeFragment(entity: Entity) {
+ const writer = new EntityFragmentWriter(new ArrayBuffer(0), this.configuration);
+ writer.units = this.units;
+ return scopeFragmentIds(writer.writeFragment(entity), `cad-entity-${this.fragmentIndex++}`);
+ }
+}
+
+export function convertCadBufferToSvg(buffer: ArrayBuffer, format: CadFormat) {
+ restoreAcadClassNames();
+ const document = format === "dwg" ? DwgReader.readFromStream(buffer) : DxfReader.readFromStream(new Uint8Array(buffer));
+ return convertCadDocumentToSvg(document);
+}
+
+export function convertCadDocumentToSvg(document: CadDocument) {
+ const modelSpace = document.modelSpace;
+ if (!modelSpace || modelSpace.entities.count === 0) {
+ throw new Error("CAD drawing has no model-space entities");
+ }
+
+ resolveInheritedEntityColors(document);
+ const configuration = new SvgConfiguration();
+ configuration.arcPoints = 192;
+ const units = supportedSvgUnits(modelSpace.units);
+ const unitScale = SvgConverter.toPixelSize(1, units);
+ const writer = new CadEntitySvgWriter(configuration, units);
+
+ const { blockIds, cyclicReferences } = collectReferencedBlocks(modelSpace);
+ const blockBounds = createBlockBoundsResolver(cyclicReferences);
+ const bounds = normalizedBounds(boundsForEntities(modelSpace.entities, blockBounds, cyclicReferences));
+ const definitions = Array.from(blockIds, ([block, id]) => {
+ const content = renderEntities(block.entities, writer, blockIds, cyclicReferences, unitScale);
+ return `${content}`;
+ }).join("");
+ const drawing = renderEntities(modelSpace.entities, writer, blockIds, cyclicReferences, unitScale);
+ if (!drawing.trim()) {
+ throw new Error("CAD drawing did not produce SVG output");
+ }
+
+ const minX = bounds.minX * unitScale;
+ const minY = bounds.minY * unitScale;
+ const width = (bounds.maxX - bounds.minX) * unitScale;
+ const height = (bounds.maxY - bounds.minY) * unitScale;
+ return `\n`;
+}
+
+function renderEntities(
+ entities: Iterable,
+ writer: CadEntitySvgWriter,
+ blockIds: ReadonlyMap,
+ cyclicReferences: ReadonlySet,
+ unitScale: number
+) {
+ const output: string[] = [];
+ for (const entity of entities) {
+ if (!isVisibleEntity(entity)) continue;
+ if (entity instanceof Insert) {
+ const id = entity.block ? blockIds.get(entity.block) : undefined;
+ if (id && !cyclicReferences.has(entity)) {
+ for (const matrix of insertMatrices(entity)) {
+ output.push(``);
+ }
+ }
+ for (const attribute of entity.attributes) {
+ if (isVisibleEntity(attribute)) output.push(writer.writeFragment(attribute));
+ }
+ continue;
+ }
+
+ if (entity instanceof Dimension) {
+ const id = entity.block ? blockIds.get(entity.block) : undefined;
+ if (id && !cyclicReferences.has(entity)) output.push(``);
+ continue;
+ }
+
+ output.push(writer.writeFragment(entity));
+ }
+ return output.join("");
+}
+
+function collectReferencedBlocks(modelSpace: CadBlockRecord) {
+ const blockIds = new Map();
+ const cyclicReferences = new Set();
+ const visiting = new Set();
+ const visited = new Set();
+
+ const visit = (block: CadBlockRecord) => {
+ if (visited.has(block)) return;
+ visiting.add(block);
+ for (const entity of block.entities) {
+ if (!isVisibleEntity(entity)) continue;
+ const referenced = referencedBlock(entity);
+ if (!referenced) continue;
+ if (visiting.has(referenced)) {
+ cyclicReferences.add(entity);
+ continue;
+ }
+ if (!blockIds.has(referenced)) blockIds.set(referenced, `cad-block-${blockIds.size}`);
+ visit(referenced);
+ }
+ visiting.delete(block);
+ visited.add(block);
+ };
+
+ visit(modelSpace);
+ return { blockIds, cyclicReferences };
+}
+
+function referencedBlock(entity: Entity) {
+ if (entity instanceof Insert || entity instanceof Dimension) return entity.block;
+ return null;
+}
+
+function insertMatrices(insert: CadInsert) {
+ const rowCount = positiveInteger(insert.rowCount);
+ const columnCount = positiveInteger(insert.columnCount);
+ const rotation = Number.isFinite(insert.rotation) ? insert.rotation : 0;
+ const cosine = Math.cos(rotation);
+ const sine = Math.sin(rotation);
+ const scaleX = finiteOr(insert.xScale, 1);
+ const scaleY = finiteOr(insert.yScale, 1);
+ const basePoint = insert.block?.blockEntity.basePoint;
+ const baseX = finiteOr(basePoint?.x, 0);
+ const baseY = finiteOr(basePoint?.y, 0);
+ const a = cosine * scaleX;
+ const b = sine * scaleX;
+ const c = -sine * scaleY;
+ const d = cosine * scaleY;
+ const matrices: AffineMatrix[] = [];
+
+ for (let row = 0; row < rowCount; row += 1) {
+ for (let column = 0; column < columnCount; column += 1) {
+ const offsetX = column * finiteOr(insert.columnSpacing, 0);
+ const offsetY = row * finiteOr(insert.rowSpacing, 0);
+ matrices.push({
+ a,
+ b,
+ c,
+ d,
+ e: finiteOr(insert.insertPoint.x, 0) + cosine * offsetX - sine * offsetY - a * baseX - c * baseY,
+ f: finiteOr(insert.insertPoint.y, 0) + sine * offsetX + cosine * offsetY - b * baseX - d * baseY
+ });
+ }
+ }
+ return matrices;
+}
+
+function createBlockBoundsResolver(cyclicReferences: ReadonlySet) {
+ const cache = new Map();
+ const resolving = new Set();
+
+ const resolve = (block: CadBlockRecord): CadBounds | null => {
+ if (cache.has(block)) return cache.get(block) ?? null;
+ if (resolving.has(block)) return null;
+ resolving.add(block);
+ const bounds = boundsForEntities(block.entities, resolve, cyclicReferences);
+ resolving.delete(block);
+ cache.set(block, bounds);
+ return bounds;
+ };
+ return resolve;
+}
+
+function boundsForEntities(
+ entities: Iterable,
+ blockBounds: (block: CadBlockRecord) => CadBounds | null,
+ cyclicReferences: ReadonlySet
+) {
+ let bounds: CadBounds | null = null;
+ for (const entity of entities) {
+ if (!isVisibleEntity(entity)) continue;
+ if (entity instanceof Insert) {
+ const referencedBounds = entity.block && !cyclicReferences.has(entity) ? blockBounds(entity.block) : null;
+ if (referencedBounds) {
+ for (const matrix of insertMatrices(entity)) bounds = unionBounds(bounds, transformBounds(referencedBounds, matrix));
+ }
+ for (const attribute of entity.attributes) {
+ if (isVisibleEntity(attribute)) bounds = unionBounds(bounds, entityBounds(attribute));
+ }
+ continue;
+ }
+
+ if (entity instanceof Dimension) {
+ const referencedBounds = entity.block && !cyclicReferences.has(entity) ? blockBounds(entity.block) : null;
+ bounds = unionBounds(bounds, referencedBounds);
+ bounds = unionBounds(bounds, entityBounds(entity));
+ continue;
+ }
+
+ bounds = unionBounds(bounds, entityBounds(entity));
+ }
+ return bounds;
+}
+
+function entityBounds(entity: Entity) {
+ try {
+ const bounds = entity.getBoundingBox();
+ if (!bounds || !isFinitePoint(bounds.min) || !isFinitePoint(bounds.max)) return null;
+ return { minX: bounds.min.x, minY: bounds.min.y, maxX: bounds.max.x, maxY: bounds.max.y };
+ } catch {
+ return null;
+ }
+}
+
+function transformBounds(bounds: CadBounds, matrix: AffineMatrix) {
+ const points = [
+ transformPoint(bounds.minX, bounds.minY, matrix),
+ transformPoint(bounds.minX, bounds.maxY, matrix),
+ transformPoint(bounds.maxX, bounds.minY, matrix),
+ transformPoint(bounds.maxX, bounds.maxY, matrix)
+ ];
+ return {
+ minX: Math.min(...points.map((point) => point.x)),
+ minY: Math.min(...points.map((point) => point.y)),
+ maxX: Math.max(...points.map((point) => point.x)),
+ maxY: Math.max(...points.map((point) => point.y))
+ };
+}
+
+function transformPoint(x: number, y: number, matrix: AffineMatrix) {
+ return {
+ x: matrix.a * x + matrix.c * y + matrix.e,
+ y: matrix.b * x + matrix.d * y + matrix.f
+ };
+}
+
+function unionBounds(first: CadBounds | null, second: CadBounds | null) {
+ if (!first) return second;
+ if (!second) return first;
+ return {
+ minX: Math.min(first.minX, second.minX),
+ minY: Math.min(first.minY, second.minY),
+ maxX: Math.max(first.maxX, second.maxX),
+ maxY: Math.max(first.maxY, second.maxY)
+ };
+}
+
+function normalizedBounds(bounds: CadBounds | null) {
+ if (!bounds) throw new Error("CAD drawing has no visible bounds");
+ const normalized = { ...bounds };
+ if (normalized.maxX === normalized.minX) {
+ normalized.minX -= 0.5;
+ normalized.maxX += 0.5;
+ }
+ if (normalized.maxY === normalized.minY) {
+ normalized.minY -= 0.5;
+ normalized.maxY += 0.5;
+ }
+ return normalized;
+}
+
+function svgMatrix(matrix: AffineMatrix, unitScale: number) {
+ return `matrix(${svgNumber(matrix.a)} ${svgNumber(matrix.b)} ${svgNumber(matrix.c)} ${svgNumber(matrix.d)} ${svgNumber(matrix.e * unitScale)} ${svgNumber(matrix.f * unitScale)})`;
+}
+
+function scopeFragmentIds(fragment: string, prefix: string) {
+ const ids = new Map();
+ const scoped = fragment.replace(/\bid="([^"]+)"/g, (_, id: string) => {
+ const nextId = `${prefix}-${ids.size}`;
+ ids.set(id, nextId);
+ return `id="${nextId}"`;
+ });
+ return scoped.replace(/url\(#([^)]+)\)/g, (match, id: string) => {
+ const nextId = ids.get(id);
+ return nextId ? `url(#${nextId})` : match;
+ });
+}
+
+function supportedSvgUnits(units: CadUnitsType) {
+ return units === UnitsType.Inches || units === UnitsType.Millimeters ? units : UnitsType.Unitless;
+}
+
+function positiveInteger(value: number) {
+ return Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : 1;
+}
+
+function finiteOr(value: number | null | undefined, fallback: number) {
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
+}
+
+function isFinitePoint(point: { x: number; y: number }) {
+ return Number.isFinite(point.x) && Number.isFinite(point.y);
+}
+
+function isVisibleEntity(entity: Entity) {
+ if (entity.isInvisible || !entity.layer.isOn) return false;
+ return (entity.layer.layerFlags & LayerFlags.Frozen) === 0;
+}
+
+function svgNumber(value: number) {
+ if (!Number.isFinite(value)) return "0";
+ if (Math.abs(value) < 1e-12) return "0";
+ return String(Number(value.toPrecision(12)));
+}
+
+function resolveInheritedEntityColors(document: CadDocument) {
+ const visited = new Set();
+ const visit = (block: CadBlockRecord) => {
+ if (visited.has(block)) return;
+ visited.add(block);
+ for (const entity of block.entities) {
+ if (!isRenderableColor(entity.color)) entity.color = resolveEntityColor(entity);
+ if (entity instanceof Insert) {
+ for (const attribute of entity.attributes) {
+ if (!isRenderableColor(attribute.color)) attribute.color = resolveEntityColor(attribute);
+ }
+ }
+ const referenced = referencedBlock(entity);
+ if (referenced) visit(referenced);
+ }
+ };
+ for (const block of document.blockRecords ?? []) visit(block);
+ if (document.modelSpace) visit(document.modelSpace);
+}
+
+function resolveEntityColor(entity: Entity) {
+ const candidates = [entity.getActiveColor(), entity.layer.color, Color.default];
+ return candidates.find(isRenderableColor) ?? Color.default;
+}
+
+function isRenderableColor(color: CadColor | null | undefined) {
+ if (!color || color.isByBlock || color.isByLayer) return false;
+ const rgb = color.getRgb();
+ return Array.isArray(rgb) && rgb.length === 3 && rgb.every(Number.isFinite);
+}
+
+function restoreAcadClassNames() {
+ // acad-ts resolves DXF metadata by constructor.name, which production minifiers rename.
+ for (const [exportName, value] of Object.entries(Acad)) {
+ if (typeof value !== "function" || value.name === exportName) continue;
+ const descriptor = Object.getOwnPropertyDescriptor(value, "name");
+ if (descriptor?.configurable) Object.defineProperty(value, "name", { ...descriptor, value: exportName });
+ }
+}
diff --git a/frontend/src/ui/lib/cadImport.ts b/frontend/src/ui/lib/cadImport.ts
new file mode 100644
index 0000000..43239e0
--- /dev/null
+++ b/frontend/src/ui/lib/cadImport.ts
@@ -0,0 +1,266 @@
+export type CadImportErrorCode = "conversion" | "empty" | "timeout" | "too-large" | "unsupported";
+
+type CadFormat = "dwg" | "dxf";
+
+type CadWorkerResponse =
+ | { ok: true; svg: string }
+ | { ok: false; error: string };
+
+const cadFileNamePattern = /\.(dwg|dxf)$/i;
+const maxCadFileBytes = 24 * 1024 * 1024;
+const cadConversionTimeoutMs = 45_000;
+const svgNamespace = "http://www.w3.org/2000/svg";
+const allowedSvgElements = new Set(["svg", "defs", "g", "use", "line", "circle", "path", "polyline", "polygon", "text", "tspan", "pattern", "rect"]);
+const allowedSvgAttributes = new Set([
+ "xmlns",
+ "width",
+ "height",
+ "viewBox",
+ "preserveAspectRatio",
+ "transform",
+ "vector-effect",
+ "stroke",
+ "stroke-width",
+ "stroke-dasharray",
+ "x1",
+ "y1",
+ "x2",
+ "y2",
+ "x",
+ "y",
+ "cx",
+ "cy",
+ "r",
+ "d",
+ "dy",
+ "fill",
+ "points",
+ "patternUnits",
+ "patternTransform",
+ "id",
+ "href",
+ "text-anchor",
+ "alignment-baseline",
+ "visibility",
+ "font-size",
+ "font-family"
+]);
+
+export class CadImportError extends Error {
+ constructor(
+ readonly code: CadImportErrorCode,
+ message: string
+ ) {
+ super(message);
+ this.name = "CadImportError";
+ }
+}
+
+export function isCadFile(file: File) {
+ return cadFileNamePattern.test(file.name);
+}
+
+export function isCanvasImportFile(file: File) {
+ return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(file.name) || isCadFile(file);
+}
+
+export function cadImportMessageKey(error: unknown) {
+ if (!(error instanceof CadImportError)) return "cadImportFailed";
+ switch (error.code) {
+ case "too-large":
+ return "cadImportTooLarge";
+ case "timeout":
+ return "cadImportTimeout";
+ case "empty":
+ return "cadImportEmpty";
+ case "unsupported":
+ return "cadImportUnsupported";
+ default:
+ return "cadImportFailed";
+ }
+}
+
+export async function prepareCanvasImportFile(file: File) {
+ if (!isCadFile(file)) return file;
+ if (file.size > maxCadFileBytes) {
+ throw new CadImportError("too-large", "CAD files must be 24 MB or smaller");
+ }
+
+ const format = cadFormat(file.name);
+ const buffer = await file.arrayBuffer();
+ const rawSvg = await convertCadInWorker(buffer, format);
+ const svg = normalizeCadSvg(rawSvg, file.name);
+ const baseName = file.name.replace(/\.(dwg|dxf)$/i, "").trim() || "drawing";
+ return new File([svg], `${baseName} (${format.toUpperCase()}).svg`, {
+ type: "image/svg+xml",
+ lastModified: file.lastModified || Date.now()
+ });
+}
+
+function cadFormat(fileName: string): CadFormat {
+ const extension = fileName.trim().toLowerCase().split(".").pop();
+ if (extension === "dwg" || extension === "dxf") return extension;
+ throw new CadImportError("unsupported", "Only DWG and DXF CAD files are supported");
+}
+
+function convertCadInWorker(buffer: ArrayBuffer, format: CadFormat) {
+ if (typeof Worker === "undefined") {
+ return Promise.reject(new CadImportError("unsupported", "This browser cannot run the CAD converter"));
+ }
+
+ return new Promise((resolve, reject) => {
+ const worker = new Worker(new URL("./cadImport.worker.ts", import.meta.url), { type: "module" });
+ const timeout = window.setTimeout(() => {
+ worker.terminate();
+ reject(new CadImportError("timeout", "CAD conversion timed out"));
+ }, cadConversionTimeoutMs);
+
+ const finish = () => {
+ window.clearTimeout(timeout);
+ worker.terminate();
+ };
+
+ worker.onmessage = (event: MessageEvent) => {
+ finish();
+ if (event.data.ok) {
+ resolve(event.data.svg);
+ return;
+ }
+ reject(new CadImportError(event.data.error.includes("no model-space entities") ? "empty" : "conversion", event.data.error));
+ };
+ worker.onerror = (event) => {
+ finish();
+ reject(new CadImportError("conversion", event.message || "CAD conversion worker failed"));
+ };
+ worker.postMessage({ buffer, format }, [buffer]);
+ });
+}
+
+function normalizeCadSvg(rawSvg: string, sourceName: string) {
+ const repairedSvg = repairAnonymousClosingTags(rawSvg);
+ const parsed = new DOMParser().parseFromString(repairedSvg, "image/svg+xml");
+ if (parsed.querySelector("parsererror")) {
+ throw new CadImportError("conversion", "Converted CAD output is not valid SVG");
+ }
+
+ const svg = parsed.documentElement;
+ if (svg.localName !== "svg") {
+ throw new CadImportError("conversion", "Converted CAD output has no SVG root");
+ }
+ sanitizeSvg(svg);
+
+ const viewBox = parseViewBox(svg.getAttribute("viewBox"));
+ if (!viewBox || viewBox.width <= 0 || viewBox.height <= 0) {
+ throw new CadImportError("empty", "CAD drawing has no visible bounds");
+ }
+ if (!svg.querySelector("line,circle,path,polyline,polygon,text,rect")) {
+ throw new CadImportError("empty", "CAD drawing has no supported visible entities");
+ }
+
+ svg.removeAttribute("transform");
+ svg.setAttribute("viewBox", `${formatNumber(viewBox.minX)} ${formatNumber(viewBox.minY)} ${formatNumber(viewBox.width)} ${formatNumber(viewBox.height)}`);
+ svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
+ const preview = cadPreviewSize(viewBox.width, viewBox.height);
+ svg.setAttribute("width", String(preview.width));
+ svg.setAttribute("height", String(preview.height));
+
+ const drawing = parsed.createElementNS(svgNamespace, "g");
+ drawing.setAttribute("transform", `translate(0 ${formatNumber(viewBox.minY * 2 + viewBox.height)}) scale(1 -1)`);
+ Array.from(svg.childNodes).forEach((child) => drawing.appendChild(child));
+ const title = parsed.createElementNS(svgNamespace, "title");
+ title.textContent = sourceName;
+ svg.append(title, drawing);
+
+ return new XMLSerializer().serializeToString(svg);
+}
+
+function repairAnonymousClosingTags(source: string) {
+ const stack: string[] = [];
+ const repaired = source.replace(/<[^>]+>/g, (token) => {
+ if (token.startsWith("") || token.startsWith("$/.test(token)) {
+ const name = stack.pop();
+ if (!name) throw new CadImportError("conversion", "CAD SVG contains an unmatched closing element");
+ return `${name}>`;
+ }
+
+ const closing = token.match(/^<\/\s*([A-Za-z][\w:.-]*)\s*>$/);
+ if (closing) {
+ const name = stack.pop();
+ if (name !== closing[1]) throw new CadImportError("conversion", "CAD SVG contains mismatched elements");
+ return token;
+ }
+
+ const opening = token.match(/^<\s*([A-Za-z][\w:.-]*)\b/);
+ if (opening && !/\/\s*>$/.test(token)) stack.push(opening[1]);
+ return token;
+ });
+ if (stack.length > 0) throw new CadImportError("conversion", "CAD SVG contains unclosed elements");
+ return repaired;
+}
+
+function sanitizeSvg(svg: Element) {
+ removeComments(svg);
+ [svg, ...Array.from(svg.querySelectorAll("*"))].forEach((element) => {
+ if (!allowedSvgElements.has(element.localName)) {
+ element.remove();
+ return;
+ }
+
+ const style = element.getAttribute("style") || "";
+ const fontSize = style.match(/font:\s*([0-9]+(?:\.[0-9]+)?(?:px|mm|cm|in)?)/i)?.[1];
+ Array.from(element.attributes).forEach((attribute) => {
+ if (!allowedSvgAttributes.has(attribute.name)) element.removeAttribute(attribute.name);
+ });
+ if (fontSize && (element.localName === "text" || element.localName === "tspan")) {
+ element.setAttribute("font-size", fontSize);
+ element.setAttribute("font-family", "Noto Sans SC, Helvetica Neue, sans-serif");
+ }
+
+ for (const paintAttribute of ["stroke", "fill"] as const) {
+ const paint = element.getAttribute(paintAttribute)?.trim().toLowerCase();
+ if (paint === "rgb(255,255,255)" || paint === "#fff" || paint === "#ffffff" || paint === "white") {
+ element.setAttribute(paintAttribute, "#16181d");
+ } else if (paint?.includes("url(") && !/^url\(#[^/)]+\)$/.test(paint)) {
+ element.removeAttribute(paintAttribute);
+ }
+ }
+
+ const strokeWidth = element.getAttribute("stroke-width");
+ if (strokeWidth) {
+ const numericWidth = Number.parseFloat(strokeWidth);
+ const pixelWidth = strokeWidth.endsWith("mm") ? numericWidth * 3.7795275591 : numericWidth;
+ if (Number.isFinite(pixelWidth) && pixelWidth < 0.75) element.setAttribute("stroke-width", "0.75");
+ }
+ });
+}
+
+function removeComments(node: Node) {
+ Array.from(node.childNodes).forEach((child) => {
+ if (child.nodeType === 8) child.remove();
+ else removeComments(child);
+ });
+}
+
+function parseViewBox(value: string | null) {
+ const parts = value
+ ?.trim()
+ .split(/[\s,]+/)
+ .map(Number);
+ if (!parts || parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) return null;
+ return { minX: parts[0], minY: parts[1], width: parts[2], height: parts[3] };
+}
+
+function cadPreviewSize(width: number, height: number) {
+ const maxDimension = Math.max(width, height);
+ const displayMax = Math.min(1200, Math.max(480, maxDimension));
+ const scale = displayMax / maxDimension;
+ return {
+ width: Math.max(1, Math.round(width * scale)),
+ height: Math.max(1, Math.round(height * scale))
+ };
+}
+
+function formatNumber(value: number) {
+ return Number(value.toFixed(6)).toString();
+}
diff --git a/frontend/src/ui/lib/cadImport.worker.ts b/frontend/src/ui/lib/cadImport.worker.ts
new file mode 100644
index 0000000..0167c9f
--- /dev/null
+++ b/frontend/src/ui/lib/cadImport.worker.ts
@@ -0,0 +1,30 @@
+import { convertCadBufferToSvg, type CadFormat } from "./cadConversion";
+
+type CadWorkerRequest = {
+ buffer: ArrayBuffer;
+ format: CadFormat;
+};
+
+type CadWorkerResponse =
+ | { ok: true; svg: string }
+ | { ok: false; error: string };
+
+type WorkerScope = {
+ onmessage: ((event: MessageEvent) => void) | null;
+ postMessage: (message: CadWorkerResponse) => void;
+};
+
+const workerScope = globalThis as unknown as WorkerScope;
+
+workerScope.onmessage = (event) => {
+ try {
+ const { buffer, format } = event.data;
+ const svg = convertCadBufferToSvg(buffer, format);
+ workerScope.postMessage({ ok: true, svg });
+ } catch (error) {
+ workerScope.postMessage({
+ ok: false,
+ error: error instanceof Error ? error.message : String(error)
+ });
+ }
+};
diff --git a/frontend/src/ui/lib/vectorImage.ts b/frontend/src/ui/lib/vectorImage.ts
new file mode 100644
index 0000000..0754c69
--- /dev/null
+++ b/frontend/src/ui/lib/vectorImage.ts
@@ -0,0 +1,18 @@
+import type { CanvasNode } from "../../domain/design";
+
+export function isVectorImageNode(node: CanvasNode) {
+ if (node.type !== "image") return false;
+ if (node.layerRole === "vectorized-image" || node.layerRole === "vector-image") return true;
+ return isSvgSource(node.title) || isSvgSource(node.content);
+}
+
+export function isSvgSource(value: string) {
+ const trimmed = value.trim();
+ if (/^data:image\/svg\+xml(?:[;,]|$)/i.test(trimmed)) return true;
+ const path = trimmed.split(/[?#]/, 1)[0] ?? "";
+ try {
+ return decodeURIComponent(path).toLowerCase().endsWith(".svg");
+ } catch {
+ return path.toLowerCase().endsWith(".svg");
+ }
+}
diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/WorkspaceToolbar.tsx b/frontend/src/ui/pages/CanvasWorkspace/canvas/WorkspaceToolbar.tsx
index 19acdff..8e8964b 100644
--- a/frontend/src/ui/pages/CanvasWorkspace/canvas/WorkspaceToolbar.tsx
+++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/WorkspaceToolbar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Grid3X3, Hand, Hash, Image as ImageIcon, ImageUp, MapPin, MousePointer2, PenLine, Sparkles, Type } from "lucide-react";
+import { FileUp, Grid3X3, Hand, Hash, Image as ImageIcon, MapPin, MousePointer2, PenLine, Sparkles, Type } from "lucide-react";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { useI18n } from "@/i18n/i18n";
import { ShapeToolMenu } from "@/ui/pages/CanvasWorkspace/canvas/ShapeToolMenu";
@@ -20,7 +20,7 @@ export function WorkspaceToolbar({
onChooseTool,
onChooseShapeKind,
onToggleGrid,
- onUploadImage
+ onUploadAsset
}: {
activeTool: CanvasTool;
canvasToolMenuOpen: boolean;
@@ -34,7 +34,7 @@ export function WorkspaceToolbar({
onChooseTool: (tool: CanvasTool) => void;
onChooseShapeKind: (kind: ShapeKind) => void;
onToggleGrid: () => void;
- onUploadImage: () => void;
+ onUploadAsset: () => void;
}) {
const { t } = useI18n();
const offsetClass = offsetBy === "files" ? "is-offset-by-files-panel" : offsetBy === "layers" ? "is-offset-by-layers-panel" : "";
@@ -80,8 +80,8 @@ export function WorkspaceToolbar({
-