From 97171962647f8089bc7607ca76dccb7e561a8ec9 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 3 Sep 2026 16:50:14 +0800 Subject: [PATCH] fix(claw): assemble legacy 3d models by default --- .../OpenClawInteractiveModelViewer.tsx | 26 +++++++++++++++- .../chat-panel/OpenClawModel3DPreview.tsx | 13 ++++++-- lib/openclaw-chat/artifacts.ts | 16 ++++++++++ lib/openclaw-chat/model-interaction.ts | 12 ++++++++ tests/routes/openclaw-3d-artifacts.test.ts | 30 +++++++++++++++++++ 5 files changed, 94 insertions(+), 3 deletions(-) diff --git a/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx b/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx index dae71ef76..cc8117a9f 100644 --- a/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx +++ b/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx @@ -14,6 +14,7 @@ import type { } from "@/lib/openclaw-chat/model-interaction"; import { getOpenClawExplodedOffsetMm, + getOpenClawSourceNodeOffset, mapOpenClawCadOffsetToGlb, } from "@/lib/openclaw-chat/model-interaction"; @@ -154,6 +155,7 @@ function ModelInteractionScene({ resetAllToken, resetSelectedToken, selectedNodeName, + sourceVariant, transformRequest, }: { allowDragging: boolean; @@ -164,12 +166,31 @@ function ModelInteractionScene({ resetAllToken: number; resetSelectedToken: number; selectedNodeName: string | null; + sourceVariant: "assembled" | "exploded"; transformRequest: OpenClawModelTransformRequest; }) { const { camera, gl, scene: rootScene } = useThree(); const controlsRef = useRef(null); const gltf = useGLTF(modelUrl, true); - const scene = useMemo(() => cloneScene(gltf.scene), [gltf.scene]); + const scene = useMemo(() => { + const cloned = cloneScene(gltf.scene); + if (sourceVariant !== "exploded") return cloned; + const partsByNodeName = new Map( + manifest.parts.map((part) => [part.nodeName, part]) + ); + cloned.traverse((node) => { + const part = partsByNodeName.get(node.name); + if (!part) return; + const offset = getOpenClawSourceNodeOffset( + part, + sourceVariant, + manifest.scaleMmToGlb + ); + node.position.add(new THREE.Vector3(offset.x, offset.y, offset.z)); + }); + cloned.updateMatrixWorld(true); + return cloned; + }, [gltf.scene, manifest, sourceVariant]); const fitTransform = useMemo(() => getFitTransform(scene), [scene]); const entriesRef = useRef>(new Map()); const objectEntriesRef = useRef>(new Map()); @@ -543,6 +564,7 @@ export function OpenClawInteractiveModelViewer({ resetSelectedToken, resetViewToken, selectedNodeName, + sourceVariant, transformRequest, }: { allowDragging: boolean; @@ -556,6 +578,7 @@ export function OpenClawInteractiveModelViewer({ resetSelectedToken: number; resetViewToken: number; selectedNodeName: string | null; + sourceVariant: "assembled" | "exploded"; transformRequest: OpenClawModelTransformRequest; }) { return ( @@ -595,6 +618,7 @@ export function OpenClawInteractiveModelViewer({ resetAllToken={resetAllToken} resetSelectedToken={resetSelectedToken} selectedNodeName={selectedNodeName} + sourceVariant={sourceVariant} transformRequest={transformRequest} /> diff --git a/components/canvas/chat-panel/OpenClawModel3DPreview.tsx b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx index 46177adf9..547d83018 100644 --- a/components/canvas/chat-panel/OpenClawModel3DPreview.tsx +++ b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx @@ -15,6 +15,7 @@ import { import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { buildOpenClawArtifactProxyUrl, + getOpenClaw3DArtifactVariant, getOpenClaw3DArtifactBundle, type OpenClawArtifact, } from "@/lib/openclaw-chat/artifacts"; @@ -203,7 +204,14 @@ export function OpenClawModel3DPreview({ const isAssembledModel = model.artifactRole.toLowerCase() === "assembled_3d" || model.viewer?.variant === "assembled"; - const supportsNodeInteraction = Boolean(manifest && isAssembledModel); + const sourceVariant = getOpenClaw3DArtifactVariant(model); + const supportsNodeInteraction = Boolean( + manifest && (isAssembledModel || sourceVariant === "exploded") + ); + const displayedModelName = + supportsNodeInteraction && sourceVariant === "exploded" + ? "可旋转3D整装成品模型" + : model.displayName || model.filename || "3D 模型"; const canDragParts = Boolean( supportsNodeInteraction && (model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable)) @@ -259,7 +267,7 @@ export function OpenClawModel3DPreview({
- {model.displayName || model.filename || "3D 模型"} + {displayedModelName} {model.viewer?.format || "glb"} @@ -321,6 +329,7 @@ export function OpenClawModel3DPreview({ resetSelectedToken={resetSelectedToken} resetViewToken={resetViewToken} selectedNodeName={selectedPart?.nodeName || null} + sourceVariant={sourceVariant} transformRequest={transformRequest} /> diff --git a/lib/openclaw-chat/artifacts.ts b/lib/openclaw-chat/artifacts.ts index 4eeb845ef..0402ac704 100644 --- a/lib/openclaw-chat/artifacts.ts +++ b/lib/openclaw-chat/artifacts.ts @@ -274,6 +274,22 @@ export type OpenClaw3DArtifactBundle = { previewIndex: number; }; +export function getOpenClaw3DArtifactVariant( + artifact: OpenClawArtifact +): "assembled" | "exploded" { + if (artifact.viewer?.variant) return artifact.viewer.variant; + const role = artifact.artifactRole.toLowerCase(); + const filename = artifact.filename.toLowerCase(); + if ( + role === "interactive_3d" || + filename.includes("exploded") || + filename.includes("interactive") + ) { + return "exploded"; + } + return "assembled"; +} + export function getOpenClaw3DArtifactBundle( artifacts: OpenClawArtifact[] ): OpenClaw3DArtifactBundle | null { diff --git a/lib/openclaw-chat/model-interaction.ts b/lib/openclaw-chat/model-interaction.ts index 870506196..18d1afe43 100644 --- a/lib/openclaw-chat/model-interaction.ts +++ b/lib/openclaw-chat/model-interaction.ts @@ -287,3 +287,15 @@ export function mapOpenClawCadOffsetToGlb( z: -offset.y * scaleMmToGlb, }; } + +export function getOpenClawSourceNodeOffset( + part: OpenClawModelInteractionPart, + sourceVariant: "assembled" | "exploded", + scaleMmToGlb: number +): OpenClawModelVector { + if (sourceVariant === "assembled") return { x: 0, y: 0, z: 0 }; + const explodedOffset = getOpenClawExplodedOffsetMm(part); + if (!explodedOffset) return { x: 0, y: 0, z: 0 }; + const mapped = mapOpenClawCadOffsetToGlb(explodedOffset, scaleMmToGlb); + return { x: -mapped.x, y: -mapped.y, z: -mapped.z }; +} diff --git a/tests/routes/openclaw-3d-artifacts.test.ts b/tests/routes/openclaw-3d-artifacts.test.ts index 751007981..f1bfd8b61 100644 --- a/tests/routes/openclaw-3d-artifacts.test.ts +++ b/tests/routes/openclaw-3d-artifacts.test.ts @@ -2,12 +2,14 @@ import { expect, test } from "@playwright/test"; import { buildOpenClawArtifactProxyUrl, extractOpenClawArtifacts, + getOpenClaw3DArtifactVariant, getOpenClaw3DArtifactBundle, isOpenClawInteractive3DArtifact, normalizeOpenClawArtifacts, } from "@/lib/openclaw-chat/artifacts"; import { getOpenClawExplodedOffsetMm, + getOpenClawSourceNodeOffset, mapOpenClawCadOffsetToGlb, normalizeOpenClawModelInteractionManifest, } from "@/lib/openclaw-chat/model-interaction"; @@ -132,6 +134,34 @@ test.describe("OpenClaw 3D artifacts", () => { expect(bundle?.previewIndex).toBe(3); }); + test("treats legacy interactive-only GLB as exploded source and can restore assembly", () => { + const artifacts = normalizeOpenClawArtifacts([modelFile, manifestFile], { + baseUrl: "https://claw.example.com", + }); + const bundle = getOpenClaw3DArtifactBundle(artifacts); + expect(bundle?.model.artifactRole).toBe("interactive_3d"); + expect(getOpenClaw3DArtifactVariant(bundle!.model)).toBe("exploded"); + + const manifest = normalizeOpenClawModelInteractionManifest({ + schema: "dalongxia.furniture-model-interaction.v2", + coordinates: { scale_mm_to_glb: 0.001 }, + parts: [ + { + node_name: "DOOR-001:1", + part_id: "DOOR-001", + exploded_offset_mm: { x: 2726, y: 120, z: -40 }, + }, + ], + }); + expect( + getOpenClawSourceNodeOffset( + manifest!.parts[0], + "exploded", + manifest!.scaleMmToGlb + ) + ).toEqual({ x: -2.726, y: 0.04, z: 0.12 }); + }); + test("normalizes v2 interaction transforms and CAD-to-GLB coordinates", () => { const manifest = normalizeOpenClawModelInteractionManifest({ schema: "dalongxia.furniture-model-interaction.v2",