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) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 12:09:05 +08:00
parent 09b961fcc4
commit 078b0bee32
16 changed files with 1020 additions and 27 deletions
+5 -1
View File
@@ -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:
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
+4 -2
View File
@@ -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",
+7
View File
@@ -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
+8
View File
@@ -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",
+8
View File
@@ -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": "文本节点",
+380
View File
@@ -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<typeof SvgConfiguration>;
private readonly units: CadUnitsType;
constructor(configuration: InstanceType<typeof SvgConfiguration>, 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 `<g id="${id}">${content}</g>`;
}).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 `<?xml version="1.0" encoding="utf-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="${svgNumber(width)}" height="${svgNumber(height)}" viewBox="${svgNumber(minX)} ${svgNumber(minY)} ${svgNumber(width)} ${svgNumber(height)}"><defs>${definitions}</defs><g>${drawing}</g></svg>`;
}
function renderEntities(
entities: Iterable<Entity>,
writer: CadEntitySvgWriter,
blockIds: ReadonlyMap<CadBlockRecord, string>,
cyclicReferences: ReadonlySet<Entity>,
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(`<use href="#${id}" transform="${svgMatrix(matrix, unitScale)}" />`);
}
}
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(`<use href="#${id}" />`);
continue;
}
output.push(writer.writeFragment(entity));
}
return output.join("");
}
function collectReferencedBlocks(modelSpace: CadBlockRecord) {
const blockIds = new Map<CadBlockRecord, string>();
const cyclicReferences = new Set<Entity>();
const visiting = new Set<CadBlockRecord>();
const visited = new Set<CadBlockRecord>();
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<Entity>) {
const cache = new Map<CadBlockRecord, CadBounds | null>();
const resolving = new Set<CadBlockRecord>();
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<Entity>,
blockBounds: (block: CadBlockRecord) => CadBounds | null,
cyclicReferences: ReadonlySet<Entity>
) {
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<string, string>();
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<CadBlockRecord>();
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 });
}
}
+266
View File
@@ -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<string>((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<CadWorkerResponse>) => {
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("<!")) return token;
if (/^<\/\s*>$/.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();
}
+30
View File
@@ -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<CadWorkerRequest>) => 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)
});
}
};
+18
View File
@@ -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");
}
}
@@ -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({
<button disabled={disabled} className={activeTool === "pin" ? "active" : ""} onClick={() => onChooseTool("pin")} aria-label={t("pin")} title={t("pin")}>
<MapPin size={18} />
</button>
<button disabled={disabled} aria-label={t("uploadImageNode")} title={t("uploadImageNode")} onClick={onUploadImage}>
<ImageUp size={18} />
<button disabled={disabled} aria-label={t("uploadCanvasAsset")} title={t("uploadCanvasAsset")} onClick={onUploadAsset}>
<FileUp size={18} />
</button>
<button disabled={disabled} className={showGrid ? "active" : ""} aria-label={t("grid")} title={t("grid")} onClick={onToggleGrid}>
<Grid3X3 size={18} />
@@ -412,11 +412,12 @@ async function exportImageNodeAsSvg(node: CanvasNode) {
}
async function downloadSvgSource(source: string, title: string) {
const fileName = title.toLowerCase().endsWith(".svg") ? title : `${title}.svg`;
try {
const blob = await imageBlobForExport(source);
downloadBlob(blob, `${title}.svg`, "image/svg+xml");
downloadBlob(blob, fileName, "image/svg+xml");
} catch {
downloadOriginalImage(source, title);
downloadOriginalImage(source, title.replace(/\.svg$/i, ""));
}
}
+59 -15
View File
@@ -38,6 +38,8 @@ import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
import { SharePopover } from "@/ui/components/SharePopover";
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
import { cadImportMessageKey, isCadFile, isCanvasImportFile, prepareCanvasImportFile } from "@/ui/lib/cadImport";
import { isSvgSource, isVectorImageNode } from "@/ui/lib/vectorImage";
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
import { createModelAgentContentPreparer } from "@/ui/lib/modelImageReferences";
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
@@ -558,7 +560,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const { showToast } = useGlobalToast();
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasImageInputRef = useRef<HTMLInputElement | null>(null);
const canvasAssetInputRef = useRef<HTMLInputElement | null>(null);
const agentComposerRef = useRef<PromptComposerHandle | null>(null);
const assistantFeedRef = useRef<HTMLDivElement | null>(null);
const assistantFooterRef = useRef<HTMLDivElement | null>(null);
@@ -3420,13 +3422,32 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const uploadImagesToCanvas = async (files: File[], anchor = canvasCenterPoint()) => {
const currentProject = projectRef.current;
const imageFiles = files.filter(isImageFile);
if (!currentProject || imageFiles.length === 0) return;
const importFiles = files.filter(isCanvasImportFile);
if (!currentProject || importFiles.length === 0) return;
setSaving(true);
setError("");
const preparedUploads: Array<{ file: File; previewUrl: string; node: CanvasNode }> = [];
try {
const imageFiles: File[] = [];
let cadImportCount = 0;
let cadImportError: unknown = null;
if (importFiles.some(isCadFile)) showCanvasToast(t("cadImportConverting"));
for (const file of importFiles) {
try {
imageFiles.push(await prepareCanvasImportFile(file));
if (isCadFile(file)) cadImportCount += 1;
} catch (error) {
cadImportError ??= error;
}
}
if (cadImportError) {
const message = t(cadImportMessageKey(cadImportError));
setError(message);
showCanvasToast(message, "error");
}
if (imageFiles.length === 0) return;
for (const [index, file] of imageFiles.entries()) {
const previewUrl = URL.createObjectURL(file);
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
@@ -3445,6 +3466,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
height: size.height,
content: previewUrl,
tone,
layerRole: isSvgSource(file.name) || file.type.toLowerCase().includes("svg") ? "vector-image" : undefined,
status: "uploading"
}
});
@@ -3491,7 +3513,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], currentProject);
applyProject(savedProject);
setDirty(false);
if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) showCanvasToast(t("imageUploadSuccess"));
if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) {
showCanvasToast(cadImportCount > 0 ? t("cadImportSuccess", { count: cadImportCount }) : t("imageUploadSuccess"));
}
} catch (err) {
preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl));
const failedUploadIds = new Set(preparedUploads.map((item) => item.node.id));
@@ -3507,7 +3531,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
}
};
const handleCanvasImageInput = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleCanvasAssetInput = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
void uploadImagesToCanvas(files);
@@ -3521,7 +3545,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
};
const handleCanvasDragOver = (event: React.DragEvent<HTMLDivElement>) => {
if (!dataTransferHasImage(event.dataTransfer)) return;
if (!dataTransferHasCanvasFile(event.dataTransfer)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setCanvasFileDragging(true);
@@ -3533,7 +3557,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
};
const handleCanvasDrop = (event: React.DragEvent<HTMLDivElement>) => {
const files = dataTransferImageFiles(event.dataTransfer);
const files = dataTransferCanvasFiles(event.dataTransfer);
if (files.length === 0) return;
event.preventDefault();
event.stopPropagation();
@@ -3586,7 +3610,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
onDragLeave={canEdit ? handleCanvasDragLeave : undefined}
onDrop={canEdit ? handleCanvasDrop : undefined}
>
<input ref={canvasImageInputRef} type="file" accept="image/*" multiple hidden disabled={!canEdit} onChange={handleCanvasImageInput} />
<input ref={canvasAssetInputRef} type="file" accept="image/*,.dwg,.dxf" multiple hidden disabled={!canEdit} onChange={handleCanvasAssetInput} />
<div className={`workspace-stage-title ${generatedFilesPanelOpen || layersPanelOpen ? "is-offset-by-canvas-panel" : ""}`}>
<WorkspaceTitle
title={displayProjectTitle}
@@ -3648,7 +3672,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
messages={project?.messages ?? []}
selectedIds={selectedIds}
onSelect={selectOnlyNode}
onDownload={(node) => exportImageNode(node, isVectorizedImageNode(node) ? "svg" : "png")}
onDownload={(node) => exportImageNode(node, isVectorImageNode(node) ? "svg" : "png")}
onClose={() => setGeneratedFilesPanelOpen(false)}
/>
)}
@@ -3773,7 +3797,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
{selectedImageNode && selectedNodeBounds && (
<>
{isVectorizedImageNode(selectedImageNode) ? (
{isVectorImageNode(selectedImageNode) ? (
<VectorizedImageToolbar bounds={selectedNodeBounds} leftInset={floatingToolbarLeftInset} onDownload={() => exportImageNode(selectedImageNode, "svg")} />
) : (
<>
@@ -4095,7 +4119,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
onChooseTool={chooseCanvasTool}
onChooseShapeKind={setSelectedShapeKind}
onToggleGrid={() => setShowGrid((value) => !value)}
onUploadImage={() => canvasImageInputRef.current?.click()}
onUploadAsset={() => canvasAssetInputRef.current?.click()}
/>
{canEdit && activeTool === "pen" && (
<PenToolControls
@@ -5554,6 +5578,30 @@ function dataTransferHasImage(dataTransfer: DataTransfer) {
return Array.from(dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"));
}
function dataTransferCanvasFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-asset") {
const candidates = [
...Array.from(dataTransfer.files),
...Array.from(dataTransfer.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file))
];
const seen = new Set<string>();
return candidates.filter(isCanvasImportFile).reduce<File[]>((files, file, index) => {
const normalizedFile = isImageFile(file) ? ensureImageFileName(file, `${fallbackBaseName}-${Date.now()}-${index + 1}`) : file;
const key = `${normalizedFile.name}:${normalizedFile.type}:${normalizedFile.size}:${normalizedFile.lastModified}`;
if (seen.has(key)) return files;
seen.add(key);
files.push(normalizedFile);
return files;
}, []);
}
function dataTransferHasCanvasFile(dataTransfer: DataTransfer) {
if (dataTransferCanvasFiles(dataTransfer).length > 0) return true;
return Array.from(dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"));
}
function ensureImageFileName(file: File, baseName: string) {
if (file.name.trim()) return file;
const type = file.type || "image/png";
@@ -5806,10 +5854,6 @@ function visualAnnotationToolLabel(tool: VisualAnnotationTool, t: (key: string)
return t(keys[tool]);
}
function isVectorizedImageNode(node: CanvasNode) {
return node.type === "image" && node.layerRole === "vectorized-image";
}
function createOptimisticAgentMessages(threadId: string, contents: AgentContent[]): AgentMessage[] {
const now = Date.now();
const promptText = agentContentsToDisplayText(contents);
+197
View File
@@ -0,0 +1,197 @@
import assert from "node:assert/strict";
import test from "node:test";
import { BlockRecord, CadDocument, Insert, Line, Point, XYZ } from "@node-projects/acad-ts";
import { convertCadBufferToSvg, convertCadDocumentToSvg } from "../src/ui/lib/cadConversion.ts";
const r12PointDrawing = `0
SECTION
2
HEADER
9
$ACADVER
1
AC1009
0
ENDSEC
0
SECTION
2
TABLES
0
TABLE
2
LTYPE
70
1
0
LTYPE
2
CONTINUOUS
70
0
3
Solid line
72
65
73
0
40
0.0
0
ENDTAB
0
TABLE
2
LAYER
70
1
0
LAYER
2
0
70
0
62
7
6
CONTINUOUS
0
ENDTAB
0
ENDSEC
0
SECTION
2
BLOCKS
0
ENDSEC
0
SECTION
2
ENTITIES
0
POINT
8
0
10
12.5
20
8.25
30
0.0
0
TEXT
8
0
10
18.0
20
14.0
30
0.0
40
2.5
1
R12 label
0
ENDSEC
0
EOF
`;
test("converts R12 DXF entities whose colors are inherited from their layer", () => {
Object.defineProperty(Point, "name", { configurable: true, value: "a" });
const input = new TextEncoder().encode(r12PointDrawing);
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
const svg = convertCadBufferToSvg(buffer, "dxf");
assert.equal(Point.name, "Point");
assert.match(svg, /<circle\b/);
assert.match(svg, /<text\b/);
assert.match(svg, /fill="rgb\(255,255,255\)"/);
});
test("emits repeated block inserts as reusable SVG references with full transforms", () => {
const document = new CadDocument();
const block = new BlockRecord("REPEATED_PART");
block.blockEntity.basePoint = new XYZ(2, 3, 0);
block.entities.add(new Line(new XYZ(2, 3, 0), new XYZ(12, 3, 0)));
document.blockRecords.add(block);
for (let index = 0; index < 200; index += 1) {
const insert = new Insert();
insert.block = block;
insert.insertPoint = new XYZ(index * 20, 10, 0);
insert.xScale = 2;
insert.yScale = 3;
insert.rotation = Math.PI / 2;
document.modelSpace.entities.add(insert);
}
const svg = convertCadDocumentToSvg(document);
assert.equal((svg.match(/<line\b/g) ?? []).length, 1);
assert.equal((svg.match(/<use\b/g) ?? []).length, 200);
assert.equal((svg.match(/id="cad-block-/g) ?? []).length, 1);
assert.match(svg, /transform="matrix\(0 2 -3 0 9 6\)"/);
assert.ok(svg.length < 50_000, `expected reusable output, received ${svg.length} bytes`);
});
test("expands MINSERT rows and columns without duplicating block geometry", () => {
const document = new CadDocument();
const block = new BlockRecord("ARRAY_PART");
block.entities.add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)));
document.blockRecords.add(block);
const insert = new Insert();
insert.block = block;
insert.columnCount = 3;
insert.columnSpacing = 10;
insert.rowCount = 2;
insert.rowSpacing = 5;
document.modelSpace.entities.add(insert);
const svg = convertCadDocumentToSvg(document);
assert.equal((svg.match(/<line\b/g) ?? []).length, 1);
assert.equal((svg.match(/<use\b/g) ?? []).length, 6);
assert.match(svg, /matrix\(1 0 0 1 20 5\)/);
});
test("cuts cyclic nested block references instead of recursively expanding them", () => {
const document = new CadDocument();
const first = new BlockRecord("CYCLE_A");
const second = new BlockRecord("CYCLE_B");
document.blockRecords.add(first);
document.blockRecords.add(second);
first.entities.add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0)));
const firstToSecond = new Insert();
firstToSecond.block = second;
first.entities.add(firstToSecond);
const secondToFirst = new Insert();
secondToFirst.block = first;
second.entities.add(secondToFirst);
const rootInsert = new Insert();
rootInsert.block = first;
document.modelSpace.entities.add(rootInsert);
const svg = convertCadDocumentToSvg(document);
assert.equal((svg.match(/id="cad-block-/g) ?? []).length, 2);
assert.equal((svg.match(/<use\b/g) ?? []).length, 2);
assert.ok(svg.length < 10_000);
});
test("excludes invisible CAD entities from output and drawing bounds", () => {
const document = new CadDocument();
document.modelSpace.entities.add(new Line(new XYZ(0, 0, 0), new XYZ(100, 50, 0)));
const invisibleOutlier = new Line(new XYZ(500_000, 500_000, 0), new XYZ(501_000, 501_000, 0));
invisibleOutlier.isInvisible = true;
document.modelSpace.entities.add(invisibleOutlier);
const svg = convertCadDocumentToSvg(document);
assert.equal((svg.match(/<line\b/g) ?? []).length, 1);
assert.match(svg, /viewBox="0 0 100 50"/);
});
+28
View File
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isSvgSource, isVectorImageNode } from "../src/ui/lib/vectorImage.ts";
const imageNode = {
id: "node-1",
type: "image",
title: "image.png",
x: 0,
y: 0,
width: 100,
height: 100,
content: "/uploads/image.png"
};
test("recognizes uploaded and CAD-derived SVG image nodes", () => {
assert.equal(isVectorImageNode({ ...imageNode, title: "drawing (DWG).svg" }), true);
assert.equal(isVectorImageNode({ ...imageNode, content: "/uploads/plan.svg?version=2" }), true);
assert.equal(isVectorImageNode({ ...imageNode, content: "data:image/svg+xml,%3Csvg/%3E" }), true);
assert.equal(isVectorImageNode({ ...imageNode, layerRole: "vectorized-image" }), true);
assert.equal(isVectorImageNode(imageNode), false);
});
test("recognizes encoded SVG paths without treating raster paths as vector", () => {
assert.equal(isSvgSource("https://assets.example.com/%E5%9B%BE%E7%BA%B8.svg#preview"), true);
assert.equal(isSvgSource("https://assets.example.com/image.png?format=svg"), false);
});
File diff suppressed because one or more lines are too long