From 85b28885a4f70a33316e15d39ba23ab37827cc2d Mon Sep 17 00:00:00 2001 From: 134 Date: Sun, 14 Jun 2026 10:53:04 +0800 Subject: [PATCH] fix: refine 3d world viewer controls --- app/canvas/[id]/model3d/page.tsx | 361 +++++++++----------- components/canvas/world3d/World3DViewer.tsx | 156 +++++++-- 2 files changed, 292 insertions(+), 225 deletions(-) diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index a0898230d..c180855c4 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -3916,70 +3916,56 @@ export default function CanvasModel3DPage() { {/* 3. Main Viewer Area */}
{workspaceMode === "world" && ( -
-
-
-
- Viewer Controls -
-

- 3D 世界控制 -

-
-
- - -
-
- -
+
+ + +
+
{[ { 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) => ( ))}
- -
- {worldViewportMode === "splat" ? ( - <> -
- {worldFirstPerson ? "漫游:WASD / 方向键" : "观察:拖拽旋转"} -
-
- {worldFirstPerson - ? "左键转向 · WASD 移动 · Q/R 上升 · E/F 下降" - : "左键旋转 · 右键平移 · 滚轮缩放"} -
- - ) : worldViewportMode === "mesh" ? ( - <> -
- Mesh 视图 -
-
检查几何结构与模型资产。
- - ) : ( - <> -
- 图像预览 -
-
查看全景/缩略图结果。
- - )} -
)} @@ -4213,134 +4170,140 @@ export default function CanvasModel3DPage() { const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl; return ( - - ) : null} - {canImportWorld ? ( - - ) : null} - {canShareWorld ? ( - - ) : null} - {task.status === "success" && resolveTaskModelUrl(task) ? ( - - ) : null} - {previewUrl ? ( - preview - ) : ( -
- -
- )} -
-
-
- - {statusLabel(task.status)} - - - {task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"} - -
-
- - {modeLabel(task.mode)} - - - {formatDistanceToNow(new Date(task.createdAt), { - addSuffix: true, - locale: zhCN, - })} - -
- {task.provider === "worldlabs-3d" && task.shareEnabled ? ( -
- 已分享 + +
+
+ + {currentTask?.taskId === task.taskId + ? "当前" + : statusLabel(task.status)} + + + {task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"} +
- ) : null} +
+
{modeLabel(task.mode)}
+
+ + {formatDistanceToNow(new Date(task.createdAt), { + addSuffix: true, + locale: zhCN, + })} + + {task.provider === "worldlabs-3d" && task.shareEnabled ? ( + 已分享 + ) : null} +
+
+
+ {canShareWorld ? ( + + ) : ( +
+ )} + {canShareWorld ? ( + + ) : ( +
+ )} + {task.status === "success" && resolveTaskModelUrl(task) ? ( + + ) : ( +
+ )} +
+ {canImportWorld ? ( + + ) : null} +
- ); })} {visibleTaskHistory.length === 0 && ( diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 5e4ff661a..065623f30 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -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,