fix(claw): assemble legacy 3d models by default
This commit is contained in:
parent
2817aab197
commit
9717196264
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user