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

This commit is contained in:
134 2026-09-03 16:50:14 +08:00 committed by root
parent 2817aab197
commit 9717196264
5 changed files with 94 additions and 3 deletions

View File

@ -14,6 +14,7 @@ import type {
} from "@/lib/openclaw-chat/model-interaction"; } from "@/lib/openclaw-chat/model-interaction";
import { import {
getOpenClawExplodedOffsetMm, getOpenClawExplodedOffsetMm,
getOpenClawSourceNodeOffset,
mapOpenClawCadOffsetToGlb, mapOpenClawCadOffsetToGlb,
} from "@/lib/openclaw-chat/model-interaction"; } from "@/lib/openclaw-chat/model-interaction";
@ -154,6 +155,7 @@ function ModelInteractionScene({
resetAllToken, resetAllToken,
resetSelectedToken, resetSelectedToken,
selectedNodeName, selectedNodeName,
sourceVariant,
transformRequest, transformRequest,
}: { }: {
allowDragging: boolean; allowDragging: boolean;
@ -164,12 +166,31 @@ function ModelInteractionScene({
resetAllToken: number; resetAllToken: number;
resetSelectedToken: number; resetSelectedToken: number;
selectedNodeName: string | null; selectedNodeName: string | null;
sourceVariant: "assembled" | "exploded";
transformRequest: OpenClawModelTransformRequest; transformRequest: OpenClawModelTransformRequest;
}) { }) {
const { camera, gl, scene: rootScene } = useThree(); const { camera, gl, scene: rootScene } = useThree();
const controlsRef = useRef<OrbitControlsImpl | null>(null); const controlsRef = useRef<OrbitControlsImpl | null>(null);
const gltf = useGLTF(modelUrl, true); 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 fitTransform = useMemo(() => getFitTransform(scene), [scene]);
const entriesRef = useRef<Map<string, InteractiveEntry>>(new Map()); const entriesRef = useRef<Map<string, InteractiveEntry>>(new Map());
const objectEntriesRef = useRef<Map<Object3D, InteractiveEntry>>(new Map()); const objectEntriesRef = useRef<Map<Object3D, InteractiveEntry>>(new Map());
@ -543,6 +564,7 @@ export function OpenClawInteractiveModelViewer({
resetSelectedToken, resetSelectedToken,
resetViewToken, resetViewToken,
selectedNodeName, selectedNodeName,
sourceVariant,
transformRequest, transformRequest,
}: { }: {
allowDragging: boolean; allowDragging: boolean;
@ -556,6 +578,7 @@ export function OpenClawInteractiveModelViewer({
resetSelectedToken: number; resetSelectedToken: number;
resetViewToken: number; resetViewToken: number;
selectedNodeName: string | null; selectedNodeName: string | null;
sourceVariant: "assembled" | "exploded";
transformRequest: OpenClawModelTransformRequest; transformRequest: OpenClawModelTransformRequest;
}) { }) {
return ( return (
@ -595,6 +618,7 @@ export function OpenClawInteractiveModelViewer({
resetAllToken={resetAllToken} resetAllToken={resetAllToken}
resetSelectedToken={resetSelectedToken} resetSelectedToken={resetSelectedToken}
selectedNodeName={selectedNodeName} selectedNodeName={selectedNodeName}
sourceVariant={sourceVariant}
transformRequest={transformRequest} transformRequest={transformRequest}
/> />
</Suspense> </Suspense>

View File

@ -15,6 +15,7 @@ import {
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { import {
buildOpenClawArtifactProxyUrl, buildOpenClawArtifactProxyUrl,
getOpenClaw3DArtifactVariant,
getOpenClaw3DArtifactBundle, getOpenClaw3DArtifactBundle,
type OpenClawArtifact, type OpenClawArtifact,
} from "@/lib/openclaw-chat/artifacts"; } from "@/lib/openclaw-chat/artifacts";
@ -203,7 +204,14 @@ export function OpenClawModel3DPreview({
const isAssembledModel = const isAssembledModel =
model.artifactRole.toLowerCase() === "assembled_3d" || model.artifactRole.toLowerCase() === "assembled_3d" ||
model.viewer?.variant === "assembled"; 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( const canDragParts = Boolean(
supportsNodeInteraction && supportsNodeInteraction &&
(model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable)) (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"> <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">
{model.displayName || model.filename || "3D 模型"} {displayedModelName}
</span> </span>
<span className="shrink-0 text-[11px] uppercase text-[#9aa2af] dark:text-zinc-500"> <span className="shrink-0 text-[11px] uppercase text-[#9aa2af] dark:text-zinc-500">
{model.viewer?.format || "glb"} {model.viewer?.format || "glb"}
@ -321,6 +329,7 @@ export function OpenClawModel3DPreview({
resetSelectedToken={resetSelectedToken} resetSelectedToken={resetSelectedToken}
resetViewToken={resetViewToken} resetViewToken={resetViewToken}
selectedNodeName={selectedPart?.nodeName || null} selectedNodeName={selectedPart?.nodeName || null}
sourceVariant={sourceVariant}
transformRequest={transformRequest} transformRequest={transformRequest}
/> />

View File

@ -274,6 +274,22 @@ export type OpenClaw3DArtifactBundle = {
previewIndex: number; 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( export function getOpenClaw3DArtifactBundle(
artifacts: OpenClawArtifact[] artifacts: OpenClawArtifact[]
): OpenClaw3DArtifactBundle | null { ): OpenClaw3DArtifactBundle | null {

View File

@ -287,3 +287,15 @@ export function mapOpenClawCadOffsetToGlb(
z: -offset.y * scaleMmToGlb, 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 { import {
buildOpenClawArtifactProxyUrl, buildOpenClawArtifactProxyUrl,
extractOpenClawArtifacts, extractOpenClawArtifacts,
getOpenClaw3DArtifactVariant,
getOpenClaw3DArtifactBundle, getOpenClaw3DArtifactBundle,
isOpenClawInteractive3DArtifact, isOpenClawInteractive3DArtifact,
normalizeOpenClawArtifacts, normalizeOpenClawArtifacts,
} from "@/lib/openclaw-chat/artifacts"; } from "@/lib/openclaw-chat/artifacts";
import { import {
getOpenClawExplodedOffsetMm, getOpenClawExplodedOffsetMm,
getOpenClawSourceNodeOffset,
mapOpenClawCadOffsetToGlb, mapOpenClawCadOffsetToGlb,
normalizeOpenClawModelInteractionManifest, normalizeOpenClawModelInteractionManifest,
} from "@/lib/openclaw-chat/model-interaction"; } from "@/lib/openclaw-chat/model-interaction";
@ -132,6 +134,34 @@ test.describe("OpenClaw 3D artifacts", () => {
expect(bundle?.previewIndex).toBe(3); 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", () => { test("normalizes v2 interaction transforms and CAD-to-GLB coordinates", () => {
const manifest = normalizeOpenClawModelInteractionManifest({ const manifest = normalizeOpenClawModelInteractionManifest({
schema: "dalongxia.furniture-model-interaction.v2", schema: "dalongxia.furniture-model-interaction.v2",