fix: improve world 3d controls and restore mesh selection
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-04-08 18:10:36 +08:00
parent 59b28461f9
commit 1a71072497
3 changed files with 159 additions and 56 deletions

View File

@ -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(

View File

@ -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}
/>

View File

@ -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}
/>