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,
Camera,
Copy,
Crosshair,
Cuboid,
Download,
ImageIcon,
Loader2,
MoreVertical,
Sparkles,
Trash2,
Waypoints,
type LucideIcon,
} from "lucide-react";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
@ -51,7 +48,7 @@ import {
worldLabsGenerationService,
} from "@/services/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";
interface World3DNodeProps {
@ -68,7 +65,6 @@ const VIEW_META: Array<{
}> = [
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
{ id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" },
{ id: "mesh", label: "M", icon: Cuboid, toolbarKey: "world3dViewMesh" },
];
const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000;
@ -188,6 +184,7 @@ function resolveActiveView(data: World3DNodeData) {
const hasSplat = availableViews.includes("splat");
if (
data.activeView &&
data.activeView !== "mesh" &&
availableViews.includes(data.activeView)
) {
return data.activeView;
@ -255,7 +252,6 @@ export function World3DNode({
}: World3DNodeProps) {
const data = node.data as World3DNodeData;
const [isHovered, setIsHovered] = useState(false);
const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
const [viewerCapture, setViewerCapture] = useState<(() => Promise<Blob | null>) | null>(
null
);
@ -272,14 +268,6 @@ export function World3DNode({
const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
const world3dFirstPersonItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dFirstPerson"
);
const world3dHotspotItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dHotspot"
);
const world3dMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dMore");
const world3dOpenMeshItem = getCanvasToolbarItem(
canvasToolbarConfig,
@ -354,7 +342,7 @@ export function World3DNode({
);
const primaryUrl = resolvePrimaryUrl(data, activeView);
const modelDownloadTarget = inferModelDownloadTarget(data);
const hotspotCount = data.hotspots?.length || 0;
const interactionMode = "navigate" as const;
const placeholderSizeScale = useMemo(
() => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76),
[node.size.height, node.size.width]
@ -618,7 +606,7 @@ export function World3DNode({
modelVersion,
status: "completed",
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
firstPerson: data.firstPerson ?? false,
firstPerson: data.firstPerson ?? true,
hotspots: data.hotspots || [],
semanticsMetadata:
assetPayload.semanticsMetadata &&
@ -770,7 +758,7 @@ export function World3DNode({
: imageUrl
? "image"
: data.activeView,
firstPerson: data.firstPerson ?? false,
firstPerson: data.firstPerson ?? true,
semanticsMetadata:
assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object"
? (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 () => {
if (!primaryUrl) {
toast.error("暂无可复制的世界地址");
@ -1144,14 +1083,13 @@ export function World3DNode({
>
<World3DViewer
className="h-full w-full"
firstPerson={Boolean(data.firstPerson) && activeView === "splat"}
firstPerson={activeView === "splat"}
hotspots={data.hotspots}
imageUrl={data.imageUrl}
interactionMode={interactionMode}
meshUrl={renderableMeshUrl}
minimalFrame
mode={activeView}
onPick={handlePick}
previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata}
splatUrl={renderableSplatUrl}
@ -1197,41 +1135,6 @@ export function World3DNode({
})}
</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 ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button

View File

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