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