fix: improve world 3d controls and restore mesh selection
This commit is contained in:
parent
59b28461f9
commit
1a71072497
@ -630,6 +630,7 @@ export default function CanvasModel3DPage() {
|
||||
);
|
||||
const [worldViewportMode, setWorldViewportMode] =
|
||||
useState<WorldViewportMode>("world");
|
||||
const [viewerResetToken, setViewerResetToken] = useState(0);
|
||||
|
||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
@ -991,7 +992,7 @@ export default function CanvasModel3DPage() {
|
||||
[taskHistory, workspaceMode]
|
||||
);
|
||||
|
||||
const currentModelUrl = currentTask?.modelUrl || null;
|
||||
const currentModelUrl = resolveTaskModelUrl(currentTask);
|
||||
const currentPreviewImage =
|
||||
currentTask?.previewImageUrl ||
|
||||
(workspaceMode === "model"
|
||||
@ -2184,6 +2185,11 @@ export default function CanvasModel3DPage() {
|
||||
workspaceMode === "world" ? "3D 世界工作台" : "3D 工作台";
|
||||
const viewerEmptyStateTitle =
|
||||
workspaceMode === "world" ? "准备生成 3D 世界" : "准备生成 3D 资产";
|
||||
|
||||
useEffect(() => {
|
||||
setViewerResetToken((prev) => prev + 1);
|
||||
}, [currentTask?.taskId, workspaceMode, worldViewportMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceMode !== "world") return;
|
||||
setWorldViewportMode(preferredWorldViewportMode);
|
||||
@ -3262,39 +3268,99 @@ export default function CanvasModel3DPage() {
|
||||
{/* 3. Main Viewer Area */}
|
||||
<div className="relative flex-1 bg-black">
|
||||
{workspaceMode === "world" && (
|
||||
<div className="absolute right-6 top-6 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/75 p-2 shadow-2xl backdrop-blur-md">
|
||||
{[
|
||||
{
|
||||
id: "splat" as WorldViewportMode,
|
||||
label: "World",
|
||||
icon: Sparkles,
|
||||
disabled: !currentPrimarySpzUrl,
|
||||
},
|
||||
{
|
||||
id: "imagery" as WorldViewportMode,
|
||||
label: "图像",
|
||||
icon: ImageIcon,
|
||||
disabled: !currentWorldImageUrl,
|
||||
},
|
||||
{
|
||||
id: "mesh" as WorldViewportMode,
|
||||
label: "Mesh",
|
||||
icon: Box,
|
||||
disabled: !currentModelUrl,
|
||||
},
|
||||
].map((view) => (
|
||||
<div className="absolute right-6 top-6 z-10 w-[320px] rounded-[24px] border border-white/10 bg-zinc-900/82 p-3 shadow-[0_24px_60px_rgba(0,0,0,0.32)] backdrop-blur-md">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-zinc-500">
|
||||
Viewer Controls
|
||||
</div>
|
||||
<h3 className="mt-1 font-semibold text-sm text-white">
|
||||
3D 世界控制
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 rounded-full"
|
||||
disabled={view.disabled}
|
||||
key={view.id}
|
||||
onClick={() => setWorldViewportMode(view.id)}
|
||||
className="h-8 rounded-full border-white/10 bg-white/5 px-3 text-zinc-200 hover:bg-white/10 hover:text-white"
|
||||
onClick={() => setViewerResetToken((prev) => prev + 1)}
|
||||
size="sm"
|
||||
variant={worldViewportMode === view.id ? "secondary" : "ghost"}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<view.icon className="h-4 w-4" />
|
||||
{view.label}
|
||||
<RotateCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
回正
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{
|
||||
id: "splat" as WorldViewportMode,
|
||||
label: "World",
|
||||
icon: Sparkles,
|
||||
disabled: !currentPrimarySpzUrl,
|
||||
},
|
||||
{
|
||||
id: "imagery" as WorldViewportMode,
|
||||
label: "图像",
|
||||
icon: ImageIcon,
|
||||
disabled: !currentWorldImageUrl,
|
||||
},
|
||||
{
|
||||
id: "mesh" as WorldViewportMode,
|
||||
label: "Mesh",
|
||||
icon: Box,
|
||||
disabled: !currentModelUrl,
|
||||
},
|
||||
].map((view) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 justify-start rounded-2xl border px-3 text-left text-xs",
|
||||
worldViewportMode === view.id
|
||||
? "border-yellow-500/50 bg-yellow-500/14 text-white hover:bg-yellow-500/20"
|
||||
: "border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10 hover:text-white"
|
||||
)}
|
||||
disabled={view.disabled}
|
||||
key={view.id}
|
||||
onClick={() => setWorldViewportMode(view.id)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<view.icon className="mr-2 h-3.5 w-3.5 shrink-0" />
|
||||
{view.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-black/20 px-3 py-2.5 text-[11px] leading-5 text-zinc-400">
|
||||
{worldViewportMode === "splat" ? (
|
||||
<>
|
||||
<div className="font-medium text-zinc-200">
|
||||
鼠标拖拽会围绕场景原点旋转
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
左键拖拽旋转,滚轮缩放;已关闭横向平移。需要切换模型时,直接点击右侧资产卡片。
|
||||
</div>
|
||||
</>
|
||||
) : worldViewportMode === "mesh" ? (
|
||||
<>
|
||||
<div className="font-medium text-zinc-200">
|
||||
Mesh 视图已恢复
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
当前世界关联的 Mesh 会在这里直接显示,也可以从右侧资产库点击缩略图切换当前模型。
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-zinc-200">
|
||||
当前为图像预览
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
可以切回 World 查看空间结果,或切到 Mesh 继续检查几何结构。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -3309,6 +3375,7 @@ export default function CanvasModel3DPage() {
|
||||
onMeshLayersChange={handleLayersChange}
|
||||
onMeshStatsChange={setViewerStats}
|
||||
previewImageUrl={currentPreviewImage}
|
||||
resetViewToken={viewerResetToken}
|
||||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||||
splatUrl={currentPrimarySpzUrl}
|
||||
worldUrl={currentWorldAsset?.worldUrl}
|
||||
@ -3321,6 +3388,7 @@ export default function CanvasModel3DPage() {
|
||||
onLayersChange={handleLayersChange}
|
||||
onStatsChange={setViewerStats}
|
||||
previewImageUrl={currentPreviewImage}
|
||||
resetViewToken={viewerResetToken}
|
||||
settings={viewerSettings}
|
||||
/>
|
||||
)}
|
||||
@ -3390,9 +3458,14 @@ export default function CanvasModel3DPage() {
|
||||
{/* 4. Right Assets Panel */}
|
||||
<div className="z-20 flex w-72 flex-col border-white/10 border-l bg-zinc-900/50 backdrop-blur-sm">
|
||||
<div className="flex h-14 items-center justify-between border-white/10 border-b px-4">
|
||||
<h2 className="font-semibold text-sm text-white">
|
||||
{workspaceMode === "world" ? "世界资产库" : "模型资产库"}
|
||||
</h2>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-sm text-white">
|
||||
{workspaceMode === "world" ? "世界资产库" : "模型资产库"}
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
点击缩略图切换当前{workspaceMode === "world" ? "世界 / 模型" : "模型"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 w-8 text-zinc-400 hover:text-white"
|
||||
onClick={handleClearHistory}
|
||||
@ -3409,12 +3482,17 @@ export default function CanvasModel3DPage() {
|
||||
className={cn(
|
||||
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 transition-all hover:border-yellow-500/50",
|
||||
currentTask?.taskId === task.taskId &&
|
||||
"border-yellow-500 ring-1 ring-yellow-500/20"
|
||||
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]"
|
||||
)}
|
||||
key={task.taskId}
|
||||
onClick={() => handleSelectTask(task)}
|
||||
type="button"
|
||||
>
|
||||
{currentTask?.taskId === task.taskId ? (
|
||||
<Badge className="absolute left-2 bottom-2 z-10 border-yellow-400/40 bg-yellow-500/20 text-[9px] text-yellow-50">
|
||||
当前
|
||||
</Badge>
|
||||
) : null}
|
||||
{task.provider === "worldlabs-3d" && task.status === "success" ? (
|
||||
<Button
|
||||
className={cn(
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
|
||||
import React, { Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import type { Mesh, Object3D } from "three";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@ -56,6 +57,7 @@ type Model3DViewerProps = {
|
||||
layerVisibility?: Record<string, boolean>;
|
||||
onLayersChange?: (layers: Model3DLayer[]) => void;
|
||||
onStatsChange?: (stats: Model3DStats) => void;
|
||||
resetViewToken?: number;
|
||||
};
|
||||
|
||||
const CAMERA_PRESET_POSITIONS: Record<
|
||||
@ -71,9 +73,11 @@ const CAMERA_PRESET_POSITIONS: Record<
|
||||
function CameraPresetController({
|
||||
controlsRef,
|
||||
preset,
|
||||
resetToken,
|
||||
}: {
|
||||
controlsRef: React.MutableRefObject<any>;
|
||||
preset: Model3DCameraPreset;
|
||||
resetToken?: number;
|
||||
}) {
|
||||
const { camera } = useThree();
|
||||
|
||||
@ -94,7 +98,7 @@ function CameraPresetController({
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}
|
||||
}, [camera, controlsRef, preset]);
|
||||
}, [camera, controlsRef, preset, resetToken]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -274,6 +278,7 @@ export function Model3DViewer({
|
||||
layerVisibility,
|
||||
onLayersChange,
|
||||
onStatsChange,
|
||||
resetViewToken,
|
||||
}: Model3DViewerProps) {
|
||||
const controlsRef = useRef<any>(null);
|
||||
const resolvedModelUrl = useMemo(() => {
|
||||
@ -347,6 +352,7 @@ export function Model3DViewer({
|
||||
<CameraPresetController
|
||||
controlsRef={controlsRef}
|
||||
preset={settings.cameraPreset}
|
||||
resetToken={resetViewToken}
|
||||
/>
|
||||
<ambientLight intensity={0.5 * settings.lightIntensity} />
|
||||
<directionalLight
|
||||
@ -397,11 +403,18 @@ export function Model3DViewer({
|
||||
</Suspense>
|
||||
<OrbitControls
|
||||
autoRotate={settings.autoRotate}
|
||||
enablePan
|
||||
dampingFactor={0.08}
|
||||
enableDamping
|
||||
enablePan={false}
|
||||
enableRotate
|
||||
enableZoom
|
||||
makeDefault
|
||||
maxPolarAngle={Math.PI / 1.75}
|
||||
mouseButtons={{
|
||||
LEFT: THREE.MOUSE.ROTATE,
|
||||
MIDDLE: THREE.MOUSE.DOLLY,
|
||||
RIGHT: THREE.MOUSE.ROTATE,
|
||||
}}
|
||||
minPolarAngle={0}
|
||||
ref={controlsRef}
|
||||
/>
|
||||
|
||||
@ -73,6 +73,7 @@ type World3DViewerProps = {
|
||||
onHotspotSelect?: (hotspotId: string) => void;
|
||||
semanticsMetadata?: Record<string, unknown> | null;
|
||||
className?: string;
|
||||
resetViewToken?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||
@ -84,6 +85,8 @@ const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||
wireframe: false,
|
||||
};
|
||||
|
||||
const WORLD_ORBIT_TARGET = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
const EMPTY_MOVEMENT_STATE: MovementState = {
|
||||
forward: false,
|
||||
backward: false,
|
||||
@ -304,9 +307,7 @@ function positionCameraInsideWorld({
|
||||
const cameraPosition = anchor
|
||||
.clone()
|
||||
.addScaledVector(horizontalAxis, Math.max(majorSpan * 0.12, 0.65));
|
||||
const targetPosition = anchor
|
||||
.clone()
|
||||
.addScaledVector(horizontalAxis, -Math.max(majorSpan * 0.2, 1.1));
|
||||
const targetPosition = WORLD_ORBIT_TARGET.clone();
|
||||
|
||||
cameraPosition.x = THREE.MathUtils.clamp(
|
||||
cameraPosition.x,
|
||||
@ -318,17 +319,6 @@ function positionCameraInsideWorld({
|
||||
worldBounds.min.z + marginZ,
|
||||
worldBounds.max.z - marginZ
|
||||
);
|
||||
targetPosition.x = THREE.MathUtils.clamp(
|
||||
targetPosition.x,
|
||||
worldBounds.min.x + marginX * 0.5,
|
||||
worldBounds.max.x - marginX * 0.5
|
||||
);
|
||||
targetPosition.z = THREE.MathUtils.clamp(
|
||||
targetPosition.z,
|
||||
worldBounds.min.z + marginZ * 0.5,
|
||||
worldBounds.max.z - marginZ * 0.5
|
||||
);
|
||||
|
||||
camera.position.copy(cameraPosition);
|
||||
camera.lookAt(targetPosition);
|
||||
camera.updateProjectionMatrix();
|
||||
@ -641,10 +631,7 @@ function CameraMovementControls({
|
||||
moveRef.current.normalize().multiplyScalar(distance);
|
||||
camera.position.add(moveRef.current);
|
||||
|
||||
if (!firstPerson && orbitControlsRef.current?.target) {
|
||||
orbitControlsRef.current.target.add(moveRef.current);
|
||||
orbitControlsRef.current.update();
|
||||
}
|
||||
orbitControlsRef.current?.update?.();
|
||||
});
|
||||
|
||||
if (!enabled || !firstPerson) return null;
|
||||
@ -689,6 +676,7 @@ function WorldSplatScene({
|
||||
semanticsMetadata,
|
||||
onLoadStateChange,
|
||||
onRevealComplete,
|
||||
resetViewToken,
|
||||
}: {
|
||||
splatUrl: string;
|
||||
revealPhase?: SplatLoadingPhase;
|
||||
@ -700,6 +688,7 @@ function WorldSplatScene({
|
||||
semanticsMetadata?: Record<string, unknown> | null;
|
||||
onLoadStateChange?: (state: "ready" | "error") => void;
|
||||
onRevealComplete?: () => void;
|
||||
resetViewToken?: number;
|
||||
}) {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const renderer = useThree((state) => state.gl);
|
||||
@ -806,6 +795,16 @@ function WorldSplatScene({
|
||||
};
|
||||
}, [camera, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAppliedInitialViewRef.current) return;
|
||||
positionCameraInsideWorld({
|
||||
camera,
|
||||
controls: orbitControlsRef.current,
|
||||
groundPlaneOffset,
|
||||
splatMesh,
|
||||
});
|
||||
}, [camera, groundPlaneOffset, resetViewToken, splatMesh]);
|
||||
|
||||
useFrame(() => {
|
||||
const phase = revealPhaseRef.current;
|
||||
const revealElapsed =
|
||||
@ -888,18 +887,25 @@ function WorldSplatScene({
|
||||
/>
|
||||
))}
|
||||
<CameraMovementControls
|
||||
enabled={interactionMode === "navigate"}
|
||||
enabled={interactionMode === "navigate" && firstPerson}
|
||||
firstPerson={firstPerson}
|
||||
orbitControlsRef={orbitControlsRef}
|
||||
/>
|
||||
{!firstPerson ? (
|
||||
<OrbitControls
|
||||
autoRotate={false}
|
||||
enablePan
|
||||
dampingFactor={0.08}
|
||||
enableDamping
|
||||
enablePan={false}
|
||||
enableRotate
|
||||
enableZoom
|
||||
makeDefault
|
||||
maxDistance={16}
|
||||
mouseButtons={{
|
||||
LEFT: THREE.MOUSE.ROTATE,
|
||||
MIDDLE: THREE.MOUSE.DOLLY,
|
||||
RIGHT: THREE.MOUSE.ROTATE,
|
||||
}}
|
||||
minDistance={0.5}
|
||||
ref={orbitControlsRef}
|
||||
/>
|
||||
@ -956,6 +962,7 @@ function WorldSplatViewer({
|
||||
onPick,
|
||||
onHotspotSelect,
|
||||
semanticsMetadata,
|
||||
resetViewToken,
|
||||
}: {
|
||||
splatUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
@ -965,6 +972,7 @@ function WorldSplatViewer({
|
||||
onPick?: (pick: World3DPickResult) => void;
|
||||
onHotspotSelect?: (hotspotId: string) => void;
|
||||
semanticsMetadata?: Record<string, unknown> | null;
|
||||
resetViewToken?: number;
|
||||
}) {
|
||||
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
|
||||
@ -1027,6 +1035,7 @@ function WorldSplatViewer({
|
||||
onPick={onPick}
|
||||
onRevealComplete={() => setOverlayPhase("hidden")}
|
||||
revealPhase={overlayPhase}
|
||||
resetViewToken={resetViewToken}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
splatUrl={splatUrl}
|
||||
/>
|
||||
@ -1057,6 +1066,7 @@ export function World3DViewer({
|
||||
onHotspotSelect,
|
||||
semanticsMetadata,
|
||||
className,
|
||||
resetViewToken,
|
||||
}: World3DViewerProps) {
|
||||
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
||||
|
||||
@ -1070,6 +1080,7 @@ export function World3DViewer({
|
||||
onLayersChange={onMeshLayersChange}
|
||||
onStatsChange={onMeshStatsChange}
|
||||
previewImageUrl={previewImageUrl}
|
||||
resetViewToken={resetViewToken}
|
||||
settings={meshSettings || DEFAULT_MESH_SETTINGS}
|
||||
/>
|
||||
</div>
|
||||
@ -1135,6 +1146,7 @@ export function World3DViewer({
|
||||
onHotspotSelect={onHotspotSelect}
|
||||
onPick={onPick}
|
||||
previewImageUrl={previewImageUrl}
|
||||
resetViewToken={resetViewToken}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
splatUrl={splatUrl}
|
||||
/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user