From 078b0bee323d34fd609b29e6e41dc4292babe2fa Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 15 Jul 2026 12:09:05 +0800 Subject: [PATCH] feat(canvas): import DWG/DXF CAD drawings as canvas assets Accept DWG and DXF files (up to 24 MB) through the canvas asset picker and drag-and-drop. Conversion runs locally in a browser worker via acad-ts, uploads a sanitized derived SVG, and leaves the source file untouched. DXF covers ASCII and binary drawings; DWG covers AutoCAD R14 through 2018. Rename the image-only upload input/handler to a generic canvas asset flow, add vector-image node detection, tag imported SVGs as vector-image nodes, localize CAD import status/error messages, and switch the Next build to the webpack compiler so the worker bundles correctly. Add a node --test suite covering CAD conversion and vector image helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +- frontend/next-env.d.ts | 2 +- frontend/package.json | 6 +- frontend/pnpm-lock.yaml | 7 + frontend/src/i18n/locales/en-US.json | 8 + frontend/src/i18n/locales/zh-CN.json | 8 + frontend/src/ui/lib/cadConversion.ts | 380 ++++++++++++++++++ frontend/src/ui/lib/cadImport.ts | 266 ++++++++++++ frontend/src/ui/lib/cadImport.worker.ts | 30 ++ frontend/src/ui/lib/vectorImage.ts | 18 + .../canvas/WorkspaceToolbar.tsx | 10 +- .../CanvasWorkspace/canvas/nodeExport.ts | 5 +- .../src/ui/pages/CanvasWorkspace/index.tsx | 74 +++- frontend/tests/cadConversion.test.mjs | 197 +++++++++ frontend/tests/vectorImage.test.mjs | 28 ++ frontend/tsconfig.tsbuildinfo | 2 +- 16 files changed, 1020 insertions(+), 27 deletions(-) create mode 100644 frontend/src/ui/lib/cadConversion.ts create mode 100644 frontend/src/ui/lib/cadImport.ts create mode 100644 frontend/src/ui/lib/cadImport.worker.ts create mode 100644 frontend/src/ui/lib/vectorImage.ts create mode 100644 frontend/tests/cadConversion.test.mjs create mode 100644 frontend/tests/vectorImage.test.mjs 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${definitions}${drawing}`; +} + +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("$/.test(token)) { + const name = stack.pop(); + if (!name) throw new CadImportError("conversion", "CAD SVG contains an unmatched closing element"); + return ``; + } + + 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({ -