fix: refine 3d world viewer controls
This commit is contained in:
parent
e5838f480a
commit
85b28885a4
@ -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 && (
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user