fix: simplify world controls and camera init
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-26 21:41:03 +08:00
parent b30b093f54
commit d89040e9f1
2 changed files with 47 additions and 117 deletions

View File

@ -6,15 +6,12 @@ import {
Box, Box,
Camera, Camera,
Copy, Copy,
Crosshair,
Cuboid,
Download, Download,
ImageIcon, ImageIcon,
Loader2, Loader2,
MoreVertical, MoreVertical,
Sparkles, Sparkles,
Trash2, Trash2,
Waypoints,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
@ -51,7 +48,7 @@ import {
worldLabsGenerationService, worldLabsGenerationService,
} from "@/services/canvas"; } from "@/services/canvas";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas"; import type { CanvasNode, World3DNodeData, World3DViewMode } from "@/types/canvas";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
interface World3DNodeProps { interface World3DNodeProps {
@ -68,7 +65,6 @@ const VIEW_META: Array<{
}> = [ }> = [
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" }, { id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
{ id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" }, { id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" },
{ id: "mesh", label: "M", icon: Cuboid, toolbarKey: "world3dViewMesh" },
]; ];
const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000; const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000;
@ -188,6 +184,7 @@ function resolveActiveView(data: World3DNodeData) {
const hasSplat = availableViews.includes("splat"); const hasSplat = availableViews.includes("splat");
if ( if (
data.activeView && data.activeView &&
data.activeView !== "mesh" &&
availableViews.includes(data.activeView) availableViews.includes(data.activeView)
) { ) {
return data.activeView; return data.activeView;
@ -255,7 +252,6 @@ export function World3DNode({
}: World3DNodeProps) { }: World3DNodeProps) {
const data = node.data as World3DNodeData; const data = node.data as World3DNodeData;
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
const [viewerCapture, setViewerCapture] = useState<(() => Promise<Blob | null>) | null>( const [viewerCapture, setViewerCapture] = useState<(() => Promise<Blob | null>) | null>(
null null
); );
@ -272,14 +268,6 @@ export function World3DNode({
const projectId = useCanvasStore((state) => state.projectId); const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState); const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const viewportZoom = useCanvasStore((state) => state.viewport.zoom); const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
const world3dFirstPersonItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dFirstPerson"
);
const world3dHotspotItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dHotspot"
);
const world3dMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dMore"); const world3dMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dMore");
const world3dOpenMeshItem = getCanvasToolbarItem( const world3dOpenMeshItem = getCanvasToolbarItem(
canvasToolbarConfig, canvasToolbarConfig,
@ -354,7 +342,7 @@ export function World3DNode({
); );
const primaryUrl = resolvePrimaryUrl(data, activeView); const primaryUrl = resolvePrimaryUrl(data, activeView);
const modelDownloadTarget = inferModelDownloadTarget(data); const modelDownloadTarget = inferModelDownloadTarget(data);
const hotspotCount = data.hotspots?.length || 0; const interactionMode = "navigate" as const;
const placeholderSizeScale = useMemo( const placeholderSizeScale = useMemo(
() => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76), () => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76),
[node.size.height, node.size.width] [node.size.height, node.size.width]
@ -618,7 +606,7 @@ export function World3DNode({
modelVersion, modelVersion,
status: "completed", status: "completed",
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image", activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
firstPerson: data.firstPerson ?? false, firstPerson: data.firstPerson ?? true,
hotspots: data.hotspots || [], hotspots: data.hotspots || [],
semanticsMetadata: semanticsMetadata:
assetPayload.semanticsMetadata && assetPayload.semanticsMetadata &&
@ -770,7 +758,7 @@ export function World3DNode({
: imageUrl : imageUrl
? "image" ? "image"
: data.activeView, : data.activeView,
firstPerson: data.firstPerson ?? false, firstPerson: data.firstPerson ?? true,
semanticsMetadata: semanticsMetadata:
assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object" assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object"
? (assetPayload.semanticsMetadata as Record<string, any>) ? (assetPayload.semanticsMetadata as Record<string, any>)
@ -820,55 +808,6 @@ export function World3DNode({
}); });
}; };
const handleToggleFirstPerson = () => {
const nextFirstPerson = !data.firstPerson;
updateNode(node.id, {
data: {
...data,
firstPerson: nextFirstPerson,
} as World3DNodeData,
});
if (nextFirstPerson) {
setInteractionMode("navigate");
}
};
const handleToggleHotspotMode = () => {
const nextMode = interactionMode === "hotspot" ? "navigate" : "hotspot";
setInteractionMode(nextMode);
if (nextMode === "hotspot" && data.firstPerson) {
updateNode(node.id, {
data: {
...data,
firstPerson: false,
} as World3DNodeData,
});
}
};
const handlePick = (pick: { point: [number, number, number] }) => {
if (interactionMode !== "hotspot") return;
const nextHotspot: World3DHotspot = {
id: crypto.randomUUID(),
label: `热点 ${hotspotCount + 1}`,
position: {
x: pick.point[0],
y: pick.point[1],
z: pick.point[2],
},
createdAt: new Date().toISOString(),
};
updateNode(node.id, {
data: {
...data,
hotspots: [...(data.hotspots || []), nextHotspot],
} as World3DNodeData,
});
setInteractionMode("navigate");
toast.success("已添加世界热点");
};
const handleCopy = async () => { const handleCopy = async () => {
if (!primaryUrl) { if (!primaryUrl) {
toast.error("暂无可复制的世界地址"); toast.error("暂无可复制的世界地址");
@ -1144,14 +1083,13 @@ export function World3DNode({
> >
<World3DViewer <World3DViewer
className="h-full w-full" className="h-full w-full"
firstPerson={Boolean(data.firstPerson) && activeView === "splat"} firstPerson={activeView === "splat"}
hotspots={data.hotspots} hotspots={data.hotspots}
imageUrl={data.imageUrl} imageUrl={data.imageUrl}
interactionMode={interactionMode} interactionMode={interactionMode}
meshUrl={renderableMeshUrl} meshUrl={renderableMeshUrl}
minimalFrame minimalFrame
mode={activeView} mode={activeView}
onPick={handlePick}
previewImageUrl={data.previewImage} previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata} semanticsMetadata={data.semanticsMetadata}
splatUrl={renderableSplatUrl} splatUrl={renderableSplatUrl}
@ -1197,41 +1135,6 @@ export function World3DNode({
})} })}
</div> </div>
{activeView === "splat" ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button
className="h-5 rounded-full px-1.5"
onClick={handleToggleFirstPerson}
size="sm"
title={world3dFirstPersonItem.label}
type="button"
variant={data.firstPerson ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={!data.firstPerson}
className="h-2.5 w-2.5"
fallback={Waypoints}
item={world3dFirstPersonItem}
/>
</Button>
<Button
className="h-5 rounded-full px-1.5"
onClick={handleToggleHotspotMode}
size="sm"
title={world3dHotspotItem.label}
type="button"
variant={interactionMode === "hotspot" ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={interactionMode !== "hotspot"}
className="h-2.5 w-2.5"
fallback={Crosshair}
item={world3dHotspotItem}
/>
</Button>
</div>
) : null}
{captureEnabled ? ( {captureEnabled ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm"> <div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button <Button

View File

@ -49,6 +49,14 @@ type PrefetchedSplatAsset = {
fileName: string; fileName: string;
url: string; url: string;
}; };
type SplatSampleSource = {
getNumSplats?: () => number;
getSplat: (index: number) => {
center: THREE.Vector3;
opacity: number;
};
numSplats?: number;
};
type SparkRevealProfile = { type SparkRevealProfile = {
blurAmount: number; blurAmount: number;
preBlurAmount: number; preBlurAmount: number;
@ -313,11 +321,15 @@ function getPercentileValue(values: number[], percentile: number) {
} }
function getSplatWorldBounds(splatMesh: SparkSplatMesh) { function getSplatWorldBounds(splatMesh: SparkSplatMesh) {
splatMesh.updateMatrixWorld(true); try {
return splatMesh splatMesh.updateMatrixWorld(true);
.getBoundingBox(true) return splatMesh
.clone() .getBoundingBox(true)
.applyMatrix4(splatMesh.matrixWorld); .clone()
.applyMatrix4(splatMesh.matrixWorld);
} catch {
return new THREE.Box3();
}
} }
function getWorldLabsDisplayGroundPlaneOffset(value: unknown) { function getWorldLabsDisplayGroundPlaneOffset(value: unknown) {
@ -343,12 +355,24 @@ function getLoadedSplatCount(splatMesh: SparkSplatMesh) {
} }
} }
function getSplatSampleSource(splatMesh: SparkSplatMesh): SplatSampleSource | null {
return splatMesh.packedSplats || splatMesh.extSplats || null;
}
function getSplatSampleSourceCount(source: SplatSampleSource) {
try {
return Math.max(0, source.getNumSplats?.() ?? source.numSplats ?? 0);
} catch {
return 0;
}
}
function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) { function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) {
const packedSplats = splatMesh.packedSplats; const splats = getSplatSampleSource(splatMesh);
if (!packedSplats) { if (!splats) {
return getSplatWorldBounds(splatMesh); return getSplatWorldBounds(splatMesh);
} }
const totalSplats = Math.max(0, packedSplats?.numSplats || 0); const totalSplats = getSplatSampleSourceCount(splats);
if (totalSplats < 32) { if (totalSplats < 32) {
return getSplatWorldBounds(splatMesh); return getSplatWorldBounds(splatMesh);
} }
@ -362,7 +386,7 @@ function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) {
const zs: number[] = []; const zs: number[] = [];
for (let index = 0; index < totalSplats; index += sampleStep) { for (let index = 0; index < totalSplats; index += sampleStep) {
const splat = packedSplats.getSplat(index); const splat = splats.getSplat(index);
if (splat.opacity < 0.025) continue; if (splat.opacity < 0.025) continue;
point.copy(splat.center).applyMatrix4(splatMesh.matrixWorld); point.copy(splat.center).applyMatrix4(splatMesh.matrixWorld);
@ -1374,20 +1398,23 @@ function WorldSplatScene({
const markReady = () => { const markReady = () => {
if (cancelled || hasAppliedInitialViewRef.current) return; if (cancelled || hasAppliedInitialViewRef.current) return;
if (!isSplatRenderable()) return; if (!isSplatRenderable()) return;
clearReadyTimers(); let nextCameraPose: WorldCameraPose | null = null;
try { try {
lastCameraPoseRef.current = positionCameraInsideWorld({ nextCameraPose = positionCameraInsideWorld({
camera, camera,
controls: orbitControlsRef.current, controls: orbitControlsRef.current,
firstPerson, firstPerson,
groundPlaneOffset, groundPlaneOffset,
splatMesh, splatMesh,
}); });
} catch (error) { } catch {
console.warn("[WorldSplatScene] Failed to position camera for SPZ", error); nextCameraPose = null;
lastCameraPoseRef.current = null;
} }
if (!nextCameraPose) return;
clearReadyTimers();
lastCameraPoseRef.current = nextCameraPose;
hasAppliedInitialViewRef.current = true; hasAppliedInitialViewRef.current = true;
onLoadStateChange?.("ready"); onLoadStateChange?.("ready");