merge
This commit is contained in:
Generated
+22
-28
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user