fix(claw): assemble legacy 3d models by default

This commit is contained in:
134 2026-09-03 16:50:14 +08:00
parent bbfece5720
commit 813cb42863
5 changed files with 94 additions and 3 deletions

View File

@ -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<OrbitControlsImpl | null>(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<Map<string, InteractiveEntry>>(new Map());
const objectEntriesRef = useRef<Map<Object3D, InteractiveEntry>>(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}
/>
</Suspense>

View File

@ -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({
<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">
{model.displayName || model.filename || "3D 模型"}
{displayedModelName}
</span>
<span className="shrink-0 text-[11px] uppercase text-[#9aa2af] dark:text-zinc-500">
{model.viewer?.format || "glb"}
@ -321,6 +329,7 @@ export function OpenClawModel3DPreview({
resetSelectedToken={resetSelectedToken}
resetViewToken={resetViewToken}
selectedNodeName={selectedPart?.nodeName || null}
sourceVariant={sourceVariant}
transformRequest={transformRequest}
/>

View File

@ -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 {

View File

@ -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 };
}

View File

@ -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",