fix: simplify world controls and camera init
This commit is contained in:
parent
b30b093f54
commit
d89040e9f1
@ -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
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user