feat(claw): support interactive 3D previews

This commit is contained in:
134 2026-09-02 20:21:40 +08:00
parent 6ab68e8ac9
commit bbfece5720
5 changed files with 1513 additions and 49 deletions

View 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>
);
}

View File

@ -2,13 +2,27 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Box, Download, Loader2, RotateCcw } from "lucide-react"; import {
import { useMemo, useState } from "react"; Box,
Download,
Loader2,
Maximize2,
Minimize2,
RotateCcw,
Undo2,
X,
} from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { import {
buildOpenClawArtifactProxyUrl, buildOpenClawArtifactProxyUrl,
isOpenClawInteractive3DArtifact, getOpenClaw3DArtifactBundle,
type OpenClawArtifact, type OpenClawArtifact,
} from "@/lib/openclaw-chat/artifacts"; } from "@/lib/openclaw-chat/artifacts";
import {
normalizeOpenClawModelInteractionManifest,
type OpenClawModelInteractionManifest,
type OpenClawModelInteractionPart,
} from "@/lib/openclaw-chat/model-interaction";
const Model3DViewer = dynamic( const Model3DViewer = dynamic(
() => () =>
@ -17,14 +31,31 @@ const Model3DViewer = dynamic(
), ),
{ {
ssr: false, ssr: false,
loading: () => ( loading: () => <ViewerLoading />,
<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>
),
} }
); );
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() { function supportsWebGl() {
try { try {
const canvas = document.createElement("canvas"); 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({ export function OpenClawModel3DPreview({
artifacts, artifacts,
chatId, chatId,
@ -47,40 +84,146 @@ export function OpenClawModel3DPreview({
chatId: string; chatId: string;
messageId: string; messageId: string;
}) { }) {
const modelIndex = useMemo( const viewerId = useId();
() => artifacts.findIndex(isOpenClawInteractive3DArtifact), const bundle = useMemo(
() => getOpenClaw3DArtifactBundle(artifacts),
[artifacts] [artifacts]
); );
const model = modelIndex >= 0 ? artifacts[modelIndex] : null; const model = bundle?.model || null;
const fallbackRole = model?.viewer?.fallbackArtifactRole || "cad_preview";
const previewIndex = artifacts.findIndex(
(artifact) => artifact.artifactRole === fallbackRole
);
const [isActive, setIsActive] = useState(false); const [isActive, setIsActive] = useState(false);
const [webGlUnavailable, setWebGlUnavailable] = useState(false); const [webGlUnavailable, setWebGlUnavailable] = useState(false);
const [resetViewToken, setResetViewToken] = useState(0); 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({ const modelUrl = buildOpenClawArtifactProxyUrl({
chatId, chatId,
messageId, messageId,
artifactIndex: modelIndex, artifactIndex: bundle.modelIndex,
}); });
const downloadUrl = buildOpenClawArtifactProxyUrl({ const downloadUrl = buildOpenClawArtifactProxyUrl({
chatId, chatId,
messageId, messageId,
artifactIndex: modelIndex, artifactIndex: bundle.modelIndex,
download: true, download: true,
}); });
const previewUrl = const previewUrl =
previewIndex >= 0 bundle.previewIndex >= 0
? buildOpenClawArtifactProxyUrl({ ? buildOpenClawArtifactProxyUrl({
chatId, chatId,
messageId, messageId,
artifactIndex: previewIndex, artifactIndex: bundle.previewIndex,
}) })
: null; : 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 = () => { const activateViewer = () => {
if (!supportsWebGl()) { if (!supportsWebGl()) {
@ -88,12 +231,31 @@ export function OpenClawModel3DPreview({
return; return;
} }
setWebGlUnavailable(false); setWebGlUnavailable(false);
window.dispatchEvent(new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId }));
setIsActive(true); 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 ( 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"> <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"> <div className="flex min-w-0 items-center gap-2">
<Box className="h-4 w-4 shrink-0 text-[#687386] dark:text-zinc-400" /> <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"> <span className="truncate text-[13px] font-medium text-[#2f3640] dark:text-zinc-200">
@ -105,15 +267,27 @@ export function OpenClawModel3DPreview({
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{isActive ? ( {isActive ? (
<button <>
type="button" <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" type="button"
aria-label="复位3D视角" 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"
title="复位3D视角" aria-label="复位3D视角"
onClick={() => setResetViewToken((value) => value + 1)} title="复位3D视角"
> onClick={() => setResetViewToken((value) => value + 1)}
<RotateCcw className="h-4 w-4" /> >
</button> <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} ) : null}
<a <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" 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> </header>
<div <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" }} style={{ backgroundColor: model.viewer?.background || "#f7f9fc" }}
> >
{isActive ? ( {isActive ? (
<Model3DViewer manifest === undefined && bundle.manifestIndex >= 0 ? (
autoFitModel <ViewerLoading />
modelFormat={model.viewer?.format || "glb"} ) : supportsNodeInteraction && manifest ? (
modelUrl={modelUrl} <>
previewImageUrl={previewUrl} <OpenClawInteractiveModelViewer
resetViewToken={resetViewToken} allowDragging={canDragParts}
settings={{ background={model.viewer?.background || "#f7f9fc"}
autoRotate: model.viewer?.autoRotate || false, manifest={manifest}
cameraPreset: "iso", modelUrl={modelUrl}
envPreset: "studio", onMatchChange={handleMatchChange}
lightIntensity: 1, onSelectedPartChange={setSelectedPart}
showGrid: false, previewImageUrl={previewUrl}
wireframe: false, 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"> <div className="relative flex h-full w-full items-center justify-center bg-[#f7f9fc] dark:bg-zinc-950">
{previewUrl ? ( {previewUrl ? (
@ -160,7 +450,7 @@ export function OpenClawModel3DPreview({
)} )}
<button <button
type="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} onClick={activateViewer}
> >
<Box className="h-4 w-4" /> <Box className="h-4 w-4" />
@ -169,9 +459,11 @@ export function OpenClawModel3DPreview({
</div> </div>
)} )}
</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"> <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> </p>
) : null} ) : null}
</section> </section>

View File

@ -1,10 +1,23 @@
export type OpenClawArtifactViewer = { export type OpenClawArtifactViewer = {
type: "model_3d"; type: "model_3d";
format: "glb" | "gltf"; format: "glb" | "gltf";
variant: "assembled" | "exploded" | null;
isDefault: boolean;
cameraControls: boolean; cameraControls: boolean;
autoRotate: boolean; autoRotate: boolean;
background: string | null; background: string | null;
fallbackArtifactRole: 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 = { export type OpenClawArtifact = {
@ -40,6 +53,18 @@ function readBoolean(value: unknown, fallback: boolean) {
return typeof value === "boolean" ? value : fallback; 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) { function normalizeArtifactUrl(value: unknown, baseUrl?: string) {
const raw = readText(value, MAX_URL_LENGTH); const raw = readText(value, MAX_URL_LENGTH);
if (!raw) return ""; if (!raw) return "";
@ -90,6 +115,7 @@ function normalizeViewer(
if (type && type !== "model_3d") return null; if (type && type !== "model_3d") return null;
const background = readText(raw.background, 32); const background = readText(raw.background, 32);
const variant = readText(raw.variant, 32).toLowerCase();
return { return {
type: "model_3d", type: "model_3d",
format: inferViewerFormat({ format: inferViewerFormat({
@ -97,11 +123,48 @@ function normalizeViewer(
mimeType: artifact.mimeType, mimeType: artifact.mimeType,
value: raw.format, 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), cameraControls: readBoolean(raw.camera_controls ?? raw.cameraControls, true),
autoRotate: readBoolean(raw.auto_rotate ?? raw.autoRotate, false), autoRotate: readBoolean(raw.auto_rotate ?? raw.autoRotate, false),
background: background || null, background: background || null,
fallbackArtifactRole: fallbackArtifactRole:
readText(raw.fallback_artifact_role ?? raw.fallbackArtifactRole) || null, 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 mimeType = artifact.mimeType.toLowerCase();
const filename = artifact.filename.toLowerCase(); const filename = artifact.filename.toLowerCase();
return ( return (
role === "assembled_3d" ||
role === "interactive_3d" || role === "interactive_3d" ||
mimeType === "model/gltf-binary" || mimeType === "model/gltf-binary" ||
mimeType === "model/gltf+json" || 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: { export function buildOpenClawArtifactProxyUrl(params: {
chatId: string; chatId: string;
messageId: string; messageId: string;

View 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,
};
}

View File

@ -2,9 +2,15 @@ import { expect, test } from "@playwright/test";
import { import {
buildOpenClawArtifactProxyUrl, buildOpenClawArtifactProxyUrl,
extractOpenClawArtifacts, extractOpenClawArtifacts,
getOpenClaw3DArtifactBundle,
isOpenClawInteractive3DArtifact, isOpenClawInteractive3DArtifact,
normalizeOpenClawArtifacts, normalizeOpenClawArtifacts,
} from "@/lib/openclaw-chat/artifacts"; } from "@/lib/openclaw-chat/artifacts";
import {
getOpenClawExplodedOffsetMm,
mapOpenClawCadOffsetToGlb,
normalizeOpenClawModelInteractionManifest,
} from "@/lib/openclaw-chat/model-interaction";
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
const modelFile = { const modelFile = {
@ -34,6 +40,39 @@ const previewFile = {
download_url: "https://files.example.com/wood-demo-preview.png", 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.describe("OpenClaw 3D artifacts", () => {
test("normalizes the documented GLB viewer contract", () => { test("normalizes the documented GLB viewer contract", () => {
const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], { const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], {
@ -60,6 +99,118 @@ test.describe("OpenClaw 3D artifacts", () => {
expect(isOpenClawInteractive3DArtifact(artifacts[1])).toBe(false); 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", () => { test("extracts files from durable task results without duplicating them", () => {
const artifacts = extractOpenClawArtifacts( const artifacts = extractOpenClawArtifacts(
{ {