fix: align 3d world entry view
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-05-20 17:29:04 +08:00
parent 0f91db7661
commit a93daefe54
2 changed files with 93 additions and 55 deletions

View File

@ -65,7 +65,6 @@ const VIEW_META: Array<{
icon: LucideIcon;
toolbarKey: CanvasToolbarItemKey;
}> = [
{ id: "splat", label: "W", icon: Sparkles, toolbarKey: "world3dViewSplat" },
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
{ id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" },
];
@ -77,18 +76,27 @@ const WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS = 360;
function resolveAvailableViews(data: World3DNodeData) {
const views: World3DViewMode[] = [];
if (isArchivedWorldLabsAssetUrl(data.splatUrl)) views.push("splat");
if (data.imageUrl || data.previewImage) views.push("image");
if (isArchivedWorldLabsAssetUrl(data.meshUrl)) views.push("mesh");
if (views.length === 0 && isArchivedWorldLabsAssetUrl(data.splatUrl)) {
views.push("splat");
}
return views;
}
function resolveActiveView(data: World3DNodeData) {
const availableViews = resolveAvailableViews(data);
if (data.activeView && availableViews.includes(data.activeView)) {
if (
data.activeView &&
data.activeView !== "splat" &&
availableViews.includes(data.activeView)
) {
return data.activeView;
}
return availableViews[0] || "splat";
if (availableViews.includes("mesh")) {
return "mesh";
}
return availableViews[0] || "image";
}
function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) {
@ -153,6 +161,11 @@ function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function readFiniteNumber(value: unknown) {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
export function World3DNode({
node,
isSelected,
@ -165,6 +178,7 @@ export function World3DNode({
null
);
const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false);
const [generationTick, setGenerationTick] = useState(0);
const hydrationAttemptRef = useRef(0);
const hydrationTaskRef = useRef<string | null>(null);
const generationRecoveryAttemptRef = useRef(0);
@ -286,6 +300,33 @@ export function World3DNode({
}),
[placeholderViewportScale]
);
const displayedGenerationProgress = useMemo(() => {
if (data.status !== "generating") return 100;
const explicitProgress = readFiniteNumber(metadata.progress);
if (explicitProgress !== null) {
return Math.round(clampNumber(explicitProgress, 1, 99));
}
const startedAtRaw =
readStringValue(metadata.generationStartedAt) ||
readStringValue(metadata.createdAt) ||
readStringValue(metadata.startedAt);
const startedAt = startedAtRaw ? Date.parse(startedAtRaw) : NaN;
const elapsedMs = Number.isFinite(startedAt)
? Math.max(0, Date.now() - startedAt)
: generationTick * 1000;
const simulatedProgress =
8 + Math.floor((1 - Math.exp(-elapsedMs / 180000)) * 91);
return Math.round(clampNumber(simulatedProgress, 8, 99));
}, [
data.status,
generationTick,
metadata.createdAt,
metadata.generationStartedAt,
metadata.progress,
metadata.startedAt,
]);
const topLeftControlsStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${overlayChromeScale})`,
@ -303,6 +344,19 @@ export function World3DNode({
const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh");
const showTopLeftControls = data.status === "completed";
useEffect(() => {
if (data.status !== "generating") {
setGenerationTick(0);
return;
}
setGenerationTick(0);
const timer = window.setInterval(() => {
setGenerationTick((tick) => tick + 1);
}, 1000);
return () => window.clearInterval(timer);
}, [data.status, hydrationTaskId]);
const persistCanvasStateSnapshot = useCallback(() => {
if (!projectId || typeof window === "undefined") return;
window.setTimeout(() => {
@ -471,7 +525,7 @@ export function World3DNode({
prompt: promptText,
modelVersion,
status: "completed",
activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : "image",
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : "image",
firstPerson: data.firstPerson ?? false,
hotspots: data.hotspots || [],
semanticsMetadata:
@ -616,7 +670,7 @@ export function World3DNode({
splatUrl: splatUrl || renderableSplatUrl,
previewImage: previewImageUrl || imageUrl || data.previewImage,
status: "completed",
activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : data.activeView,
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : data.activeView,
firstPerson: data.firstPerson ?? false,
semanticsMetadata:
assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object"
@ -840,10 +894,18 @@ export function World3DNode({
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="text-sm font-medium text-[#3f4b63] dark:text-zinc-100">
3D 世界生成中...
3D 世界生成中... {displayedGenerationProgress}%
</span>
<span className="relative h-1.5 w-36 overflow-hidden rounded-full bg-[#dfe6f3] dark:bg-zinc-800">
<span
className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-[#5b76fe] via-[#35d4ff] to-[#7cf4d2] transition-[width] duration-700 ease-out"
style={{ width: `${displayedGenerationProgress}%` }}
/>
</span>
<span className="text-xs text-[#7a8395] dark:text-zinc-400">
{worldModelLabel || "世界资产正在构建中"}
{displayedGenerationProgress >= 95
? "已接近完成,正在同步空间数据"
: worldModelLabel || "世界资产正在构建中"}
</span>
</div>
</div>

View File

@ -656,29 +656,27 @@ function SplatParticleCloud({
});
return (
<group rotation={[Math.PI, 0, 0]}>
<points
frustumCulled={false}
ref={pointsRef}
renderOrder={11}
>
<bufferGeometry ref={geometryRef}>
<bufferAttribute attach="attributes-position" args={[positionBuffer, 3]} />
<bufferAttribute attach="attributes-color" args={[data.colors, 3]} />
</bufferGeometry>
<pointsMaterial
blending={THREE.AdditiveBlending}
depthTest
depthWrite={false}
ref={materialRef}
size={0.02}
sizeAttenuation
toneMapped={false}
transparent
vertexColors
/>
</points>
</group>
<points
frustumCulled={false}
ref={pointsRef}
renderOrder={11}
>
<bufferGeometry ref={geometryRef}>
<bufferAttribute attach="attributes-position" args={[positionBuffer, 3]} />
<bufferAttribute attach="attributes-color" args={[data.colors, 3]} />
</bufferGeometry>
<pointsMaterial
blending={THREE.AdditiveBlending}
depthTest
depthWrite={false}
ref={materialRef}
size={0.02}
sizeAttenuation
toneMapped={false}
transparent
vertexColors
/>
</points>
);
}
@ -735,28 +733,6 @@ function WorldSplatLoadingOverlay({
</div>
) : null}
<div
className={cn(
"pointer-events-none absolute bottom-5 left-5 transition-all duration-300 opacity-100"
)}
>
<div className="rounded-2xl border border-white/10 bg-black/45 px-3 py-2 shadow-[0_20px_60px_rgba(0,0,0,0.38)] backdrop-blur-xl">
<div className="flex items-center gap-2 text-[11px] text-zinc-100">
<Sparkles className="h-3.5 w-3.5 text-cyan-200" />
<span>{isRevealing ? "高清世界正在接管预览" : "正在载入世界数据"}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-zinc-400">
<Loader2 className="h-3 w-3 animate-spin" />
<span>
{isRevealing
? "预览图已显示,世界细节正在补齐"
: previewImageUrl
? "先展示世界预览图,空间数据后台继续加载"
: "空间数据准备中"}
</span>
</div>
</div>
</div>
</>
);
}
@ -1077,7 +1053,7 @@ function WorldSplatScene({
);
const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number"
? -semanticsMetadata.ground_plane_offset
? semanticsMetadata.ground_plane_offset
: null;
const handleOrbitControlsRef = React.useCallback((controls: any | null) => {
orbitControlsRef.current = controls;
@ -1111,7 +1087,7 @@ function WorldSplatScene({
revealCompletedRef.current = false;
setParticleCloudData(null);
splatMesh.frustumCulled = false;
splatMesh.rotation.x = Math.PI;
splatMesh.rotation.set(0, 0, 0);
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
let cancelled = false;