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
+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);