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:
@@ -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:
|
||||
|
||||
Vendored
+1
-1
@@ -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,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",
|
||||
|
||||
Generated
+7
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "文本节点",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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, ""));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user