This commit is contained in:
2026-07-27 15:54:56 +08:00
parent 7b136b8562
commit 9e74a5254a
220 changed files with 62267 additions and 3768 deletions
+22 -28
View File
@@ -18,7 +18,8 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwind-merge": "^3.5.0",
"three": "0.160.0"
"three": "0.160.0",
"three-mesh-bvh": "^0.8.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
@@ -72,6 +73,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -323,33 +325,10 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -3186,6 +3165,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3848,6 +3828,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4010,6 +3991,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4022,6 +4004,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -4227,7 +4210,17 @@
"version": "0.160.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.0",
"resolved": "https://registry.npmmirror.com/three-mesh-bvh/-/three-mesh-bvh-0.8.0.tgz",
"integrity": "sha512-Q3Rx3Ja2d/sqNW965wcoqr9NbRGbolk8jrKvSVu1zO5+Vv15vwLfwiTes+jssrd1Odq2+HQhq99Nknwp0JfISg==",
"license": "MIT",
"peerDependencies": {
"three": ">= 0.158.0"
}
},
"node_modules/throttleit": {
"version": "2.1.0",
@@ -4372,6 +4365,7 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
+2 -1
View File
@@ -28,7 +28,8 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwind-merge": "^3.5.0",
"three": "0.160.0"
"three": "0.160.0",
"three-mesh-bvh": "^0.8.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
@@ -1746,6 +1746,7 @@ function disposeSceneObject(object) {
}
object.parent?.remove(object);
if (object.geometry?.userData?.cadSceneCachedGeometry !== true) {
object.geometry?.disposeBoundsTree?.();
object.geometry?.dispose?.();
}
const materials = Array.isArray(object.material) ? object.material : [object.material];
@@ -1762,6 +1763,113 @@ function clearSceneGroup(group) {
}
}
function buildBoundsTreesForObject(object) {
object?.traverse?.((child) => {
const geometry = child?.geometry;
if (!geometry?.computeBoundsTree || geometry.boundsTree) {
return;
}
try {
geometry.computeBoundsTree({ indirect: true });
} catch {
// BVH is an acceleration path only; fall back to Three.js raycasting if construction fails.
}
});
}
function finiteVector3FromArray(THREE, value) {
if (!Array.isArray(value) || value.length < 3) {
return null;
}
const vector = value.slice(0, 3).map((component) => Number(component));
return vector.every(Number.isFinite) ? new THREE.Vector3(vector[0], vector[1], vector[2]) : null;
}
function editGuideBasis(THREE, pick) {
const surface = pick?.surface || {};
const normal = finiteVector3FromArray(THREE, surface.normal || pick?.normal) || new THREE.Vector3(0, 0, 1);
if (normal.lengthSq() < 1e-9) {
normal.set(0, 0, 1);
}
normal.normalize();
const xDir = finiteVector3FromArray(THREE, surface.xDir) || new THREE.Vector3(1, 0, 0);
if (xDir.lengthSq() < 1e-9 || Math.abs(xDir.clone().normalize().dot(normal)) > 0.98) {
xDir.set(1, 0, 0);
if (Math.abs(xDir.dot(normal)) > 0.98) {
xDir.set(0, 1, 0);
}
}
xDir.addScaledVector(normal, -xDir.dot(normal)).normalize();
const yDir = finiteVector3FromArray(THREE, surface.yDir) || new THREE.Vector3().crossVectors(normal, xDir);
if (yDir.lengthSq() < 1e-9) {
yDir.crossVectors(normal, xDir);
}
yDir.addScaledVector(normal, -yDir.dot(normal)).normalize();
return { xDir, yDir, normal };
}
function editGuidePoint(THREE, pick) {
return finiteVector3FromArray(THREE, pick?.pointModel);
}
function numericEditGuideParameter(parameters, name, fallback) {
const value = Number(parameters?.[name]);
return Number.isFinite(value) ? value : fallback;
}
function createEditGuideLine(THREE, points, material) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, material);
line.renderOrder = 60;
return line;
}
function createEditGuideCircle(THREE, center, basis, radius, material, segments = 72) {
const safeRadius = Math.max(Number(radius) || 0, 1e-6);
const points = [];
for (let index = 0; index <= segments; index += 1) {
const angle = (index / segments) * Math.PI * 2;
points.push(center.clone()
.addScaledVector(basis.xDir, Math.cos(angle) * safeRadius)
.addScaledVector(basis.yDir, Math.sin(angle) * safeRadius));
}
return createEditGuideLine(THREE, points, material);
}
function createEditGuideRect(THREE, center, basis, width, height, material) {
const halfWidth = Math.max(Number(width) || 0, 1e-6) / 2;
const halfHeight = Math.max(Number(height) || 0, 1e-6) / 2;
const corners = [
center.clone().addScaledVector(basis.xDir, -halfWidth).addScaledVector(basis.yDir, -halfHeight),
center.clone().addScaledVector(basis.xDir, halfWidth).addScaledVector(basis.yDir, -halfHeight),
center.clone().addScaledVector(basis.xDir, halfWidth).addScaledVector(basis.yDir, halfHeight),
center.clone().addScaledVector(basis.xDir, -halfWidth).addScaledVector(basis.yDir, halfHeight),
center.clone().addScaledVector(basis.xDir, -halfWidth).addScaledVector(basis.yDir, -halfHeight)
];
return createEditGuideLine(THREE, corners, material);
}
function createEditGuideAnchor(THREE, point, radius, material, ringMaterial, basis = null) {
const group = new THREE.Group();
const sphere = new THREE.Mesh(new THREE.SphereGeometry(radius, 18, 12), material);
sphere.position.copy(point);
sphere.renderOrder = 62;
group.add(sphere);
if (basis) {
const ringRadius = radius * 2.4;
group.add(createEditGuideCircle(THREE, point, basis, ringRadius, ringMaterial, 48));
group.add(createEditGuideLine(THREE, [
point.clone().addScaledVector(basis.xDir, -ringRadius * 1.35),
point.clone().addScaledVector(basis.xDir, ringRadius * 1.35)
], ringMaterial));
group.add(createEditGuideLine(THREE, [
point.clone().addScaledVector(basis.yDir, -ringRadius * 1.35),
point.clone().addScaledVector(basis.yDir, ringRadius * 1.35)
], ringMaterial));
}
return group;
}
function getEdgeThickness(edgeSettings = null, viewerTheme = null) {
const fallbackThickness = Number.isFinite(Number(viewerTheme?.edgeThickness))
? Number(viewerTheme.edgeThickness)
@@ -2091,6 +2199,18 @@ const CadViewer = forwardRef(function CadViewer({
onActivateReference,
onDoubleActivateReference,
onContextReference,
editPointPickEnabled = false,
onEditPointPick,
editToolPickKind = "surface_point",
activeEditToolId = "",
editToolPicks = [],
editToolHoverPick = null,
editToolParameters = {},
onEditToolPick,
onEditToolHover,
aiSelectionMode = "",
onAiSelectionDraftChange,
onAiSelectionComplete,
onViewerAlertChange,
onStepModuleTransformDetectedChange,
urdfPosePicker = null
@@ -3534,6 +3654,7 @@ const CadViewer = forwardRef(function CadViewer({
});
modelGroup.add(cadScene.modelGroup);
edgesGroup.add(cadScene.edgesGroup);
buildBoundsTreesForObject(cadScene.modelGroup);
runtime.cadScene = cadScene;
runtime.displayRecords = cadScene.displayRecords;
runtime.hasVisibleModel = true;
@@ -3640,6 +3761,7 @@ const CadViewer = forwardRef(function CadViewer({
edgePickGroup.updateMatrixWorld(true);
vertexPickGroup.updateMatrixWorld(true);
syncSelectorPickGroups(runtime, displaySelectorRuntime, modelOffset, { clearSceneGroup });
buildBoundsTreesForObject(runtime.facePickGroup);
syncRuntimeStepClipPlane(runtime, clipSettingsRef.current);
const currentPartVisualState = partVisualStateRef.current;
@@ -4032,6 +4154,7 @@ const CadViewer = forwardRef(function CadViewer({
const effectiveRuntime = nextEdgeRuntimes.selectorRuntime;
syncDisplayMeshFaceIds(runtime, meshData, effectiveRuntime);
syncSelectorPickGroups(runtime, effectiveRuntime, modelTransformRef.current.offset, { clearSceneGroup });
buildBoundsTreesForObject(runtime.facePickGroup);
runtime.requestRender?.();
}, [
visualEdgeSettings,
@@ -4179,6 +4302,7 @@ const CadViewer = forwardRef(function CadViewer({
syncDisplayMeshFaceIds(runtime, meshData, activeSelectorRuntime);
syncSelectorPickGroups(runtime, activeSelectorRuntime, modelTransformRef.current.offset, { clearSceneGroup });
buildBoundsTreesForObject(runtime.facePickGroup);
syncRuntimeStepClipPlane(runtime, clipSettingsRef.current);
}, [activeSelectorRuntime, meshData, modelKey, viewerReadyTick]);
@@ -4685,6 +4809,180 @@ const CadViewer = forwardRef(function CadViewer({
};
}, [activeSelectorRuntime, hoveredReferenceId, pickableReferenceMap, selectedReferenceIds, viewerReadyTick, viewerTheme, displayEdgeSettings]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime?.THREE || !runtime?.modelGroup) {
return;
}
const { THREE, modelGroup } = runtime;
if (!runtime.editToolGuideGroup || runtime.editToolGuideGroup.parent !== modelGroup) {
runtime.editToolGuideGroup = new THREE.Group();
runtime.editToolGuideGroup.renderOrder = 60;
modelGroup.add(runtime.editToolGuideGroup);
}
const group = runtime.editToolGuideGroup;
clearSceneGroup(group);
const activeTool = String(activeEditToolId || "").trim();
if (!activeTool) {
runtime.requestRender?.();
return;
}
const visiblePicks = Array.isArray(editToolPicks) ? editToolPicks : [];
const hoverPick = editToolHoverPick?.kind ? editToolHoverPick : null;
const hoverPoint = editGuidePoint(THREE, hoverPick);
const pickedEntries = visiblePicks
.map((pick) => ({ pick, point: editGuidePoint(THREE, pick), hover: false }))
.filter((entry) => entry.point);
const hoverEntry = hoverPoint && !pickedEntries.some((entry) => entry.point.distanceTo(hoverPoint) < 1e-7)
? { pick: hoverPick, point: hoverPoint, hover: true }
: null;
const entries = hoverEntry ? [...pickedEntries, hoverEntry] : pickedEntries;
if (!entries.length) {
runtime.requestRender?.();
return;
}
const modelRadius = Math.max(Number(runtime.modelRadius) || 1, 1);
const anchorRadius = Math.min(Math.max(modelRadius / 140, 0.08), 1.2);
const guideOffset = Math.min(Math.max(modelRadius / 2000, 0.01), 0.15);
const anchorMaterial = new THREE.MeshBasicMaterial({
color: 0x3b82f6,
transparent: true,
opacity: 0.96,
depthTest: false,
depthWrite: false,
toneMapped: false
});
const hoverAnchorMaterial = new THREE.MeshBasicMaterial({
color: 0x60a5fa,
transparent: true,
opacity: 0.58,
depthTest: false,
depthWrite: false,
toneMapped: false
});
const guideMaterial = new THREE.LineBasicMaterial({
color: 0x60a5fa,
transparent: true,
opacity: 0.92,
depthTest: false,
depthWrite: false,
toneMapped: false
});
const previewMaterial = new THREE.LineBasicMaterial({
color: 0x3b82f6,
transparent: true,
opacity: 0.78,
depthTest: false,
depthWrite: false,
toneMapped: false
});
const softMaterial = new THREE.LineBasicMaterial({
color: 0xbfdbfe,
transparent: true,
opacity: 0.7,
depthTest: false,
depthWrite: false,
toneMapped: false
});
for (const entry of entries) {
const basis = editGuideBasis(THREE, entry.pick);
const point = entry.point.clone().addScaledVector(basis.normal, guideOffset);
group.add(createEditGuideAnchor(
THREE,
point,
entry.hover ? anchorRadius * 0.82 : anchorRadius,
entry.hover ? hoverAnchorMaterial : anchorMaterial,
guideMaterial,
basis
));
}
if (activeTool === "add_slot" && entries.length >= 2) {
const basis = editGuideBasis(THREE, entries[0].pick);
const start = entries[0].point.clone().addScaledVector(basis.normal, guideOffset);
const end = entries[1].point.clone().addScaledVector(basis.normal, guideOffset);
group.add(createEditGuideLine(THREE, [start, end], previewMaterial));
const slotWidth = numericEditGuideParameter(editToolParameters, "slotWidth", 2);
if (slotWidth > 0 && start.distanceTo(end) > 1e-7) {
const direction = end.clone().sub(start).normalize();
const side = new THREE.Vector3().crossVectors(basis.normal, direction);
if (side.lengthSq() > 1e-9) {
side.normalize().multiplyScalar(slotWidth / 2);
group.add(createEditGuideLine(THREE, [start.clone().add(side), end.clone().add(side)], softMaterial));
group.add(createEditGuideLine(THREE, [start.clone().sub(side), end.clone().sub(side)], softMaterial));
group.add(createEditGuideCircle(THREE, start, { ...basis, xDir: side.clone().normalize(), yDir: direction }, slotWidth / 2, softMaterial, 36));
group.add(createEditGuideCircle(THREE, end, { ...basis, xDir: side.clone().normalize(), yDir: direction }, slotWidth / 2, softMaterial, 36));
}
}
runtime.requestRender?.();
return () => {
clearSceneGroup(group);
};
}
const anchorEntry = pickedEntries[pickedEntries.length - 1] || hoverEntry;
if (!anchorEntry) {
runtime.requestRender?.();
return;
}
const basis = editGuideBasis(THREE, anchorEntry.pick);
const center = anchorEntry.point.clone().addScaledVector(basis.normal, guideOffset);
const addCircle = (diameter, material = previewMaterial) => {
group.add(createEditGuideCircle(THREE, center, basis, Math.max(Number(diameter) || 0, 0) / 2, material));
};
if (activeTool === "add_hole") {
addCircle(numericEditGuideParameter(editToolParameters, "holeDiameter", 2));
} else if (activeTool === "add_counterbore") {
addCircle(numericEditGuideParameter(editToolParameters, "counterboreDiameter", 6), previewMaterial);
addCircle(numericEditGuideParameter(editToolParameters, "holeDiameter", 3), softMaterial);
} else if (activeTool === "add_countersink") {
addCircle(numericEditGuideParameter(editToolParameters, "countersinkDiameter", 6), previewMaterial);
addCircle(numericEditGuideParameter(editToolParameters, "holeDiameter", 3), softMaterial);
} else if (activeTool === "add_circular_pocket") {
addCircle(numericEditGuideParameter(editToolParameters, "diameter", 8), previewMaterial);
} else if (activeTool === "add_pocket") {
group.add(createEditGuideRect(
THREE,
center,
basis,
numericEditGuideParameter(editToolParameters, "width", 10),
numericEditGuideParameter(editToolParameters, "height", 6),
previewMaterial
));
} else if (activeTool === "add_hole_pattern") {
const rows = Math.min(Math.max(Math.round(numericEditGuideParameter(editToolParameters, "rows", 2)), 1), 12);
const columns = Math.min(Math.max(Math.round(numericEditGuideParameter(editToolParameters, "columns", 2)), 1), 12);
const pitchX = numericEditGuideParameter(editToolParameters, "pitchX", 8);
const pitchY = numericEditGuideParameter(editToolParameters, "pitchY", 8);
const diameter = numericEditGuideParameter(editToolParameters, "holeDiameter", 2);
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const x = (column - (columns - 1) / 2) * pitchX;
const y = (row - (rows - 1) / 2) * pitchY;
const holeCenter = center.clone().addScaledVector(basis.xDir, x).addScaledVector(basis.yDir, y);
group.add(createEditGuideCircle(THREE, holeCenter, basis, diameter / 2, previewMaterial, 36));
}
}
}
runtime.requestRender?.();
return () => {
clearSceneGroup(group);
};
}, [
activeEditToolId,
editToolHoverPick,
editToolParameters,
editToolPicks,
runtimeRef,
viewerReadyTick
]);
useViewerDrawingOverlay({
drawingCanvasRef,
drawingDraftRef,
@@ -4727,6 +5025,14 @@ const CadViewer = forwardRef(function CadViewer({
onActivateReference,
onDoubleActivateReference,
onContextReference,
editPointPickEnabled,
onEditPointPick,
editToolPickKind,
onEditToolPick,
onEditToolHover,
aiSelectionMode,
onAiSelectionDraftChange,
onAiSelectionComplete,
viewerReadyTick
});
@@ -53,6 +53,7 @@ import {
import { clonePerspectiveSnapshot } from "cadjs/lib/perspective";
import {
ASSET_STATUS,
AI_SELECTION_MODE,
DRAWING_TOOL,
RENDER_FORMAT,
REFERENCE_STATUS,
@@ -309,6 +310,27 @@ import {
buildParameterValuesCopyText,
parseParameterValuesPasteText
} from "@/workbench/parameterControls";
import {
buildAiGeometrySelectionIntent,
buildAiGeometrySelectionIntentText,
buildCadEditIntent,
buildCadEditIntentText
} from "@/workbench/cadEditIntent";
import {
bridgePayloadFromIntent,
isAgentBridgeEnabled,
postAgentBridgeEvent,
viewerBridgeState
} from "@/workbench/agentBridge";
import {
buildCadEditSelection,
cadEditToolForOperation,
cadEditToolInstruction,
cadEditToolNextPickKind,
cadEditToolPickComplete,
cadEditToolPrompt,
defaultCadEditToolParameters
} from "@/workbench/cadEditTools";
import {
normalizeImplicitGraphicsSettings
} from "@/workbench/implicitGraphicsSettings";
@@ -1142,6 +1164,7 @@ export default function CadWorkspace({
const explicitDirParam = readCadDirParam();
const explicitFileParam = readCadParam();
const viewerAssetBackend = viewerAssetBackendFromEnv();
const agentBridgeEnabled = isAgentBridgeEnabled();
const activeGeneratorFiles = useMemo(() => (
Object.entries(generationStatus?.files || {})
.filter(([, status]) => status?.running === true)
@@ -1195,6 +1218,14 @@ export default function CadWorkspace({
const [hiddenPartIds, setHiddenPartIds] = useState([]);
const [isolatedAssemblyNodeIds, setIsolatedAssemblyNodeIds] = useState([]);
const [viewerContextMenu, setViewerContextMenu] = useState(null);
const [editPointPickEnabled, setEditPointPickEnabled] = useState(false);
const [activeEditToolId, setActiveEditToolId] = useState("");
const [editToolPicks, setEditToolPicks] = useState([]);
const [editToolHoverPick, setEditToolHoverPick] = useState(null);
const [editToolParameters, setEditToolParameters] = useState({});
const [aiSelectionMode, setAiSelectionMode] = useState(AI_SELECTION_MODE.POINT);
const [aiGeometrySelection, setAiGeometrySelection] = useState(null);
const [aiSelectionDraft, setAiSelectionDraft] = useState({ active: false, points: [] });
const [displaySettings, setDisplaySettings] = useState(() => normalizeDisplaySettings());
const [hoveredListPartId, setHoveredListPartId] = useState("");
const [hoveredModelPartId, setHoveredModelPartId] = useState("");
@@ -1504,12 +1535,31 @@ export default function CadWorkspace({
);
const activeViewerDir = readActiveCadDir({ assetBackend: viewerAssetBackend });
const activeDirectory = catalogRootDir || activeViewerDir;
const currentViewerBridgeState = useCallback(() => viewerBridgeState({
entry: selectedEntry,
activeDirectory,
selectedKey
}), [activeDirectory, selectedEntry, selectedKey]);
const directorySelectionEligible = !explicitDirParam && !activeDirectory;
const directorySelectionActive = directorySelectionEligible && directoryOptions.length > 1;
const directoryAutoEnterDir = directorySelectionEligible && !String(explicitFileParam || "").trim() && directoryOptions.length === 1
? directoryOptions[0].dir
: "";
const directoryNavigationAvailable = !directorySelectionActive;
useEffect(() => {
if (!agentBridgeEnabled) {
return;
}
postAgentBridgeEvent("cadViewer.ready", currentViewerBridgeState());
}, [agentBridgeEnabled, currentViewerBridgeState]);
useEffect(() => {
if (!agentBridgeEnabled || !selectedEntry) {
return;
}
postAgentBridgeEvent("cadViewer.artifactChanged", currentViewerBridgeState());
}, [agentBridgeEnabled, currentViewerBridgeState, selectedEntry]);
const stepArtifactGenerationAvailable = viewerServerInfo
? viewerServerInfo.stepArtifactGenerationAvailable !== false
: viewerAssetBackend === LOCAL_ASSET_BACKEND;
@@ -3278,6 +3328,7 @@ export default function CadWorkspace({
const selectedReferenceIdsRef = useRef(selectedReferenceIds);
const selectedMateIdsRef = useRef(selectedMateIds);
const selectedPartIdsRef = useRef(selectedPartIds);
const aiGeometrySelectionRef = useRef(aiGeometrySelection);
const selectedEntryBuildSnapshotRef = useRef({
fileRef: "",
stepHash: ""
@@ -4458,6 +4509,10 @@ export default function CadWorkspace({
selectedReferenceIdsRef.current = selectedReferenceIds;
}, [selectedReferenceIds]);
useEffect(() => {
aiGeometrySelectionRef.current = aiGeometrySelection;
}, [aiGeometrySelection]);
useEffect(() => {
selectedMateIdsRef.current = selectedMateIds;
}, [selectedMateIds]);
@@ -5509,6 +5564,67 @@ export default function CadWorkspace({
viewerPickableEdges.length ||
viewerPickableVertices.length
);
const editPointPickAvailable = isStepView && selectedEntryHasReferences && (viewerPickableFaces.length > 0 || viewerPickableEdges.length > 0);
const aiSelectionAvailable = isStepView && selectedEntryHasReferences && hasViewerPickableTopology;
const aiSelectionReferenceIds = useMemo(() => (
uniqueStringList(
[
...(Array.isArray(aiGeometrySelection?.referenceIds) ? aiGeometrySelection.referenceIds : []),
...(Array.isArray(aiGeometrySelection?.entities)
? aiGeometrySelection.entities.map((entity) => entity?.referenceId)
: [])
]
.map((id) => String(id || "").trim())
.filter(Boolean)
)
), [aiGeometrySelection]);
const editToolPickKind = activeEditToolId ? cadEditToolNextPickKind(activeEditToolId, editToolPicks.length) : "surface_point";
const editToolSelectionResult = useMemo(() => {
if (!activeEditToolId || !cadEditToolPickComplete(activeEditToolId, editToolPicks)) {
return { selection: null, error: "" };
}
return buildCadEditSelection(activeEditToolId, editToolPicks);
}, [activeEditToolId, editToolPicks]);
const editToolSelectionReady = Boolean(activeEditToolId && editToolSelectionResult.selection && !editToolSelectionResult.error);
const editToolPrompt = activeEditToolId
? editToolSelectionReady
? `${cadEditToolForOperation(activeEditToolId).label}:已选中位置`
: cadEditToolPrompt(activeEditToolId, editToolPicks.length)
: "";
const editToolInstruction = activeEditToolId
? cadEditToolInstruction(activeEditToolId, editToolPicks.length, { selectionReady: editToolSelectionReady })
: "";
const effectiveEditPointPickEnabled = Boolean(activeEditToolId && editPointPickEnabled && !editToolSelectionReady);
useEffect(() => {
if (!editPointPickAvailable && editPointPickEnabled) {
setEditPointPickEnabled(false);
}
if (!editPointPickAvailable && activeEditToolId) {
setActiveEditToolId("");
setEditToolPicks([]);
setEditToolHoverPick(null);
setEditToolParameters({});
}
}, [activeEditToolId, editPointPickAvailable, editPointPickEnabled]);
useEffect(() => {
if (!aiSelectionAvailable) {
if (aiSelectionMode) {
setAiSelectionMode(AI_SELECTION_MODE.NONE);
}
if (aiGeometrySelectionRef.current) {
aiGeometrySelectionRef.current = null;
setAiGeometrySelection(null);
}
setAiSelectionDraft({ active: false, points: [] });
return;
}
if (!activeEditToolId && !aiSelectionMode) {
setAiSelectionMode(AI_SELECTION_MODE.POINT);
}
}, [activeEditToolId, aiSelectionAvailable, aiSelectionMode]);
const topologySelectionActive =
(isAssemblyView && requestedStepTreeTopologyNodeIds.length > 0) ||
topLevelReferenceSelectionActive;
@@ -7563,6 +7679,13 @@ export default function CadWorkspace({
setViewerContextMenu(null);
}, [selectedKey]);
useEffect(() => {
setAiSelectionMode(AI_SELECTION_MODE.POINT);
setAiGeometrySelection(null);
aiGeometrySelectionRef.current = null;
setAiSelectionDraft({ active: false, points: [] });
}, [selectedKey]);
const openGlobalViewerContextMenu = useCallback(({ clientX = 0, clientY = 0 } = {}) => {
if (!isStepView) {
setViewerContextMenu(null);
@@ -7598,7 +7721,342 @@ export default function CadWorkspace({
loadableStepTreeTopologyNodeIds
]);
const handleModelReferenceContext = useCallback((referenceId, { clientX = 0, clientY = 0 } = {}) => {
const copyEditPointPayload = useCallback(async (payload) => {
if (!isStepView || !selectedEntryHasReferences) {
setCopyStatus("Edit point selection requires STEP topology");
return;
}
if (!payload?.pointModel || !payload?.referenceId) {
setCopyStatus("No editable surface point was picked");
return;
}
try {
const intent = buildCadEditIntent({
payload,
entry: selectedEntry,
activeDir: activeDirectory
});
if (postAgentBridgeEvent("cadViewer.editIntent", bridgePayloadFromIntent(intent, currentViewerBridgeState()))) {
setCopyStatus("已发送编辑点给 Agent");
return;
}
await copyTextToClipboard(buildCadEditIntentText({
payload,
entry: selectedEntry,
activeDir: activeDirectory
}));
setCopyStatus("Copied edit point");
} catch (error) {
setCopyStatus(error instanceof Error ? error.message : "Failed to copy edit point");
}
}, [
activeDirectory,
currentViewerBridgeState,
isStepView,
selectedEntry,
selectedEntryHasReferences
]);
const resetEditToolState = useCallback(() => {
setActiveEditToolId("");
setEditToolPicks([]);
setEditToolHoverPick(null);
setEditToolParameters({});
setEditPointPickEnabled(false);
}, []);
const returnToAiPointSelection = useCallback(() => {
resetEditToolState();
setViewerContextMenu(null);
setAiSelectionDraft({ active: false, points: [] });
setAiSelectionMode(aiSelectionAvailable ? AI_SELECTION_MODE.POINT : AI_SELECTION_MODE.NONE);
if (aiSelectionAvailable) {
setCopyStatus("点选模式:点击模型上的面或边");
}
}, [aiSelectionAvailable, resetEditToolState]);
const handleAiSelectionModeChange = useCallback((mode) => {
const normalizedMode = mode === AI_SELECTION_MODE.LASSO
? AI_SELECTION_MODE.LASSO
: AI_SELECTION_MODE.POINT;
if (!aiSelectionAvailable) {
setCopyStatus("点选/圈选需要 STEP topology");
return;
}
resetEditToolState();
setViewerContextMenu(null);
setAiSelectionDraft({ active: false, points: [] });
setAiSelectionMode(normalizedMode);
if (normalizedMode === AI_SELECTION_MODE.POINT) {
setCopyStatus("点选模式:点击模型上的面或边");
} else if (normalizedMode === AI_SELECTION_MODE.LASSO) {
setCopyStatus("圈选模式:按住鼠标画圈选择区域");
}
}, [aiSelectionAvailable, resetEditToolState]);
const handleAiSelectionDraftChange = useCallback((draft) => {
setAiSelectionDraft({
active: Boolean(draft?.active),
points: Array.isArray(draft?.points) ? draft.points : []
});
}, []);
const handleAiSelectionComplete = useCallback(async (selection) => {
setAiSelectionDraft({ active: false, points: [] });
if (!selection?.kind) {
setCopyStatus(aiSelectionMode === AI_SELECTION_MODE.LASSO ? "圈选区域太小或没有形成闭合区域" : "没有拾取到可编辑几何");
return;
}
const selectionReferenceIds = (candidate, { includePick = false } = {}) => {
const values = [
...(Array.isArray(candidate?.referenceIds) ? candidate.referenceIds : []),
...(Array.isArray(candidate?.entities) ? candidate.entities.map((entity) => entity?.referenceId) : []),
];
if (includePick) {
values.push(candidate?.pick?.referenceId);
}
return uniqueStringList(values
.map((id) => String(id || "").trim())
.filter(Boolean)
);
};
const referenceIds = selectionReferenceIds(selection, { includePick: true });
let nextSelection = {
...selection,
referenceIds
};
if (selection.kind === "point_pick" && selection.multiSelect && referenceIds.length) {
const pickedReferenceId = String(selection.pick?.referenceId || referenceIds[0] || "").trim();
const previousSelection = aiGeometrySelectionRef.current;
const previousReferenceIds = selectionReferenceIds(previousSelection);
const nextReferenceIds = previousReferenceIds.includes(pickedReferenceId)
? previousReferenceIds.filter((referenceId) => referenceId !== pickedReferenceId)
: uniqueStringList([...previousReferenceIds, pickedReferenceId]);
const entityByReferenceId = new Map();
for (const entity of [
...(Array.isArray(previousSelection?.entities) ? previousSelection.entities : []),
...(Array.isArray(selection.entities) ? selection.entities : [])
]) {
const entityReferenceId = String(entity?.referenceId || "").trim();
if (entityReferenceId) {
entityByReferenceId.set(entityReferenceId, entity);
}
}
const entities = nextReferenceIds
.map((referenceId) => entityByReferenceId.get(referenceId))
.filter(Boolean);
nextSelection = {
...selection,
entities,
entityCount: entities.length,
referenceIds: nextReferenceIds
};
} else if (selection.kind === "point_pick") {
nextSelection = {
...selection,
entityCount: Array.isArray(selection.entities) ? selection.entities.length : referenceIds.length,
referenceIds
};
}
const nextReferenceIds = selectionReferenceIds(nextSelection);
nextSelection = {
...nextSelection,
referenceIds: nextReferenceIds
};
if (Number(nextSelection.entityCount) !== nextReferenceIds.length) {
nextSelection.entityCount = Array.isArray(nextSelection.entities) ? nextSelection.entities.length : nextReferenceIds.length;
}
aiGeometrySelectionRef.current = nextSelection;
setAiGeometrySelection(nextSelection);
if (!nextReferenceIds.length) {
if (selection.kind === "point_pick") {
clearReferenceSelection();
}
setCopyStatus(selection.kind === "region_pick" ? "圈选范围内没有可编辑几何" : "已清空点选几何");
return;
}
try {
const intent = buildAiGeometrySelectionIntent({
selection: nextSelection,
entry: selectedEntry,
activeDir: activeDirectory
});
if (postAgentBridgeEvent("cadViewer.selection", bridgePayloadFromIntent(intent, currentViewerBridgeState()))) {
setCopyStatus(selection.kind === "region_pick"
? `已发送圈选信息给 Agent(${nextReferenceIds.length} 个引用)`
: nextReferenceIds.length > 1
? `已发送点选信息给 Agent(${nextReferenceIds.length} 个引用)`
: "已发送点选信息给 Agent");
return;
}
await copyTextToClipboard(buildAiGeometrySelectionIntentText({
selection: nextSelection,
entry: selectedEntry,
activeDir: activeDirectory
}));
setCopyStatus(selection.kind === "region_pick"
? `已复制圈选信息(${nextReferenceIds.length} 个引用)`
: nextReferenceIds.length > 1
? `已复制点选信息(${nextReferenceIds.length} 个引用)`
: "已复制点选信息");
} catch (error) {
setCopyStatus(error instanceof Error ? error.message : "复制选区信息失败");
}
}, [
activeDirectory,
aiSelectionMode,
clearReferenceSelection,
currentViewerBridgeState,
selectedEntry
]);
const handleEditToolSelect = useCallback((toolId) => {
const normalizedToolId = String(toolId || "").trim();
if (!normalizedToolId) {
returnToAiPointSelection();
return;
}
if (!editPointPickAvailable) {
setCopyStatus("编辑工具需要 STEP topology");
return;
}
setAiSelectionMode(AI_SELECTION_MODE.NONE);
setAiSelectionDraft({ active: false, points: [] });
const tool = cadEditToolForOperation(normalizedToolId);
setActiveEditToolId(tool.id);
setEditToolPicks([]);
setEditToolHoverPick(null);
setEditToolParameters(defaultCadEditToolParameters(tool.id));
setEditPointPickEnabled(true);
setCopyStatus(cadEditToolPrompt(tool.id, 0));
}, [editPointPickAvailable, returnToAiPointSelection]);
const handleEditToolPick = useCallback((payload) => {
if (!activeEditToolId) {
void copyEditPointPayload(payload);
return;
}
if (!editPointPickAvailable) {
setCopyStatus("编辑工具需要 STEP topology");
return;
}
if (!payload?.kind) {
setCopyStatus(editToolPickKind === "edge" ? "没有拾取到可编辑边" : "没有拾取到可编辑表面点");
return;
}
if (editToolPickKind === "edge" && payload.kind !== "edge") {
setCopyStatus("请为此工具点击一条可编辑边");
return;
}
if (editToolPickKind !== "edge" && payload.kind !== "surface_point") {
setCopyStatus("请为此工具点击一个可编辑表面点");
return;
}
const nextPicks = [...editToolPicks, payload];
setEditToolHoverPick(null);
if (!cadEditToolPickComplete(activeEditToolId, nextPicks)) {
setEditToolPicks(nextPicks);
setCopyStatus(cadEditToolPrompt(activeEditToolId, nextPicks.length));
return;
}
const { selection, error } = buildCadEditSelection(activeEditToolId, nextPicks);
if (!selection || error) {
if (activeEditToolId === "add_slot") {
setEditToolPicks(nextPicks.slice(0, 1));
setEditPointPickEnabled(true);
}
setCopyStatus(error || "无效的编辑选择");
return;
}
setEditToolPicks(nextPicks);
setEditPointPickEnabled(false);
setCopyStatus(`${cadEditToolForOperation(activeEditToolId).label}选择已就绪`);
}, [
activeEditToolId,
copyEditPointPayload,
editPointPickAvailable,
editToolPickKind,
editToolPicks
]);
const handleEditToolHover = useCallback((payload) => {
if (!activeEditToolId || editToolSelectionReady) {
setEditToolHoverPick(null);
return;
}
if (!payload?.kind) {
setEditToolHoverPick(null);
return;
}
if (editToolPickKind === "edge" && payload.kind !== "edge") {
setEditToolHoverPick(null);
return;
}
if (editToolPickKind !== "edge" && payload.kind !== "surface_point") {
setEditToolHoverPick(null);
return;
}
setEditToolHoverPick(payload);
}, [activeEditToolId, editToolPickKind, editToolSelectionReady]);
const handleEditToolParameterChange = useCallback((name, value) => {
const parameterName = String(name || "").trim();
if (!parameterName) {
return;
}
const tool = cadEditToolForOperation(activeEditToolId);
const field = (tool.parameterFields || []).find((candidate) => candidate.name === parameterName);
const nextValue = field?.type === "number"
? Number(value)
: String(value || "").trim();
setEditToolParameters((current) => ({
...current,
[parameterName]: field?.type === "number" && !Number.isFinite(nextValue) ? current?.[parameterName] : nextValue
}));
}, [activeEditToolId]);
const handleEditToolCopyRequest = useCallback(async () => {
if (!activeEditToolId || !editToolSelectionResult.selection) {
setCopyStatus("还没有可复制的编辑选择");
return;
}
try {
const intent = buildCadEditIntent({
operation: activeEditToolId,
selection: editToolSelectionResult.selection,
parameters: editToolParameters,
entry: selectedEntry,
activeDir: activeDirectory
});
if (postAgentBridgeEvent("cadViewer.editIntent", bridgePayloadFromIntent(intent, currentViewerBridgeState()))) {
setCopyStatus(`已发送${cadEditToolForOperation(activeEditToolId).label}请求给 Agent`);
return;
}
await copyTextToClipboard(buildCadEditIntentText({
operation: activeEditToolId,
selection: editToolSelectionResult.selection,
parameters: editToolParameters,
entry: selectedEntry,
activeDir: activeDirectory
}));
setCopyStatus(`已复制${cadEditToolForOperation(activeEditToolId).label}请求`);
} catch (error) {
setCopyStatus(error instanceof Error ? error.message : "复制编辑请求失败");
}
}, [
activeDirectory,
activeEditToolId,
currentViewerBridgeState,
editToolParameters,
editToolSelectionResult.selection,
selectedEntry
]);
const handleEditPointPick = useCallback((payload) => {
void copyEditPointPayload(payload);
}, [copyEditPointPayload]);
const handleModelReferenceContext = useCallback((referenceId, { clientX = 0, clientY = 0, pickPayload = null } = {}) => {
if (stepUpdateInProgress || stepModuleTreeSelectionDisabled) {
setViewerContextMenu(null);
return;
@@ -7649,6 +8107,13 @@ export default function CadWorkspace({
selectedReferenceIds: actionReferenceIds,
copyReferenceMap: stepTreeCopyReferenceMap
});
const editPointCopyText = pickPayload?.referenceId
? buildCadEditIntentText({
payload: pickPayload,
entry: selectedEntry,
activeDir: activeDirectory
})
: "";
setViewerContextMenu({
x: Number(clientX) || 0,
y: Number(clientY) || 0,
@@ -7660,6 +8125,7 @@ export default function CadWorkspace({
focused: false,
actionCount: actionReferenceIds.length || 1,
copyText: lines.join("\n"),
editPointCopyText,
showIsolate: false,
showHideOther: false,
showVisibility: false,
@@ -7769,6 +8235,7 @@ export default function CadWorkspace({
}, [
assemblyPartMap,
assemblyRoot,
activeDirectory,
displayStepTreeRoot,
focusedAssemblyNodeIds,
effectiveActiveReferenceMap,
@@ -7805,6 +8272,20 @@ export default function CadWorkspace({
}
}, []);
const copyViewerContextMenuEditPoint = useCallback(async (menu) => {
const copyText = String(menu?.editPointCopyText || "").trim();
if (!copyText) {
setCopyStatus("No editable surface point was picked");
return;
}
try {
await copyTextToClipboard(copyText);
setCopyStatus("Copied edit point");
} catch (error) {
setCopyStatus(error instanceof Error ? error.message : "Failed to copy edit point");
}
}, []);
const copyStepTreeContextMenuReference = useCallback(async (id, { topology = false } = {}) => {
const normalizedId = String(id || "").trim();
if (!normalizedId) {
@@ -8472,6 +8953,7 @@ export default function CadWorkspace({
const selectionToolActive = effectiveRenderFormat === RENDER_FORMAT.STEP && tabToolMode === TAB_TOOL_MODE.REFERENCES;
const drawToolActive = drawModeActive;
const selectionCount = selectionCountBase;
const activeReferenceId = String(selectedReferenceIds[selectedReferenceIds.length - 1] || "").trim();
const activeReferencePartTreeNodeId = useMemo(() => {
if (!activeReferenceId) {
@@ -8633,9 +9115,38 @@ export default function CadWorkspace({
handleModelReferenceActivate={handleModelReferenceActivate}
handleModelReferenceDoubleActivate={handleModelReferenceDoubleActivate}
handleModelReferenceContext={handleModelReferenceContext}
editPointPickEnabled={effectiveEditPointPickEnabled}
editPointPickAvailable={editPointPickAvailable}
onEditPointPickEnabledChange={setEditPointPickEnabled}
onEditPointPick={handleEditPointPick}
activeEditToolId={activeEditToolId}
editToolPickKind={editToolPickKind}
editToolPrompt={editToolPrompt}
editToolInstruction={editToolInstruction}
editToolPicks={editToolPicks}
editToolHoverPick={editToolHoverPick}
editToolParameters={editToolParameters}
editToolSelectionReady={editToolSelectionReady}
editToolCopyDisabled={!editToolSelectionReady}
editToolSubmitLabel={agentBridgeEnabled ? "发送给 Agent" : "复制请求"}
onEditToolSelect={handleEditToolSelect}
onEditToolPick={handleEditToolPick}
onEditToolHover={handleEditToolHover}
onEditToolParameterChange={handleEditToolParameterChange}
onEditToolCopyRequest={handleEditToolCopyRequest}
onEditToolCancel={returnToAiPointSelection}
aiSelectionMode={aiSelectionMode}
aiSelectionAvailable={aiSelectionAvailable}
aiSelection={aiGeometrySelection}
aiSelectionDraft={aiSelectionDraft}
aiSelectionReferenceIds={aiSelectionReferenceIds}
onAiSelectionModeChange={handleAiSelectionModeChange}
onAiSelectionDraftChange={handleAiSelectionDraftChange}
onAiSelectionComplete={handleAiSelectionComplete}
viewerContextMenu={viewerContextMenu}
onViewerContextMenuClose={closeViewerContextMenu}
onViewerContextMenuCopyReference={copyViewerContextMenuReference}
onViewerContextMenuCopyEditPoint={copyViewerContextMenuEditPoint}
onViewerContextMenuSelect={selectViewerContextMenuNode}
onViewerContextMenuFocus={focusViewerContextMenuNode}
onViewerContextMenuExitAllIsolate={handleExitIsolate}
@@ -0,0 +1,117 @@
function finiteVector3(value) {
const raw = Array.isArray(value)
? value
: value && typeof value === "object"
? [value.x, value.y, value.z]
: null;
if (!Array.isArray(raw) || raw.length < 3) {
return null;
}
const vector = raw.slice(0, 3).map((component) => Number(component));
return vector.every(Number.isFinite) ? vector : null;
}
function finiteIntegerOrNull(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const numericValue = Number(value);
return Number.isInteger(numericValue) ? numericValue : null;
}
function normalizeSurfaceForEditIntent(surface) {
if (!surface || typeof surface !== "object") {
return null;
}
const normalized = {
type: String(surface.type || surface.surfaceType || "").trim() || null,
origin: finiteVector3(surface.origin),
xDir: finiteVector3(surface.xDir),
yDir: finiteVector3(surface.yDir),
normal: finiteVector3(surface.normal),
axis: finiteVector3(surface.axis),
radius: Number.isFinite(Number(surface.radius)) ? Number(surface.radius) : null
};
return Object.fromEntries(Object.entries(normalized).filter(([, value]) => value !== null));
}
export function referenceSelectorType(reference) {
return String(reference?.selectorType || reference?.pickData?.selectorType || "").trim();
}
export function buildSurfacePointPickPayload({
reference,
intersection,
localPoint,
clientX,
clientY
}) {
if (!reference || !intersection?.point || !localPoint) {
return null;
}
const pointWorld = finiteVector3(intersection.point);
const pointModel = finiteVector3(localPoint);
if (!pointWorld || !pointModel) {
return null;
}
const pickData = reference.pickData || {};
return {
kind: "surface_point",
clientX: Number(clientX) || 0,
clientY: Number(clientY) || 0,
referenceId: String(reference.id || "").trim(),
selector: String(reference.displaySelector || reference.normalizedSelector || "").trim(),
normalizedSelector: String(reference.normalizedSelector || reference.displaySelector || "").trim(),
partId: String(reference.partId || "").trim(),
entityType: String(reference.entityType || "").trim(),
selectorType: referenceSelectorType(reference),
faceIndex: finiteIntegerOrNull(intersection.faceIndex),
rowIndex: finiteIntegerOrNull(reference.rowIndex ?? pickData.rowIndex),
pointWorld,
pointModel,
normal: finiteVector3(pickData.normal || pickData.surface?.normal),
surface: normalizeSurfaceForEditIntent(pickData.surface || {
type: pickData.surfaceType,
normal: pickData.normal
})
};
}
export function buildEdgePickPayload({
reference,
intersection,
localPoint,
clientX,
clientY
}) {
if (!reference) {
return null;
}
const referenceId = String(reference.id || "").trim();
if (!referenceId) {
return null;
}
const pickData = reference.pickData || {};
const pointWorld = finiteVector3(intersection?.point);
const pointModel = finiteVector3(localPoint);
return {
kind: "edge",
clientX: Number(clientX) || 0,
clientY: Number(clientY) || 0,
referenceId,
selector: String(reference.displaySelector || reference.normalizedSelector || "").trim(),
normalizedSelector: String(reference.normalizedSelector || reference.displaySelector || "").trim(),
partId: String(reference.partId || "").trim(),
entityType: String(reference.entityType || "").trim(),
selectorType: referenceSelectorType(reference) || "edge",
rowIndex: finiteIntegerOrNull(reference.rowIndex ?? pickData.rowIndex),
pointWorld,
pointModel,
curve: pickData.curve && typeof pickData.curve === "object" ? pickData.curve : null,
adjacentFaces: Array.isArray(pickData.adjacentFaces)
? pickData.adjacentFaces
: Array.isArray(pickData.adjacentFaceSelectors)
? pickData.adjacentFaceSelectors
: []
};
}
@@ -2,6 +2,11 @@ import { useEffect, useRef } from "react";
import { VIEWER_PICK_MODE } from "cadjs/lib/viewer/constants";
import { pointVisibleByClipPlane } from "cadjs/lib/viewer/clipPlane";
import { screenLimitedPickThreshold } from "cadjs/lib/viewer/pickingThresholds";
import {
buildEdgePickPayload,
buildSurfacePointPickPayload,
referenceSelectorType
} from "./editPointPickPayload.js";
import { createViewerContextMenuGestureState } from "./viewerContextMenuGesture.js";
const FACE_BOUNDS_EPSILON = 0.25;
@@ -25,6 +30,8 @@ const CORNER_HOVER_PRIORITY_WITH_OTHER_PX = 3;
const HOVER_PICK_MIN_MOVE_PX = 2;
const FINE_POINTER_TAP_SLOP_PX = 4;
const COARSE_POINTER_TAP_SLOP_PX = 12;
const LASSO_MIN_POINT_DISTANCE_PX = 3;
const LASSO_MIN_POINTS = 4;
export const VIEWER_DOUBLE_CLICK_ACTIVATION_DELAY_MS = 220;
function clamp(value, min, max) {
@@ -107,6 +114,120 @@ function distancePointToLoop2d(point, loop) {
return best;
}
function finiteVector3(value) {
const raw = Array.isArray(value)
? value
: value && typeof value === "object"
? [value.x, value.y, value.z]
: null;
if (!Array.isArray(raw) || raw.length < 3) {
return null;
}
const vector = raw.slice(0, 3).map((component) => Number(component));
return vector.every(Number.isFinite) ? vector : null;
}
function bboxCenter(bbox) {
if (!bbox || !Array.isArray(bbox.min) || !Array.isArray(bbox.max)) {
return null;
}
const min = finiteVector3(bbox.min);
const max = finiteVector3(bbox.max);
if (!min || !max) {
return null;
}
return [
(min[0] + max[0]) / 2,
(min[1] + max[1]) / 2,
(min[2] + max[2]) / 2
];
}
function normalizeClientPoint(clientX, clientY) {
const x = Number(clientX);
const y = Number(clientY);
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
}
function lassoBounds(points) {
const normalized = (Array.isArray(points) ? points : []).filter((point) => (
Number.isFinite(Number(point?.x)) && Number.isFinite(Number(point?.y))
));
if (!normalized.length) {
return null;
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of normalized) {
const x = Number(point.x);
const y = Number(point.y);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
return {
x: minX,
y: minY,
width: Math.max(maxX - minX, 0),
height: Math.max(maxY - minY, 0)
};
}
function pointInsideClientPolygon(point, points) {
if (!point || !Array.isArray(points) || points.length < 3) {
return false;
}
return pointInPolygon2d(
[Number(point.x), Number(point.y)],
points.map((candidate) => [Number(candidate.x), Number(candidate.y)])
);
}
function referenceCenter(reference) {
return finiteVector3(reference?.pickData?.center || reference?.center) ||
bboxCenter(reference?.pickData?.bbox || reference?.bbox);
}
function referenceSurfaceType(reference) {
return String(reference?.pickData?.surfaceType || reference?.pickData?.surface?.type || reference?.surfaceType || "").trim();
}
function referenceNormal(reference) {
return finiteVector3(reference?.pickData?.normal || reference?.normal || reference?.pickData?.surface?.normal);
}
function referenceSummaryForSelection(reference, screenPoint = null) {
if (!reference) {
return null;
}
const referenceId = String(reference.id || "").trim();
if (!referenceId) {
return null;
}
return {
referenceId,
selector: String(reference.displaySelector || reference.normalizedSelector || "").trim(),
normalizedSelector: String(reference.normalizedSelector || reference.displaySelector || "").trim(),
partId: String(reference.partId || "").trim(),
entityType: String(reference.entityType || "").trim(),
selectorType: referenceSelectorType(reference),
rowIndex: Number.isInteger(Number(reference.rowIndex ?? reference.pickData?.rowIndex))
? Number(reference.rowIndex ?? reference.pickData?.rowIndex)
: null,
center: referenceCenter(reference),
normal: referenceNormal(reference),
surfaceType: referenceSurfaceType(reference),
bbox: reference?.pickData?.bbox || reference?.bbox || null,
screenPoint,
adjacentSelectors: Array.isArray(reference?.pickData?.adjacentSelectors)
? reference.pickData.adjacentSelectors
: []
};
}
function compareReferenceIdentity(a, b) {
const aKey = String(a?.entityId ?? a?.id ?? "");
const bKey = String(b?.entityId ?? b?.id ?? "");
@@ -440,6 +561,14 @@ export function useViewerPicking({
onActivateReference,
onDoubleActivateReference,
onContextReference,
editPointPickEnabled = false,
onEditPointPick,
editToolPickKind = "surface_point",
onEditToolPick,
onEditToolHover,
aiSelectionMode = "",
onAiSelectionDraftChange,
onAiSelectionComplete,
viewerReadyTick
}) {
// Keep pointer listeners stable across parent rerenders; hover itself updates parent state.
@@ -454,6 +583,14 @@ export function useViewerPicking({
const onActivateReferenceRef = useRef(onActivateReference);
const onDoubleActivateReferenceRef = useRef(onDoubleActivateReference);
const onContextReferenceRef = useRef(onContextReference);
const editPointPickEnabledRef = useRef(editPointPickEnabled);
const onEditPointPickRef = useRef(onEditPointPick);
const editToolPickKindRef = useRef(editToolPickKind);
const onEditToolPickRef = useRef(onEditToolPick);
const onEditToolHoverRef = useRef(onEditToolHover);
const aiSelectionModeRef = useRef(aiSelectionMode);
const onAiSelectionDraftChangeRef = useRef(onAiSelectionDraftChange);
const onAiSelectionCompleteRef = useRef(onAiSelectionComplete);
const allowedFaceReferenceIdsRef = useRef(new Set());
const allowedEdgeReferenceIdsRef = useRef(new Set());
const allowedVertexReferenceIdsRef = useRef(new Set());
@@ -469,6 +606,14 @@ export function useViewerPicking({
onActivateReferenceRef.current = onActivateReference;
onDoubleActivateReferenceRef.current = onDoubleActivateReference;
onContextReferenceRef.current = onContextReference;
editPointPickEnabledRef.current = editPointPickEnabled;
onEditPointPickRef.current = onEditPointPick;
editToolPickKindRef.current = editToolPickKind;
onEditToolPickRef.current = onEditToolPick;
onEditToolHoverRef.current = onEditToolHover;
aiSelectionModeRef.current = aiSelectionMode;
onAiSelectionDraftChangeRef.current = onAiSelectionDraftChange;
onAiSelectionCompleteRef.current = onAiSelectionComplete;
allowedFaceReferenceIdsRef.current = new Set(
(Array.isArray(pickableFaces) ? pickableFaces : [])
.map((reference) => String(reference?.id || "").trim())
@@ -532,6 +677,11 @@ export function useViewerPicking({
const doubleClickEnabled = !defaultToCoarsePointer;
let activationTimerId = 0;
const contextMenuGesture = createViewerContextMenuGestureState();
const lassoGesture = {
active: false,
pointerId: 0,
points: []
};
function pointerButtons(event) {
const buttons = Number(event?.buttons);
@@ -600,6 +750,18 @@ export function useViewerPicking({
};
}
function projectModelPointToClient(point) {
const modelPoint = finiteVector3(point);
if (!modelPoint || !runtime?.THREE || !runtime?.camera) {
return null;
}
const vector = new runtime.THREE.Vector3(modelPoint[0], modelPoint[1], modelPoint[2]);
if (runtime.modelGroup?.localToWorld) {
runtime.modelGroup.localToWorld(vector);
}
return projectPointToClient(vector);
}
function edgeScreenDistance(intersection, clientX, clientY) {
const clientPoint = projectPointToClient(intersection?.point);
if (!clientPoint) {
@@ -654,12 +816,32 @@ export function useViewerPicking({
.map((record) => record.mesh);
}
function rayIntersectsObjectBounds(object) {
if (!object || !runtime.raycaster?.ray) {
return false;
}
const geometry = object.geometry;
if (!geometry?.boundingSphere) {
geometry?.computeBoundingSphere?.();
}
if (!geometry?.boundingSphere) {
return true;
}
const sphere = geometry.boundingSphere.clone();
sphere.applyMatrix4(object.matrixWorld);
return runtime.raycaster.ray.intersectsSphere(sphere);
}
function intersectVisibleModelMeshes() {
const modelMeshes = visibleModelMeshes();
if (!modelMeshes.length) {
return [];
}
return filterClippedIntersections(runtime, runtime.raycaster.intersectObjects(modelMeshes, false));
const boundedMeshes = modelMeshes.filter(rayIntersectsObjectBounds);
if (!boundedMeshes.length) {
return [];
}
return filterClippedIntersections(runtime, runtime.raycaster.intersectObjects(boundedMeshes, false));
}
function pickPartReferenceFromIntersections(intersections) {
@@ -768,6 +950,71 @@ export function useViewerPicking({
return null;
}
function pickSurfacePointPayload(modelIntersections, clientX, clientY) {
const pickableFaces = Array.isArray(pickableFacesRef.current) ? pickableFacesRef.current : [];
if (!pickableFaces.length) {
return null;
}
for (const intersection of frontMostModelIntersections(modelIntersections)) {
const reference = faceReferenceFromIntersection(intersection);
if (!reference || referenceSelectorType(reference) !== "face") {
continue;
}
const localPoint = intersection.object?.worldToLocal
? intersection.object.worldToLocal(intersection.point.clone())
: intersection.point;
const payload = buildSurfacePointPickPayload({
reference,
intersection,
localPoint,
clientX,
clientY
});
if (payload) {
return payload;
}
}
if (!runtime.facePickMesh) {
return null;
}
const intersections = filterClippedIntersections(runtime, runtime.raycaster.intersectObject(runtime.facePickMesh, false));
if (!intersections.length) {
return null;
}
const nearestSurfaceDistance = Number(frontMostModelIntersections(modelIntersections)[0]?.distance);
const depthWindow = Number.isFinite(nearestSurfaceDistance)
? Math.max(FRONT_LAYER_DISTANCE_MIN, nearestSurfaceDistance * FRONT_LAYER_DISTANCE_FACTOR)
: Infinity;
for (const intersection of intersections) {
if (Number.isFinite(depthWindow) && Number(intersection?.distance) > nearestSurfaceDistance + depthWindow) {
continue;
}
const reference = faceReferenceFromIntersection(intersection);
if (!reference || referenceSelectorType(reference) !== "face") {
continue;
}
const localPoint = intersection.object?.worldToLocal
? intersection.object.worldToLocal(intersection.point.clone())
: intersection.point;
const payload = buildSurfacePointPickPayload({
reference,
intersection,
localPoint,
clientX,
clientY
});
if (payload) {
return payload;
}
}
return null;
}
function pickSurfacePointPayloadAtPosition(clientX, clientY) {
setPointerFromPosition(clientX, clientY);
return pickSurfacePointPayload(intersectVisibleModelMeshes(), clientX, clientY);
}
function pickEdgeCandidate(modelIntersections, clientX, clientY, {
thresholdScale = 1,
maxScreenDistancePx = EDGE_PICK_MAX_SCREEN_DISTANCE_PX
@@ -813,10 +1060,237 @@ export function useViewerPicking({
}
return {
reference,
screenDistance: bestScreenDistance
screenDistance: bestScreenDistance,
intersection: best
};
}
function pickEditToolPayload(modelIntersections, clientX, clientY) {
if (editToolPickKindRef.current !== "edge") {
return pickSurfacePointPayload(modelIntersections, clientX, clientY);
}
const edgeCandidate = pickEdgeCandidate(modelIntersections, clientX, clientY, {
thresholdScale: AUTO_EDGE_PICK_THRESHOLD_FACTOR,
maxScreenDistancePx: EDGE_PICK_MAX_SCREEN_DISTANCE_PX
});
if (!edgeCandidate?.reference) {
return null;
}
return buildEdgePickPayload({
reference: edgeCandidate.reference,
intersection: edgeCandidate.intersection,
localPoint: edgeCandidate.intersection?.object?.worldToLocal && edgeCandidate.intersection?.point?.clone
? edgeCandidate.intersection.object.worldToLocal(edgeCandidate.intersection.point.clone())
: edgeCandidate.intersection?.point,
clientX,
clientY
});
}
function pickEditToolPayloadAtPosition(clientX, clientY) {
setPointerFromPosition(clientX, clientY);
return pickEditToolPayload(intersectVisibleModelMeshes(), clientX, clientY);
}
function buildCurrentViewPayload() {
const rect = container.getBoundingClientRect();
const camera = runtime?.camera;
const target = runtime?.controls?.target;
return {
projection: camera?.isOrthographicCamera ? "orthographic" : "perspective",
cameraPosition: finiteVector3(camera?.position),
cameraTarget: finiteVector3(target),
cameraUp: finiteVector3(camera?.up),
zoom: Number.isFinite(Number(camera?.zoom)) ? Number(camera.zoom) : null,
viewport: {
left: Number(rect.left) || 0,
top: Number(rect.top) || 0,
width: Number(rect.width) || 0,
height: Number(rect.height) || 0
}
};
}
function buildPointSelectionPayload(clientX, clientY) {
setPointerFromPosition(clientX, clientY);
const modelIntersections = intersectVisibleModelMeshes();
let pick = pickSurfacePointPayload(modelIntersections, clientX, clientY);
if (!pick) {
const edgeCandidate = pickEdgeCandidate(modelIntersections, clientX, clientY, {
thresholdScale: AUTO_EDGE_PICK_THRESHOLD_FACTOR,
maxScreenDistancePx: EDGE_PICK_MAX_SCREEN_DISTANCE_PX
});
pick = edgeCandidate?.reference
? buildEdgePickPayload({
reference: edgeCandidate.reference,
intersection: edgeCandidate.intersection,
localPoint: edgeCandidate.intersection?.object?.worldToLocal && edgeCandidate.intersection?.point?.clone
? edgeCandidate.intersection.object.worldToLocal(edgeCandidate.intersection.point.clone())
: edgeCandidate.intersection?.point,
clientX,
clientY
})
: null;
}
if (!pick?.referenceId) {
return null;
}
const reference = pickableReferenceMap().get(pick.referenceId) || null;
const entity = referenceSummaryForSelection(reference, normalizeClientPoint(clientX, clientY));
return {
kind: "point_pick",
selectionMode: "point",
pick,
entities: entity ? [entity] : [],
referenceIds: [pick.referenceId],
view: buildCurrentViewPayload()
};
}
function pickableReferenceMap() {
const map = new Map();
for (const reference of [
...(Array.isArray(pickableFacesRef.current) ? pickableFacesRef.current : []),
...(Array.isArray(pickableEdgesRef.current) ? pickableEdgesRef.current : []),
...(Array.isArray(pickableVerticesRef.current) ? pickableVerticesRef.current : [])
]) {
const id = String(reference?.id || "").trim();
if (id) {
map.set(id, reference);
}
}
return map;
}
function selectReferencesInsideLasso(points) {
const entities = [];
const seen = new Set();
for (const reference of [
...(Array.isArray(pickableFacesRef.current) ? pickableFacesRef.current : []),
...(Array.isArray(pickableEdgesRef.current) ? pickableEdgesRef.current : []),
...(Array.isArray(pickableVerticesRef.current) ? pickableVerticesRef.current : [])
]) {
const referenceId = String(reference?.id || "").trim();
if (!referenceId || seen.has(referenceId)) {
continue;
}
const screenPoint = projectModelPointToClient(referenceCenter(reference));
if (!screenPoint || !pointInsideClientPolygon(screenPoint, points)) {
continue;
}
const entity = referenceSummaryForSelection(reference, screenPoint);
if (!entity) {
continue;
}
seen.add(referenceId);
entities.push(entity);
}
return entities;
}
function completeAiPointSelection(event) {
const payload = buildPointSelectionPayload(event.clientX, event.clientY);
onAiSelectionCompleteRef.current?.(payload
? {
...payload,
multiSelect: Boolean(event.ctrlKey || event.metaKey)
}
: {
kind: "point_pick",
selectionMode: "point",
multiSelect: Boolean(event.ctrlKey || event.metaKey),
pick: null,
entities: [],
referenceIds: [],
screenPoint: normalizeClientPoint(event.clientX, event.clientY),
view: buildCurrentViewPayload()
});
return true;
}
function startLassoSelection(event) {
const point = normalizeClientPoint(event.clientX, event.clientY);
if (!point) {
return false;
}
lassoGesture.active = true;
lassoGesture.pointerId = event.pointerId;
lassoGesture.points = [point];
primaryPointer.active = true;
primaryPointer.x = event.clientX;
primaryPointer.y = event.clientY;
primaryPointer.pointerType = event.pointerType || "";
onAiSelectionDraftChangeRef.current?.({
active: true,
points: lassoGesture.points
});
try {
container.setPointerCapture?.(event.pointerId);
} catch {
// Pointer capture is best-effort; document-level listeners keep the gesture usable.
}
event.preventDefault();
event.stopPropagation();
return true;
}
function updateLassoSelection(event) {
if (!lassoGesture.active || lassoGesture.pointerId !== event.pointerId) {
return false;
}
const point = normalizeClientPoint(event.clientX, event.clientY);
const previous = lassoGesture.points[lassoGesture.points.length - 1];
if (!point || (previous && Math.hypot(point.x - previous.x, point.y - previous.y) < LASSO_MIN_POINT_DISTANCE_PX)) {
return true;
}
lassoGesture.points = [...lassoGesture.points, point];
onAiSelectionDraftChangeRef.current?.({
active: true,
points: lassoGesture.points
});
event.preventDefault();
event.stopPropagation();
return true;
}
function finishLassoSelection(event, { canceled = false } = {}) {
if (!lassoGesture.active || lassoGesture.pointerId !== event.pointerId) {
return false;
}
const points = lassoGesture.points;
lassoGesture.active = false;
lassoGesture.pointerId = 0;
lassoGesture.points = [];
onAiSelectionDraftChangeRef.current?.({ active: false, points: [] });
try {
container.releasePointerCapture?.(event.pointerId);
} catch {
// Ignore missing capture.
}
event.preventDefault();
event.stopPropagation();
if (canceled || points.length < LASSO_MIN_POINTS) {
onAiSelectionCompleteRef.current?.(null);
return true;
}
const entities = selectReferencesInsideLasso(points);
onAiSelectionCompleteRef.current?.({
kind: "region_pick",
selectionMode: "lasso",
stroke2d: points,
bbox2d: lassoBounds(points),
entities,
entityCount: entities.length,
referenceIds: entities.map((entity) => entity.referenceId).filter(Boolean),
view: buildCurrentViewPayload()
});
return true;
}
function aiSelectionModeActive() {
return aiSelectionModeRef.current === "point" || aiSelectionModeRef.current === "lasso";
}
function pickVertexCandidate(modelIntersections, clientX, clientY, {
thresholdScale = 1,
maxScreenDistancePx = CORNER_PICK_MAX_SCREEN_DISTANCE_PX
@@ -967,9 +1441,7 @@ export function useViewerPicking({
return edgeCandidate?.reference?.id || faceReference?.id || vertexCandidate?.reference?.id || null;
}
function pickReferenceAtPosition(clientX, clientY, { hover = false, preferTopology = false } = {}) {
setPointerFromPosition(clientX, clientY);
const modelIntersections = intersectVisibleModelMeshes();
function pickReferenceFromModelIntersections(modelIntersections, clientX, clientY, { hover = false, preferTopology = false } = {}) {
const pickMode = pickModeRef.current;
if (preferTopology) {
const topologyReference = pickTopologyReference(modelIntersections, clientX, clientY, { hover });
@@ -990,6 +1462,16 @@ export function useViewerPicking({
return null;
}
function pickReferenceAtPosition(clientX, clientY, { hover = false, preferTopology = false } = {}) {
setPointerFromPosition(clientX, clientY);
return pickReferenceFromModelIntersections(
intersectVisibleModelMeshes(),
clientX,
clientY,
{ hover, preferTopology }
);
}
function pickActivationReference(clientX, clientY, pointerType = "") {
if (canHoverWithPointer(pointerType)) {
return String(hoverState.hoveredReferenceId || "").trim() || pickReferenceAtPosition(clientX, clientY, { hover: true });
@@ -1013,6 +1495,9 @@ export function useViewerPicking({
if (!(target instanceof Node)) {
return false;
}
if (target instanceof Element && target.closest("[data-viewer-interaction-overlay]")) {
return false;
}
if (sceneMount?.contains(target)) {
return true;
}
@@ -1048,6 +1533,7 @@ export function useViewerPicking({
hoverState.lastX = NaN;
hoverState.lastY = NaN;
container.style.cursor = "";
onEditToolHoverRef.current?.(null);
if (!hoverState.hoveredReferenceId) {
return;
}
@@ -1087,7 +1573,21 @@ export function useViewerPicking({
}
hoverState.lastX = hoverState.x;
hoverState.lastY = hoverState.y;
commitHoverState(pickReferenceAtPosition(hoverState.x, hoverState.y, { hover: true }));
setPointerFromPosition(hoverState.x, hoverState.y);
const modelIntersections = intersectVisibleModelMeshes();
const referenceId = pickReferenceFromModelIntersections(
modelIntersections,
hoverState.x,
hoverState.y,
{ hover: true }
);
if (editPointPickEnabledRef.current && onEditToolHoverRef.current) {
const payload = pickEditToolPayload(modelIntersections, hoverState.x, hoverState.y);
onEditToolHoverRef.current(payload);
commitHoverState(referenceId || payload?.referenceId || "");
return;
}
commitHoverState(referenceId);
}
function scheduleHoverPick(clientX, clientY) {
@@ -1144,10 +1644,12 @@ export function useViewerPicking({
contextMenuGesture.suppressNextContextMenu();
}
const referenceId = pickActivationReference(event.clientX, event.clientY, event.pointerType || contextPointer.pointerType || "") || "";
const pickPayload = pickSurfacePointPayloadAtPosition(event.clientX, event.clientY);
onContextReferenceRef.current?.(referenceId || "", {
clientX: event.clientX,
clientY: event.clientY,
multiSelect: !!event.shiftKey
multiSelect: !!event.shiftKey,
pickPayload
});
resetContextPointer();
}
@@ -1182,6 +1684,10 @@ export function useViewerPicking({
if (!isSceneEvent(event)) {
return;
}
if (aiSelectionModeRef.current === "lasso" && event.button === 0) {
startLassoSelection(event);
return;
}
if (event.button === 0) {
if (contextPointer.active || contextButtonHeld(event) || chordButtonsHeld(event)) {
suppressContextMenuFromPanChord();
@@ -1203,6 +1709,11 @@ export function useViewerPicking({
if (!isSceneEvent(event)) {
return;
}
if (aiSelectionModeRef.current === "lasso" && event.button === 0) {
event.preventDefault();
event.stopPropagation();
return;
}
if (event.button === 0) {
if (contextPointer.active || contextButtonHeld(event) || chordButtonsHeld(event)) {
suppressContextMenuFromPanChord();
@@ -1224,6 +1735,10 @@ export function useViewerPicking({
if (!isActiveSceneGestureEvent(event)) {
return;
}
if (aiSelectionModeRef.current === "lasso" && lassoGesture.active) {
updateLassoSelection(event);
return;
}
updateContextPointerMove(event);
if (chordButtonsHeld(event) || (primaryPointer.active && contextButtonHeld(event))) {
suppressContextMenuFromPanChord();
@@ -1234,6 +1749,11 @@ export function useViewerPicking({
if (!isActiveSceneGestureEvent(event)) {
return;
}
if (aiSelectionModeRef.current === "lasso") {
event.preventDefault();
event.stopPropagation();
return;
}
updateContextPointerMove(event);
if (chordButtonsHeld(event) || (primaryPointer.active && contextButtonHeld(event))) {
suppressContextMenuFromPanChord();
@@ -1241,6 +1761,9 @@ export function useViewerPicking({
}
function handlePointerUpCapture(event) {
if (aiSelectionModeRef.current === "lasso" && lassoGesture.active && finishLassoSelection(event)) {
return;
}
if (event.button === 0) {
resetPrimaryPointer();
} else if (event.button === 2) {
@@ -1249,6 +1772,11 @@ export function useViewerPicking({
}
function handleMouseUpCapture(event) {
if (aiSelectionModeRef.current === "lasso" && event.button === 0) {
event.preventDefault();
event.stopPropagation();
return;
}
if (event.button === 0) {
resetPrimaryPointer();
} else if (event.button === 2) {
@@ -1272,6 +1800,14 @@ export function useViewerPicking({
clearHoverState();
return;
}
if (aiSelectionModeRef.current === "point") {
scheduleHoverPick(event.clientX, event.clientY);
return;
}
if (aiSelectionModeRef.current === "lasso") {
container.style.cursor = "crosshair";
return;
}
const tapSlop = tapSlopForPointer(pointerDown.pointerType || event.pointerType);
if (pointerDown.active) {
const moved = Math.hypot(event.clientX - pointerDown.x, event.clientY - pointerDown.y);
@@ -1287,6 +1823,9 @@ export function useViewerPicking({
}
function handlePointerLeave() {
if (lassoGesture.active) {
return;
}
clearHoverState();
if (pointerDown.active || primaryPointer.active || contextPointer.active) {
suppressContextMenuFromPanChord();
@@ -1310,6 +1849,19 @@ export function useViewerPicking({
if (!isSceneInteractionTarget(event.target)) {
return;
}
if (aiSelectionModeRef.current === "point") {
pointerDown.active = true;
pointerDown.x = event.clientX;
pointerDown.y = event.clientY;
pointerDown.pointerType = event.pointerType || "";
pointerDown.referenceId = "";
recordPrimaryPointerDown(event);
return;
}
if (aiSelectionModeRef.current === "lasso") {
startLassoSelection(event);
return;
}
if (chordButtonsHeld(event) || contextButtonHeld(event)) {
suppressContextMenuFromPanChord();
return;
@@ -1327,6 +1879,11 @@ export function useViewerPicking({
}
function handlePointerUp(event) {
if (aiSelectionModeRef.current === "lasso" && lassoGesture.active && finishLassoSelection(event)) {
resetPrimaryPointer();
pointerDown.active = false;
return;
}
if (event.button === 0) {
resetPrimaryPointer();
} else if (event.button === 2) {
@@ -1350,6 +1907,21 @@ export function useViewerPicking({
pointerDown.active = false;
pointerDown.pointerType = "";
pointerDown.referenceId = "";
if (aiSelectionModeRef.current === "point") {
event.preventDefault();
event.stopPropagation();
completeAiPointSelection(event);
return;
}
if (editPointPickEnabledRef.current) {
const payload = pickEditToolPayloadAtPosition(event.clientX, event.clientY);
if (onEditToolPickRef.current) {
onEditToolPickRef.current(payload);
} else {
onEditPointPickRef.current?.(payload);
}
return;
}
const referenceId = pointerDownReferenceId || pickActivationReference(event.clientX, event.clientY, event.pointerType || "");
scheduleActivation(referenceId || "", { multiSelect: !!event.shiftKey });
}
@@ -1358,6 +1930,11 @@ export function useViewerPicking({
if (!isSceneInteractionTarget(event.target)) {
return;
}
if (aiSelectionModeActive()) {
event.preventDefault();
event.stopPropagation();
return;
}
clearPendingActivation();
const referenceId = pickActivationReference(event.clientX, event.clientY, event.pointerType || "");
onDoubleActivateReferenceRef.current?.(referenceId || "", { multiSelect: !!event.shiftKey });
@@ -1367,6 +1944,11 @@ export function useViewerPicking({
if (!isSceneEvent(event)) {
return;
}
if (aiSelectionModeActive()) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
clearPendingActivation();
@@ -1409,6 +1991,7 @@ export function useViewerPicking({
contextMenuGesture.clear();
clearPendingActivation();
clearHoverState();
onAiSelectionDraftChangeRef.current?.({ active: false, points: [] });
container.style.cursor = "";
};
}, [
@@ -6,6 +6,10 @@ import {
createViewerContextMenuGestureState,
VIEWER_CONTEXT_MENU_SUPPRESSION_MS
} from "./viewerContextMenuGesture.js";
import {
buildEdgePickPayload,
buildSurfacePointPickPayload
} from "./editPointPickPayload.js";
test("worldUnitsPerPixelAtDistance converts perspective depth to screen scale", () => {
const camera = {
@@ -56,6 +60,70 @@ test("screenLimitedPickThreshold falls back to the scaled base threshold when sc
assert.equal(threshold, 0.45);
});
test("surface point pick payload captures face selector and model coordinates", () => {
const payload = buildSurfacePointPickPayload({
clientX: 120,
clientY: 240,
reference: {
id: "topology|part-a|face|o1.f3",
selectorType: "face",
displaySelector: "o1.f3",
normalizedSelector: "o1.f3",
partId: "part-a",
entityType: "ADVANCED_FACE",
rowIndex: 3,
pickData: {
normal: [0, 0, 1],
surface: {
type: "PLANE",
origin: [0, 0, 25],
xDir: [1, 0, 0],
yDir: [0, 1, 0],
normal: [0, 0, 1]
}
}
},
intersection: {
faceIndex: 7,
point: { x: 12.4, y: 8.2, z: 25 }
},
localPoint: [12.4, 8.2, 25]
});
assert.equal(payload.referenceId, "topology|part-a|face|o1.f3");
assert.equal(payload.selector, "o1.f3");
assert.equal(payload.faceIndex, 7);
assert.deepEqual(payload.pointModel, [12.4, 8.2, 25]);
assert.deepEqual(payload.normal, [0, 0, 1]);
assert.equal(payload.surface.type, "PLANE");
});
test("edge pick payload captures edge selector", () => {
const payload = buildEdgePickPayload({
clientX: 44,
clientY: 55,
reference: {
id: "topology|part-a|edge|e12",
selectorType: "edge",
displaySelector: "e12",
normalizedSelector: "e12",
partId: "part-a",
entityType: "EDGE_CURVE",
rowIndex: 12,
pickData: {
adjacentFaceSelectors: ["f1", "f2"]
}
}
});
assert.equal(payload.kind, "edge");
assert.equal(payload.referenceId, "topology|part-a|edge|e12");
assert.equal(payload.selector, "e12");
assert.equal(payload.selectorType, "edge");
assert.equal(payload.rowIndex, 12);
assert.deepEqual(payload.adjacentFaces, ["f1", "f2"]);
});
test("viewer context menu gesture suppression blocks one menu event", () => {
let time = 1000;
const gesture = createViewerContextMenuGestureState({
@@ -93,7 +93,8 @@ export function useViewerRuntime({
{ LineGeometry },
{ LineSegments2 },
{ LineSegmentsGeometry },
{ LineMaterial }
{ LineMaterial },
meshBvh
] = await Promise.all([
import("three"),
import("three/examples/jsm/controls/OrbitControls.js"),
@@ -101,13 +102,23 @@ export function useViewerRuntime({
import("three/examples/jsm/lines/LineGeometry.js"),
import("three/examples/jsm/lines/LineSegments2.js"),
import("three/examples/jsm/lines/LineSegmentsGeometry.js"),
import("three/examples/jsm/lines/LineMaterial.js")
import("three/examples/jsm/lines/LineMaterial.js"),
import("three-mesh-bvh")
]);
if (cancelled || !mountRef.current) {
return;
}
const container = mountRef.current;
if (meshBvh?.acceleratedRaycast && THREE.Mesh.prototype.raycast !== meshBvh.acceleratedRaycast) {
THREE.Mesh.prototype.raycast = meshBvh.acceleratedRaycast;
}
if (meshBvh?.computeBoundsTree && !THREE.BufferGeometry.prototype.computeBoundsTree) {
THREE.BufferGeometry.prototype.computeBoundsTree = meshBvh.computeBoundsTree;
}
if (meshBvh?.disposeBoundsTree && !THREE.BufferGeometry.prototype.disposeBoundsTree) {
THREE.BufferGeometry.prototype.disposeBoundsTree = meshBvh.disposeBoundsTree;
}
const coarsePointerQuery = typeof window.matchMedia === "function"
? window.matchMedia("(pointer: coarse)")
: null;
@@ -568,6 +579,7 @@ export function useViewerRuntime({
LineSegments2,
LineSegmentsGeometry,
LineMaterial,
meshBvh,
controls,
stageGroup,
modelGroup,
@@ -11,6 +11,8 @@ export default function AssemblyContextMenuItems({
hidden = false,
actionCount = 1,
copyReferenceDisabled = false,
copyEditPointDisabled = true,
showCopyEditPoint = false,
selectDisabled = false,
showIsolate = true,
isolateDisabled = false,
@@ -32,6 +34,7 @@ export default function AssemblyContextMenuItems({
expandAllDisabled = true,
collapseAllDisabled = true,
onCopyReference,
onCopyEditPoint,
onSelect,
onIsolate,
onExitAllIsolate,
@@ -62,6 +65,15 @@ export default function AssemblyContextMenuItems({
>
<AssemblyContextMenuItemLabel>Copy Reference</AssemblyContextMenuItemLabel>
</Item>
{showCopyEditPoint ? (
<Item
className={itemClassName}
disabled={copyEditPointDisabled}
onSelect={onCopyEditPoint}
>
<AssemblyContextMenuItemLabel>Copy edit point</AssemblyContextMenuItemLabel>
</Item>
) : null}
<Separator />
<Item
className={itemClassName}
@@ -2,9 +2,24 @@ import { useEffect, useMemo } from "react";
import CadViewer from "../CadViewer";
import DxfViewer from "../DxfViewer";
import ImplicitCadViewer from "../ImplicitCadViewer";
import { CircleAlert, X } from "lucide-react";
import {
CircleAlert,
CircleDot,
CircleDotDashed,
CornerDownRight,
Disc,
Drill,
Grid2X2Plus,
LassoSelect,
MousePointerClick,
Radius,
SquareDashed,
SquareSplitHorizontal,
X
} from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,8 +29,10 @@ import {
} from "../ui/dropdown-menu";
import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
import AssemblyContextMenuItems from "./AssemblyContextMenuItems";
import { ToolbarButton } from "./ToolbarButton";
import { TooltipProvider } from "../ui/tooltip";
import { cn } from "@/ui/utils";
import { RENDER_FORMAT } from "@/workbench/constants";
import { AI_SELECTION_MODE, RENDER_FORMAT } from "@/workbench/constants";
import {
isMeshRenderFormat,
isRobotRenderFormat
@@ -28,8 +45,20 @@ import { VIEWER_SCENE_SCALE } from "cadjs/lib/viewer/sceneScale";
import { VIEWER_PICK_MODE } from "cadjs/lib/viewer/constants";
import { useStepAnimationSnapshot } from "@/workbench/stepAnimationStore";
import { viewerPickModeForRenderPane } from "@/workbench/viewerPickMode";
import { CAD_EDIT_TOOLS } from "@/workbench/cadEditTools";
const EMPTY_LIST = Object.freeze([]);
const CAD_EDIT_TOOL_ICONS = Object.freeze({
add_hole: Drill,
add_counterbore: CircleDot,
add_countersink: CircleDotDashed,
add_slot: SquareSplitHorizontal,
add_pocket: SquareDashed,
add_circular_pocket: Disc,
add_hole_pattern: Grid2X2Plus,
add_chamfer: CornerDownRight,
add_fillet: Radius
});
const VIEWPORT_ISSUE_META = Object.freeze({
error: {
label: "Error",
@@ -128,11 +157,493 @@ function DxfViewModeControl({
);
}
function EditToolsControl({
activeToolId = "",
available = false,
disabled = false,
aiSelectionMode = AI_SELECTION_MODE.NONE,
aiSelectionAvailable = false,
onAiSelectionModeChange,
onSelectTool,
style
}) {
const selectionDisabled = !aiSelectionAvailable || disabled;
return (
<div
data-viewer-interaction-overlay="true"
className="cad-glass-surface pointer-events-auto absolute z-30 inline-flex min-h-10 max-w-[min(22rem,calc(100vw-1.5rem))] flex-wrap items-center gap-1 rounded-md border border-sidebar-border p-1.5 text-sidebar-foreground shadow-sm shadow-black/5"
style={style}
onPointerDown={(event) => {
event.stopPropagation();
}}
aria-label="CAD 编辑工具"
role="toolbar"
>
<TooltipProvider delayDuration={250}>
<ToolbarButton
label="点选几何"
active={aiSelectionMode === AI_SELECTION_MODE.POINT}
tooltipSide="bottom"
onClick={() => onAiSelectionModeChange?.(AI_SELECTION_MODE.POINT)}
disabled={selectionDisabled}
aria-pressed={aiSelectionMode === AI_SELECTION_MODE.POINT}
>
<MousePointerClick className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
label="圈选几何"
active={aiSelectionMode === AI_SELECTION_MODE.LASSO}
tooltipSide="bottom"
onClick={() => onAiSelectionModeChange?.(AI_SELECTION_MODE.LASSO)}
disabled={selectionDisabled}
aria-pressed={aiSelectionMode === AI_SELECTION_MODE.LASSO}
>
<LassoSelect className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<span
className="mx-0.5 h-5 w-px shrink-0 bg-sidebar-border/80"
aria-hidden="true"
/>
{CAD_EDIT_TOOLS.map((tool) => {
const Icon = CAD_EDIT_TOOL_ICONS[tool.id] || Drill;
const active = activeToolId === tool.id;
return (
<ToolbarButton
key={tool.id}
label={tool.label}
active={active}
tooltipSide="bottom"
onClick={() => onSelectTool?.(active ? "" : tool.id)}
disabled={!available || disabled}
aria-pressed={active}
>
<Icon className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
);
})}
</TooltipProvider>
</div>
);
}
function EditToolParameterPanel({
activeToolId = "",
parameters = {},
selectionReady = false,
copyDisabled = false,
submitLabel = "复制请求",
onParameterChange,
onCopyRequest,
onCancel,
style
}) {
const activeTool = CAD_EDIT_TOOLS.find((tool) => tool.id === activeToolId) || null;
if (!activeTool || !selectionReady) {
return null;
}
return (
<div
data-viewer-interaction-overlay="true"
className="cad-glass-popover pointer-events-auto absolute z-30 w-64 rounded-lg border border-sidebar-border p-3 text-popover-foreground shadow-lg"
style={style}
role="dialog"
aria-label={`${activeTool.label}参数`}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div>
<div className="text-xs font-semibold">{activeTool.label}</div>
<div className="text-[10px] text-muted-foreground">编辑参数</div>
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6"
onClick={onCancel}
aria-label="取消编辑工具"
>
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
</Button>
</div>
<div className="grid gap-2">
{(activeTool.parameterFields || []).map((field) => {
const value = parameters?.[field.name] ?? "";
return (
<label key={field.name} className="grid gap-1 text-[11px]">
<span className="flex items-center justify-between gap-2">
<span>{field.label}</span>
{field.unit ? <span className="text-muted-foreground">{field.unit}</span> : null}
</span>
{field.type === "select" ? (
<select
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
value={String(value)}
onChange={(event) => onParameterChange?.(field.name, event.target.value)}
>
{(field.options || []).map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
) : field.type === "string" ? (
<Input
type="text"
value={String(value)}
placeholder={field.placeholder}
className="h-8 px-2 text-xs"
onChange={(event) => onParameterChange?.(field.name, event.target.value)}
/>
) : (
<Input
type="number"
value={String(value)}
min={field.min}
step={field.step}
className="h-8 px-2 text-xs"
onChange={(event) => onParameterChange?.(field.name, event.target.value)}
/>
)}
</label>
);
})}
</div>
<div className="mt-3 flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" onClick={onCancel}>
取消
</Button>
<Button type="button" size="sm" className="h-8 text-xs" disabled={copyDisabled} onClick={onCopyRequest}>
{submitLabel}
</Button>
</div>
</div>
);
}
function finiteClientPoint(pick) {
const x = Number(pick?.clientX);
const y = Number(pick?.clientY);
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
}
function editToolPickMarkerLabel(activeToolId, pick, index) {
if (activeToolId === "add_slot") {
return index === 0 ? "起点" : "终点";
}
if (pick?.kind === "edge") {
return "边";
}
if (activeToolId === "add_hole_pattern") {
return "中心";
}
return "位置";
}
function numericEditParameter(parameters, name, fallback) {
const value = Number(parameters?.[name]);
return Number.isFinite(value) ? value : fallback;
}
function previewPixelsFromMm(valueMm, {
scale = 5,
min = 12,
max = 180
} = {}) {
const value = Math.abs(Number(valueMm));
if (!Number.isFinite(value) || value <= 0) {
return min;
}
return Math.min(Math.max(value * scale, min), max);
}
function virtualPreviewSpec(activeToolId, parameters, anchorPoint) {
if (!activeToolId || !anchorPoint) {
return null;
}
if (activeToolId === "add_hole") {
return {
kind: "circle",
point: anchorPoint,
diameterPx: previewPixelsFromMm(numericEditParameter(parameters, "holeDiameter", 2))
};
}
if (activeToolId === "add_counterbore") {
return {
kind: "double_circle",
point: anchorPoint,
innerDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "holeDiameter", 3)),
outerDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "counterboreDiameter", 6))
};
}
if (activeToolId === "add_countersink") {
return {
kind: "countersink",
point: anchorPoint,
innerDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "holeDiameter", 3)),
outerDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "countersinkDiameter", 6))
};
}
if (activeToolId === "add_circular_pocket") {
return {
kind: "circle",
point: anchorPoint,
diameterPx: previewPixelsFromMm(numericEditParameter(parameters, "diameter", 8), { scale: 4, min: 18, max: 220 })
};
}
if (activeToolId === "add_pocket") {
return {
kind: "rect",
point: anchorPoint,
widthPx: previewPixelsFromMm(numericEditParameter(parameters, "width", 10), { scale: 4, min: 24, max: 240 }),
heightPx: previewPixelsFromMm(numericEditParameter(parameters, "height", 6), { scale: 4, min: 18, max: 180 })
};
}
if (activeToolId === "add_hole_pattern") {
return {
kind: "hole_pattern",
point: anchorPoint,
holeDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "holeDiameter", 2), { scale: 4, min: 8, max: 48 }),
rows: Math.min(Math.max(Math.round(numericEditParameter(parameters, "rows", 2)), 1), 8),
columns: Math.min(Math.max(Math.round(numericEditParameter(parameters, "columns", 2)), 1), 8),
pitchXPx: previewPixelsFromMm(numericEditParameter(parameters, "pitchX", 8), { scale: 3, min: 18, max: 90 }),
pitchYPx: previewPixelsFromMm(numericEditParameter(parameters, "pitchY", 8), { scale: 3, min: 18, max: 90 })
};
}
return null;
}
function EditToolVirtualPreview({ spec }) {
if (!spec?.point) {
return null;
}
if (spec.kind === "circle") {
const radius = spec.diameterPx / 2;
return (
<circle
cx={spec.point.x}
cy={spec.point.y}
r={radius}
className="fill-primary/10 stroke-primary/80"
strokeWidth="2"
strokeDasharray="5 4"
/>
);
}
if (spec.kind === "double_circle" || spec.kind === "countersink") {
return (
<g>
<circle
cx={spec.point.x}
cy={spec.point.y}
r={spec.outerDiameterPx / 2}
className="fill-primary/10 stroke-primary/85"
strokeWidth="2"
strokeDasharray={spec.kind === "countersink" ? "7 4" : "none"}
/>
<circle
cx={spec.point.x}
cy={spec.point.y}
r={spec.innerDiameterPx / 2}
className="fill-transparent stroke-primary-foreground/80"
strokeWidth="1.5"
strokeDasharray="4 4"
/>
</g>
);
}
if (spec.kind === "rect") {
return (
<rect
x={spec.point.x - spec.widthPx / 2}
y={spec.point.y - spec.heightPx / 2}
width={spec.widthPx}
height={spec.heightPx}
rx="6"
className="fill-primary/10 stroke-primary/80"
strokeWidth="2"
strokeDasharray="6 4"
/>
);
}
if (spec.kind === "hole_pattern") {
const startX = spec.point.x - ((spec.columns - 1) * spec.pitchXPx) / 2;
const startY = spec.point.y - ((spec.rows - 1) * spec.pitchYPx) / 2;
const holes = [];
for (let row = 0; row < spec.rows; row += 1) {
for (let column = 0; column < spec.columns; column += 1) {
holes.push({
x: startX + column * spec.pitchXPx,
y: startY + row * spec.pitchYPx
});
}
}
return (
<g>
{holes.map((hole, index) => (
<circle
key={`${index}:${hole.x}:${hole.y}`}
cx={hole.x}
cy={hole.y}
r={spec.holeDiameterPx / 2}
className="fill-primary/10 stroke-primary/80"
strokeWidth="1.5"
strokeDasharray="4 3"
/>
))}
</g>
);
}
return null;
}
function slotPreviewWidthPx(parameters) {
return previewPixelsFromMm(numericEditParameter(parameters, "slotWidth", 2), { scale: 5, min: 12, max: 80 });
}
function EditToolPickOverlay({
activeToolId = "",
picks = EMPTY_LIST,
hoverPick = null,
parameters = {}
}) {
if (!activeToolId) {
return null;
}
const visiblePicks = Array.isArray(picks) ? picks : [];
const hoverPoint = finiteClientPoint(hoverPick);
const points = visiblePicks
.map((pick, index) => ({
pick,
index,
hover: false,
point: finiteClientPoint(pick)
}))
.filter((entry) => entry.point);
const hoverEntry = hoverPoint && !points.some((entry) => (
Math.hypot(entry.point.x - hoverPoint.x, entry.point.y - hoverPoint.y) < 1
))
? {
pick: hoverPick,
index: points.length,
hover: true,
point: hoverPoint
}
: null;
const allPoints = hoverEntry ? [...points, hoverEntry] : points;
if (!allPoints.length) {
return null;
}
const slotLine = activeToolId === "add_slot" && allPoints.length >= 2
? { start: allPoints[0].point, end: allPoints[1].point, preview: allPoints[1].hover }
: null;
const anchorPoint = activeToolId === "add_slot"
? null
: (points[points.length - 1]?.point || hoverEntry?.point || null);
const previewSpec = virtualPreviewSpec(activeToolId, parameters, anchorPoint);
const slotWidthPx = slotLine ? slotPreviewWidthPx(parameters) : 0;
return (
<div className="pointer-events-none fixed inset-0 z-[25]" aria-hidden="true">
{slotLine || previewSpec ? (
<svg className="fixed inset-0 size-full overflow-visible">
{slotLine ? (
<>
<line
x1={slotLine.start.x}
y1={slotLine.start.y}
x2={slotLine.end.x}
y2={slotLine.end.y}
className="stroke-primary/15"
strokeWidth={slotWidthPx}
strokeLinecap="round"
/>
<line
x1={slotLine.start.x}
y1={slotLine.start.y}
x2={slotLine.end.x}
y2={slotLine.end.y}
className={slotLine.preview ? "stroke-primary/65" : "stroke-primary/90"}
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={slotLine.preview ? "7 5" : "none"}
/>
<line
x1={slotLine.start.x}
y1={slotLine.start.y}
x2={slotLine.end.x}
y2={slotLine.end.y}
className="stroke-primary-foreground/70"
strokeWidth="1"
strokeLinecap="round"
strokeDasharray="5 5"
/>
</>
) : null}
<EditToolVirtualPreview spec={previewSpec} />
</svg>
) : null}
{allPoints.map(({ pick, index, point, hover }) => (
<div
key={`${index}:${pick?.referenceId || ""}:${point.x}:${point.y}`}
className="fixed"
style={{
left: `${point.x}px`,
top: `${point.y}px`,
transform: "translate(-50%, -50%)"
}}
>
<span className={cn("absolute left-1/2 top-1/2 h-12 w-px -translate-x-1/2 -translate-y-1/2", hover ? "bg-primary/20" : "bg-primary/35")} />
<span className={cn("absolute left-1/2 top-1/2 h-px w-12 -translate-x-1/2 -translate-y-1/2", hover ? "bg-primary/20" : "bg-primary/35")} />
<span className={cn(
"absolute left-1/2 top-1/2 size-7 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-primary/10 shadow-[0_0_22px_rgba(59,130,246,0.35)]",
hover ? "border-primary/25 border-dashed" : "border-primary/35"
)} />
<span className={cn(
"relative block rounded-full border border-primary-foreground/80 bg-primary shadow-md shadow-black/25",
hover ? "size-2 opacity-70" : "size-3"
)} />
<span className="cad-glass-popover absolute left-4 top-3 whitespace-nowrap rounded-full border border-primary/20 px-2 py-0.5 text-[10px] font-semibold text-popover-foreground shadow-sm">
{hover ? "预选" : editToolPickMarkerLabel(activeToolId, pick, index)}
</span>
</div>
))}
</div>
);
}
function AiSelectionOverlay({
draft = null
}) {
const draftPoints = Array.isArray(draft?.points) ? draft.points : [];
const points = draft?.active ? draftPoints : [];
const polylinePoints = points
.map((point) => `${Number(point.x)},${Number(point.y)}`)
.join(" ");
if (!polylinePoints) {
return null;
}
return (
<div className="pointer-events-none fixed inset-0 z-[26]" aria-hidden="true">
<svg className="fixed inset-0 size-full overflow-visible">
<polyline
points={polylinePoints}
fill="none"
className="stroke-primary/85"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
function ViewerContextMenu({
menu,
positionStyle,
onClose,
onCopyReference,
onCopyEditPoint,
onSelect,
onFocus,
onExitAllIsolate,
@@ -247,6 +758,8 @@ function ViewerContextMenu({
hidden={hidden}
actionCount={menu.actionCount}
copyReferenceDisabled={!String(menu.copyText || "").trim()}
copyEditPointDisabled={!String(menu.editPointCopyText || "").trim()}
showCopyEditPoint={String(menu.editPointCopyText || "").trim().length > 0}
selectDisabled={menu.selectDisabled === true}
showIsolate={menu.showIsolate !== false}
isolateDisabled={menu.isolateDisabled === true}
@@ -268,6 +781,7 @@ function ViewerContextMenu({
expandAllDisabled={menu.expandAllDisabled !== false}
collapseAllDisabled={menu.collapseAllDisabled !== false}
onCopyReference={() => handleAction(onCopyReference)}
onCopyEditPoint={() => handleAction(onCopyEditPoint)}
onSelect={() => handleAction(onSelect)}
onIsolate={() => handleAction(onFocus)}
onExitAllIsolate={() => handleAction(onExitAllIsolate)}
@@ -345,9 +859,38 @@ export default function CadRenderPane({
handleModelReferenceActivate,
handleModelReferenceDoubleActivate,
handleModelReferenceContext,
editPointPickEnabled = false,
editPointPickAvailable = false,
onEditPointPickEnabledChange,
onEditPointPick,
activeEditToolId = "",
editToolPickKind = "surface_point",
editToolPrompt = "",
editToolInstruction = "",
editToolPicks = EMPTY_LIST,
editToolHoverPick = null,
editToolParameters = {},
editToolSelectionReady = false,
editToolCopyDisabled = false,
editToolSubmitLabel = "复制请求",
onEditToolSelect,
onEditToolPick,
onEditToolHover,
onEditToolParameterChange,
onEditToolCopyRequest,
onEditToolCancel,
aiSelectionMode = AI_SELECTION_MODE.NONE,
aiSelectionAvailable = false,
aiSelection = null,
aiSelectionDraft = null,
aiSelectionReferenceIds = EMPTY_LIST,
onAiSelectionModeChange,
onAiSelectionDraftChange,
onAiSelectionComplete,
viewerContextMenu = null,
onViewerContextMenuClose,
onViewerContextMenuCopyReference,
onViewerContextMenuCopyEditPoint,
onViewerContextMenuSelect,
onViewerContextMenuFocus,
onViewerContextMenuExitAllIsolate,
@@ -415,6 +958,14 @@ export default function CadRenderPane({
left: `calc(${Math.max(Number(viewportFrameInsets?.left) || 0, 0)}px + 0.75rem)`,
top: `calc(${Math.max(Number(viewportFrameInsets?.top) || 0, 0)}px + 0.75rem)`
};
const editPointControlStyle = {
left: `calc(${Math.max(Number(viewportFrameInsets?.left) || 0, 0)}px + 0.75rem)`,
top: `calc(${Math.max(Number(viewportFrameInsets?.top) || 0, 0)}px + 0.75rem)`
};
const editToolPanelStyle = {
left: `calc(${Math.max(Number(viewportFrameInsets?.left) || 0, 0)}px + 0.75rem)`,
top: `calc(${Math.max(Number(viewportFrameInsets?.top) || 0, 0)}px + 4rem)`
};
const ctaMode = !dxfMode && !pathPreviewMode && drawToolActive
? "screenshot"
: !dxfMode && !pathPreviewMode && selectionCount > 0
@@ -466,6 +1017,15 @@ export default function CadRenderPane({
() => viewerContextMenuAnchorStyle(viewerContextMenu, viewportFrameInsets),
[viewerContextMenu, viewportFrameInsets]
);
const viewerSelectedReferenceIds = useMemo(() => {
const selected = Array.isArray(selectedReferenceIds) ? selectedReferenceIds : [];
const aiSelected = Array.isArray(aiSelectionReferenceIds) ? aiSelectionReferenceIds : [];
return [...new Set(
[...selected, ...aiSelected]
.map((id) => String(id || "").trim())
.filter(Boolean)
)];
}, [aiSelectionReferenceIds, selectedReferenceIds]);
useEffect(() => {
if (!urdfPosePickerActive || typeof window === "undefined" || typeof document === "undefined") {
@@ -571,7 +1131,7 @@ export default function CadRenderPane({
selectedMateIds={dxfMode || pathPreviewMode ? [] : selectedMateIds}
hoveredMateId={dxfMode || pathPreviewMode ? "" : hoveredMateId}
hoveredReferenceId={dxfMode || pathPreviewMode ? "" : hoveredReferenceId}
selectedReferenceIds={dxfMode || pathPreviewMode ? [] : selectedReferenceIds}
selectedReferenceIds={dxfMode || pathPreviewMode ? [] : viewerSelectedReferenceIds}
selectorRuntime={dxfMode || pathPreviewMode ? null : selectorRuntime}
displayEdgeRuntime={dxfMode || pathPreviewMode ? null : displayEdgeRuntime}
stepParameters={dxfMode || pathPreviewMode ? null : resolvedStepParameters}
@@ -580,7 +1140,7 @@ export default function CadRenderPane({
pickableVertices={dxfMode || pathPreviewMode ? [] : pickableVertices}
focusedPartId={dxfMode || pathPreviewMode ? "" : focusedPartIds}
boundsAnimationActive={cadViewerBoundsAnimationActive}
drawingEnabled={!dxfMode && !pathPreviewMode && drawToolActive}
drawingEnabled={!dxfMode && !pathPreviewMode && drawToolActive && !aiSelectionMode}
drawingTool={drawingTool}
drawingStrokes={dxfMode || pathPreviewMode ? [] : drawingStrokes}
onDrawingStrokesChange={handleDrawingStrokesChange}
@@ -589,6 +1149,18 @@ export default function CadRenderPane({
onActivateReference={handleModelReferenceActivate}
onDoubleActivateReference={handleModelReferenceDoubleActivate}
onContextReference={handleModelReferenceContext}
editPointPickEnabled={!dxfMode && !pathPreviewMode && editPointPickEnabled}
onEditPointPick={onEditPointPick}
editToolPickKind={editToolPickKind}
activeEditToolId={activeEditToolId}
editToolPicks={editToolPicks}
editToolHoverPick={editToolHoverPick}
editToolParameters={editToolParameters}
onEditToolPick={onEditToolPick}
onEditToolHover={onEditToolHover}
aiSelectionMode={!dxfMode && !pathPreviewMode && !urdfMode ? aiSelectionMode : AI_SELECTION_MODE.NONE}
onAiSelectionDraftChange={onAiSelectionDraftChange}
onAiSelectionComplete={onAiSelectionComplete}
onViewerAlertChange={handleViewerAlertChange}
onStepModuleTransformDetectedChange={handleStepModuleTransformDetectedChange}
urdfPosePicker={urdfPosePicker}
@@ -600,6 +1172,7 @@ export default function CadRenderPane({
positionStyle={viewerContextMenuStyle}
onClose={onViewerContextMenuClose}
onCopyReference={onViewerContextMenuCopyReference}
onCopyEditPoint={onViewerContextMenuCopyEditPoint}
onSelect={onViewerContextMenuSelect}
onFocus={onViewerContextMenuFocus}
onExitAllIsolate={onViewerContextMenuExitAllIsolate}
@@ -615,6 +1188,11 @@ export default function CadRenderPane({
onCollapseAll={onViewerContextMenuCollapseAll}
/>
) : null}
{!previewMode && !dxfMode && !pathPreviewMode && !urdfMode ? (
<AiSelectionOverlay
draft={aiSelectionDraft}
/>
) : null}
{!previewMode && missingFileLabel ? (
<div
className="pointer-events-none absolute z-30 flex min-w-0 items-center justify-center px-4 py-4"
@@ -714,6 +1292,37 @@ export default function CadRenderPane({
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
</Button>
) : null}
{!previewMode && !dxfMode && !pathPreviewMode && !urdfMode ? (
<EditToolsControl
activeToolId={activeEditToolId}
available={editPointPickAvailable}
disabled={stepUpdateInProgress || topologySelectionPending || topologySelectionUnavailable || topologySelectionDeferred}
aiSelectionMode={aiSelectionMode}
aiSelectionAvailable={aiSelectionAvailable}
onAiSelectionModeChange={onAiSelectionModeChange}
style={editPointControlStyle}
onSelectTool={(toolId) => {
if (onEditToolSelect) {
onEditToolSelect(toolId);
} else {
onEditPointPickEnabledChange?.(!editPointPickEnabled);
}
}}
/>
) : null}
{!previewMode && !dxfMode && !pathPreviewMode && !urdfMode ? (
<EditToolParameterPanel
activeToolId={activeEditToolId}
parameters={editToolParameters}
selectionReady={editToolSelectionReady}
copyDisabled={editToolCopyDisabled}
submitLabel={editToolSubmitLabel}
style={editToolPanelStyle}
onParameterChange={onEditToolParameterChange}
onCopyRequest={onEditToolCopyRequest}
onCancel={onEditToolCancel}
/>
) : null}
{!previewMode && urdfPosePickerActive ? (
<div className="pointer-events-none absolute z-20 flex justify-center px-4" style={modelViewportBottomOverlayStyle}>
<Alert
@@ -724,7 +1333,20 @@ export default function CadRenderPane({
</Alert>
</div>
) : null}
{!previewMode && ctaMode && !stepUpdateInProgress && !topologySelectionPending && !topologySelectionUnavailable && !topologySelectionDeferred ? (
{!previewMode && !dxfMode && !pathPreviewMode && !urdfMode && activeEditToolId && !urdfPosePickerActive ? (
<div className="pointer-events-none absolute z-20 flex justify-center px-4" style={modelViewportBottomOverlayStyle}>
<Alert
role="status"
className="cad-glass-popover w-auto max-w-[min(34rem,calc(100%-2rem))] px-3 py-2 text-popover-foreground shadow-sm"
>
<div className="text-[11px] font-semibold">{editToolPrompt || "点击 STEP 几何以准备编辑请求"}</div>
{editToolInstruction ? (
<div className="mt-0.5 text-[10px] leading-4 text-muted-foreground">{editToolInstruction}</div>
) : null}
</Alert>
</div>
) : null}
{!previewMode && ctaMode && !activeEditToolId && !editPointPickEnabled && !stepUpdateInProgress && !topologySelectionPending && !topologySelectionUnavailable && !topologySelectionDeferred ? (
<div
className="pointer-events-none absolute z-20 flex min-w-0 justify-center"
style={ctaOverlayStyle}
@@ -1,4 +1,3 @@
import { useState } from "react";
import {
Crosshair,
Focus,
@@ -13,16 +12,44 @@ import {
isMeshRenderFormat,
isRobotRenderFormat
} from "cadjs/lib/fileFormats";
import {
CAMERA_PROJECTION,
normalizeCameraProjection
} from "cadjs/lib/perspective";
import { TooltipProvider } from "../ui/tooltip";
import DrawingToolbar from "./DrawingToolbar";
import { ToolbarButton } from "./ToolbarButton";
import { CAD_WORKSPACE_TOOLBAR_DESKTOP_WIDTH_CLASS } from "./ToolbarShell";
import { DisplayProjectionControl } from "../viewer/DisplayProjectionControl";
import { DISPLAY_MODE_OPTIONS } from "../viewer/DisplayModeOptions";
import {
OrthographicProjectionIcon,
PerspectiveProjectionIcon
} from "../viewer/ProjectionModeIcons";
const FLOATING_TOOL_BAR_SURFACE_CLASS =
"cad-glass-surface border border-sidebar-border text-sidebar-foreground shadow-sm";
const FLOATING_TOOL_BAR_BUTTON_CLASSES =
"grid size-6 shrink-0 place-items-center rounded-sm text-sidebar-foreground/70 shadow-none transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground";
"cad-glass-surface border border-sidebar-border text-sidebar-foreground shadow-sm shadow-black/5";
const PROJECTION_TOOLBAR_OPTIONS = Object.freeze([
Object.freeze({
value: CAMERA_PROJECTION.ORTHOGRAPHIC,
label: "Orthographic",
Icon: OrthographicProjectionIcon
}),
Object.freeze({
value: CAMERA_PROJECTION.PERSPECTIVE,
label: "Perspective",
Icon: PerspectiveProjectionIcon
})
]);
function ToolbarDivider() {
return (
<span
className="mx-0.5 hidden h-5 w-px shrink-0 bg-sidebar-border/80 sm:block"
aria-hidden="true"
/>
);
}
function DesktopFloatingToolBar({
renderFormat,
@@ -60,7 +87,6 @@ function DesktopFloatingToolBar({
handleEnterPreviewMode,
handleScreenshotCopy
}) {
const [displayMenuOpen, setDisplayMenuOpen] = useState(false);
const dxfMode = renderFormat === RENDER_FORMAT.DXF;
const implicitMode = renderFormat === RENDER_FORMAT.IMPLICIT;
const urdfMode = renderFormat === RENDER_FORMAT.URDF;
@@ -81,6 +107,7 @@ function DesktopFloatingToolBar({
const displayControlAvailable = renderFormat === RENDER_FORMAT.STEP &&
typeof onDisplayModeChange === "function" &&
typeof onProjectionChange === "function";
const normalizedProjection = normalizeCameraProjection(projection);
return (
<div
@@ -88,7 +115,7 @@ function DesktopFloatingToolBar({
style={floatingCadToolbarPosition}
>
<TooltipProvider delayDuration={250}>
<div className={`pointer-events-auto inline-flex h-8 w-fit items-center gap-0.5 self-end rounded-md p-1 ${FLOATING_TOOL_BAR_SURFACE_CLASS}`}>
<div className={`pointer-events-auto inline-flex min-h-10 w-fit max-w-[min(32rem,calc(100vw-2rem))] flex-wrap items-center justify-end gap-1 self-end rounded-md p-1.5 ${FLOATING_TOOL_BAR_SURFACE_CLASS}`}>
{!dxfMode && !implicitMode && !robotMode && !meshOnlyMode ? (
<>
<ToolbarButton
@@ -98,7 +125,7 @@ function DesktopFloatingToolBar({
disabled={selectDisabled}
aria-pressed={referenceSelectionDeferred ? false : selectionToolActive}
>
<MousePointer2 className="size-3" strokeWidth={2} aria-hidden="true" />
<MousePointer2 className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
@@ -108,22 +135,45 @@ function DesktopFloatingToolBar({
disabled={viewerLoading || !selectedMeshData}
aria-pressed={drawToolActive}
>
<PenTool className="size-3" strokeWidth={2} aria-hidden="true" />
<PenTool className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
{displayControlAvailable ? (
<DisplayProjectionControl
displayMode={displayMode}
onDisplayModeChange={onDisplayModeChange}
projection={projection}
onProjectionChange={onProjectionChange}
open={displayMenuOpen}
onOpenChange={setDisplayMenuOpen}
triggerClassName={FLOATING_TOOL_BAR_BUTTON_CLASSES}
contentAlign="end"
contentSide="bottom"
contentSideOffset={6}
/>
<>
<ToolbarDivider />
{DISPLAY_MODE_OPTIONS.map(({ value, label, title, Icon }) => {
const active = displayMode === value;
return (
<ToolbarButton
key={value}
label={title || label}
active={active}
onClick={() => onDisplayModeChange(value)}
disabled={viewerLoading || !selectedMeshData}
aria-pressed={active}
>
<Icon className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
);
})}
<ToolbarDivider />
{PROJECTION_TOOLBAR_OPTIONS.map(({ value, label, Icon }) => {
const active = normalizedProjection === value;
return (
<ToolbarButton
key={value}
label={label}
active={active}
onClick={() => onProjectionChange(value)}
disabled={viewerLoading || !selectedMeshData}
aria-pressed={active}
>
<Icon className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
);
})}
</>
) : null}
{showStepAnimationPlay ? (
@@ -135,9 +185,9 @@ function DesktopFloatingToolBar({
aria-pressed={stepAnimationPlaying}
>
{stepAnimationPlaying ? (
<Pause className="size-3" strokeWidth={2} aria-hidden="true" />
<Pause className="size-3.5" strokeWidth={2} aria-hidden="true" />
) : (
<Play className="size-3" strokeWidth={2} aria-hidden="true" />
<Play className="size-3.5" strokeWidth={2} aria-hidden="true" />
)}
</ToolbarButton>
) : null}
@@ -152,7 +202,7 @@ function DesktopFloatingToolBar({
disabled={posePickerDisabled}
aria-pressed={urdfPosePickerActive}
>
<Crosshair className="size-3" strokeWidth={2} aria-hidden="true" />
<Crosshair className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
) : null}
@@ -162,7 +212,7 @@ function DesktopFloatingToolBar({
onClick={handleEnterPreviewMode}
disabled={viewerLoading || !renderReady}
>
<Orbit className="size-3" strokeWidth={2} aria-hidden="true" />
<Orbit className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
) : null}
@@ -173,7 +223,7 @@ function DesktopFloatingToolBar({
}}
disabled={captureDisabled}
>
<Focus className="size-3" strokeWidth={2} aria-hidden="true" />
<Focus className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
</div>
</TooltipProvider>
@@ -22,7 +22,7 @@ export function ToolbarButton({
variant={active ? "secondary" : "ghost"}
size="icon-xs"
className={cn(
"size-6 rounded-sm text-sidebar-foreground/70 shadow-none transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/45 data-[variant=secondary]:bg-sidebar-accent data-[variant=secondary]:text-sidebar-accent-foreground data-[variant=secondary]:hover:bg-sidebar-accent",
"size-7 rounded-md border border-transparent text-sidebar-foreground/72 shadow-none transition duration-150 hover:border-sidebar-border/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring/45 data-[variant=secondary]:border-sidebar-border data-[variant=secondary]:bg-sidebar-accent data-[variant=secondary]:text-sidebar-accent-foreground data-[variant=secondary]:shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--sidebar-foreground)_8%,transparent)] data-[variant=secondary]:hover:bg-sidebar-accent",
className
)}
aria-label={label}
@@ -0,0 +1,73 @@
const AGENT_BRIDGE_QUERY_PARAM = "agentBridge";
const AGENT_BRIDGE_ORIGIN_QUERY_PARAM = "agentBridgeOrigin";
function currentUrl() {
if (typeof window === "undefined") {
return null;
}
try {
return new URL(window.location.href);
} catch {
return null;
}
}
export function isAgentBridgeEnabled() {
const url = currentUrl();
return url?.searchParams.get(AGENT_BRIDGE_QUERY_PARAM) === "1";
}
export function agentBridgeTargetOrigin() {
const url = currentUrl();
const configured = String(url?.searchParams.get(AGENT_BRIDGE_ORIGIN_QUERY_PARAM) || "").trim();
if (!configured) {
return "";
}
try {
return new URL(configured).origin;
} catch {
return "";
}
}
export function postAgentBridgeEvent(type, payload = {}) {
if (!isAgentBridgeEnabled() || typeof window === "undefined" || !window.parent || window.parent === window) {
return false;
}
const targetOrigin = agentBridgeTargetOrigin();
if (!targetOrigin) {
return false;
}
window.parent.postMessage({
type,
payload,
source: "cad-viewer",
sentAt: new Date().toISOString()
}, targetOrigin);
return true;
}
export function viewerBridgeState({ entry, activeDirectory, selectedKey, viewerUrl = "" } = {}) {
const source = entry?.source && typeof entry.source === "object" ? entry.source : {};
return {
entryFile: String(entry?.file || entry?.path || ""),
sourcePath: String(source.sourcePath || source.path || source.file || ""),
stepPath: String(entry?.stepPath || entry?.file || entry?.path || ""),
selectedKey: String(selectedKey || ""),
activeDirectory: String(activeDirectory || ""),
viewerUrl: String(viewerUrl || (typeof window !== "undefined" ? window.location.href : ""))
};
}
export function bridgePayloadFromIntent(intent, viewerState) {
const source = intent?.source && typeof intent.source === "object" ? intent.source : {};
return {
...intent,
entryFile: String(source.entryFile || viewerState?.entryFile || ""),
sourcePath: String(source.sourcePath || viewerState?.sourcePath || ""),
stepPath: String(source.stepPath || viewerState?.stepPath || ""),
selection: intent?.selection || null,
parameters: intent?.parameters || {},
viewerState: viewerState || null
};
}
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
bridgePayloadFromIntent,
isAgentBridgeEnabled,
postAgentBridgeEvent,
viewerBridgeState
} from "./agentBridge.js";
test("agent bridge is disabled without a browser window", () => {
assert.equal(isAgentBridgeEnabled(), false);
assert.equal(postAgentBridgeEvent("cadViewer.ready", {}), false);
});
test("bridgePayloadFromIntent exposes top-level CAD fields for agent clients", () => {
const payload = bridgePayloadFromIntent({
schema: "cad-edit-intent.v1",
source: {
entryFile: "part.step",
sourcePath: "part.py",
stepPath: "/tmp/part.step"
},
selection: { kind: "surface_point" },
parameters: { holeDiameter: 4 }
}, viewerBridgeState({
entry: { file: "part.step", source: { path: "part.py" } },
activeDirectory: "/tmp",
selectedKey: "part.step"
}));
assert.equal(payload.schema, "cad-edit-intent.v1");
assert.equal(payload.entryFile, "part.step");
assert.equal(payload.sourcePath, "part.py");
assert.deepEqual(payload.parameters, { holeDiameter: 4 });
assert.equal(payload.viewerState.activeDirectory, "/tmp");
});
@@ -0,0 +1,362 @@
function normalizeString(value) {
return String(value || "").trim();
}
function finiteNumber(value) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
function finiteIntegerOrNull(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const numericValue = Number(value);
return Number.isInteger(numericValue) ? numericValue : null;
}
function normalizeVector3(value) {
if (!Array.isArray(value) || value.length < 3) {
return null;
}
const vector = value.slice(0, 3).map(finiteNumber);
return vector.every((component) => component !== null) ? vector : null;
}
function normalizeArray(value) {
return Array.isArray(value) ? value : [];
}
function normalizeSurface(surface) {
const source = surface && typeof surface === "object" ? surface : {};
return {
type: normalizeString(source.type || source.surfaceType),
origin: normalizeVector3(source.origin),
xDir: normalizeVector3(source.xDir),
yDir: normalizeVector3(source.yDir),
normal: normalizeVector3(source.normal),
axis: normalizeVector3(source.axis),
radius: finiteNumber(source.radius)
};
}
function normalizeSelection(selection) {
const source = selection && typeof selection === "object" ? selection : {};
const kind = normalizeString(source.kind) || "surface_point";
if (kind === "surface_segment") {
return {
kind,
referenceId: normalizeString(source.referenceId),
selector: normalizeString(source.selector || source.normalizedSelector),
partId: normalizeString(source.partId),
entityType: normalizeString(source.entityType),
selectorType: normalizeString(source.selectorType),
start: normalizeSelection(source.start),
end: normalizeSelection(source.end),
normal: normalizeVector3(source.normal),
surface: normalizeSurface(source.surface)
};
}
if (kind === "edge") {
return {
kind,
referenceId: normalizeString(source.referenceId),
selector: normalizeString(source.selector || source.normalizedSelector),
partId: normalizeString(source.partId),
entityType: normalizeString(source.entityType),
selectorType: normalizeString(source.selectorType),
rowIndex: finiteIntegerOrNull(source.rowIndex),
curve: source.curve && typeof source.curve === "object" ? source.curve : null,
adjacentFaces: normalizeArray(source.adjacentFaces)
};
}
return {
kind: "surface_point",
referenceId: normalizeString(source.referenceId),
selector: normalizeString(source.selector || source.normalizedSelector),
partId: normalizeString(source.partId),
entityType: normalizeString(source.entityType),
selectorType: normalizeString(source.selectorType),
faceIndex: finiteIntegerOrNull(source.faceIndex),
rowIndex: finiteIntegerOrNull(source.rowIndex),
pointModel: normalizeVector3(source.pointModel),
pointWorld: normalizeVector3(source.pointWorld),
normal: normalizeVector3(source.normal),
surface: normalizeSurface(source.surface)
};
}
function sourceObject(entry) {
return entry?.source && typeof entry.source === "object" ? entry.source : {};
}
function inferSourceBackend(entry) {
const source = sourceObject(entry);
const explicitBackend = normalizeString(source.backend || entry?.backend || entry?.route?.selected_backend);
if (explicitBackend) {
return explicitBackend;
}
const sourcePath = normalizeString(source.sourcePath || source.path || source.file);
if (sourcePath.endsWith(".json")) {
return "simplecadapi";
}
if (sourcePath.endsWith(".py")) {
return "build123d";
}
if (sourcePath.endsWith(".scad")) {
return "cadam";
}
return "unknown";
}
function joinActiveDir(activeDir, fileRef) {
const dir = normalizeString(activeDir);
const file = normalizeString(fileRef);
if (!file || file.startsWith("/") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(file)) {
return file;
}
return dir ? `${dir.replace(/\/+$/, "")}/${file.replace(/^\/+/, "")}` : file;
}
export const CAD_EDIT_OPERATION_DEFAULT_PARAMETERS = Object.freeze({
add_hole: Object.freeze({
holeDiameter: 2,
depth: "through",
direction: "face_normal"
}),
add_counterbore: Object.freeze({
holeDiameter: 3,
counterboreDiameter: 6,
counterboreDepth: 2,
depth: "through",
direction: "face_normal"
}),
add_countersink: Object.freeze({
holeDiameter: 3,
countersinkDiameter: 6,
countersinkAngleDeg: 90,
depth: "through",
direction: "face_normal"
}),
add_threaded_hole: Object.freeze({
threadSpec: "M4",
threadDepth: 6,
drillDiameter: "auto",
depth: "blind",
direction: "face_normal"
}),
add_slot: Object.freeze({
slotWidth: 2,
depth: "through",
direction: "face_normal"
}),
add_pocket: Object.freeze({
shape: "rectangle",
width: 10,
height: 6,
depth: 1,
direction: "face_normal",
orientation: "surface_xy"
}),
add_circular_pocket: Object.freeze({
diameter: 8,
depth: 1,
direction: "face_normal"
}),
add_hole_pattern: Object.freeze({
holeDiameter: 2,
rows: 2,
columns: 2,
pitchX: 8,
pitchY: 8,
depth: "through",
direction: "face_normal",
orientation: "surface_xy"
}),
add_chamfer: Object.freeze({
distance: 1
}),
add_fillet: Object.freeze({
radius: 1
})
});
export function normalizeCadEditOperation(operation) {
const normalized = normalizeString(operation);
return CAD_EDIT_OPERATION_DEFAULT_PARAMETERS[normalized] ? normalized : "add_hole";
}
export function defaultCadEditParameters(operation) {
return {
...CAD_EDIT_OPERATION_DEFAULT_PARAMETERS[normalizeCadEditOperation(operation)]
};
}
export function buildCadEditIntent({
payload,
selection,
operation = "add_hole",
parameters,
entry,
activeDir = ""
} = {}) {
const normalizedOperation = normalizeCadEditOperation(operation);
const source = sourceObject(entry);
const rawSelection = selection || payload;
const entryFile = normalizeString(entry?.file || entry?.path || payload?.entryFile || selection?.entryFile);
const sourcePath = normalizeString(source.sourcePath || source.path || source.file);
const stepPath = joinActiveDir(activeDir, normalizeString(entry?.stepPath || entryFile));
return {
schema: "cad-edit-intent.v1",
operation: normalizedOperation,
source: {
entryFile,
stepPath,
sourcePath,
backend: inferSourceBackend(entry),
units: normalizeString(entry?.units || source.units) || "mm"
},
selection: normalizeSelection(rawSelection),
parameters: {
...defaultCadEditParameters(normalizedOperation),
...(parameters && typeof parameters === "object" ? parameters : {})
}
};
}
export function buildCadEditIntentText({
payload,
selection,
operation = "add_hole",
parameters,
entry,
activeDir = ""
} = {}) {
const intent = buildCadEditIntent({ payload, selection, operation, parameters, entry, activeDir });
return [
"使用 cad-router 修改 CAD 模型:根据 viewer 选中的几何位置执行 CAD 编辑操作。",
"",
"请优先修改可编辑源文件,不要直接编辑 STEP。完成后重新生成 STEP、mesh/topology viewer 资产。",
"",
"```json",
JSON.stringify(intent, null, 2),
"```"
].join("\n");
}
function normalizeScreenPoint(point) {
const source = point && typeof point === "object" ? point : {};
const x = finiteNumber(source.x ?? source.clientX);
const y = finiteNumber(source.y ?? source.clientY);
return x === null || y === null ? null : { x, y };
}
function normalizeScreenPointList(points) {
return normalizeArray(points)
.map(normalizeScreenPoint)
.filter(Boolean);
}
function normalizeSelectionEntity(entity) {
const source = entity && typeof entity === "object" ? entity : {};
return {
referenceId: normalizeString(source.referenceId || source.id),
selector: normalizeString(source.selector || source.normalizedSelector),
normalizedSelector: normalizeString(source.normalizedSelector || source.selector),
partId: normalizeString(source.partId),
entityType: normalizeString(source.entityType),
selectorType: normalizeString(source.selectorType),
rowIndex: finiteIntegerOrNull(source.rowIndex),
center: normalizeVector3(source.center),
normal: normalizeVector3(source.normal),
surfaceType: normalizeString(source.surfaceType || source.surface?.type),
bbox: source.bbox && typeof source.bbox === "object" ? source.bbox : null,
screenPoint: normalizeScreenPoint(source.screenPoint),
adjacentSelectors: normalizeArray(source.adjacentSelectors).map(normalizeString).filter(Boolean)
};
}
function normalizeReferenceIds(value) {
const seen = new Set();
const result = [];
for (const candidate of normalizeArray(value)) {
const referenceId = normalizeString(candidate);
if (!referenceId || seen.has(referenceId)) {
continue;
}
seen.add(referenceId);
result.push(referenceId);
}
return result;
}
function normalizeAiGeometrySelection(selection) {
const source = selection && typeof selection === "object" ? selection : {};
const kind = normalizeString(source.kind) || "point_pick";
if (kind === "region_pick") {
const entities = normalizeArray(source.entities).map(normalizeSelectionEntity);
return {
kind,
selectionMode: normalizeString(source.selectionMode || "lasso"),
stroke2d: normalizeScreenPointList(source.stroke2d),
bbox2d: source.bbox2d && typeof source.bbox2d === "object" ? source.bbox2d : null,
referenceIds: normalizeReferenceIds(source.referenceIds),
entities,
entityCount: finiteIntegerOrNull(source.entityCount) ?? entities.length,
view: source.view && typeof source.view === "object" ? source.view : null
};
}
const entities = normalizeArray(source.entities).map(normalizeSelectionEntity);
return {
kind: "point_pick",
selectionMode: normalizeString(source.selectionMode || "point"),
multiSelect: Boolean(source.multiSelect),
pick: source.pick ? normalizeSelection(source.pick) : normalizeSelection(source),
screenPoint: normalizeScreenPoint(source.pick || source),
referenceIds: normalizeReferenceIds(source.referenceIds),
entities,
entityCount: finiteIntegerOrNull(source.entityCount) ?? entities.length,
view: source.view && typeof source.view === "object" ? source.view : null
};
}
export function buildAiGeometrySelectionIntent({
selection,
entry,
activeDir = ""
} = {}) {
const source = sourceObject(entry);
const entryFile = normalizeString(entry?.file || entry?.path || selection?.entryFile);
const sourcePath = normalizeString(source.sourcePath || source.path || source.file);
const stepPath = joinActiveDir(activeDir, normalizeString(entry?.stepPath || entryFile));
return {
schema: "cad-ai-geometry-selection.v1",
intent: "modify_selected_geometry",
source: {
entryFile,
stepPath,
sourcePath,
backend: inferSourceBackend(entry),
units: normalizeString(entry?.units || source.units) || "mm"
},
selection: normalizeAiGeometrySelection(selection),
instruction: "用户通过 CAD Viewer 指定了要修改的几何区域。请优先根据 selection 中的 referenceId、selector、点位、圈选线和当前视角理解位置,再修改可编辑源文件并重新生成 STEP 与 viewer 资产。"
};
}
export function buildAiGeometrySelectionIntentText({
selection,
entry,
activeDir = ""
} = {}) {
const intent = buildAiGeometrySelectionIntent({ selection, entry, activeDir });
return [
"使用 cad-router 修改 CAD 模型:用户已经在 viewer 中点选/圈选了需要修改的位置。",
"",
"请根据 selection 里的几何引用、选中点、圈选线坐标和相机视角判断要修改的区域。优先修改可编辑源文件,不要直接编辑 STEP;完成后重新生成 STEP、mesh/topology viewer 资产。",
"",
"```json",
JSON.stringify(intent, null, 2),
"```"
].join("\n");
}
@@ -0,0 +1,200 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
buildCadEditIntent,
buildCadEditIntentText
} from "./cadEditIntent.js";
import {
buildSurfaceSegmentSelection,
defaultCadEditToolParameters
} from "./cadEditTools.js";
test("cad edit intent captures source and surface point selection", () => {
const intent = buildCadEditIntent({
activeDir: "/tmp/task",
entry: {
file: "parts/bracket.step",
source: {
sourcePath: "/tmp/task/parts/part.py"
}
},
payload: {
referenceId: "topology|part-a|face|o1.f3",
selector: "o1.f3",
partId: "part-a",
entityType: "ADVANCED_FACE",
selectorType: "face",
faceIndex: 12,
rowIndex: 3,
pointModel: [12.4, 8.2, 25],
pointWorld: [12.4, 8.2, 25],
normal: [0, 0, 1],
surface: {
type: "PLANE",
origin: [0, 0, 25],
xDir: [1, 0, 0],
yDir: [0, 1, 0],
normal: [0, 0, 1]
}
}
});
assert.equal(intent.schema, "cad-edit-intent.v1");
assert.equal(intent.operation, "add_hole");
assert.equal(intent.source.backend, "build123d");
assert.equal(intent.source.stepPath, "/tmp/task/parts/bracket.step");
assert.equal(intent.selection.selector, "o1.f3");
assert.deepEqual(intent.selection.pointModel, [12.4, 8.2, 25]);
assert.equal(intent.parameters.holeDiameter, 2);
assert.equal(intent.parameters.depth, "through");
});
test("cad edit intent text keeps stable JSON when editable source is unknown", () => {
const text = buildCadEditIntentText({
entry: {
file: "loose.step"
},
payload: {
referenceId: "topology|part-a|face|f1",
selector: "f1",
pointModel: [1, 2, 3],
pointWorld: [1, 2, 3]
}
});
assert.match(text, /使用 cad-router 修改 CAD 模型/);
assert.match(text, /"schema": "cad-edit-intent\.v1"/);
assert.match(text, /"sourcePath": ""/);
assert.match(text, /"backend": "unknown"/);
assert.match(text, /"pointModel": \[\n 1,\n 2,\n 3\n \]/);
});
test("cad edit intent supports all V1 edit operations with default parameters", () => {
const operationDefaults = {
add_hole: { holeDiameter: 2 },
add_counterbore: { holeDiameter: 3, counterboreDiameter: 6, counterboreDepth: 2 },
add_countersink: { holeDiameter: 3, countersinkDiameter: 6, countersinkAngleDeg: 90 },
add_threaded_hole: { threadSpec: "M4", threadDepth: 6, drillDiameter: "auto", depth: "blind" },
add_slot: { slotWidth: 2 },
add_pocket: { width: 10, height: 6, depth: 1 },
add_circular_pocket: { diameter: 8, depth: 1 },
add_hole_pattern: { holeDiameter: 2, rows: 2, columns: 2, pitchX: 8, pitchY: 8 },
add_chamfer: { distance: 1 },
add_fillet: { radius: 1 }
};
for (const [operation, expectedDefaults] of Object.entries(operationDefaults)) {
const selection = operation === "add_chamfer" || operation === "add_fillet"
? {
kind: "edge",
referenceId: "topology|part-a|edge|e12",
selector: "e12",
selectorType: "edge",
rowIndex: 12
}
: {
kind: "surface_point",
referenceId: "topology|part-a|face|f1",
selector: "f1",
selectorType: "face",
pointModel: [1, 2, 3],
pointWorld: [1, 2, 3],
surface: { type: "PLANE" }
};
const intent = buildCadEditIntent({
operation,
selection,
entry: { file: "part.step" }
});
assert.equal(intent.operation, operation);
if (selection.kind === "surface_point") {
assert.equal(intent.selection.kind, "surface_point");
}
assert.deepEqual(
Object.fromEntries(Object.keys(expectedDefaults).map((key) => [key, intent.parameters[key]])),
expectedDefaults
);
}
});
test("cad edit intent accepts explicit operation parameters", () => {
const intent = buildCadEditIntent({
operation: "add_fillet",
parameters: {
radius: 2.5
},
selection: {
kind: "edge",
referenceId: "e3",
selector: "e3"
},
entry: {
file: "part.step"
}
});
assert.equal(intent.operation, "add_fillet");
assert.equal(intent.selection.kind, "edge");
assert.equal(intent.parameters.radius, 2.5);
});
test("cad edit intent preserves string parameters for threaded holes", () => {
const intent = buildCadEditIntent({
operation: "add_threaded_hole",
parameters: {
threadSpec: "M6",
drillDiameter: "auto"
},
selection: {
kind: "surface_point",
referenceId: "f1",
selector: "f1",
pointModel: [1, 2, 3],
pointWorld: [1, 2, 3]
},
entry: {
file: "part.step"
}
});
assert.equal(intent.operation, "add_threaded_hole");
assert.equal(intent.selection.kind, "surface_point");
assert.equal(intent.parameters.threadSpec, "M6");
assert.equal(intent.parameters.drillDiameter, "auto");
assert.equal(intent.parameters.threadDepth, 6);
});
test("surface segment selection requires two points on the same planar face", () => {
const start = {
kind: "surface_point",
referenceId: "f1",
selector: "f1",
pointModel: [0, 0, 4],
pointWorld: [0, 0, 4],
normal: [0, 0, 1],
surface: { type: "PLANE" }
};
const end = {
...start,
pointModel: [8, 0, 4],
pointWorld: [8, 0, 4]
};
const result = buildSurfaceSegmentSelection(start, end);
assert.equal(result.error, "");
assert.equal(result.selection.kind, "surface_segment");
assert.equal(result.selection.referenceId, "f1");
const wrongFace = buildSurfaceSegmentSelection(start, { ...end, referenceId: "f2" });
assert.equal(wrongFace.selection, null);
assert.equal(wrongFace.error, "请在同一个平面上拾取第二个点");
});
test("default edit tool parameters are cloned", () => {
const first = defaultCadEditToolParameters("add_hole");
const second = defaultCadEditToolParameters("add_hole");
first.holeDiameter = 99;
assert.equal(second.holeDiameter, 2);
});
@@ -0,0 +1,221 @@
import { defaultCadEditParameters, normalizeCadEditOperation } from "./cadEditIntent.js";
export const CAD_EDIT_TOOL_IDS = Object.freeze({
HOLE: "add_hole",
COUNTERBORE: "add_counterbore",
COUNTERSINK: "add_countersink",
SLOT: "add_slot",
POCKET: "add_pocket",
CIRCULAR_POCKET: "add_circular_pocket",
HOLE_PATTERN: "add_hole_pattern",
CHAMFER: "add_chamfer",
FILLET: "add_fillet"
});
export const CAD_EDIT_TOOLS = Object.freeze([
Object.freeze({
id: CAD_EDIT_TOOL_IDS.HOLE,
label: "打孔",
pickKinds: Object.freeze(["surface_point"]),
prompt: "打孔:点击一个表面点",
parameterFields: Object.freeze([
Object.freeze({ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "depth", label: "深度", type: "select", options: Object.freeze(["through", "blind"]) })
])
}),
Object.freeze({
id: CAD_EDIT_TOOL_IDS.COUNTERBORE,
label: "沉孔",
pickKinds: Object.freeze(["surface_point"]),
prompt: "沉孔:点击一个表面点",
parameterFields: Object.freeze([
Object.freeze({ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "counterboreDiameter", label: "沉孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "counterboreDepth", label: "沉孔深度", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "depth", label: "深度", type: "select", options: Object.freeze(["through", "blind"]) })
])
}),
Object.freeze({
id: CAD_EDIT_TOOL_IDS.COUNTERSINK,
label: "锥沉孔",
pickKinds: Object.freeze(["surface_point"]),
prompt: "锥沉孔:点击一个表面点",
parameterFields: Object.freeze([
Object.freeze({ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "countersinkDiameter", label: "锥口直径", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "countersinkAngleDeg", label: "锥角", type: "number", unit: "°", min: 1, step: 1 }),
Object.freeze({ name: "depth", label: "深度", type: "select", options: Object.freeze(["through", "blind"]) })
])
}),
Object.freeze({
id: CAD_EDIT_TOOL_IDS.SLOT,
label: "开槽",
pickKinds: Object.freeze(["surface_point", "surface_point"]),
prompts: Object.freeze(["开槽:点击起点", "开槽:在同一平面上点击终点"]),
parameterFields: Object.freeze([
Object.freeze({ name: "slotWidth", label: "槽宽", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "depth", label: "深度", type: "select", options: Object.freeze(["through", "blind"]) })
])
}),
Object.freeze({
id: CAD_EDIT_TOOL_IDS.POCKET,
label: "铣型腔",
pickKinds: Object.freeze(["surface_point"]),
prompt: "铣型腔:点击一个平面位置",
parameterFields: Object.freeze([
Object.freeze({ name: "width", label: "宽度", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "height", label: "高度", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 })
])
}),
Object.freeze({
id: CAD_EDIT_TOOL_IDS.CIRCULAR_POCKET,
label: "圆形型腔",
pickKinds: Object.freeze(["surface_point"]),
prompt: "圆形型腔:点击一个平面位置",
parameterFields: Object.freeze([
Object.freeze({ name: "diameter", label: "直径", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 })
])
}),
Object.freeze({
id: CAD_EDIT_TOOL_IDS.HOLE_PATTERN,
label: "阵列孔",
pickKinds: Object.freeze(["surface_point"]),
prompt: "阵列孔:点击阵列中心点",
parameterFields: Object.freeze([
Object.freeze({ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "rows", label: "行数", type: "number", min: 1, step: 1 }),
Object.freeze({ name: "columns", label: "列数", type: "number", min: 1, step: 1 }),
Object.freeze({ name: "pitchX", label: "X 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "pitchY", label: "Y 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 }),
Object.freeze({ name: "depth", label: "深度", type: "select", options: Object.freeze(["through", "blind"]) })
])
}),
Object.freeze({
id: CAD_EDIT_TOOL_IDS.CHAMFER,
label: "倒角",
pickKinds: Object.freeze(["edge"]),
prompt: "倒角:点击一条边",
parameterFields: Object.freeze([
Object.freeze({ name: "distance", label: "倒角距离", type: "number", unit: "mm", min: 0.01, step: 0.1 })
])
}),
Object.freeze({
id: CAD_EDIT_TOOL_IDS.FILLET,
label: "圆角",
pickKinds: Object.freeze(["edge"]),
prompt: "圆角:点击一条边",
parameterFields: Object.freeze([
Object.freeze({ name: "radius", label: "圆角半径", type: "number", unit: "mm", min: 0.01, step: 0.1 })
])
})
]);
const CAD_EDIT_TOOL_BY_ID = new Map(CAD_EDIT_TOOLS.map((tool) => [tool.id, tool]));
export function cadEditToolForOperation(operation) {
return CAD_EDIT_TOOL_BY_ID.get(normalizeCadEditOperation(operation)) || CAD_EDIT_TOOL_BY_ID.get(CAD_EDIT_TOOL_IDS.HOLE);
}
export function defaultCadEditToolParameters(operation) {
return defaultCadEditParameters(operation);
}
export function cadEditToolPrompt(operation, pickCount = 0) {
const tool = cadEditToolForOperation(operation);
if (Array.isArray(tool.prompts)) {
return tool.prompts[Math.min(Math.max(Number(pickCount) || 0, 0), tool.prompts.length - 1)] || tool.label;
}
return tool.prompt || tool.label;
}
export function cadEditToolInstruction(operation, pickCount = 0, { selectionReady = false } = {}) {
const tool = cadEditToolForOperation(operation);
if (selectionReady) {
return `已选中${tool.label}位置。请检查参数,然后提交请求给 Agent 或 cad-router。`;
}
const normalizedPickCount = Math.max(Number(pickCount) || 0, 0);
if (tool.id === CAD_EDIT_TOOL_IDS.SLOT) {
return normalizedPickCount < 1
? "第 1/2 步:移动鼠标预览起点,点击确认;系统会保留辅助点。"
: "第 2/2 步:移动鼠标预览槽长和槽宽,在同一平面点击终点。";
}
const nextPickKind = cadEditToolNextPickKind(operation, normalizedPickCount);
if (nextPickKind === "edge") {
return `移动鼠标预选边,点击确认;选中后设置${tool.label}参数并提交请求。`;
}
if (tool.id === CAD_EDIT_TOOL_IDS.HOLE_PATTERN) {
return "移动鼠标预览阵列中心,点击确认;阵列将沿该平面的 X/Y 方向展开。";
}
if (tool.id === CAD_EDIT_TOOL_IDS.POCKET || tool.id === CAD_EDIT_TOOL_IDS.CIRCULAR_POCKET) {
return "移动鼠标预览型腔位置和尺寸,点击平面确认后设置参数。";
}
return "移动鼠标预览加工位置和尺寸,点击目标表面确认后设置参数。";
}
export function cadEditToolNextPickKind(operation, pickCount = 0) {
const tool = cadEditToolForOperation(operation);
const pickKinds = Array.isArray(tool.pickKinds) ? tool.pickKinds : [];
return pickKinds[Math.min(Math.max(Number(pickCount) || 0, 0), pickKinds.length - 1)] || "surface_point";
}
export function cadEditToolPickComplete(operation, picks) {
const tool = cadEditToolForOperation(operation);
const requiredPickCount = Array.isArray(tool.pickKinds) ? tool.pickKinds.length : 1;
return (Array.isArray(picks) ? picks : []).length >= requiredPickCount;
}
function normalizedString(value) {
return String(value || "").trim();
}
function normalizedSurfaceType(selection) {
return normalizedString(selection?.surface?.type).toUpperCase();
}
export function buildSurfaceSegmentSelection(start, end) {
const startReferenceId = normalizedString(start?.referenceId);
const endReferenceId = normalizedString(end?.referenceId);
if (!start || !end || start?.kind !== "surface_point" || end?.kind !== "surface_point") {
return { selection: null, error: "开槽需要两个表面点" };
}
if (!startReferenceId || startReferenceId !== endReferenceId) {
return { selection: null, error: "请在同一个平面上拾取第二个点" };
}
const startSurfaceType = normalizedSurfaceType(start);
const endSurfaceType = normalizedSurfaceType(end);
if (startSurfaceType && startSurfaceType !== "PLANE") {
return { selection: null, error: "开槽 V1 需要平面 face" };
}
if (endSurfaceType && endSurfaceType !== "PLANE") {
return { selection: null, error: "开槽 V1 需要平面 face" };
}
return {
error: "",
selection: {
kind: "surface_segment",
referenceId: startReferenceId,
selector: normalizedString(start.selector),
partId: normalizedString(start.partId),
entityType: normalizedString(start.entityType),
selectorType: normalizedString(start.selectorType),
start,
end,
normal: start.normal || end.normal || null,
surface: start.surface || end.surface || null
}
};
}
export function buildCadEditSelection(operation, picks) {
const normalizedOperation = normalizeCadEditOperation(operation);
const normalizedPicks = Array.isArray(picks) ? picks : [];
if (normalizedOperation === CAD_EDIT_TOOL_IDS.SLOT) {
return buildSurfaceSegmentSelection(normalizedPicks[0], normalizedPicks[1]);
}
return {
selection: normalizedPicks[0] || null,
error: normalizedPicks[0] ? "" : "没有拾取到可编辑几何"
};
}
@@ -31,3 +31,9 @@ export const DRAWING_TOOL = {
FILL: "fill",
ERASE: "erase"
};
export const AI_SELECTION_MODE = {
NONE: "",
POINT: "point",
LASSO: "lasso"
};