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
|
# img-infinite-canvas
|
||||||
|
|
||||||
Moteva 风格的 AI 设计工作台复刻 MVP:主页 Prompt 创建项目,项目页提供无限画布、节点拖拽、缩放平移、右侧设计对话、历史回放、图片资产上传、项目保存与再生成。
|
Moteva 风格的 AI 设计工作台复刻 MVP:主页 Prompt 创建项目,项目页提供无限画布、节点拖拽、缩放平移、右侧设计对话、历史回放、图片与 DWG/DXF 图纸导入、项目保存与再生成。
|
||||||
|
|
||||||
## Stack
|
## 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`.
|
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
|
## Cache Config
|
||||||
|
|
||||||
Default in-memory cache:
|
Default in-memory cache:
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"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",
|
"start": "next start -H 0.0.0.0 -p 3000",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@node-projects/acad-ts": "2.3.0",
|
||||||
"@radix-ui/react-context-menu": "^2.3.2",
|
"@radix-ui/react-context-menu": "^2.3.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||||
"@radix-ui/react-popover": "^1.1.18",
|
"@radix-ui/react-popover": "^1.1.18",
|
||||||
|
|||||||
Generated
+7
@@ -5,6 +5,9 @@ settings:
|
|||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@node-projects/acad-ts':
|
||||||
|
specifier: 2.3.0
|
||||||
|
version: 2.3.0
|
||||||
'@radix-ui/react-context-menu':
|
'@radix-ui/react-context-menu':
|
||||||
specifier: ^2.3.2
|
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)
|
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
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
/@node-projects/acad-ts@2.3.0:
|
||||||
|
resolution: {integrity: sha512-qeEHH6apsBZPWVXx489ju0Qln/YyhIffREic+alksgj5F4KsaD+GAE96si1P8RNFbJbZQOSG85bo0749MpzCDA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@oxc-project/types@0.138.0:
|
/@oxc-project/types@0.138.0:
|
||||||
resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==}
|
resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|||||||
@@ -432,7 +432,15 @@
|
|||||||
"canvasCursor": "Canvas status",
|
"canvasCursor": "Canvas status",
|
||||||
"imageNode": "Image node",
|
"imageNode": "Image node",
|
||||||
"uploadImageNode": "Upload image node",
|
"uploadImageNode": "Upload image node",
|
||||||
|
"uploadCanvasAsset": "Upload image or CAD file",
|
||||||
"imageUploadSuccess": "Image uploaded",
|
"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",
|
"uploadingImage": "Uploading",
|
||||||
"extractingText": "Extract text",
|
"extractingText": "Extract text",
|
||||||
"textNode": "Text node",
|
"textNode": "Text node",
|
||||||
|
|||||||
@@ -432,7 +432,15 @@
|
|||||||
"canvasCursor": "画布状态",
|
"canvasCursor": "画布状态",
|
||||||
"imageNode": "图片节点",
|
"imageNode": "图片节点",
|
||||||
"uploadImageNode": "上传图片节点",
|
"uploadImageNode": "上传图片节点",
|
||||||
|
"uploadCanvasAsset": "上传图片或 CAD 文件",
|
||||||
"imageUploadSuccess": "图片上传成功",
|
"imageUploadSuccess": "图片上传成功",
|
||||||
|
"cadImportConverting": "正在转换 CAD 图纸",
|
||||||
|
"cadImportSuccess": "已导入 {count} 个 CAD 图纸",
|
||||||
|
"cadImportTooLarge": "CAD 文件不能超过 24 MB。",
|
||||||
|
"cadImportTimeout": "CAD 图纸转换超时,请尝试精简图纸后重试。",
|
||||||
|
"cadImportEmpty": "CAD 图纸中没有可显示的模型空间内容。",
|
||||||
|
"cadImportUnsupported": "当前浏览器无法转换此 CAD 文件。",
|
||||||
|
"cadImportFailed": "CAD 图纸转换失败,请确认文件完整且版本受支持。",
|
||||||
"uploadingImage": "上传中",
|
"uploadingImage": "上传中",
|
||||||
"extractingText": "提取文字",
|
"extractingText": "提取文字",
|
||||||
"textNode": "文本节点",
|
"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";
|
"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 { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||||
import { useI18n } from "@/i18n/i18n";
|
import { useI18n } from "@/i18n/i18n";
|
||||||
import { ShapeToolMenu } from "@/ui/pages/CanvasWorkspace/canvas/ShapeToolMenu";
|
import { ShapeToolMenu } from "@/ui/pages/CanvasWorkspace/canvas/ShapeToolMenu";
|
||||||
@@ -20,7 +20,7 @@ export function WorkspaceToolbar({
|
|||||||
onChooseTool,
|
onChooseTool,
|
||||||
onChooseShapeKind,
|
onChooseShapeKind,
|
||||||
onToggleGrid,
|
onToggleGrid,
|
||||||
onUploadImage
|
onUploadAsset
|
||||||
}: {
|
}: {
|
||||||
activeTool: CanvasTool;
|
activeTool: CanvasTool;
|
||||||
canvasToolMenuOpen: boolean;
|
canvasToolMenuOpen: boolean;
|
||||||
@@ -34,7 +34,7 @@ export function WorkspaceToolbar({
|
|||||||
onChooseTool: (tool: CanvasTool) => void;
|
onChooseTool: (tool: CanvasTool) => void;
|
||||||
onChooseShapeKind: (kind: ShapeKind) => void;
|
onChooseShapeKind: (kind: ShapeKind) => void;
|
||||||
onToggleGrid: () => void;
|
onToggleGrid: () => void;
|
||||||
onUploadImage: () => void;
|
onUploadAsset: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const offsetClass = offsetBy === "files" ? "is-offset-by-files-panel" : offsetBy === "layers" ? "is-offset-by-layers-panel" : "";
|
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")}>
|
<button disabled={disabled} className={activeTool === "pin" ? "active" : ""} onClick={() => onChooseTool("pin")} aria-label={t("pin")} title={t("pin")}>
|
||||||
<MapPin size={18} />
|
<MapPin size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button disabled={disabled} aria-label={t("uploadImageNode")} title={t("uploadImageNode")} onClick={onUploadImage}>
|
<button disabled={disabled} aria-label={t("uploadCanvasAsset")} title={t("uploadCanvasAsset")} onClick={onUploadAsset}>
|
||||||
<ImageUp size={18} />
|
<FileUp size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button disabled={disabled} className={showGrid ? "active" : ""} aria-label={t("grid")} title={t("grid")} onClick={onToggleGrid}>
|
<button disabled={disabled} className={showGrid ? "active" : ""} aria-label={t("grid")} title={t("grid")} onClick={onToggleGrid}>
|
||||||
<Grid3X3 size={18} />
|
<Grid3X3 size={18} />
|
||||||
|
|||||||
@@ -412,11 +412,12 @@ async function exportImageNodeAsSvg(node: CanvasNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadSvgSource(source: string, title: string) {
|
async function downloadSvgSource(source: string, title: string) {
|
||||||
|
const fileName = title.toLowerCase().endsWith(".svg") ? title : `${title}.svg`;
|
||||||
try {
|
try {
|
||||||
const blob = await imageBlobForExport(source);
|
const blob = await imageBlobForExport(source);
|
||||||
downloadBlob(blob, `${title}.svg`, "image/svg+xml");
|
downloadBlob(blob, fileName, "image/svg+xml");
|
||||||
} catch {
|
} 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 { SharePopover } from "@/ui/components/SharePopover";
|
||||||
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
|
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
|
||||||
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
|
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 { canvasImageUrl } from "@/ui/lib/imageDelivery";
|
||||||
import { createModelAgentContentPreparer } from "@/ui/lib/modelImageReferences";
|
import { createModelAgentContentPreparer } from "@/ui/lib/modelImageReferences";
|
||||||
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
|
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
|
||||||
@@ -558,7 +560,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
const { showToast } = useGlobalToast();
|
const { showToast } = useGlobalToast();
|
||||||
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
|
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
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 agentComposerRef = useRef<PromptComposerHandle | null>(null);
|
||||||
const assistantFeedRef = useRef<HTMLDivElement | null>(null);
|
const assistantFeedRef = useRef<HTMLDivElement | null>(null);
|
||||||
const assistantFooterRef = 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 uploadImagesToCanvas = async (files: File[], anchor = canvasCenterPoint()) => {
|
||||||
const currentProject = projectRef.current;
|
const currentProject = projectRef.current;
|
||||||
const imageFiles = files.filter(isImageFile);
|
const importFiles = files.filter(isCanvasImportFile);
|
||||||
if (!currentProject || imageFiles.length === 0) return;
|
if (!currentProject || importFiles.length === 0) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
const preparedUploads: Array<{ file: File; previewUrl: string; node: CanvasNode }> = [];
|
const preparedUploads: Array<{ file: File; previewUrl: string; node: CanvasNode }> = [];
|
||||||
try {
|
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()) {
|
for (const [index, file] of imageFiles.entries()) {
|
||||||
const previewUrl = URL.createObjectURL(file);
|
const previewUrl = URL.createObjectURL(file);
|
||||||
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
|
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
|
||||||
@@ -3445,6 +3466,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
height: size.height,
|
height: size.height,
|
||||||
content: previewUrl,
|
content: previewUrl,
|
||||||
tone,
|
tone,
|
||||||
|
layerRole: isSvgSource(file.name) || file.type.toLowerCase().includes("svg") ? "vector-image" : undefined,
|
||||||
status: "uploading"
|
status: "uploading"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3491,7 +3513,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], currentProject);
|
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], currentProject);
|
||||||
applyProject(savedProject);
|
applyProject(savedProject);
|
||||||
setDirty(false);
|
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) {
|
} catch (err) {
|
||||||
preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||||||
const failedUploadIds = new Set(preparedUploads.map((item) => item.node.id));
|
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 ?? []);
|
const files = Array.from(event.target.files ?? []);
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
void uploadImagesToCanvas(files);
|
void uploadImagesToCanvas(files);
|
||||||
@@ -3521,7 +3545,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCanvasDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
const handleCanvasDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
if (!dataTransferHasImage(event.dataTransfer)) return;
|
if (!dataTransferHasCanvasFile(event.dataTransfer)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "copy";
|
event.dataTransfer.dropEffect = "copy";
|
||||||
setCanvasFileDragging(true);
|
setCanvasFileDragging(true);
|
||||||
@@ -3533,7 +3557,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCanvasDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
const handleCanvasDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
const files = dataTransferImageFiles(event.dataTransfer);
|
const files = dataTransferCanvasFiles(event.dataTransfer);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -3586,7 +3610,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
onDragLeave={canEdit ? handleCanvasDragLeave : undefined}
|
onDragLeave={canEdit ? handleCanvasDragLeave : undefined}
|
||||||
onDrop={canEdit ? handleCanvasDrop : 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" : ""}`}>
|
<div className={`workspace-stage-title ${generatedFilesPanelOpen || layersPanelOpen ? "is-offset-by-canvas-panel" : ""}`}>
|
||||||
<WorkspaceTitle
|
<WorkspaceTitle
|
||||||
title={displayProjectTitle}
|
title={displayProjectTitle}
|
||||||
@@ -3648,7 +3672,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
messages={project?.messages ?? []}
|
messages={project?.messages ?? []}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSelect={selectOnlyNode}
|
onSelect={selectOnlyNode}
|
||||||
onDownload={(node) => exportImageNode(node, isVectorizedImageNode(node) ? "svg" : "png")}
|
onDownload={(node) => exportImageNode(node, isVectorImageNode(node) ? "svg" : "png")}
|
||||||
onClose={() => setGeneratedFilesPanelOpen(false)}
|
onClose={() => setGeneratedFilesPanelOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -3773,7 +3797,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
|
|
||||||
{selectedImageNode && selectedNodeBounds && (
|
{selectedImageNode && selectedNodeBounds && (
|
||||||
<>
|
<>
|
||||||
{isVectorizedImageNode(selectedImageNode) ? (
|
{isVectorImageNode(selectedImageNode) ? (
|
||||||
<VectorizedImageToolbar bounds={selectedNodeBounds} leftInset={floatingToolbarLeftInset} onDownload={() => exportImageNode(selectedImageNode, "svg")} />
|
<VectorizedImageToolbar bounds={selectedNodeBounds} leftInset={floatingToolbarLeftInset} onDownload={() => exportImageNode(selectedImageNode, "svg")} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -4095,7 +4119,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
onChooseTool={chooseCanvasTool}
|
onChooseTool={chooseCanvasTool}
|
||||||
onChooseShapeKind={setSelectedShapeKind}
|
onChooseShapeKind={setSelectedShapeKind}
|
||||||
onToggleGrid={() => setShowGrid((value) => !value)}
|
onToggleGrid={() => setShowGrid((value) => !value)}
|
||||||
onUploadImage={() => canvasImageInputRef.current?.click()}
|
onUploadAsset={() => canvasAssetInputRef.current?.click()}
|
||||||
/>
|
/>
|
||||||
{canEdit && activeTool === "pen" && (
|
{canEdit && activeTool === "pen" && (
|
||||||
<PenToolControls
|
<PenToolControls
|
||||||
@@ -5554,6 +5578,30 @@ function dataTransferHasImage(dataTransfer: DataTransfer) {
|
|||||||
return Array.from(dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"));
|
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) {
|
function ensureImageFileName(file: File, baseName: string) {
|
||||||
if (file.name.trim()) return file;
|
if (file.name.trim()) return file;
|
||||||
const type = file.type || "image/png";
|
const type = file.type || "image/png";
|
||||||
@@ -5806,10 +5854,6 @@ function visualAnnotationToolLabel(tool: VisualAnnotationTool, t: (key: string)
|
|||||||
return t(keys[tool]);
|
return t(keys[tool]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVectorizedImageNode(node: CanvasNode) {
|
|
||||||
return node.type === "image" && node.layerRole === "vectorized-image";
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOptimisticAgentMessages(threadId: string, contents: AgentContent[]): AgentMessage[] {
|
function createOptimisticAgentMessages(threadId: string, contents: AgentContent[]): AgentMessage[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const promptText = agentContentsToDisplayText(contents);
|
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