feat(claw): support interactive 3D previews
This commit is contained in:
parent
6ab68e8ac9
commit
bbfece5720
604
components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx
Normal file
604
components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx
Normal file
@ -0,0 +1,604 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Environment, OrbitControls, useGLTF } from "@react-three/drei";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import React, { Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import type { Mesh, Object3D } from "three";
|
||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||
import type {
|
||||
OpenClawModelInteractionManifest,
|
||||
OpenClawModelInteractionPart,
|
||||
} from "@/lib/openclaw-chat/model-interaction";
|
||||
import {
|
||||
getOpenClawExplodedOffsetMm,
|
||||
mapOpenClawCadOffsetToGlb,
|
||||
} from "@/lib/openclaw-chat/model-interaction";
|
||||
|
||||
export type OpenClawModelTransformRequest = {
|
||||
actionId: string | null;
|
||||
amount: number;
|
||||
animate: boolean;
|
||||
durationMs: number;
|
||||
nonce: number;
|
||||
};
|
||||
|
||||
type InteractiveEntry = {
|
||||
node: Object3D;
|
||||
part: OpenClawModelInteractionPart;
|
||||
initialPosition: THREE.Vector3;
|
||||
};
|
||||
|
||||
type AnimationState = {
|
||||
startedAt: number;
|
||||
durationMs: number;
|
||||
starts: Map<string, THREE.Vector3>;
|
||||
targets: Map<string, THREE.Vector3>;
|
||||
easing: string;
|
||||
cameraStart: THREE.Vector3;
|
||||
cameraTarget: THREE.Vector3;
|
||||
controlsTargetStart: THREE.Vector3;
|
||||
controlsTargetEnd: THREE.Vector3;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
entry: InteractiveEntry;
|
||||
plane: THREE.Plane;
|
||||
startIntersection: THREE.Vector3;
|
||||
startWorldPosition: THREE.Vector3;
|
||||
startLocalPosition: THREE.Vector3;
|
||||
pointerId: number;
|
||||
};
|
||||
|
||||
class InteractiveViewerErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; fallback: React.ReactNode; resetKey: string },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidUpdate(previousProps: { resetKey: string }) {
|
||||
if (
|
||||
this.state.hasError &&
|
||||
previousProps.resetKey !== this.props.resetKey
|
||||
) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function isMesh(node: Object3D): node is Mesh {
|
||||
return (node as Mesh).isMesh === true;
|
||||
}
|
||||
|
||||
function cloneScene(scene: Object3D) {
|
||||
const clone = scene.clone(true);
|
||||
clone.traverse((node) => {
|
||||
if (!isMesh(node)) return;
|
||||
node.castShadow = true;
|
||||
node.receiveShadow = true;
|
||||
const materials = Array.isArray(node.material)
|
||||
? node.material
|
||||
: [node.material];
|
||||
const clones = materials.map((material) => material.clone());
|
||||
node.material = Array.isArray(node.material)
|
||||
? clones
|
||||
: clones[0] || node.material;
|
||||
});
|
||||
return clone;
|
||||
}
|
||||
|
||||
function getFitTransform(scene: Object3D) {
|
||||
const bounds = new THREE.Box3().setFromObject(scene);
|
||||
if (bounds.isEmpty()) {
|
||||
return {
|
||||
position: [0, 0, 0] as [number, number, number],
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
const size = bounds.getSize(new THREE.Vector3());
|
||||
const center = bounds.getCenter(new THREE.Vector3());
|
||||
const largestDimension = Math.max(size.x, size.y, size.z);
|
||||
if (!Number.isFinite(largestDimension) || largestDimension <= 0) {
|
||||
return {
|
||||
position: [0, 0, 0] as [number, number, number],
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
const scale = 2 / largestDimension;
|
||||
return {
|
||||
position: [
|
||||
-center.x * scale,
|
||||
-center.y * scale,
|
||||
-center.z * scale,
|
||||
] as [number, number, number],
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
function getEasedProgress(progress: number, easing: string) {
|
||||
if (easing.toLowerCase() === "linear") return progress;
|
||||
return progress < 0.5
|
||||
? 4 * progress * progress * progress
|
||||
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
|
||||
}
|
||||
|
||||
function getPartTargetPosition(
|
||||
entry: InteractiveEntry,
|
||||
manifest: OpenClawModelInteractionManifest,
|
||||
amount: number
|
||||
) {
|
||||
const offsetMm = getOpenClawExplodedOffsetMm(entry.part);
|
||||
if (!offsetMm) return entry.initialPosition.clone();
|
||||
const offset = mapOpenClawCadOffsetToGlb(offsetMm, manifest.scaleMmToGlb);
|
||||
return entry.initialPosition
|
||||
.clone()
|
||||
.add(new THREE.Vector3(offset.x, offset.y, offset.z).multiplyScalar(amount));
|
||||
}
|
||||
|
||||
function ModelInteractionScene({
|
||||
allowDragging,
|
||||
manifest,
|
||||
modelUrl,
|
||||
onMatchChange,
|
||||
onSelectedPartChange,
|
||||
resetAllToken,
|
||||
resetSelectedToken,
|
||||
selectedNodeName,
|
||||
transformRequest,
|
||||
}: {
|
||||
allowDragging: boolean;
|
||||
manifest: OpenClawModelInteractionManifest;
|
||||
modelUrl: string;
|
||||
onMatchChange: (matched: number, total: number) => void;
|
||||
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
|
||||
resetAllToken: number;
|
||||
resetSelectedToken: number;
|
||||
selectedNodeName: string | null;
|
||||
transformRequest: OpenClawModelTransformRequest;
|
||||
}) {
|
||||
const { camera, gl, scene: rootScene } = useThree();
|
||||
const controlsRef = useRef<OrbitControlsImpl | null>(null);
|
||||
const gltf = useGLTF(modelUrl, true);
|
||||
const scene = useMemo(() => cloneScene(gltf.scene), [gltf.scene]);
|
||||
const fitTransform = useMemo(() => getFitTransform(scene), [scene]);
|
||||
const entriesRef = useRef<Map<string, InteractiveEntry>>(new Map());
|
||||
const objectEntriesRef = useRef<Map<Object3D, InteractiveEntry>>(new Map());
|
||||
const animationRef = useRef<AnimationState | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const currentAmountRef = useRef(0);
|
||||
const selectionHelperRef = useRef<THREE.BoxHelper | null>(null);
|
||||
|
||||
const getCameraFrame = (
|
||||
starts: Map<string, THREE.Vector3>,
|
||||
targets: Map<string, THREE.Vector3>
|
||||
) => {
|
||||
for (const [nodeName, target] of targets) {
|
||||
entriesRef.current.get(nodeName)?.node.position.copy(target);
|
||||
}
|
||||
scene.updateMatrixWorld(true);
|
||||
const bounds = new THREE.Box3().setFromObject(scene);
|
||||
for (const [nodeName, start] of starts) {
|
||||
entriesRef.current.get(nodeName)?.node.position.copy(start);
|
||||
}
|
||||
scene.updateMatrixWorld(true);
|
||||
|
||||
const controlsTargetStart = controlsRef.current?.target?.clone?.() ||
|
||||
new THREE.Vector3();
|
||||
if (bounds.isEmpty()) {
|
||||
return {
|
||||
cameraTarget: camera.position.clone(),
|
||||
controlsTargetEnd: controlsTargetStart.clone(),
|
||||
};
|
||||
}
|
||||
const sphere = bounds.getBoundingSphere(new THREE.Sphere());
|
||||
const perspectiveCamera = camera as THREE.PerspectiveCamera;
|
||||
const verticalHalfFov = THREE.MathUtils.degToRad(perspectiveCamera.fov) / 2;
|
||||
const horizontalHalfFov = Math.atan(
|
||||
Math.tan(verticalHalfFov) * perspectiveCamera.aspect
|
||||
);
|
||||
const limitingHalfFov = Math.max(
|
||||
0.1,
|
||||
Math.min(verticalHalfFov, horizontalHalfFov)
|
||||
);
|
||||
const distance = Math.max(
|
||||
1.2,
|
||||
(sphere.radius / Math.sin(limitingHalfFov)) * 1.12
|
||||
);
|
||||
const direction = camera.position.clone().sub(controlsTargetStart);
|
||||
if (direction.lengthSq() < 0.0001) direction.set(1, 0.75, 1);
|
||||
direction.normalize();
|
||||
return {
|
||||
cameraTarget: sphere.center.clone().addScaledVector(direction, distance),
|
||||
controlsTargetEnd: sphere.center.clone(),
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const partsByNodeName = new Map(
|
||||
manifest.parts.map((part) => [part.nodeName, part])
|
||||
);
|
||||
const entries = new Map<string, InteractiveEntry>();
|
||||
const objectEntries = new Map<Object3D, InteractiveEntry>();
|
||||
scene.traverse((node) => {
|
||||
const part = partsByNodeName.get(node.name);
|
||||
if (!part || entries.has(part.nodeName)) return;
|
||||
const entry = {
|
||||
node,
|
||||
part,
|
||||
initialPosition: node.position.clone(),
|
||||
};
|
||||
entries.set(part.nodeName, entry);
|
||||
objectEntries.set(node, entry);
|
||||
});
|
||||
entriesRef.current = entries;
|
||||
objectEntriesRef.current = objectEntries;
|
||||
onMatchChange(entries.size, manifest.parts.length);
|
||||
|
||||
return () => {
|
||||
entriesRef.current.clear();
|
||||
objectEntriesRef.current.clear();
|
||||
onMatchChange(0, manifest.parts.length);
|
||||
};
|
||||
}, [manifest, onMatchChange, scene]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = selectionHelperRef.current;
|
||||
if (previous) {
|
||||
rootScene.remove(previous);
|
||||
previous.geometry.dispose();
|
||||
(previous.material as THREE.Material).dispose();
|
||||
selectionHelperRef.current = null;
|
||||
}
|
||||
if (!selectedNodeName) return;
|
||||
const entry = entriesRef.current.get(selectedNodeName);
|
||||
if (!entry) return;
|
||||
const helper = new THREE.BoxHelper(entry.node, 0x3ecf8e);
|
||||
helper.raycast = () => undefined;
|
||||
rootScene.add(helper);
|
||||
selectionHelperRef.current = helper;
|
||||
return () => {
|
||||
rootScene.remove(helper);
|
||||
helper.geometry.dispose();
|
||||
(helper.material as THREE.Material).dispose();
|
||||
if (selectionHelperRef.current === helper) {
|
||||
selectionHelperRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [rootScene, selectedNodeName]);
|
||||
|
||||
useEffect(() => {
|
||||
const amount = THREE.MathUtils.clamp(transformRequest.amount, 0, 1);
|
||||
currentAmountRef.current = amount;
|
||||
const starts = new Map<string, THREE.Vector3>();
|
||||
const targets = new Map<string, THREE.Vector3>();
|
||||
for (const [nodeName, entry] of entriesRef.current) {
|
||||
starts.set(nodeName, entry.node.position.clone());
|
||||
targets.set(nodeName, getPartTargetPosition(entry, manifest, amount));
|
||||
}
|
||||
const cameraFrame = getCameraFrame(starts, targets);
|
||||
if (!transformRequest.animate) {
|
||||
animationRef.current = null;
|
||||
for (const [nodeName, target] of targets) {
|
||||
entriesRef.current.get(nodeName)?.node.position.copy(target);
|
||||
}
|
||||
camera.position.copy(cameraFrame.cameraTarget);
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.target.copy(cameraFrame.controlsTargetEnd);
|
||||
controlsRef.current.update();
|
||||
} else {
|
||||
camera.lookAt(cameraFrame.controlsTargetEnd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const targetState = amount >= 0.5 ? "exploded" : "assembled";
|
||||
const action =
|
||||
manifest.actions.find((item) => item.id === transformRequest.actionId) ||
|
||||
manifest.actions.find((item) => item.toState === targetState);
|
||||
animationRef.current = {
|
||||
startedAt: performance.now(),
|
||||
durationMs: action?.durationMs ?? transformRequest.durationMs,
|
||||
starts,
|
||||
targets,
|
||||
easing: action?.easing || "easeInOutCubic",
|
||||
cameraStart: camera.position.clone(),
|
||||
cameraTarget: cameraFrame.cameraTarget,
|
||||
controlsTargetStart:
|
||||
controlsRef.current?.target?.clone?.() || new THREE.Vector3(),
|
||||
controlsTargetEnd: cameraFrame.controlsTargetEnd,
|
||||
};
|
||||
}, [camera, manifest, scene, transformRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetAllToken <= 0) return;
|
||||
animationRef.current = null;
|
||||
for (const entry of entriesRef.current.values()) {
|
||||
entry.node.position.copy(
|
||||
getPartTargetPosition(entry, manifest, currentAmountRef.current)
|
||||
);
|
||||
}
|
||||
}, [manifest, resetAllToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetSelectedToken <= 0 || !selectedNodeName) return;
|
||||
const entry = entriesRef.current.get(selectedNodeName);
|
||||
if (!entry) return;
|
||||
entry.node.position.copy(
|
||||
getPartTargetPosition(entry, manifest, currentAmountRef.current)
|
||||
);
|
||||
}, [manifest, resetSelectedToken, selectedNodeName]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = gl.domElement;
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
const planeIntersection = new THREE.Vector3();
|
||||
const worldPosition = new THREE.Vector3();
|
||||
|
||||
const updateRay = (event: PointerEvent) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
||||
pointer.set(
|
||||
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
||||
-((event.clientY - bounds.top) / bounds.height) * 2 + 1
|
||||
);
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
return true;
|
||||
};
|
||||
const findEntry = (object: Object3D) => {
|
||||
let current: Object3D | null = object;
|
||||
while (current) {
|
||||
const entry = objectEntriesRef.current.get(current);
|
||||
if (entry) return entry;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || animationRef.current || !updateRay(event)) return;
|
||||
const intersections = raycaster.intersectObjects(
|
||||
[...entriesRef.current.values()].map((entry) => entry.node),
|
||||
true
|
||||
);
|
||||
const entry = intersections
|
||||
.map((intersection) => findEntry(intersection.object))
|
||||
.find((candidate): candidate is InteractiveEntry => Boolean(candidate));
|
||||
if (!entry) {
|
||||
onSelectedPartChange(null);
|
||||
return;
|
||||
}
|
||||
onSelectedPartChange(entry.part);
|
||||
if (!allowDragging || !entry.part.draggable) return;
|
||||
|
||||
const normal = camera.getWorldDirection(new THREE.Vector3()).normalize();
|
||||
const startWorldPosition = entry.node.getWorldPosition(new THREE.Vector3());
|
||||
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(
|
||||
normal,
|
||||
startWorldPosition
|
||||
);
|
||||
if (!raycaster.ray.intersectPlane(plane, planeIntersection)) return;
|
||||
dragRef.current = {
|
||||
entry,
|
||||
plane,
|
||||
startIntersection: planeIntersection.clone(),
|
||||
startWorldPosition,
|
||||
startLocalPosition: entry.node.position.clone(),
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
if (controlsRef.current) controlsRef.current.enabled = false;
|
||||
element.setPointerCapture?.(event.pointerId);
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId || !updateRay(event)) return;
|
||||
if (!raycaster.ray.intersectPlane(drag.plane, planeIntersection)) return;
|
||||
const targetWorld = worldPosition
|
||||
.copy(drag.startWorldPosition)
|
||||
.add(planeIntersection.clone().sub(drag.startIntersection));
|
||||
const targetLocal = drag.entry.node.parent
|
||||
? drag.entry.node.parent.worldToLocal(targetWorld.clone())
|
||||
: targetWorld;
|
||||
const axes = manifest.drag.axes;
|
||||
if (!axes.includes("x")) targetLocal.x = drag.startLocalPosition.x;
|
||||
if (!axes.includes("z")) targetLocal.y = drag.startLocalPosition.y;
|
||||
if (!axes.includes("y")) targetLocal.z = drag.startLocalPosition.z;
|
||||
|
||||
const maxDistance = manifest.drag.maxDistanceMm * manifest.scaleMmToGlb;
|
||||
const fromInitial = targetLocal.clone().sub(drag.entry.initialPosition);
|
||||
if (maxDistance > 0 && fromInitial.length() > maxDistance) {
|
||||
fromInitial.setLength(maxDistance);
|
||||
targetLocal.copy(drag.entry.initialPosition).add(fromInitial);
|
||||
}
|
||||
drag.entry.node.position.copy(targetLocal);
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
const finishDrag = (event: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const snapDistance =
|
||||
manifest.drag.snapDistanceMm * manifest.scaleMmToGlb;
|
||||
if (snapDistance > 0) {
|
||||
const delta = drag.entry.node.position
|
||||
.clone()
|
||||
.sub(drag.entry.initialPosition);
|
||||
delta.set(
|
||||
Math.round(delta.x / snapDistance) * snapDistance,
|
||||
Math.round(delta.y / snapDistance) * snapDistance,
|
||||
Math.round(delta.z / snapDistance) * snapDistance
|
||||
);
|
||||
drag.entry.node.position.copy(drag.entry.initialPosition).add(delta);
|
||||
}
|
||||
element.releasePointerCapture?.(event.pointerId);
|
||||
dragRef.current = null;
|
||||
if (controlsRef.current) controlsRef.current.enabled = true;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
element.addEventListener("pointerdown", handlePointerDown, true);
|
||||
window.addEventListener("pointermove", handlePointerMove, true);
|
||||
window.addEventListener("pointerup", finishDrag, true);
|
||||
window.addEventListener("pointercancel", finishDrag, true);
|
||||
return () => {
|
||||
element.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
window.removeEventListener("pointermove", handlePointerMove, true);
|
||||
window.removeEventListener("pointerup", finishDrag, true);
|
||||
window.removeEventListener("pointercancel", finishDrag, true);
|
||||
dragRef.current = null;
|
||||
};
|
||||
}, [allowDragging, camera, gl, manifest, onSelectedPartChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
scene.traverse((node) => {
|
||||
if (!isMesh(node)) return;
|
||||
const materials = Array.isArray(node.material)
|
||||
? node.material
|
||||
: [node.material];
|
||||
for (const material of materials) material.dispose();
|
||||
});
|
||||
};
|
||||
}, [scene]);
|
||||
|
||||
useFrame(() => {
|
||||
selectionHelperRef.current?.update();
|
||||
const animation = animationRef.current;
|
||||
if (!animation) return;
|
||||
const rawProgress =
|
||||
animation.durationMs <= 0
|
||||
? 1
|
||||
: Math.min(1, (performance.now() - animation.startedAt) / animation.durationMs);
|
||||
const progress = getEasedProgress(rawProgress, animation.easing);
|
||||
for (const [nodeName, target] of animation.targets) {
|
||||
const entry = entriesRef.current.get(nodeName);
|
||||
const start = animation.starts.get(nodeName);
|
||||
if (entry && start) entry.node.position.lerpVectors(start, target, progress);
|
||||
}
|
||||
camera.position.lerpVectors(
|
||||
animation.cameraStart,
|
||||
animation.cameraTarget,
|
||||
progress
|
||||
);
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.target.lerpVectors(
|
||||
animation.controlsTargetStart,
|
||||
animation.controlsTargetEnd,
|
||||
progress
|
||||
);
|
||||
controlsRef.current.update();
|
||||
} else {
|
||||
camera.lookAt(animation.controlsTargetEnd);
|
||||
}
|
||||
if (rawProgress >= 1) animationRef.current = null;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.75} />
|
||||
<directionalLight castShadow intensity={1.6} position={[4, 7, 5]} />
|
||||
<directionalLight intensity={0.45} position={[-4, 2, -3]} />
|
||||
<primitive
|
||||
object={scene}
|
||||
position={fitTransform.position}
|
||||
scale={fitTransform.scale}
|
||||
/>
|
||||
<Environment preset="studio" />
|
||||
<OrbitControls
|
||||
dampingFactor={0.08}
|
||||
enableDamping
|
||||
enablePan
|
||||
enableRotate
|
||||
enableZoom
|
||||
makeDefault
|
||||
maxPolarAngle={Math.PI / 1.65}
|
||||
minDistance={1.2}
|
||||
minPolarAngle={0}
|
||||
ref={controlsRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenClawInteractiveModelViewer({
|
||||
allowDragging,
|
||||
background,
|
||||
manifest,
|
||||
modelUrl,
|
||||
onMatchChange,
|
||||
onSelectedPartChange,
|
||||
previewImageUrl,
|
||||
resetAllToken,
|
||||
resetSelectedToken,
|
||||
resetViewToken,
|
||||
selectedNodeName,
|
||||
transformRequest,
|
||||
}: {
|
||||
allowDragging: boolean;
|
||||
background: string;
|
||||
manifest: OpenClawModelInteractionManifest;
|
||||
modelUrl: string;
|
||||
onMatchChange: (matched: number, total: number) => void;
|
||||
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
|
||||
previewImageUrl?: string | null;
|
||||
resetAllToken: number;
|
||||
resetSelectedToken: number;
|
||||
resetViewToken: number;
|
||||
selectedNodeName: string | null;
|
||||
transformRequest: OpenClawModelTransformRequest;
|
||||
}) {
|
||||
return (
|
||||
<InteractiveViewerErrorBoundary
|
||||
resetKey={`${modelUrl}:${resetViewToken}`}
|
||||
fallback={
|
||||
<div className="relative flex h-full w-full items-center justify-center overflow-hidden bg-zinc-950 text-zinc-200">
|
||||
{previewImageUrl ? (
|
||||
<img
|
||||
alt="3D 模型静态预览"
|
||||
className="absolute inset-0 h-full w-full object-contain opacity-80"
|
||||
src={previewImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex max-w-xs flex-col items-center gap-2 rounded-md bg-black/70 px-4 py-3 text-center backdrop-blur-sm">
|
||||
<AlertCircle className="h-6 w-6 text-amber-300" />
|
||||
<p className="text-xs">3D 交互预览加载失败,已显示静态预览</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Canvas
|
||||
camera={{ fov: 42, position: [2.4, 1.8, 2.4] }}
|
||||
dpr={[1, 1.75]}
|
||||
gl={{ antialias: true, powerPreference: "high-performance" }}
|
||||
key={resetViewToken}
|
||||
shadows
|
||||
style={{ background }}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<ModelInteractionScene
|
||||
allowDragging={allowDragging}
|
||||
manifest={manifest}
|
||||
modelUrl={modelUrl}
|
||||
onMatchChange={onMatchChange}
|
||||
onSelectedPartChange={onSelectedPartChange}
|
||||
resetAllToken={resetAllToken}
|
||||
resetSelectedToken={resetSelectedToken}
|
||||
selectedNodeName={selectedNodeName}
|
||||
transformRequest={transformRequest}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</InteractiveViewerErrorBoundary>
|
||||
);
|
||||
}
|
||||
@ -2,13 +2,27 @@
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, Download, Loader2, RotateCcw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Download,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import {
|
||||
buildOpenClawArtifactProxyUrl,
|
||||
isOpenClawInteractive3DArtifact,
|
||||
getOpenClaw3DArtifactBundle,
|
||||
type OpenClawArtifact,
|
||||
} from "@/lib/openclaw-chat/artifacts";
|
||||
import {
|
||||
normalizeOpenClawModelInteractionManifest,
|
||||
type OpenClawModelInteractionManifest,
|
||||
type OpenClawModelInteractionPart,
|
||||
} from "@/lib/openclaw-chat/model-interaction";
|
||||
|
||||
const Model3DViewer = dynamic(
|
||||
() =>
|
||||
@ -17,14 +31,31 @@ const Model3DViewer = dynamic(
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full w-full items-center justify-center bg-zinc-950 text-zinc-400">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
),
|
||||
loading: () => <ViewerLoading />,
|
||||
}
|
||||
);
|
||||
|
||||
const OpenClawInteractiveModelViewer = dynamic(
|
||||
() =>
|
||||
import(
|
||||
"@/components/canvas/chat-panel/OpenClawInteractiveModelViewer"
|
||||
).then((module) => module.OpenClawInteractiveModelViewer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <ViewerLoading />,
|
||||
}
|
||||
);
|
||||
|
||||
const ACTIVE_VIEWER_EVENT = "openclaw-active-3d-viewer";
|
||||
|
||||
function ViewerLoading() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-zinc-950 text-zinc-400">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function supportsWebGl() {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
@ -38,6 +69,12 @@ function supportsWebGl() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDimensions(part: OpenClawModelInteractionPart) {
|
||||
const dimensions = part.dimensionsMm;
|
||||
if (!dimensions) return null;
|
||||
return `${dimensions.x} x ${dimensions.y} x ${dimensions.z} mm`;
|
||||
}
|
||||
|
||||
export function OpenClawModel3DPreview({
|
||||
artifacts,
|
||||
chatId,
|
||||
@ -47,40 +84,146 @@ export function OpenClawModel3DPreview({
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
}) {
|
||||
const modelIndex = useMemo(
|
||||
() => artifacts.findIndex(isOpenClawInteractive3DArtifact),
|
||||
const viewerId = useId();
|
||||
const bundle = useMemo(
|
||||
() => getOpenClaw3DArtifactBundle(artifacts),
|
||||
[artifacts]
|
||||
);
|
||||
const model = modelIndex >= 0 ? artifacts[modelIndex] : null;
|
||||
const fallbackRole = model?.viewer?.fallbackArtifactRole || "cad_preview";
|
||||
const previewIndex = artifacts.findIndex(
|
||||
(artifact) => artifact.artifactRole === fallbackRole
|
||||
);
|
||||
const model = bundle?.model || null;
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [webGlUnavailable, setWebGlUnavailable] = useState(false);
|
||||
const [resetViewToken, setResetViewToken] = useState(0);
|
||||
const [resetSelectedToken, setResetSelectedToken] = useState(0);
|
||||
const [resetAllToken, setResetAllToken] = useState(0);
|
||||
const [manifest, setManifest] =
|
||||
useState<OpenClawModelInteractionManifest | null>();
|
||||
const [manifestError, setManifestError] = useState<string | null>(null);
|
||||
const [selectedPart, setSelectedPart] =
|
||||
useState<OpenClawModelInteractionPart | null>(null);
|
||||
const [matchState, setMatchState] = useState({ matched: 0, total: 0 });
|
||||
const [explodeAmount, setExplodeAmount] = useState(0);
|
||||
const [transformRequest, setTransformRequest] = useState({
|
||||
actionId: null as string | null,
|
||||
amount: 0,
|
||||
animate: false,
|
||||
durationMs: 700,
|
||||
nonce: 0,
|
||||
});
|
||||
|
||||
if (!model) return null;
|
||||
const closeViewer = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setSelectedPart(null);
|
||||
setMatchState({ matched: 0, total: 0 });
|
||||
}, []);
|
||||
const handleMatchChange = useCallback((matched: number, total: number) => {
|
||||
setMatchState((current) =>
|
||||
current.matched === matched && current.total === total
|
||||
? current
|
||||
: { matched, total }
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOtherViewerActivated = (event: Event) => {
|
||||
const activeId = (event as CustomEvent<string>).detail;
|
||||
if (activeId !== viewerId) closeViewer();
|
||||
};
|
||||
window.addEventListener(ACTIVE_VIEWER_EVENT, handleOtherViewerActivated);
|
||||
return () => {
|
||||
window.removeEventListener(ACTIVE_VIEWER_EVENT, handleOtherViewerActivated);
|
||||
};
|
||||
}, [closeViewer, viewerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
closeViewer();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [closeViewer, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !bundle || manifest !== undefined) return;
|
||||
if (bundle.manifestIndex < 0) {
|
||||
setManifest(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const manifestUrl = buildOpenClawArtifactProxyUrl({
|
||||
chatId,
|
||||
messageId,
|
||||
artifactIndex: bundle.manifestIndex,
|
||||
});
|
||||
setManifestError(null);
|
||||
fetch(manifestUrl, { cache: "no-store", signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`交互清单读取失败(HTTP ${response.status})`);
|
||||
}
|
||||
const normalized = normalizeOpenClawModelInteractionManifest(
|
||||
await response.json()
|
||||
);
|
||||
if (!normalized) throw new Error("交互清单格式不正确");
|
||||
setManifest(normalized);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setManifest(null);
|
||||
setManifestError(
|
||||
error instanceof Error ? error.message : "交互清单读取失败"
|
||||
);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [bundle, chatId, isActive, manifest, messageId]);
|
||||
|
||||
if (!bundle || !model) return null;
|
||||
|
||||
const modelUrl = buildOpenClawArtifactProxyUrl({
|
||||
chatId,
|
||||
messageId,
|
||||
artifactIndex: modelIndex,
|
||||
artifactIndex: bundle.modelIndex,
|
||||
});
|
||||
const downloadUrl = buildOpenClawArtifactProxyUrl({
|
||||
chatId,
|
||||
messageId,
|
||||
artifactIndex: modelIndex,
|
||||
artifactIndex: bundle.modelIndex,
|
||||
download: true,
|
||||
});
|
||||
const previewUrl =
|
||||
previewIndex >= 0
|
||||
bundle.previewIndex >= 0
|
||||
? buildOpenClawArtifactProxyUrl({
|
||||
chatId,
|
||||
messageId,
|
||||
artifactIndex: previewIndex,
|
||||
artifactIndex: bundle.previewIndex,
|
||||
})
|
||||
: null;
|
||||
const isAssembledModel =
|
||||
model.artifactRole.toLowerCase() === "assembled_3d" ||
|
||||
model.viewer?.variant === "assembled";
|
||||
const supportsNodeInteraction = Boolean(manifest && isAssembledModel);
|
||||
const canDragParts = Boolean(
|
||||
supportsNodeInteraction &&
|
||||
(model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable))
|
||||
);
|
||||
const canExplode = Boolean(
|
||||
supportsNodeInteraction &&
|
||||
(model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode)
|
||||
);
|
||||
const canAssemble = Boolean(
|
||||
supportsNodeInteraction &&
|
||||
(model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble)
|
||||
);
|
||||
const showExplodeSlider = Boolean(
|
||||
supportsNodeInteraction &&
|
||||
(model.viewer?.explodeSlider || manifest?.capabilities.explodeSlider)
|
||||
);
|
||||
const canResetParts = Boolean(
|
||||
supportsNodeInteraction &&
|
||||
(model.viewer?.resetParts || manifest?.parts.some((part) => part.draggable))
|
||||
);
|
||||
|
||||
const activateViewer = () => {
|
||||
if (!supportsWebGl()) {
|
||||
@ -88,12 +231,31 @@ export function OpenClawModel3DPreview({
|
||||
return;
|
||||
}
|
||||
setWebGlUnavailable(false);
|
||||
window.dispatchEvent(new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId }));
|
||||
setIsActive(true);
|
||||
};
|
||||
const updateExplodeAmount = (
|
||||
amount: number,
|
||||
animate: boolean,
|
||||
actionId: string | null = null
|
||||
) => {
|
||||
const normalized = Math.max(0, Math.min(1, amount));
|
||||
setExplodeAmount(normalized);
|
||||
setTransformRequest((current) => ({
|
||||
actionId,
|
||||
amount: normalized,
|
||||
animate,
|
||||
durationMs: model.viewer?.transitionDurationMs || 700,
|
||||
nonce: current.nonce + 1,
|
||||
}));
|
||||
};
|
||||
const selectedDimensions = selectedPart
|
||||
? formatDimensions(selectedPart)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="mt-3 w-full max-w-[760px] overflow-hidden rounded-lg border border-black/10 bg-white dark:border-white/10 dark:bg-zinc-950">
|
||||
<header className="flex h-11 items-center justify-between gap-3 border-b border-black/8 px-3 dark:border-white/10">
|
||||
<header className="flex min-h-11 items-center justify-between gap-2 border-b border-black/8 px-3 py-1.5 dark:border-white/10">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Box className="h-4 w-4 shrink-0 text-[#687386] dark:text-zinc-400" />
|
||||
<span className="truncate text-[13px] font-medium text-[#2f3640] dark:text-zinc-200">
|
||||
@ -105,15 +267,27 @@ export function OpenClawModel3DPreview({
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-zinc-100"
|
||||
aria-label="复位3D视角"
|
||||
title="复位3D视角"
|
||||
onClick={() => setResetViewToken((value) => value + 1)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-zinc-100"
|
||||
aria-label="复位3D视角"
|
||||
title="复位3D视角"
|
||||
onClick={() => setResetViewToken((value) => value + 1)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center justify-center gap-1 rounded-md px-2 text-[12px] font-medium text-[#535d6d] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
aria-label="退出3D预览"
|
||||
title="退出3D预览,恢复页面滚动"
|
||||
onClick={closeViewer}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">退出预览</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<a
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-zinc-100"
|
||||
@ -127,25 +301,141 @@ export function OpenClawModel3DPreview({
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="relative h-[clamp(280px,42vw,430px)] w-full overflow-hidden"
|
||||
className="relative h-[clamp(300px,44vw,460px)] w-full overflow-hidden"
|
||||
style={{ backgroundColor: model.viewer?.background || "#f7f9fc" }}
|
||||
>
|
||||
{isActive ? (
|
||||
<Model3DViewer
|
||||
autoFitModel
|
||||
modelFormat={model.viewer?.format || "glb"}
|
||||
modelUrl={modelUrl}
|
||||
previewImageUrl={previewUrl}
|
||||
resetViewToken={resetViewToken}
|
||||
settings={{
|
||||
autoRotate: model.viewer?.autoRotate || false,
|
||||
cameraPreset: "iso",
|
||||
envPreset: "studio",
|
||||
lightIntensity: 1,
|
||||
showGrid: false,
|
||||
wireframe: false,
|
||||
}}
|
||||
/>
|
||||
manifest === undefined && bundle.manifestIndex >= 0 ? (
|
||||
<ViewerLoading />
|
||||
) : supportsNodeInteraction && manifest ? (
|
||||
<>
|
||||
<OpenClawInteractiveModelViewer
|
||||
allowDragging={canDragParts}
|
||||
background={model.viewer?.background || "#f7f9fc"}
|
||||
manifest={manifest}
|
||||
modelUrl={modelUrl}
|
||||
onMatchChange={handleMatchChange}
|
||||
onSelectedPartChange={setSelectedPart}
|
||||
previewImageUrl={previewUrl}
|
||||
resetAllToken={resetAllToken}
|
||||
resetSelectedToken={resetSelectedToken}
|
||||
resetViewToken={resetViewToken}
|
||||
selectedNodeName={selectedPart?.nodeName || null}
|
||||
transformRequest={transformRequest}
|
||||
/>
|
||||
|
||||
{selectedPart ? (
|
||||
<div className="pointer-events-none absolute left-3 top-3 max-w-[calc(100%-24px)] rounded-md bg-black/72 px-3 py-2 text-white shadow-sm backdrop-blur-sm">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{selectedPart.partId} · {selectedPart.partName}
|
||||
</p>
|
||||
{selectedDimensions ? (
|
||||
<p className="mt-0.5 text-[11px] text-white/65">
|
||||
{selectedDimensions}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 border-t border-black/10 bg-white/94 px-3 py-2.5 backdrop-blur-md dark:border-white/10 dark:bg-zinc-950/94">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canExplode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-[#202631] px-3 text-xs font-medium text-white hover:bg-[#11151c] dark:bg-[#3ecf8e] dark:text-[#07120d] dark:hover:bg-[#55dda0]"
|
||||
onClick={() =>
|
||||
updateExplodeAmount(
|
||||
1,
|
||||
true,
|
||||
model.viewer?.explodeActionId || "explode_all"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
一键炸开
|
||||
</button>
|
||||
) : null}
|
||||
{canAssemble ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-black/10 px-3 text-xs font-medium text-[#3e4652] hover:bg-black/5 dark:border-white/15 dark:text-zinc-200 dark:hover:bg-white/10"
|
||||
onClick={() =>
|
||||
updateExplodeAmount(
|
||||
0,
|
||||
true,
|
||||
model.viewer?.assembleActionId || "assemble_all"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
一键还原
|
||||
</button>
|
||||
) : null}
|
||||
{selectedPart && canResetParts ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
aria-label="归位当前配件"
|
||||
title="归位当前配件"
|
||||
onClick={() => setResetSelectedToken((value) => value + 1)}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{canResetParts ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
aria-label="全部配件归位"
|
||||
title="全部配件归位"
|
||||
onClick={() => setResetAllToken((value) => value + 1)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{matchState.total > 0 && matchState.matched < matchState.total ? (
|
||||
<span className="text-[11px] text-amber-700 dark:text-amber-300">
|
||||
已识别 {matchState.matched}/{matchState.total} 个配件
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{showExplodeSlider ? (
|
||||
<label className="mt-2 flex items-center gap-3 text-[11px] text-[#697386] dark:text-zinc-400">
|
||||
<span className="shrink-0">爆炸程度</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={Math.round(explodeAmount * 100)}
|
||||
className="h-1.5 min-w-0 flex-1 accent-[#202631] dark:accent-[#3ecf8e]"
|
||||
onChange={(event) =>
|
||||
updateExplodeAmount(Number(event.target.value) / 100, false)
|
||||
}
|
||||
/>
|
||||
<span className="w-8 text-right tabular-nums">
|
||||
{Math.round(explodeAmount * 100)}%
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Model3DViewer
|
||||
autoFitModel
|
||||
modelFormat={model.viewer?.format || "glb"}
|
||||
modelUrl={modelUrl}
|
||||
previewImageUrl={previewUrl}
|
||||
resetViewToken={resetViewToken}
|
||||
settings={{
|
||||
autoRotate: model.viewer?.autoRotate || false,
|
||||
cameraPreset: "iso",
|
||||
envPreset: "studio",
|
||||
lightIntensity: 1,
|
||||
showGrid: false,
|
||||
wireframe: false,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-[#f7f9fc] dark:bg-zinc-950">
|
||||
{previewUrl ? (
|
||||
@ -160,7 +450,7 @@ export function OpenClawModel3DPreview({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#202631] px-4 text-[13px] font-medium text-white shadow-sm transition-colors hover:bg-[#11151c] dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-white"
|
||||
className="relative inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#202631] px-4 text-[13px] font-medium text-white shadow-sm transition-colors hover:bg-[#11151c] dark:bg-[#3ecf8e] dark:text-[#07120d] dark:hover:bg-[#55dda0]"
|
||||
onClick={activateViewer}
|
||||
>
|
||||
<Box className="h-4 w-4" />
|
||||
@ -169,9 +459,11 @@ export function OpenClawModel3DPreview({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{webGlUnavailable ? (
|
||||
{webGlUnavailable || manifestError ? (
|
||||
<p className="border-t border-black/8 px-3 py-2 text-xs text-amber-700 dark:border-white/10 dark:text-amber-300">
|
||||
当前浏览器无法启动3D预览,已保留静态预览和模型下载。
|
||||
{webGlUnavailable
|
||||
? "当前浏览器无法启动3D预览,已保留静态预览和模型下载。"
|
||||
: `${manifestError},已切换为基础3D预览。`}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
export type OpenClawArtifactViewer = {
|
||||
type: "model_3d";
|
||||
format: "glb" | "gltf";
|
||||
variant: "assembled" | "exploded" | null;
|
||||
isDefault: boolean;
|
||||
cameraControls: boolean;
|
||||
autoRotate: boolean;
|
||||
background: string | null;
|
||||
fallbackArtifactRole: string | null;
|
||||
alternateArtifactRole: string | null;
|
||||
interactionManifestArtifactRole: string | null;
|
||||
partSelection: boolean;
|
||||
partDragging: boolean;
|
||||
resetParts: boolean;
|
||||
oneClickExplode: boolean;
|
||||
oneClickAssemble: boolean;
|
||||
explodeActionId: string | null;
|
||||
assembleActionId: string | null;
|
||||
transitionDurationMs: number;
|
||||
explodeSlider: boolean;
|
||||
};
|
||||
|
||||
export type OpenClawArtifact = {
|
||||
@ -40,6 +53,18 @@ function readBoolean(value: unknown, fallback: boolean) {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function readNumber(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
limits?: { min?: number; max?: number }
|
||||
) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
if (limits?.min !== undefined && parsed < limits.min) return fallback;
|
||||
if (limits?.max !== undefined && parsed > limits.max) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeArtifactUrl(value: unknown, baseUrl?: string) {
|
||||
const raw = readText(value, MAX_URL_LENGTH);
|
||||
if (!raw) return "";
|
||||
@ -90,6 +115,7 @@ function normalizeViewer(
|
||||
if (type && type !== "model_3d") return null;
|
||||
|
||||
const background = readText(raw.background, 32);
|
||||
const variant = readText(raw.variant, 32).toLowerCase();
|
||||
return {
|
||||
type: "model_3d",
|
||||
format: inferViewerFormat({
|
||||
@ -97,11 +123,48 @@ function normalizeViewer(
|
||||
mimeType: artifact.mimeType,
|
||||
value: raw.format,
|
||||
}),
|
||||
variant:
|
||||
variant === "assembled" || variant === "exploded" ? variant : null,
|
||||
isDefault: readBoolean(raw.default ?? raw.isDefault, false),
|
||||
cameraControls: readBoolean(raw.camera_controls ?? raw.cameraControls, true),
|
||||
autoRotate: readBoolean(raw.auto_rotate ?? raw.autoRotate, false),
|
||||
background: background || null,
|
||||
fallbackArtifactRole:
|
||||
readText(raw.fallback_artifact_role ?? raw.fallbackArtifactRole) || null,
|
||||
alternateArtifactRole:
|
||||
readText(raw.alternate_artifact_role ?? raw.alternateArtifactRole) || null,
|
||||
interactionManifestArtifactRole:
|
||||
readText(
|
||||
raw.interaction_manifest_artifact_role ??
|
||||
raw.interactionManifestArtifactRole
|
||||
) || null,
|
||||
partSelection: readBoolean(
|
||||
raw.part_selection ?? raw.partSelection,
|
||||
false
|
||||
),
|
||||
partDragging: readBoolean(raw.part_dragging ?? raw.partDragging, false),
|
||||
resetParts: readBoolean(raw.reset_parts ?? raw.resetParts, false),
|
||||
oneClickExplode: readBoolean(
|
||||
raw.one_click_explode ?? raw.oneClickExplode,
|
||||
false
|
||||
),
|
||||
oneClickAssemble: readBoolean(
|
||||
raw.one_click_assemble ?? raw.oneClickAssemble,
|
||||
false
|
||||
),
|
||||
explodeActionId:
|
||||
readText(raw.explode_action_id ?? raw.explodeActionId, 160) || null,
|
||||
assembleActionId:
|
||||
readText(raw.assemble_action_id ?? raw.assembleActionId, 160) || null,
|
||||
transitionDurationMs: readNumber(
|
||||
raw.transition_duration_ms ?? raw.transitionDurationMs,
|
||||
700,
|
||||
{ min: 0, max: 30_000 }
|
||||
),
|
||||
explodeSlider: readBoolean(
|
||||
raw.explode_slider ?? raw.explodeSlider,
|
||||
false
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -194,6 +257,7 @@ export function isOpenClawInteractive3DArtifact(
|
||||
const mimeType = artifact.mimeType.toLowerCase();
|
||||
const filename = artifact.filename.toLowerCase();
|
||||
return (
|
||||
role === "assembled_3d" ||
|
||||
role === "interactive_3d" ||
|
||||
mimeType === "model/gltf-binary" ||
|
||||
mimeType === "model/gltf+json" ||
|
||||
@ -202,6 +266,70 @@ export function isOpenClawInteractive3DArtifact(
|
||||
);
|
||||
}
|
||||
|
||||
export type OpenClaw3DArtifactBundle = {
|
||||
modelIndex: number;
|
||||
model: OpenClawArtifact;
|
||||
alternateModelIndex: number;
|
||||
manifestIndex: number;
|
||||
previewIndex: number;
|
||||
};
|
||||
|
||||
export function getOpenClaw3DArtifactBundle(
|
||||
artifacts: OpenClawArtifact[]
|
||||
): OpenClaw3DArtifactBundle | null {
|
||||
const modelIndexes = artifacts
|
||||
.map((artifact, index) =>
|
||||
isOpenClawInteractive3DArtifact(artifact) ? index : -1
|
||||
)
|
||||
.filter((index) => index >= 0);
|
||||
if (modelIndexes.length === 0) return null;
|
||||
|
||||
const modelIndex =
|
||||
modelIndexes.find((index) => artifacts[index]?.viewer?.isDefault) ??
|
||||
modelIndexes.find(
|
||||
(index) => artifacts[index]?.artifactRole.toLowerCase() === "assembled_3d"
|
||||
) ??
|
||||
modelIndexes[0];
|
||||
const model = artifacts[modelIndex];
|
||||
if (!model) return null;
|
||||
|
||||
const alternateRole = model.viewer?.alternateArtifactRole?.toLowerCase();
|
||||
const alternateModelIndex = alternateRole
|
||||
? artifacts.findIndex(
|
||||
(artifact, index) =>
|
||||
index !== modelIndex &&
|
||||
artifact.artifactRole.toLowerCase() === alternateRole &&
|
||||
isOpenClawInteractive3DArtifact(artifact)
|
||||
)
|
||||
: modelIndexes.find((index) => index !== modelIndex) ?? -1;
|
||||
const manifestRole =
|
||||
model.viewer?.interactionManifestArtifactRole?.toLowerCase() ||
|
||||
"model_interaction_manifest";
|
||||
const manifestIndex = artifacts.findIndex(
|
||||
(artifact) => artifact.artifactRole.toLowerCase() === manifestRole
|
||||
);
|
||||
const fallbackRole =
|
||||
model.viewer?.fallbackArtifactRole?.toLowerCase() ||
|
||||
(model.viewer?.variant === "exploded" ? "cad_preview" : "assembled_preview");
|
||||
let previewIndex = artifacts.findIndex(
|
||||
(artifact) => artifact.artifactRole.toLowerCase() === fallbackRole
|
||||
);
|
||||
if (previewIndex < 0) {
|
||||
previewIndex = artifacts.findIndex((artifact) => {
|
||||
const role = artifact.artifactRole.toLowerCase();
|
||||
return role === "assembled_preview" || role === "cad_preview";
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
modelIndex,
|
||||
model,
|
||||
alternateModelIndex,
|
||||
manifestIndex,
|
||||
previewIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenClawArtifactProxyUrl(params: {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
|
||||
289
lib/openclaw-chat/model-interaction.ts
Normal file
289
lib/openclaw-chat/model-interaction.ts
Normal file
@ -0,0 +1,289 @@
|
||||
export type OpenClawModelVector = {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
|
||||
export type OpenClawModelInteractionPart = {
|
||||
nodeName: string;
|
||||
partId: string;
|
||||
partName: string;
|
||||
instanceIndex: number | null;
|
||||
dimensionsMm: OpenClawModelVector | null;
|
||||
assembledPositionMm: OpenClawModelVector | null;
|
||||
explodedPositionMm: OpenClawModelVector | null;
|
||||
explodedOffsetMm: OpenClawModelVector | null;
|
||||
color: string | null;
|
||||
draggable: boolean;
|
||||
};
|
||||
|
||||
export type OpenClawModelInteractionAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
toState: "assembled" | "exploded" | "custom";
|
||||
offsetField: string | null;
|
||||
durationMs: number;
|
||||
easing: string;
|
||||
interruptible: boolean;
|
||||
};
|
||||
|
||||
export type OpenClawModelInteractionManifest = {
|
||||
schema: string;
|
||||
scaleMmToGlb: number;
|
||||
defaultVariant: "assembled" | "exploded";
|
||||
parts: OpenClawModelInteractionPart[];
|
||||
actions: OpenClawModelInteractionAction[];
|
||||
drag: {
|
||||
axes: Array<"x" | "y" | "z">;
|
||||
snapDistanceMm: number;
|
||||
maxDistanceMm: number;
|
||||
};
|
||||
capabilities: {
|
||||
oneClickExplode: boolean;
|
||||
oneClickAssemble: boolean;
|
||||
animatedTransition: boolean;
|
||||
explodeSlider: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const MAX_PARTS = 5000;
|
||||
const MAX_ACTIONS = 32;
|
||||
const MAX_TEXT_LENGTH = 500;
|
||||
const MAX_ABSOLUTE_COORDINATE_MM = 10_000_000;
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function readText(value: unknown, maxLength = MAX_TEXT_LENGTH) {
|
||||
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback: boolean) {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function readFiniteNumber(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
limits?: { min?: number; max?: number }
|
||||
) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
if (limits?.min !== undefined && parsed < limits.min) return fallback;
|
||||
if (limits?.max !== undefined && parsed > limits.max) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeVector(value: unknown): OpenClawModelVector | null {
|
||||
const raw = readRecord(value);
|
||||
const vector = {
|
||||
x: Number(raw.x),
|
||||
y: Number(raw.y),
|
||||
z: Number(raw.z),
|
||||
};
|
||||
if (
|
||||
Object.values(vector).some(
|
||||
(coordinate) =>
|
||||
!Number.isFinite(coordinate) ||
|
||||
Math.abs(coordinate) > MAX_ABSOLUTE_COORDINATE_MM
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
function normalizePart(value: unknown): OpenClawModelInteractionPart | null {
|
||||
const raw = readRecord(value);
|
||||
const nodeName = readText(raw.node_name ?? raw.nodeName);
|
||||
if (!nodeName) return null;
|
||||
const instanceIndex = Number(raw.instance_index ?? raw.instanceIndex);
|
||||
const color = readText(raw.color, 32);
|
||||
|
||||
return {
|
||||
nodeName,
|
||||
partId: readText(raw.part_id ?? raw.partId) || nodeName,
|
||||
partName: readText(raw.part_name ?? raw.partName) || "未命名配件",
|
||||
instanceIndex:
|
||||
Number.isInteger(instanceIndex) && instanceIndex >= 0
|
||||
? instanceIndex
|
||||
: null,
|
||||
dimensionsMm: normalizeVector(raw.dimensions_mm ?? raw.dimensionsMm),
|
||||
assembledPositionMm: normalizeVector(
|
||||
raw.assembled_position_mm ?? raw.assembledPositionMm
|
||||
),
|
||||
explodedPositionMm: normalizeVector(
|
||||
raw.exploded_position_mm ?? raw.explodedPositionMm
|
||||
),
|
||||
explodedOffsetMm: normalizeVector(
|
||||
raw.exploded_offset_mm ?? raw.explodedOffsetMm
|
||||
),
|
||||
color: color || null,
|
||||
draggable: readBoolean(raw.draggable, true),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAction(
|
||||
value: unknown
|
||||
): OpenClawModelInteractionAction | null {
|
||||
const raw = readRecord(value);
|
||||
const id = readText(raw.id, 160);
|
||||
if (!id) return null;
|
||||
const toState = readText(raw.to_state ?? raw.toState, 32).toLowerCase();
|
||||
return {
|
||||
id,
|
||||
label: readText(raw.label, 160) || id,
|
||||
toState:
|
||||
toState === "exploded" || toState === "custom"
|
||||
? toState
|
||||
: "assembled",
|
||||
offsetField: readText(raw.offset_field ?? raw.offsetField, 160) || null,
|
||||
durationMs: readFiniteNumber(
|
||||
raw.duration_ms ?? raw.durationMs,
|
||||
700,
|
||||
{ min: 0, max: 30_000 }
|
||||
),
|
||||
easing: readText(raw.easing, 80) || "easeInOutCubic",
|
||||
interruptible: readBoolean(raw.interruptible, true),
|
||||
};
|
||||
}
|
||||
|
||||
function hasExplodedTransform(part: OpenClawModelInteractionPart) {
|
||||
return Boolean(
|
||||
part.explodedOffsetMm ||
|
||||
(part.assembledPositionMm && part.explodedPositionMm)
|
||||
);
|
||||
}
|
||||
|
||||
function defaultActions(): OpenClawModelInteractionAction[] {
|
||||
return [
|
||||
{
|
||||
id: "explode_all",
|
||||
label: "一键炸开",
|
||||
toState: "exploded",
|
||||
offsetField: "exploded_offset_mm",
|
||||
durationMs: 700,
|
||||
easing: "easeInOutCubic",
|
||||
interruptible: true,
|
||||
},
|
||||
{
|
||||
id: "assemble_all",
|
||||
label: "一键还原",
|
||||
toState: "assembled",
|
||||
offsetField: null,
|
||||
durationMs: 700,
|
||||
easing: "easeInOutCubic",
|
||||
interruptible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function normalizeOpenClawModelInteractionManifest(
|
||||
value: unknown
|
||||
): OpenClawModelInteractionManifest | null {
|
||||
const raw = readRecord(value);
|
||||
if (Object.keys(raw).length === 0) return null;
|
||||
|
||||
const seenNodeNames = new Set<string>();
|
||||
const parts = (Array.isArray(raw.parts) ? raw.parts : [])
|
||||
.slice(0, MAX_PARTS)
|
||||
.map(normalizePart)
|
||||
.filter((part): part is OpenClawModelInteractionPart => {
|
||||
if (!part || seenNodeNames.has(part.nodeName)) return false;
|
||||
seenNodeNames.add(part.nodeName);
|
||||
return true;
|
||||
});
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const parsedActions = (Array.isArray(raw.actions) ? raw.actions : [])
|
||||
.slice(0, MAX_ACTIONS)
|
||||
.map(normalizeAction)
|
||||
.filter((action): action is OpenClawModelInteractionAction => Boolean(action));
|
||||
const supportsTransforms = parts.some(hasExplodedTransform);
|
||||
const actions =
|
||||
parsedActions.length > 0
|
||||
? parsedActions
|
||||
: supportsTransforms
|
||||
? defaultActions()
|
||||
: [];
|
||||
const coordinates = readRecord(raw.coordinates);
|
||||
const drag = readRecord(raw.drag);
|
||||
const capabilities = readRecord(raw.capabilities);
|
||||
const axes = (Array.isArray(drag.axes) ? drag.axes : ["x", "y", "z"]).filter(
|
||||
(axis): axis is "x" | "y" | "z" =>
|
||||
axis === "x" || axis === "y" || axis === "z"
|
||||
);
|
||||
const defaultVariant = readText(
|
||||
raw.default_variant ?? raw.defaultVariant,
|
||||
32
|
||||
).toLowerCase();
|
||||
|
||||
return {
|
||||
schema: readText(raw.schema) || "dalongxia.furniture-model-interaction.v1",
|
||||
scaleMmToGlb: readFiniteNumber(
|
||||
coordinates.scale_mm_to_glb ?? coordinates.scaleMmToGlb,
|
||||
0.001,
|
||||
{ min: 0.000_000_1, max: 1 }
|
||||
),
|
||||
defaultVariant: defaultVariant === "exploded" ? "exploded" : "assembled",
|
||||
parts,
|
||||
actions,
|
||||
drag: {
|
||||
axes: axes.length > 0 ? axes : ["x", "y", "z"],
|
||||
snapDistanceMm: readFiniteNumber(
|
||||
drag.snap_distance_mm ?? drag.snapDistanceMm,
|
||||
25,
|
||||
{ min: 0, max: 100_000 }
|
||||
),
|
||||
maxDistanceMm: readFiniteNumber(
|
||||
drag.max_distance_mm ?? drag.maxDistanceMm,
|
||||
1800,
|
||||
{ min: 0, max: MAX_ABSOLUTE_COORDINATE_MM }
|
||||
),
|
||||
},
|
||||
capabilities: {
|
||||
oneClickExplode: readBoolean(
|
||||
capabilities.one_click_explode ?? capabilities.oneClickExplode,
|
||||
supportsTransforms
|
||||
),
|
||||
oneClickAssemble: readBoolean(
|
||||
capabilities.one_click_assemble ?? capabilities.oneClickAssemble,
|
||||
supportsTransforms
|
||||
),
|
||||
animatedTransition: readBoolean(
|
||||
capabilities.animated_transition ?? capabilities.animatedTransition,
|
||||
supportsTransforms
|
||||
),
|
||||
explodeSlider: readBoolean(
|
||||
capabilities.explode_slider ?? capabilities.explodeSlider,
|
||||
supportsTransforms
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getOpenClawExplodedOffsetMm(
|
||||
part: OpenClawModelInteractionPart
|
||||
): OpenClawModelVector | null {
|
||||
if (part.explodedOffsetMm) return part.explodedOffsetMm;
|
||||
if (!part.assembledPositionMm || !part.explodedPositionMm) return null;
|
||||
return {
|
||||
x: part.explodedPositionMm.x - part.assembledPositionMm.x,
|
||||
y: part.explodedPositionMm.y - part.assembledPositionMm.y,
|
||||
z: part.explodedPositionMm.z - part.assembledPositionMm.z,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapOpenClawCadOffsetToGlb(
|
||||
offset: OpenClawModelVector,
|
||||
scaleMmToGlb: number
|
||||
): OpenClawModelVector {
|
||||
return {
|
||||
x: offset.x * scaleMmToGlb,
|
||||
y: offset.z * scaleMmToGlb,
|
||||
z: -offset.y * scaleMmToGlb,
|
||||
};
|
||||
}
|
||||
@ -2,9 +2,15 @@ import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
buildOpenClawArtifactProxyUrl,
|
||||
extractOpenClawArtifacts,
|
||||
getOpenClaw3DArtifactBundle,
|
||||
isOpenClawInteractive3DArtifact,
|
||||
normalizeOpenClawArtifacts,
|
||||
} from "@/lib/openclaw-chat/artifacts";
|
||||
import {
|
||||
getOpenClawExplodedOffsetMm,
|
||||
mapOpenClawCadOffsetToGlb,
|
||||
normalizeOpenClawModelInteractionManifest,
|
||||
} from "@/lib/openclaw-chat/model-interaction";
|
||||
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
|
||||
|
||||
const modelFile = {
|
||||
@ -34,6 +40,39 @@ const previewFile = {
|
||||
download_url: "https://files.example.com/wood-demo-preview.png",
|
||||
};
|
||||
|
||||
const assembledModelFile = {
|
||||
...modelFile,
|
||||
filename: "wood-demo-assembled.glb",
|
||||
display_name: "可旋转3D整装成品模型",
|
||||
artifact_role: "assembled_3d",
|
||||
viewer: {
|
||||
...modelFile.viewer,
|
||||
variant: "assembled",
|
||||
default: true,
|
||||
fallback_artifact_role: "assembled_preview",
|
||||
alternate_artifact_role: "interactive_3d",
|
||||
interaction_manifest_artifact_role: "model_interaction_manifest",
|
||||
part_selection: true,
|
||||
part_dragging: true,
|
||||
reset_parts: true,
|
||||
one_click_explode: true,
|
||||
one_click_assemble: true,
|
||||
explode_action_id: "explode_all",
|
||||
assemble_action_id: "assemble_all",
|
||||
transition_duration_ms: 850,
|
||||
explode_slider: true,
|
||||
},
|
||||
};
|
||||
|
||||
const manifestFile = {
|
||||
type: "file",
|
||||
filename: "wood-demo-model-interaction.json",
|
||||
artifact_role: "model_interaction_manifest",
|
||||
display_mode: "inline_only",
|
||||
mime_type: "application/json",
|
||||
download_url: "https://files.example.com/wood-demo-model-interaction.json",
|
||||
};
|
||||
|
||||
test.describe("OpenClaw 3D artifacts", () => {
|
||||
test("normalizes the documented GLB viewer contract", () => {
|
||||
const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], {
|
||||
@ -60,6 +99,118 @@ test.describe("OpenClaw 3D artifacts", () => {
|
||||
expect(isOpenClawInteractive3DArtifact(artifacts[1])).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the new viewer controls and selects the default assembled bundle", () => {
|
||||
const artifacts = normalizeOpenClawArtifacts(
|
||||
[
|
||||
modelFile,
|
||||
manifestFile,
|
||||
assembledModelFile,
|
||||
{ ...previewFile, artifact_role: "assembled_preview" },
|
||||
],
|
||||
{ baseUrl: "https://claw.example.com" }
|
||||
);
|
||||
const bundle = getOpenClaw3DArtifactBundle(artifacts);
|
||||
|
||||
expect(bundle?.model.artifactRole).toBe("assembled_3d");
|
||||
expect(bundle?.model.viewer).toMatchObject({
|
||||
variant: "assembled",
|
||||
isDefault: true,
|
||||
alternateArtifactRole: "interactive_3d",
|
||||
interactionManifestArtifactRole: "model_interaction_manifest",
|
||||
partSelection: true,
|
||||
partDragging: true,
|
||||
resetParts: true,
|
||||
oneClickExplode: true,
|
||||
oneClickAssemble: true,
|
||||
explodeActionId: "explode_all",
|
||||
assembleActionId: "assemble_all",
|
||||
transitionDurationMs: 850,
|
||||
explodeSlider: true,
|
||||
});
|
||||
expect(bundle?.manifestIndex).toBe(1);
|
||||
expect(bundle?.alternateModelIndex).toBe(0);
|
||||
expect(bundle?.previewIndex).toBe(3);
|
||||
});
|
||||
|
||||
test("normalizes v2 interaction transforms and CAD-to-GLB coordinates", () => {
|
||||
const manifest = normalizeOpenClawModelInteractionManifest({
|
||||
schema: "dalongxia.furniture-model-interaction.v2",
|
||||
coordinates: { scale_mm_to_glb: 0.001 },
|
||||
capabilities: {
|
||||
one_click_explode: true,
|
||||
one_click_assemble: true,
|
||||
animated_transition: true,
|
||||
explode_slider: true,
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: "explode_all",
|
||||
label: "一键炸开",
|
||||
to_state: "exploded",
|
||||
offset_field: "exploded_offset_mm",
|
||||
duration_ms: 700,
|
||||
easing: "easeInOutCubic",
|
||||
interruptible: true,
|
||||
},
|
||||
],
|
||||
drag: {
|
||||
axes: ["x", "y", "z"],
|
||||
snap_distance_mm: 25,
|
||||
max_distance_mm: 1800,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
node_name: "DOOR-001:1",
|
||||
part_id: "DOOR-001",
|
||||
part_name: "门板",
|
||||
dimensions_mm: { x: 578, y: 20, z: 720 },
|
||||
exploded_offset_mm: { x: 2726, y: 120, z: -40 },
|
||||
draggable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(manifest).not.toBeNull();
|
||||
expect(manifest?.parts[0]).toMatchObject({
|
||||
nodeName: "DOOR-001:1",
|
||||
partId: "DOOR-001",
|
||||
partName: "门板",
|
||||
draggable: true,
|
||||
});
|
||||
const offset = getOpenClawExplodedOffsetMm(manifest!.parts[0]);
|
||||
expect(offset).toEqual({ x: 2726, y: 120, z: -40 });
|
||||
expect(mapOpenClawCadOffsetToGlb(offset!, manifest!.scaleMmToGlb)).toEqual({
|
||||
x: 2.726,
|
||||
y: -0.04,
|
||||
z: -0.12,
|
||||
});
|
||||
});
|
||||
|
||||
test("derives exploded offsets and default actions for historical v1 manifests", () => {
|
||||
const manifest = normalizeOpenClawModelInteractionManifest({
|
||||
schema: "dalongxia.furniture-model-interaction.v1",
|
||||
parts: [
|
||||
{
|
||||
node_name: "TOP-001:1",
|
||||
part_id: "TOP-001",
|
||||
assembled_position_mm: { x: 20, y: 10, z: 40 },
|
||||
exploded_position_mm: { x: 220, y: -40, z: 140 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(manifest?.actions.map((action) => action.id)).toEqual([
|
||||
"explode_all",
|
||||
"assemble_all",
|
||||
]);
|
||||
expect(getOpenClawExplodedOffsetMm(manifest!.parts[0])).toEqual({
|
||||
x: 200,
|
||||
y: -50,
|
||||
z: 100,
|
||||
});
|
||||
expect(manifest?.capabilities.explodeSlider).toBe(true);
|
||||
});
|
||||
|
||||
test("extracts files from durable task results without duplicating them", () => {
|
||||
const artifacts = extractOpenClawArtifacts(
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user