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
+152 -44
View File
@@ -38,7 +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 { cadImportMessageKey, isCadFile, isCanvasImportFile, prepareCanvasImportAsset, type PreparedCanvasImportAsset } from "@/ui/lib/cadImport";
import { cadSemanticContextFromSvgText } from "@/ui/lib/cadSemantics";
import { isSvgSource, isVectorImageNode } from "@/ui/lib/vectorImage";
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
import { createModelAgentContentPreparer } from "@/ui/lib/modelImageReferences";
@@ -48,7 +49,9 @@ import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
import { alignNodes, attachNodesToContainingFrames, autoLayoutAroundNode as autoLayoutAroundNodeOp, autoLayoutNodes, canResizeCanvasNode, canUseNodeContextMenu, cloneCanvasNode, distributeNodes, groupNodes, isCanvasPenStrokeNode, isFrameChildNode, isFrameContainerNode, isRealCanvasImageNode, moveNodesByDelta, nodeIdsWithFrameChildren, orderedCanvasNodesForRender, patchFrameStyleNode, removeNodesById, reorderLayer as reorderLayerOp, reorderNode as reorderNodeOp, resizeFrameWithChildren, resizeVisualNodeByHandle, toggleSetValues, ungroupNodes, updateNodeContent as updateNodeContentOp, updateNodeTransform as updateNodeTransformOp, visibleCanvasNodes as getVisibleCanvasNodes, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame";
import { createCroppedSvgRasterSource, createVectorCropSiblingNode } from "./canvas/cadCrop";
import { installCanvasWheelListener } from "./canvas/canvasWheel";
import { visibleCropSelectionForBounds } from "./canvas/cropViewport";
import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay";
import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar";
import { defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle";
@@ -578,6 +581,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const moveRecognitionRequestRef = useRef(0);
const quickEditFocusRef = useRef<{ viewport: CanvasViewport; dirty: boolean } | null>(null);
const webSearchHydratedRef = useRef(false);
const pendingWorkspaceCropRef = useRef<{ nodeId: string; selection: NormalizedRect } | null>(null);
const { project, setProject, projectRef, viewport, setViewport, viewportRef, nodes, setNodes, nodesRef, applyProjectDocument } = useCanvasDocument();
const { selectedId, selectedIds, setSelectedIds, selectedNodes, selectedNode, selectOnlyNode, selectNodes, toggleNodeSelection } = useCanvasSelection(nodes);
const [messages, setMessages] = useState<AgentMessage[]>([]);
@@ -1037,6 +1041,16 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const selectedControlNode = selectedNodeControlsEnabled ? selectedNode : null;
const selectedImageNode = selectedControlNode && isRealCanvasImageNode(selectedControlNode) ? selectedControlNode : null;
const selectedImageNodeBusy = selectedImageNode ? isImageActionBusy(selectedImageNode) : false;
const workspaceCropTarget = useMemo(() => {
if (selectedImageNode) return selectedImageNode;
const candidates = nodes.filter((node) => {
if (!isRealCanvasImageNode(node) || hiddenNodeIds.has(node.id) || lockedNodeIds.has(node.id)) return false;
const bounds = canvasNodeScreenBounds(node, viewport);
return bounds.left < canvasStageSize.width && bounds.top < canvasStageSize.height && bounds.left + bounds.width > 0 && bounds.top + bounds.height > 0;
});
return candidates.length === 1 ? candidates[0] : null;
}, [canvasStageSize.height, canvasStageSize.width, hiddenNodeIds, lockedNodeIds, nodes, selectedImageNode, viewport]);
const workspaceCropTargetBusy = workspaceCropTarget ? isImageActionBusy(workspaceCropTarget) : false;
const selectedImageGeneratorNode = selectedControlNode?.type === "image" && selectedControlNode.layerRole === "image-generator" ? selectedControlNode : null;
const selectedImageGeneratorBusy = selectedImageGeneratorNode?.status === "generating";
const selectedPenStrokeNode = selectedControlNode && isCanvasPenStrokeNode(selectedControlNode) ? selectedControlNode : null;
@@ -1132,7 +1146,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
pending: true,
replacePending: true
});
}, [insertCanvasImageReference, selectedImageNode?.content, selectedImageNode?.id]);
}, [insertCanvasImageReference, selectedImageNode?.content, selectedImageNode?.id, selectedImageNode?.semanticContext]);
const multiSelectionBounds = useMemo<NodeScreenBounds | null>(() => {
if (selectedNodes.length < 2) return null;
@@ -1180,6 +1194,14 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
setCropPrompt("");
}, [selectedId, selectedIds.size]);
useEffect(() => {
const pending = pendingWorkspaceCropRef.current;
if (!pending || selectedId !== pending.nodeId || selectedIds.size !== 1) return;
pendingWorkspaceCropRef.current = null;
setCropSelection(pending.selection);
setImageActionMode("crop");
}, [selectedId, selectedIds.size]);
useEffect(() => {
if (imageActionMode !== "move-object" || !selectedImageNode || !moveSelection || moveSelection.srcBox.width < 0.02 || moveSelection.srcBox.height < 0.02) {
moveRecognitionRequestRef.current += 1;
@@ -2737,7 +2759,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
source === "image-generator"
? imageGeneratorContents ?? []
: activeRef.current?.toAgentContents() ?? [{ type: "text" as const, text: activePrompt, textSource: "input" }];
const hasUserText = baseContents.some((content) => content.type === "text" && content.text.replace(/\u00a0/g, " ").trim());
const hasUserText = baseContents.some((content) => content.type === "text" && !isHiddenTextSource(content.textSource) && content.text.replace(/\u00a0/g, " ").trim());
const contents =
activeMode === "image"
? withImageGeneratorTarget(baseContents, selectedGeneratorNode?.id)
@@ -3334,6 +3356,24 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
});
};
const openCropForNode = (targetNode: CanvasNode) => {
if (isImageActionBusy(targetNode)) return;
chooseCanvasTool("select");
const selection = visibleCropSelectionForBounds(canvasNodeScreenBounds(targetNode, viewportRef.current), canvasStageSize);
if (selectedId !== targetNode.id || selectedIds.size !== 1) {
pendingWorkspaceCropRef.current = { nodeId: targetNode.id, selection };
selectOnlyNode(targetNode.id);
return;
}
setCropSelection(selection);
setImageActionMode("crop");
};
const openWorkspaceCrop = () => {
if (!workspaceCropTarget || workspaceCropTargetBusy) return;
openCropForNode(workspaceCropTarget);
};
const applyCropImage = async () => {
const targetNode = selectedImageNode;
const currentProject = projectRef.current ?? project;
@@ -3345,19 +3385,32 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
try {
const cropped = await cropImageNodeFile(targetNode, selection);
const publicUrl = await designGateway.uploadImage(cropped.file);
const nextNodes = nodesRef.current.map((node) =>
node.id === targetNode.id
? {
...node,
x: targetNode.x + targetNode.width * selection.x,
y: targetNode.y + targetNode.height * selection.y,
width: cropped.displayWidth,
height: cropped.displayHeight,
content: publicUrl,
status: "success"
}
: node
);
const preserveSource = isVectorImageNode(targetNode);
const croppedNode: CanvasNode | null = preserveSource
? createVectorCropSiblingNode(targetNode, {
id: cryptoFallbackId(),
title: t("cropResultTitle", { title: targetNode.title || t("imageNode") }),
publicUrl,
cropWidth: cropped.displayWidth,
cropHeight: cropped.displayHeight,
semanticContext: cropped.semanticContext
})
: null;
const nextNodes = croppedNode
? [...nodesRef.current, croppedNode]
: nodesRef.current.map((node) =>
node.id === targetNode.id
? {
...node,
x: targetNode.x + targetNode.width * selection.x,
y: targetNode.y + targetNode.height * selection.y,
width: cropped.displayWidth,
height: cropped.displayHeight,
content: publicUrl,
status: "success"
}
: node
);
nodesRef.current = nextNodes;
setNodes(nextNodes);
setImageActionMode(null);
@@ -3366,6 +3419,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
setDirty(true);
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], currentProject);
applyProject(savedProject);
if (croppedNode) {
selectOnlyNode(croppedNode.id);
showCanvasToast(t("cropCreated"));
}
setDirty(false);
} catch (err) {
setError(toUserMessageText(err, "generateError"));
@@ -3437,13 +3494,13 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
setError("");
const preparedUploads: Array<{ file: File; previewUrl: string; node: CanvasNode }> = [];
try {
const imageFiles: File[] = [];
const imageAssets: PreparedCanvasImportAsset[] = [];
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));
imageAssets.push(await prepareCanvasImportAsset(file));
if (isCadFile(file)) cadImportCount += 1;
} catch (error) {
cadImportError ??= error;
@@ -3454,9 +3511,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
setError(message);
showCanvasToast(message, "error");
}
if (imageFiles.length === 0) return;
if (imageAssets.length === 0) return;
for (const [index, file] of imageFiles.entries()) {
for (const [index, asset] of imageAssets.entries()) {
const { file, semanticContext } = asset;
const previewUrl = URL.createObjectURL(file);
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
const size = originalCanvasImageSize(dimensions.width, dimensions.height);
@@ -3474,7 +3532,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
height: size.height,
content: previewUrl,
tone,
layerRole: isSvgSource(file.name) || file.type.toLowerCase().includes("svg") ? "vector-image" : undefined,
layerRole: semanticContext ? "cad-vector" : isSvgSource(file.name) || file.type.toLowerCase().includes("svg") ? "vector-image" : undefined,
semanticContext,
status: "uploading"
}
});
@@ -3805,7 +3864,33 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
{selectedImageNode && selectedNodeBounds && (
<>
{isVectorImageNode(selectedImageNode) ? (
<VectorizedImageToolbar bounds={selectedNodeBounds} leftInset={floatingToolbarLeftInset} onDownload={() => exportImageNode(selectedImageNode, "svg")} />
<>
{imageActionMode !== "crop" && (
<VectorizedImageToolbar
bounds={selectedNodeBounds}
leftInset={floatingToolbarLeftInset}
onCrop={() => openCropForNode(selectedImageNode)}
onDownload={() => exportImageNode(selectedImageNode, "svg")}
/>
)}
{imageActionMode === "crop" && (
<CropOverlay
bounds={selectedNodeBounds}
sourceSize={{ width: selectedImageNode.width, height: selectedImageNode.height }}
title={selectedImageNode.title}
selection={cropSelection}
prompt={cropPrompt}
disabled={selectedImageNodeBusy}
onSelectionChange={setCropSelection}
onPromptChange={setCropPrompt}
onCancel={() => {
setImageActionMode(null);
setCropSelection(null);
}}
onApply={applyCropImage}
/>
)}
</>
) : (
<>
{imageActionMode !== "quick-edit" && imageActionMode !== "visual-annotation" && imageActionMode !== "eraser" && imageActionMode !== "edit-text" && imageActionMode !== "expand" && imageActionMode !== "crop" && imageActionMode !== "flip-rotate" && selectedImageNode.status !== "text-extracting" && mockupPendingTargetId !== selectedImageNode.id && (
@@ -3843,10 +3928,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
onMockup={prepareMockupPlacement}
onExpand={openExpandPanel}
onAdjust={openAdjustPanel}
onCrop={() => {
setCropSelection(defaultCropSelection);
setImageActionMode("crop");
}}
onCrop={() => openCropForNode(selectedImageNode)}
onVectorize={() => void runImageNodeAction("vectorize", { prompt: t("vectorPrompt") })}
onFlipRotate={() => setImageActionMode("flip-rotate")}
onDownload={() => exportImageNode(selectedImageNode, "png")}
@@ -4119,12 +4201,15 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
shapeToolMenuOpen={shapeToolMenuOpen}
selectedShapeKind={selectedShapeKind}
showGrid={showGrid}
cropEnabled={Boolean(workspaceCropTarget && !workspaceCropTargetBusy)}
cropActive={imageActionMode === "crop"}
disabled={!canEdit}
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
onCanvasToolMenuOpenChange={setCanvasToolMenuOpen}
onShapeToolMenuOpenChange={setShapeToolMenuOpen}
onChooseTool={chooseCanvasTool}
onChooseShapeKind={setSelectedShapeKind}
onCrop={openWorkspaceCrop}
onToggleGrid={() => setShowGrid((value) => !value)}
onUploadAsset={() => canvasAssetInputRef.current?.click()}
/>
@@ -4794,6 +4879,15 @@ function mockupPlacementWorldRect(target: CanvasNode, placement: MockupPlacement
};
}
function canvasNodeScreenBounds(node: CanvasNode, viewport: CanvasViewport): NodeScreenBounds {
return {
left: viewport.x + node.x * viewport.k,
top: viewport.y + node.y * viewport.k,
width: node.width * viewport.k,
height: node.height * viewport.k
};
}
function mockupPlacementScreenBounds(target: CanvasNode, placement: MockupPlacement, viewport: CanvasViewport): NodeScreenBounds {
const rect = mockupPlacementWorldRect(target, placement);
return {
@@ -5381,22 +5475,28 @@ function buildTextEditPrompt(items: EditableTextLayer[], fallback: string) {
async function cropImageNodeFile(node: CanvasNode, selection: NormalizedRect) {
const rect = clampCropSelection(selection);
const blob = await imageBlobForCanvas(node.content);
const imageUrl = URL.createObjectURL(blob);
const vectorSource = isVectorImageNode(node);
const svgText = vectorSource ? await blob.text() : "";
const semanticContext = node.layerRole === "cad-vector" ? cadSemanticContextFromSvgText(svgText, rect) : node.semanticContext || "";
const vectorCrop = vectorSource ? createCroppedSvgRasterSource(svgText, rect) : null;
const imageUrl = URL.createObjectURL(vectorCrop?.source ?? blob);
try {
const image = await loadCanvasImage(imageUrl);
const naturalWidth = image.naturalWidth || image.width;
const naturalHeight = image.naturalHeight || image.height;
const sourceX = Math.round(rect.x * naturalWidth);
const sourceY = Math.round(rect.y * naturalHeight);
const sourceWidth = Math.max(1, Math.round(rect.width * naturalWidth));
const sourceHeight = Math.max(1, Math.round(rect.height * naturalHeight));
const sourceX = vectorCrop ? 0 : Math.round(rect.x * naturalWidth);
const sourceY = vectorCrop ? 0 : Math.round(rect.y * naturalHeight);
const sourceWidth = vectorCrop ? naturalWidth : Math.max(1, Math.round(rect.width * naturalWidth));
const sourceHeight = vectorCrop ? naturalHeight : Math.max(1, Math.round(rect.height * naturalHeight));
const outputSize = vectorCrop?.outputSize ?? { width: sourceWidth, height: sourceHeight };
const canvas = document.createElement("canvas");
canvas.width = sourceWidth;
canvas.height = sourceHeight;
canvas.width = outputSize.width;
canvas.height = outputSize.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Failed to create crop canvas");
context.clearRect(0, 0, sourceWidth, sourceHeight);
context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight);
context.clearRect(0, 0, outputSize.width, outputSize.height);
if (vectorCrop) context.drawImage(image, 0, 0, outputSize.width, outputSize.height);
else context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, outputSize.width, outputSize.height);
const output = await canvasToImageBlob(canvas, "image/webp", 0.94);
if (!output) throw new Error("Failed to encode cropped image");
return {
@@ -5405,7 +5505,8 @@ async function cropImageNodeFile(node: CanvasNode, selection: NormalizedRect) {
lastModified: Date.now()
}),
displayWidth: Math.max(1, node.width * rect.width),
displayHeight: Math.max(1, node.height * rect.height)
displayHeight: Math.max(1, node.height * rect.height),
semanticContext
};
} finally {
URL.revokeObjectURL(imageUrl);
@@ -5882,10 +5983,12 @@ function createOptimisticAgentMessages(threadId: string, contents: AgentContent[
}
function agentContentsToDisplayText(contents: AgentContent[]) {
let imageIndex = 0;
return contents
.map((content, index) => {
if (content.type === "text") return content.text;
return `参考图 ${index + 1}${agentContentImageName(content)}):${content.imageUrl}`;
.map((content) => {
if (content.type === "text") return isHiddenTextSource(content.textSource) ? "" : content.text;
imageIndex += 1;
return `参考图 ${imageIndex}${agentContentImageName(content)}):${content.imageUrl}`;
})
.filter((item) => item.trim())
.join("\n\n");
@@ -5896,11 +5999,15 @@ function agentContentImageName(content: Extract<AgentContent, { type: "image" }>
}
function imageGeneratorSubmittedPrompt(contents: AgentContent[]) {
let imageIndex = 0;
return cleanImageGeneratorPrompt(
contents
.map((content, index) => {
if (content.type === "text" && !isSettingsTextSource(content.textSource)) return content.text;
if (content.type === "image") return `参考图 ${index + 1}${agentContentImageName(content)}):${content.imageUrl}`;
.map((content) => {
if (content.type === "text" && !isHiddenTextSource(content.textSource)) return content.text;
if (content.type === "image") {
imageIndex += 1;
return `参考图 ${imageIndex}${agentContentImageName(content)}):${content.imageUrl}`;
}
return "";
})
.filter((item) => item.trim())
@@ -5918,8 +6025,9 @@ function cleanImageGeneratorPrompt(value: string) {
.trim();
}
function isSettingsTextSource(value?: string) {
return value?.trim().toLowerCase() === "settings";
function isHiddenTextSource(value?: string) {
const source = value?.trim().toLowerCase();
return source === "settings" || source === "cad-context";
}
function isBlankTextContent(content: string) {