fix: refine 3d world viewer controls

This commit is contained in:
134 2026-06-14 10:53:04 +08:00
parent e5838f480a
commit 85b28885a4
2 changed files with 292 additions and 225 deletions

View File

@ -3916,70 +3916,56 @@ 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 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>
<div className="flex shrink-0 items-center gap-2">
<Button
className={cn(
"h-8 rounded-full border-white/10 px-3 text-zinc-200 hover:bg-white/10 hover:text-white",
worldFirstPerson
? "bg-yellow-500/20 text-yellow-100"
: "bg-white/5"
)}
disabled={!currentPrimarySpzUrl || worldViewportMode !== "splat"}
onClick={() => setWorldFirstPerson((prev) => !prev)}
size="sm"
type="button"
variant="outline"
>
<Crosshair className="mr-1.5 h-3.5 w-3.5" />
漫游
</Button>
<Button
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"
type="button"
variant="outline"
>
<RotateCw className="mr-1.5 h-3.5 w-3.5" />
回正
</Button>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
<div className="absolute right-6 top-6 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-950/55 p-1.5 shadow-[0_16px_40px_rgba(0,0,0,0.22)] backdrop-blur-md">
<Button
className={cn(
"h-9 w-9 rounded-full border-white/10 p-0 text-zinc-200 hover:bg-white/10 hover:text-white",
worldFirstPerson ? "bg-yellow-500/20 text-yellow-100" : "bg-white/5"
)}
disabled={!currentPrimarySpzUrl || worldViewportMode !== "splat"}
onClick={() => setWorldFirstPerson((prev) => !prev)}
size="icon"
title={worldFirstPerson ? "关闭漫游" : "开启漫游"}
type="button"
variant="outline"
>
<Crosshair className="h-4 w-4" />
</Button>
<Button
className="h-9 w-9 rounded-full border-white/10 bg-white/5 p-0 text-zinc-200 hover:bg-white/10 hover:text-white"
onClick={() => setViewerResetToken((prev) => prev + 1)}
size="icon"
title="回正视角"
type="button"
variant="outline"
>
<RotateCw className="h-4 w-4" />
</Button>
<div className="h-5 w-px bg-white/10" />
<div className="flex items-center gap-1">
{[
{
id: "splat" as WorldViewportMode,
label: "World",
label: "World 视图",
icon: Sparkles,
disabled: !currentPrimarySpzUrl,
},
{
id: "imagery" as WorldViewportMode,
label: "图像",
label: "图像预览",
icon: ImageIcon,
disabled: !currentWorldImageUrl,
},
{
id: "mesh" as WorldViewportMode,
label: "Mesh",
label: "Mesh 视图",
icon: Box,
disabled: !currentModelUrl,
},
].map((view) => (
<Button
className={cn(
"h-10 justify-start rounded-2xl border px-3 text-left text-xs",
"h-9 w-9 rounded-full border p-0",
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"
@ -3987,44 +3973,15 @@ export default function CanvasModel3DPage() {
disabled={view.disabled}
key={view.id}
onClick={() => setWorldViewportMode(view.id)}
size="sm"
size="icon"
title={view.label}
type="button"
variant="ghost"
>
<view.icon className="mr-2 h-3.5 w-3.5 shrink-0" />
{view.label}
<view.icon className="h-4 w-4" />
</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">
{worldFirstPerson ? "漫游:WASD / 方向键" : "观察:拖拽旋转"}
</div>
<div className="mt-1 truncate">
{worldFirstPerson
? "左键转向 · WASD 移动 · Q/R 上升 · E/F 下降"
: "左键旋转 · 右键平移 · 滚轮缩放"}
</div>
</>
) : worldViewportMode === "mesh" ? (
<>
<div className="font-medium text-zinc-200">
Mesh 视图
</div>
<div className="mt-1 truncate">检查几何结构与模型资产。</div>
</>
) : (
<>
<div className="font-medium text-zinc-200">
图像预览
</div>
<div className="mt-1 truncate">查看全景/缩略图结果。</div>
</>
)}
</div>
</div>
)}
@ -4213,134 +4170,140 @@ export default function CanvasModel3DPage() {
const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl;
return (
<button
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-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
isSelectedForShare && "border-cyan-300 ring-2 ring-cyan-300/40",
isSelectedForCanvas && "border-yellow-300 ring-2 ring-yellow-300/40"
)}
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}
{canShareWorld ? (
<Button
className={cn(
"absolute left-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
task.shareEnabled &&
"border-amber-400/40 bg-amber-400/20 text-amber-100 opacity-100"
)}
onClick={(event) => void handleShareTask(task, event)}
size="icon"
<div
className={cn(
"group overflow-hidden rounded-lg border border-white/5 bg-zinc-950/85 transition-all hover:border-yellow-500/50",
currentTask?.taskId === task.taskId &&
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
isSelectedForShare && "border-cyan-300 ring-2 ring-cyan-300/40",
isSelectedForCanvas && "border-yellow-300 ring-2 ring-yellow-300/40"
)}
key={task.taskId}
>
<button
className="relative block aspect-square w-full overflow-hidden bg-zinc-950"
onClick={() => handleSelectTask(task)}
type="button"
variant="ghost"
>
<Link2 className="h-3.5 w-3.5" />
</Button>
) : null}
{canImportWorld ? (
<Button
className={cn(
"absolute right-2 bottom-2 z-20 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
isSelectedForCanvas &&
"border-yellow-300/60 bg-yellow-400/25 text-yellow-50 opacity-100"
)}
onClick={(event) => handleToggleWorldCanvasSelection(task, event)}
size="icon"
title={isSelectedForCanvas ? "取消添加选择" : "选中添加到画布"}
type="button"
variant="ghost"
>
{isSelectedForCanvas ? (
<Check className="h-3.5 w-3.5" />
{previewUrl ? (
<img
alt="preview"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
src={previewUrl}
/>
) : (
<PlusCircle className="h-3.5 w-3.5" />
<div className="flex h-full w-full items-center justify-center text-zinc-700">
<Box className="h-8 w-8" />
</div>
)}
</Button>
) : null}
{canShareWorld ? (
<Button
className={cn(
"absolute left-10 top-2 z-10 h-7 rounded-full border border-white/10 bg-black/60 px-2 text-[10px] text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
isSelectedForShare &&
"border-cyan-300/60 bg-cyan-400/25 text-cyan-50 opacity-100"
)}
onClick={(event) => handleToggleWorldShareSelection(task, event)}
size="sm"
type="button"
variant="ghost"
>
{isSelectedForShare ? (
<Check className="mr-1 h-3 w-3" />
) : null}
{isSelectedForShare ? "已选" : "选择"}
</Button>
) : null}
{task.status === "success" && resolveTaskModelUrl(task) ? (
<Button
className="absolute right-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100"
onClick={(event) => handleDownloadTask(task, event)}
size="icon"
type="button"
variant="ghost"
>
<Download className="h-3.5 w-3.5" />
</Button>
) : null}
{previewUrl ? (
<img
alt="preview"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
src={previewUrl}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-700">
<Box className="h-8 w-8" />
</div>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/72 via-black/10 to-black/18 opacity-95" />
<div className="absolute inset-x-0 bottom-0 p-2">
<div className="flex items-center justify-between">
<Badge
className="h-4 px-1 text-[9px]"
variant={
task.status === "success" ? "default" : "secondary"
}
>
{statusLabel(task.status)}
</Badge>
<Badge
className="border-white/10 bg-black/50 text-[9px] text-zinc-200"
variant="outline"
>
{task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"}
</Badge>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[10px]">
<span className="min-w-0 truncate text-left text-zinc-300">
{modeLabel(task.mode)}
</span>
<span className="shrink-0 text-zinc-500">
{formatDistanceToNow(new Date(task.createdAt), {
addSuffix: true,
locale: zhCN,
})}
</span>
</div>
{task.provider === "worldlabs-3d" && task.shareEnabled ? (
<div className="mt-1 text-left text-[10px] text-amber-200">
已分享
</button>
<div className="space-y-2 border-white/10 border-t bg-zinc-950/95 p-2">
<div className="flex items-center justify-between gap-1">
<Badge
className="h-4 px-1 text-[9px]"
variant={task.status === "success" ? "default" : "secondary"}
>
{currentTask?.taskId === task.taskId
? "当前"
: statusLabel(task.status)}
</Badge>
<Badge
className="border-white/10 bg-zinc-900 text-[9px] text-zinc-200"
variant="outline"
>
{task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"}
</Badge>
</div>
) : null}
<div className="space-y-0.5 text-left text-[10px] leading-tight">
<div className="truncate text-zinc-300">{modeLabel(task.mode)}</div>
<div className="flex items-center justify-between gap-1 text-zinc-500">
<span className="truncate">
{formatDistanceToNow(new Date(task.createdAt), {
addSuffix: true,
locale: zhCN,
})}
</span>
{task.provider === "worldlabs-3d" && task.shareEnabled ? (
<span className="shrink-0 text-amber-300">已分享</span>
) : null}
</div>
</div>
<div className="grid grid-cols-3 gap-1">
{canShareWorld ? (
<Button
className={cn(
"h-7 border-white/10 bg-zinc-900 px-0 text-[10px] text-zinc-200 hover:bg-zinc-800",
task.shareEnabled &&
"border-amber-400/40 bg-amber-400/15 text-amber-100"
)}
onClick={(event) => void handleShareTask(task, event)}
size="sm"
type="button"
variant="outline"
>
<Link2 className="mr-1 h-3 w-3" />
分享
</Button>
) : (
<div />
)}
{canShareWorld ? (
<Button
className={cn(
"h-7 border-white/10 bg-zinc-900 px-0 text-[10px] text-zinc-200 hover:bg-zinc-800",
isSelectedForShare &&
"border-cyan-300/60 bg-cyan-400/20 text-cyan-50"
)}
onClick={(event) => handleToggleWorldShareSelection(task, event)}
size="sm"
type="button"
variant="outline"
>
{isSelectedForShare ? (
<Check className="mr-1 h-3 w-3" />
) : null}
{isSelectedForShare ? "已选" : "选择"}
</Button>
) : (
<div />
)}
{task.status === "success" && resolveTaskModelUrl(task) ? (
<Button
className="h-7 border-white/10 bg-zinc-900 px-0 text-[10px] text-zinc-200 hover:bg-zinc-800"
onClick={(event) => handleDownloadTask(task, event)}
size="sm"
type="button"
variant="outline"
>
<Download className="h-3.5 w-3.5" />
</Button>
) : (
<div />
)}
</div>
{canImportWorld ? (
<Button
className={cn(
"h-7 w-full border-yellow-400/25 bg-yellow-400/10 text-[10px] text-yellow-100 hover:bg-yellow-400/20",
isSelectedForCanvas &&
"border-yellow-300/60 bg-yellow-400/25 text-yellow-50"
)}
onClick={(event) => handleToggleWorldCanvasSelection(task, event)}
size="sm"
title={isSelectedForCanvas ? "取消添加选择" : "选中添加到画布"}
type="button"
variant="outline"
>
{isSelectedForCanvas ? (
<Check className="mr-1 h-3.5 w-3.5" />
) : (
<PlusCircle className="mr-1 h-3.5 w-3.5" />
)}
{isSelectedForCanvas ? "已选添加" : "选中添加"}
</Button>
) : null}
</div>
</div>
</button>
);
})}
{visibleTaskHistory.length === 0 && (

View File

@ -71,6 +71,13 @@ type WorldCameraPose = {
cameraPosition: THREE.Vector3;
targetPosition: THREE.Vector3;
};
type WorldNavigationFrame = {
bounds: THREE.Box3;
center: THREE.Vector3;
floorY: number;
majorSpan: number;
viewAxis: THREE.Vector3;
};
type World3DViewerProps = {
mode: World3DViewMode;
@ -425,6 +432,82 @@ function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) {
return robustBounds.isEmpty() ? getSplatWorldBounds(splatMesh) : robustBounds;
}
function getSplatNavigationFrame(
splatMesh: SparkSplatMesh,
worldBounds: THREE.Box3,
floorY: number
): WorldNavigationFrame {
const worldCenter = worldBounds.getCenter(new THREE.Vector3());
const worldSize = worldBounds.getSize(new THREE.Vector3());
const fallbackAxis =
worldSize.z >= worldSize.x
? WORLDLABS_REFERENCE_VIEW_AXIS.clone()
: new THREE.Vector3(1, 0, 0);
const fallbackFrame = () => ({
bounds: worldBounds.clone(),
center: new THREE.Vector3(worldCenter.x, floorY, worldCenter.z),
floorY,
majorSpan: Math.max(worldSize.z >= worldSize.x ? worldSize.z : worldSize.x, 1.4),
viewAxis: fallbackAxis,
});
const splats = getSplatSampleSource(splatMesh);
if (!splats) return fallbackFrame();
const totalSplats = getSplatSampleSourceCount(splats);
if (totalSplats < 64) return fallbackFrame();
splatMesh.updateMatrixWorld(true);
const targetCount = Math.min(totalSplats, SPLAT_WORLD_BOUNDS_SAMPLE_LIMIT);
const sampleStep = Math.max(Math.floor(totalSplats / targetCount), 1);
const point = new THREE.Vector3();
const xs: number[] = [];
const zs: number[] = [];
const minSampleY = floorY - Math.max(worldSize.y * 0.04, 0.12);
const maxSampleY = floorY + THREE.MathUtils.clamp(worldSize.y * 0.42, 0.9, 2.4);
for (let index = 0; index < totalSplats; index += sampleStep) {
const splat = splats.getSplat(index);
if (splat.opacity < 0.035) continue;
point.copy(splat.center).applyMatrix4(splatMesh.matrixWorld);
if (point.y < minSampleY || point.y > maxSampleY) continue;
xs.push(point.x);
zs.push(point.z);
}
if (xs.length < 48) return fallbackFrame();
xs.sort((a, b) => a - b);
zs.sort((a, b) => a - b);
const minX = getPercentileValue(xs, 0.08);
const maxX = getPercentileValue(xs, 0.92);
const minZ = getPercentileValue(zs, 0.08);
const maxZ = getPercentileValue(zs, 0.92);
const navigationBounds = new THREE.Box3(
new THREE.Vector3(minX, floorY, minZ),
new THREE.Vector3(maxX, floorY, maxZ)
);
if (navigationBounds.isEmpty()) return fallbackFrame();
const spanX = Math.max(maxX - minX, 0);
const spanZ = Math.max(maxZ - minZ, 0);
const viewAxis =
spanZ >= Math.max(spanX * 0.55, 0.7)
? WORLDLABS_REFERENCE_VIEW_AXIS.clone()
: new THREE.Vector3(1, 0, 0);
return {
bounds: navigationBounds,
center: new THREE.Vector3((minX + maxX) / 2, floorY, (minZ + maxZ) / 2),
floorY,
majorSpan: Math.max(spanZ >= spanX ? spanZ : spanX, 1.4),
viewAxis,
};
}
function positionCameraInsideWorld({
camera,
controls,
@ -483,7 +566,20 @@ function positionCameraInsideWorld({
worldBounds.max.y
)
: worldBounds.min.y + size.y * 0.04;
const estimatedFloorY = displayFloorY;
const navigationFrame = getSplatNavigationFrame(
splatMesh,
worldBounds,
displayFloorY
);
const navigationBounds = navigationFrame.bounds;
const navigationCenter = navigationFrame.center;
const navigationSize = navigationBounds.getSize(new THREE.Vector3());
const estimatedFloorY = navigationFrame.floorY;
const activeHorizontalAxis =
firstPerson && navigationFrame.viewAxis.lengthSq() > 0
? navigationFrame.viewAxis.clone()
: horizontalAxis.clone();
const activeMajorSpan = navigationFrame.majorSpan;
const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.24, 1.05, 1.7);
const rawEyeY = estimatedFloorY + humanEyeOffset;
const minEyeY = worldBounds.min.y + Math.max(size.y * 0.12, 0.42);
@ -495,37 +591,37 @@ function positionCameraInsideWorld({
minEyeY <= maxEyeY
? THREE.MathUtils.clamp(rawEyeY, minEyeY, maxEyeY)
: center.y - size.y * 0.08;
const anchor = new THREE.Vector3(center.x, eyeY, center.z);
const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9);
const anchor = new THREE.Vector3(navigationCenter.x, eyeY, navigationCenter.z);
const cameraDistance = THREE.MathUtils.clamp(activeMajorSpan * 0.16, 0.7, 1.9);
const cameraPosition = firstPerson
? anchor.clone()
: anchor.clone().addScaledVector(horizontalAxis, cameraDistance);
: anchor.clone().addScaledVector(activeHorizontalAxis, cameraDistance);
cameraPosition.x = clampInsideAxis(
cameraPosition.x,
worldBounds.min.x,
worldBounds.max.x,
Math.max(size.x * 0.1, 0.24),
center.x
navigationBounds.min.x,
navigationBounds.max.x,
Math.max(navigationSize.x * 0.12, 0.18),
navigationCenter.x
);
cameraPosition.z = clampInsideAxis(
cameraPosition.z,
worldBounds.min.z,
worldBounds.max.z,
Math.max(size.z * 0.1, 0.24),
center.z
navigationBounds.min.z,
navigationBounds.max.z,
Math.max(navigationSize.z * 0.12, 0.18),
navigationCenter.z
);
const lookDirection = firstPerson
? horizontalAxis.clone()
? activeHorizontalAxis.clone()
: anchor.clone().sub(cameraPosition);
if (lookDirection.lengthSq() < 0.0001) {
lookDirection.copy(horizontalAxis).multiplyScalar(-1);
lookDirection.copy(activeHorizontalAxis).multiplyScalar(-1);
}
lookDirection.normalize();
const nearTargetPosition = cameraPosition
.clone()
.addScaledVector(lookDirection, THREE.MathUtils.clamp(majorSpan * 0.24, 0.75, 2.4));
.addScaledVector(lookDirection, THREE.MathUtils.clamp(activeMajorSpan * 0.24, 0.75, 2.4));
const targetDrop = firstPerson
? THREE.MathUtils.clamp(humanEyeOffset * 0.18 + size.y * 0.018, 0.16, 0.46)
: THREE.MathUtils.clamp(size.y * 0.025, 0.04, 0.18);
@ -543,27 +639,35 @@ function positionCameraInsideWorld({
: eyeY - targetDrop;
nearTargetPosition.x = clampInsideAxis(
nearTargetPosition.x,
worldBounds.min.x,
worldBounds.max.x,
Math.max(size.x * 0.08, 0.18),
center.x
navigationBounds.min.x,
navigationBounds.max.x,
Math.max(navigationSize.x * 0.1, 0.14),
navigationCenter.x
);
nearTargetPosition.z = clampInsideAxis(
nearTargetPosition.z,
worldBounds.min.z,
worldBounds.max.z,
Math.max(size.z * 0.08, 0.18),
center.z
navigationBounds.min.z,
navigationBounds.max.z,
Math.max(navigationSize.z * 0.1, 0.14),
navigationCenter.z
);
const orbitTargetPosition = new THREE.Vector3(
navigationCenter.x,
Math.max(eyeY - THREE.MathUtils.clamp(humanEyeOffset * 0.22, 0.22, 0.5), estimatedFloorY + 0.08),
navigationCenter.z
);
const orbitRadius = Math.max(
navigationSize.length() * 0.5,
activeMajorSpan * 0.55,
1.2
);
const orbitTargetPosition = center.clone();
const orbitRadius = Math.max(size.length() * 0.5, majorSpan * 0.55, 1.2);
const orbitDistance = THREE.MathUtils.clamp(orbitRadius * 1.85, 2.2, 14);
const targetPosition = firstPerson ? nearTargetPosition : orbitTargetPosition;
if (!firstPerson) {
cameraPosition
.copy(targetPosition)
.addScaledVector(horizontalAxis, orbitDistance)
.addScaledVector(activeHorizontalAxis, orbitDistance)
.add(
new THREE.Vector3(
0,