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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user