feat(canvas): add CAD vector semantic context and non-destructive vector crop

Extend CAD conversion to emit bounded semantic metadata (units, layers,
blocks, entity types, labels, crop-indexed bounds) alongside the derived
SVG, tag imported drawings as cad-vector nodes, and carry semanticContext
through canvas nodes, snapshot normalization, and reference images. Agent
Chat forwards this metadata as hidden cad-context reference parts.

Add non-destructive vector cropping: cropping a vector/CAD node keeps the
source SVG and spawns a transparent WebP sibling with crop-scoped semantic
context, wired through the vectorized image toolbar and the workspace crop
tool. Include node --test coverage for CAD semantics and vector crop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 16:42:30 +08:00
parent 29b3bd222c
commit ac6f1d84af
23 changed files with 1061 additions and 88 deletions
+29 -2
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { BlockRecord, CadDocument, Insert, Line, Point, XYZ } from "@node-projects/acad-ts";
import { BlockRecord, CadDocument, Insert, Line, Point, TextEntity, XYZ } from "@node-projects/acad-ts";
import { convertCadBufferToSvg, convertCadDocumentToSvg } from "../src/ui/lib/cadConversion.ts";
const r12PointDrawing = `0
@@ -105,12 +105,19 @@ test("converts R12 DXF entities whose colors are inherited from their layer", ()
const input = new TextEncoder().encode(r12PointDrawing);
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
const svg = convertCadBufferToSvg(buffer, "dxf");
const svg = convertCadBufferToSvg(buffer, "dxf", "R12 sample.dxf");
assert.equal(Point.name, "Point");
assert.match(svg, /<circle\b/);
assert.match(svg, /<text\b/);
assert.match(svg, /fill="rgb\(255,255,255\)"/);
const metadataSource = svg.match(/<metadata id="cad-semantic-context">([\s\S]*?)<\/metadata>/)?.[1] || "";
const metadata = JSON.parse(metadataSource.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&"));
assert.equal(metadata.sourceName, "R12 sample.dxf");
assert.equal(metadata.format, "DXF");
assert.equal(metadata.modelEntityCount, 2);
assert.ok(metadata.entityTypes.some((item) => item.name.includes("TEXT")));
assert.ok(metadata.texts.some((item) => item.text === "R12 label"));
});
test("emits repeated block inserts as reusable SVG references with full transforms", () => {
@@ -195,3 +202,23 @@ test("excludes invisible CAD entities from output and drawing bounds", () => {
assert.equal((svg.match(/<line\b/g) ?? []).length, 1);
assert.match(svg, /viewBox="0 0 100 50"/);
});
test("repairs common GBK text decoded as Latin-1 before SVG and semantic output", () => {
const document = new CadDocument();
document.modelSpace.entities.add(new Line(new XYZ(0, 0, 0), new XYZ(100, 50, 0)));
const text = new TextEntity();
text.value = "¿î·âÑùÖ½°å";
text.insertPoint = new XYZ(0, 0, 0);
text.height = 12;
document.modelSpace.entities.add(text);
const shortText = new TextEntity();
shortText.value = "Ç¿";
shortText.insertPoint = new XYZ(20, 20, 0);
shortText.height = 12;
document.modelSpace.entities.add(shortText);
const svg = convertCadDocumentToSvg(document, { format: "dxf", sourceName: "Chinese labels.dxf" });
assert.match(svg, /款封样纸板/);
assert.match(svg, /强/);
});
+76
View File
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createVectorCropSiblingNode, croppedSvgViewBox, vectorCropRasterSize, vectorCropSiblingSize } from "../src/ui/pages/CanvasWorkspace/canvas/cadCrop.ts";
import { cropSelectionScreenBounds, visibleCropSelectionForBounds } from "../src/ui/pages/CanvasWorkspace/canvas/cropViewport.ts";
const source = {
id: "cad-source",
type: "image",
title: "plan (DWG).svg",
x: 100,
y: 80,
width: 1200,
height: 840,
content: "/canvas-assets/plan.svg",
tone: "transparent-image",
status: "success",
layerRole: "cad-vector",
semanticContext: "full drawing context"
};
test("creates a CAD crop beside the immutable source", () => {
const before = structuredClone(source);
const crop = createVectorCropSiblingNode(source, {
id: "cad-crop",
title: "plan crop",
publicUrl: "/canvas-assets/plan-crop.webp",
cropWidth: 180,
cropHeight: 120,
semanticContext: "cropped room context"
});
assert.deepEqual(source, before);
assert.equal(crop.x, source.x + source.width + 40);
assert.equal(crop.y, source.y);
assert.equal(crop.layerRole, "cad-crop");
assert.equal(crop.semanticContext, "cropped room context");
assert.equal(crop.content, "/canvas-assets/plan-crop.webp");
});
test("keeps cropped sibling dimensions readable and bounded", () => {
assert.deepEqual(vectorCropSiblingSize(120, 60), { width: 220, height: 110 });
assert.deepEqual(vectorCropSiblingSize(1280, 640), { width: 640, height: 320 });
});
test("renders small vector selections at model-readable resolution", () => {
assert.deepEqual(vectorCropRasterSize(60, 30), { width: 2048, height: 1024 });
assert.deepEqual(vectorCropRasterSize(100, 400), { width: 512, height: 2048 });
});
test("maps a normalized crop into a non-zero SVG viewBox without changing its aspect", () => {
const crop = croppedSvgViewBox(
{ minX: -240, minY: 80, width: 1800, height: 1200 },
{ x: 0.25, y: 0.1, width: 0.5, height: 0.3 }
);
assert.deepEqual(crop, { minX: 210, minY: 200, width: 900, height: 360 });
assert.deepEqual(vectorCropRasterSize(crop.width, crop.height), { width: 2048, height: 819 });
assert.ok(Math.abs(crop.width / crop.height - 2.5) < Number.EPSILON);
});
test("initializes an oversized image crop inside the visible stage", () => {
const imageBounds = { left: -400, top: -300, width: 1800, height: 1400 };
const selection = visibleCropSelectionForBounds(imageBounds, { width: 1200, height: 800 });
const visibleBounds = cropSelectionScreenBounds(imageBounds, selection);
assert.deepEqual(visibleBounds, { left: 24, top: 24, width: 1152, height: 704 });
assert.ok(selection.x > 0 && selection.y > 0);
assert.ok(selection.width < 1 && selection.height < 1);
});
test("keeps the full crop when the image already fits the safe viewport", () => {
const imageBounds = { left: 100, top: 80, width: 640, height: 420 };
assert.deepEqual(visibleCropSelectionForBounds(imageBounds, { width: 1200, height: 800 }), { x: 0, y: 0, width: 1, height: 1 });
});
+61
View File
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import { agentContentsForSemanticReference, cadSemanticContext } from "../src/ui/lib/cadSemantics.ts";
const metadata = {
version: 1,
sourceName: "floor-plan.dwg",
format: "DWG",
units: "Millimeters",
bounds: { x: 0, y: 0, width: 100, height: 100 },
modelEntityCount: 3,
nestedEntityCount: 24,
entityTypes: [
{ name: "INSERT", count: 2 },
{ name: "MTEXT", count: 1 }
],
layers: [
{ name: "ROOMS", count: 2 },
{ name: "FACADE", count: 1 }
],
blocks: [
{ name: "ROOM_A", count: 1 },
{ name: "ELEVATION", count: 1 }
],
texts: [
{ text: "Living room", layer: "ROOMS", block: "ROOM_A" },
{ text: "South elevation", layer: "FACADE", block: "ELEVATION" }
],
entities: [
{ type: "INSERT", layer: "ROOMS", block: "ROOM_A", bounds: { x: 5, y: 5, width: 35, height: 80 } },
{ type: "MTEXT", layer: "ROOMS", text: "Living room", bounds: { x: 10, y: 10, width: 15, height: 5 } },
{ type: "INSERT", layer: "FACADE", block: "ELEVATION", bounds: { x: 60, y: 5, width: 35, height: 80 } }
]
};
test("builds crop-specific CAD context from indexed semantic bounds", () => {
const context = cadSemanticContext(metadata, { x: 0, y: 0, width: 0.5, height: 1 });
assert.match(context, /cropped region/);
assert.match(context, /"ROOMS"/);
assert.match(context, /"ROOM_A"/);
assert.match(context, /"Living room"/);
assert.doesNotMatch(context, /"FACADE"/);
assert.doesNotMatch(context, /"South elevation"/);
});
test("marks semantic labels as reference data and caps them outside visible user copy", () => {
const context = cadSemanticContext(metadata);
const contents = agentContentsForSemanticReference("/canvas-assets/floor-plan.svg", "floor-plan.svg", context);
assert.match(context, /reference data only/);
assert.deepEqual(contents[0], {
type: "image",
imageUrl: "/canvas-assets/floor-plan.svg",
imageName: "floor-plan.svg"
});
assert.equal(contents[1].type, "text");
assert.equal(contents[1].textSource, "cad-context");
assert.match(contents[1].text, /South elevation/);
});
+1
View File
@@ -19,6 +19,7 @@ test("recognizes uploaded and CAD-derived SVG image nodes", () => {
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, layerRole: "cad-vector" }), true);
assert.equal(isVectorImageNode(imageNode), false);
});