ai-chatbot/components/canvas/model3d/Model3DViewer.tsx

924 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/* eslint-disable @next/next/no-img-element */
import {
ContactShadows,
Environment,
Grid,
OrbitControls,
useGLTF,
} from "@react-three/drei";
import { Canvas, useFrame, 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 { KTX2Loader } from "three-stdlib";
import { Button } from "@/components/ui/button";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
import { LocalStudioEnvironment } from "./LocalStudioEnvironment";
import { ModelLoadingFallback } from "./ModelLoadingFallback";
export type Model3DLayer = {
id: string;
name: string;
triangles: number;
vertices: number;
};
export type Model3DStats = {
triangles: number;
vertices: number;
meshCount: number;
};
export type Model3DCameraPreset = "front" | "iso" | "right" | "top";
export type Model3DViewerSettings = {
cameraPreset: Model3DCameraPreset;
envPreset:
| "city"
| "sunset"
| "studio"
| "dawn"
| "night"
| "warehouse"
| "forest"
| "apartment"
| "park"
| "lobby";
lightIntensity: number;
autoRotate: boolean;
showGrid: boolean;
wireframe: boolean;
};
type Model3DViewerProps = {
modelUrl?: string | null;
modelFormat?: "glb" | "gltf" | null;
autoFitModel?: boolean;
localStudioEnvironment?: boolean;
previewImageUrl?: string | null;
emptyStateTitle?: string;
settings: Model3DViewerSettings;
modelRotation?: [number, number, number];
showMovementControls?: boolean;
layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
};
type MovementCommand = "forward" | "backward" | "left" | "right" | "up" | "down";
type MovementState = Record<MovementCommand, boolean>;
const EMPTY_MOVEMENT_STATE: MovementState = {
forward: false,
backward: false,
left: false,
right: false,
up: false,
down: false,
};
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
KeyW: "forward",
ArrowUp: "forward",
KeyS: "backward",
ArrowDown: "backward",
KeyA: "left",
ArrowLeft: "left",
KeyD: "right",
ArrowRight: "right",
KeyQ: "up",
PageUp: "up",
KeyE: "down",
PageDown: "down",
};
const CAMERA_PRESET_POSITIONS: Record<
Model3DCameraPreset,
[number, number, number]
> = {
front: [0, 1.1, 3.2],
iso: [2, 1.5, 2],
right: [3.2, 1.1, 0],
top: [0, 3.4, 0.001],
};
const KTX2_TRANSCODER_PATH = "/three/basis/";
const MODEL_MOVE_SPEED = 1.05;
const MODEL_MAX_FRAME_MOVE_DISTANCE = 0.035;
function CameraPresetController({
controlsRef,
preset,
resetToken,
}: {
controlsRef: React.MutableRefObject<any>;
preset: Model3DCameraPreset;
resetToken?: number;
}) {
const { camera } = useThree();
useEffect(() => {
const [x, y, z] =
CAMERA_PRESET_POSITIONS[preset] || CAMERA_PRESET_POSITIONS.iso;
camera.position.set(x, y, z);
if (preset === "top") {
camera.up.set(0, 0, -1);
} else {
camera.up.set(0, 1, 0);
}
camera.lookAt(0, 0, 0);
const controls = controlsRef.current;
if (controls) {
controls.target.set(0, 0, 0);
controls.update();
}
}, [camera, controlsRef, preset, resetToken]);
return null;
}
// eslint-disable-next-line
class ViewerErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidUpdate(prevProps: { children: React.ReactNode }) {
if (prevProps.children !== this.props.children && this.state.hasError) {
this.setState({ hasError: false });
}
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
function isMesh(node: Object3D): node is Mesh {
return (node as Mesh).isMesh === true;
}
function createMovementState(): MovementState {
return { ...EMPTY_MOVEMENT_STATE };
}
function isEditableKeyboardTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const editableSelector = "input, textarea, select, [contenteditable='true'], [role='textbox']";
if (target.matches(editableSelector)) return true;
return Boolean(target.closest(editableSelector));
}
function collectSceneMeta(scene: Object3D): {
layers: Model3DLayer[];
stats: Model3DStats;
meshRefs: Array<{ layerId: string; mesh: Mesh }>;
} {
const layers: Model3DLayer[] = [];
const meshRefs: Array<{ layerId: string; mesh: Mesh }> = [];
let triangles = 0;
let vertices = 0;
let meshCount = 0;
let meshIndex = 0;
scene.traverse((node) => {
if (!isMesh(node)) return;
const geometry = node.geometry;
if (!geometry) return;
const id = `${node.name || "Mesh"}__${meshIndex}`;
meshIndex += 1;
const indexCount = geometry.index?.count || 0;
const positionCount = geometry.attributes.position?.count || 0;
const meshTriangles =
indexCount > 0
? Math.floor(indexCount / 3)
: Math.floor(positionCount / 3);
const meshVertices = positionCount;
triangles += meshTriangles;
vertices += meshVertices;
meshCount += 1;
layers.push({
id,
name: node.name?.trim() || `mesh_${meshCount}`,
triangles: meshTriangles,
vertices: meshVertices,
});
meshRefs.push({ layerId: id, mesh: node });
});
return {
layers,
stats: {
triangles,
vertices,
meshCount,
},
meshRefs,
};
}
const COLOR_TEXTURE_KEYS = new Set([
"map",
"emissiveMap",
"sheenColorMap",
"specularColorMap",
]);
const MATERIAL_TEXTURE_KEYS = [
"alphaMap",
"aoMap",
"bumpMap",
"clearcoatMap",
"clearcoatNormalMap",
"clearcoatRoughnessMap",
"displacementMap",
"emissiveMap",
"iridescenceMap",
"iridescenceThicknessMap",
"lightMap",
"map",
"metalnessMap",
"normalMap",
"roughnessMap",
"sheenColorMap",
"sheenRoughnessMap",
"specularColorMap",
"specularIntensityMap",
"specularMap",
"thicknessMap",
"transmissionMap",
] as const;
function configureMaterialTextures(
material: THREE.Material,
geometry?: THREE.BufferGeometry | null
) {
const materialRecord = material as any;
if (materialRecord.aoMap && geometry?.attributes.uv && !geometry.attributes.uv2) {
geometry.setAttribute("uv2", geometry.attributes.uv);
}
if (geometry?.attributes.color && "vertexColors" in materialRecord) {
materialRecord.vertexColors = true;
}
for (const key of MATERIAL_TEXTURE_KEYS) {
const texture = materialRecord[key];
if (!texture?.isTexture) continue;
if ("colorSpace" in texture) {
texture.colorSpace = COLOR_TEXTURE_KEYS.has(key)
? THREE.SRGBColorSpace
: THREE.NoColorSpace;
}
texture.anisotropy = Math.max(Number(texture.anisotropy || 1), 8);
texture.needsUpdate = true;
}
material.needsUpdate = true;
}
function cloneSceneWithIndependentMaterials(scene: Object3D) {
const clonedScene = scene.clone(true);
clonedScene.traverse((node) => {
if (!isMesh(node)) return;
const sourceMaterials = Array.isArray(node.material)
? node.material
: [node.material];
const clonedMaterials = sourceMaterials.map((material) => {
const clonedMaterial = material.clone();
configureMaterialTextures(clonedMaterial, node.geometry);
return clonedMaterial;
});
node.material = Array.isArray(node.material)
? clonedMaterials
: clonedMaterials[0] || node.material;
});
return clonedScene;
}
function resolveSceneGroundY(
scene: Object3D,
modelRotation?: [number, number, number],
fitTransform?: { position: [number, number, number]; scale: number }
) {
const probeScene = scene.clone(true);
if (fitTransform) {
probeScene.position.set(...fitTransform.position);
probeScene.scale.setScalar(fitTransform.scale);
}
const probeGroup = new THREE.Group();
if (modelRotation) {
probeGroup.rotation.set(...modelRotation);
}
probeGroup.add(probeScene);
probeGroup.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(probeGroup);
if (bounds.isEmpty() || !Number.isFinite(bounds.min.y)) return 0;
return bounds.min.y;
}
function getModelFitTransform(scene: Object3D) {
const bounds = new THREE.Box3().setFromObject(scene);
if (bounds.isEmpty()) return { position: [0, 0, 0] as [number, number, number], scale: 1 };
const size = bounds.getSize(new THREE.Vector3());
const center = bounds.getCenter(new THREE.Vector3());
const largestDimension = Math.max(size.x, size.y, size.z);
if (!Number.isFinite(largestDimension) || largestDimension <= 0) {
return { position: [0, 0, 0] as [number, number, number], scale: 1 };
}
const scale = 2 / largestDimension;
return {
position: [
-center.x * scale,
-center.y * scale,
-center.z * scale,
] as [number, number, number],
scale,
};
}
function ModelScene({
url,
settings,
modelRotation,
layerVisibility,
onLayersChange,
onStatsChange,
onGroundYChange,
autoFitModel,
}: {
url: string;
settings: Model3DViewerSettings;
modelRotation?: [number, number, number];
layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void;
onGroundYChange?: (groundY: number) => void;
autoFitModel?: boolean;
}) {
const { gl } = useThree();
const ktx2Loader = useMemo(() => {
const nextLoader = new KTX2Loader();
nextLoader.setTranscoderPath(KTX2_TRANSCODER_PATH);
nextLoader.detectSupport(gl);
return nextLoader;
}, [gl]);
useEffect(() => {
return () => {
ktx2Loader.dispose();
};
}, [ktx2Loader]);
const model = useGLTF(
url,
true,
true,
(loader) => {
loader.setKTX2Loader(ktx2Loader);
},
);
const scene = useMemo(
() => cloneSceneWithIndependentMaterials(model.scene),
[model.scene]
);
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
const fitTransform = useMemo(
() =>
autoFitModel
? getModelFitTransform(scene)
: { position: [0, 0, 0] as [number, number, number], scale: 1 },
[autoFitModel, scene]
);
const modelRotationKey = modelRotation?.join("|") || "";
const groundY = useMemo(
() => resolveSceneGroundY(scene, modelRotation, fitTransform),
// eslint-disable-next-line react-hooks/exhaustive-deps
[scene, modelRotationKey, fitTransform]
);
useEffect(() => {
onLayersChange?.(meta.layers);
onStatsChange?.(meta.stats);
}, [meta.layers, meta.stats, onLayersChange, onStatsChange]);
useEffect(() => {
onGroundYChange?.(groundY);
}, [groundY, onGroundYChange]);
useEffect(() => {
for (const { layerId, mesh } of meta.meshRefs) {
const nextVisible = layerVisibility?.[layerId];
mesh.visible = nextVisible !== false;
}
}, [layerVisibility, meta.meshRefs]);
useEffect(() => {
return () => {
scene.traverse((node) => {
if (!isMesh(node)) return;
const materials = Array.isArray(node.material)
? node.material
: [node.material];
for (const material of materials) {
material.dispose();
}
});
};
}, [scene]);
useEffect(() => {
for (const { mesh } of meta.meshRefs) {
mesh.castShadow = true;
mesh.receiveShadow = true;
const materials = Array.isArray(mesh.material)
? mesh.material
: [mesh.material];
for (const material of materials) {
if (!material) continue;
if ("wireframe" in material) {
(material as any).wireframe = settings.wireframe;
}
}
}
}, [meta.meshRefs, settings.wireframe]);
return (
<group rotation={modelRotation || [0, 0, 0]}>
<primitive
object={scene}
position={fitTransform.position}
scale={fitTransform.scale}
/>
</group>
);
}
function ViewerCaptureBridge({
onCaptureReady,
}: {
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
}) {
const { camera, gl, scene } = useThree();
useEffect(() => {
if (!onCaptureReady) return;
onCaptureReady(async () => {
const canvas = gl.domElement;
gl.render(scene, camera);
const blob = await new Promise<Blob | null>((resolve) => {
if (typeof canvas.toBlob === "function") {
canvas.toBlob(resolve, "image/png");
return;
}
try {
const dataUrl = canvas.toDataURL("image/png");
fetch(dataUrl)
.then((response) => response.blob())
.then((result) => resolve(result))
.catch(() => resolve(null));
} catch {
resolve(null);
}
});
return blob;
});
return () => onCaptureReady(null);
}, [camera, gl, onCaptureReady, scene]);
return null;
}
function ModelMovementControls({
enabled,
movementStateRef,
orbitControlsRef,
}: {
enabled: boolean;
movementStateRef: React.MutableRefObject<MovementState>;
orbitControlsRef: React.MutableRefObject<any>;
}) {
const { camera } = useThree();
const directionRef = useRef(new THREE.Vector3());
const rightRef = useRef(new THREE.Vector3());
const upRef = useRef(new THREE.Vector3());
const moveRef = useRef(new THREE.Vector3());
useEffect(() => {
if (!enabled) {
movementStateRef.current = createMovementState();
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
const movement = KEY_TO_MOVEMENT[event.code];
if (!movement) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isEditableKeyboardTarget(event.target)) {
movementStateRef.current = createMovementState();
return;
}
event.preventDefault();
movementStateRef.current[movement] = true;
};
const handleKeyUp = (event: KeyboardEvent) => {
const movement = KEY_TO_MOVEMENT[event.code];
if (!movement) return;
event.preventDefault();
movementStateRef.current[movement] = false;
};
const handleBlur = () => {
movementStateRef.current = createMovementState();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, [enabled, movementStateRef]);
useFrame((_, delta) => {
if (!enabled) return;
const pressed = movementStateRef.current;
if (!Object.values(pressed).some(Boolean)) return;
camera.getWorldDirection(directionRef.current);
directionRef.current.setY(0);
if (directionRef.current.lengthSq() < 0.0001) {
directionRef.current.set(0, 0, -1);
}
directionRef.current.normalize();
rightRef.current.crossVectors(directionRef.current, camera.up).normalize();
upRef.current.copy(camera.up).normalize();
moveRef.current.set(0, 0, 0);
if (pressed.forward) moveRef.current.add(directionRef.current);
if (pressed.backward) moveRef.current.addScaledVector(directionRef.current, -1);
if (pressed.left) moveRef.current.addScaledVector(rightRef.current, -1);
if (pressed.right) moveRef.current.add(rightRef.current);
if (pressed.up) moveRef.current.add(upRef.current);
if (pressed.down) moveRef.current.addScaledVector(upRef.current, -1);
if (moveRef.current.lengthSq() === 0) return;
const distance = Math.min(
MODEL_MOVE_SPEED * delta,
MODEL_MAX_FRAME_MOVE_DISTANCE
);
moveRef.current.normalize().multiplyScalar(distance);
camera.position.add(moveRef.current);
if (orbitControlsRef.current?.target) {
orbitControlsRef.current.target.add(moveRef.current);
orbitControlsRef.current.update?.();
}
});
return null;
}
function ModelMovementPad({
movementStateRef,
}: {
movementStateRef: React.MutableRefObject<MovementState>;
}) {
const setPressed = React.useCallback(
(command: MovementCommand, pressed: boolean) => {
movementStateRef.current[command] = pressed;
},
[movementStateRef]
);
const clearMovement = React.useCallback(() => {
movementStateRef.current = createMovementState();
}, [movementStateRef]);
const buttonClass =
"flex h-8 w-8 select-none items-center justify-center rounded-[8px] border border-white/15 bg-black/60 text-[12px] font-bold text-white shadow-[0_8px_22px_rgba(0,0,0,0.28)] backdrop-blur-md transition hover:bg-black/75 active:scale-95";
const buttonProps = (command: MovementCommand) => ({
"aria-label": command,
className: buttonClass,
onPointerDown: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
setPressed(command, true);
},
onPointerUp: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setPressed(command, false);
},
onPointerCancel: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
clearMovement();
},
onPointerLeave: (event: React.PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
setPressed(command, false);
},
type: "button" as const,
});
return (
<div
className="nodrag nopan nowheel pointer-events-auto absolute bottom-3 left-3 z-20 flex items-end gap-2"
data-canvas-no-drag="true"
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<div className="grid grid-cols-3 gap-1 rounded-[12px] border border-white/10 bg-black/26 p-1.5 backdrop-blur-md">
<span />
<button {...buttonProps("forward")}>W</button>
<span />
<button {...buttonProps("left")}>A</button>
<button {...buttonProps("backward")}>S</button>
<button {...buttonProps("right")}>D</button>
</div>
<div className="grid gap-1 rounded-[12px] border border-white/10 bg-black/26 p-1.5 backdrop-blur-md">
<button {...buttonProps("up")}>Q</button>
<button {...buttonProps("down")}>E</button>
</div>
</div>
);
}
function isGlbOrGltf(url?: string | null) {
const value = String(url || "").toLowerCase();
return /\.(gltf|glb)(\?|#|$)/.test(value);
}
function EmptyState({
previewImageUrl,
title,
}: {
previewImageUrl?: string | null;
title: string;
}) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950/50 p-4">
{previewImageUrl ? (
<div className="relative aspect-square w-64 overflow-hidden rounded-xl border border-white/10 bg-zinc-900/50 shadow-2xl">
<img
alt="3D 预览"
className="h-full w-full object-cover opacity-50 transition-opacity hover:opacity-100"
src={previewImageUrl}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-[1px]">
<Loader2 className="h-8 w-8 animate-spin text-white/50" />
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4 text-zinc-500">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/50 shadow-inner">
<Box className="h-10 w-10 opacity-20" />
</div>
<p className="font-medium text-sm">{title}</p>
</div>
)}
</div>
);
}
export function Model3DViewer({
modelUrl,
modelFormat,
autoFitModel = false,
localStudioEnvironment = false,
previewImageUrl,
emptyStateTitle = "准备生成 3D 资产",
settings,
modelRotation,
showMovementControls = false,
layerVisibility,
onLayersChange,
onStatsChange,
resetViewToken,
onCaptureReady,
}: Model3DViewerProps) {
const controlsRef = useRef<any>(null);
const movementStateRef = useRef<MovementState>(createMovementState());
const [groundY, setGroundY] = React.useState(0);
const resolvedModelUrl = useMemo(() => {
return buildThreeAssetUrl(modelUrl);
}, [modelUrl]);
const gridY = Number.isFinite(groundY) ? groundY - 0.08 : -0.08;
const handleGroundYChange = React.useCallback((nextGroundY: number) => {
setGroundY((previousGroundY) =>
Math.abs(previousGroundY - nextGroundY) < 0.001
? previousGroundY
: nextGroundY
);
}, []);
useEffect(() => {
setGroundY(0);
}, [resolvedModelUrl]);
if (!modelUrl) {
return (
<EmptyState previewImageUrl={previewImageUrl} title={emptyStateTitle} />
);
}
if (!isGlbOrGltf(modelUrl) && modelFormat !== "glb" && modelFormat !== "gltf") {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-zinc-950 p-4 text-zinc-400">
<AlertCircle className="h-8 w-8 text-yellow-500/50" />
<p className="text-sm">当前文件格式不支持内嵌预览,请在新窗口打开</p>
<Button
asChild
className="gap-2 border-white/10 text-zinc-300 hover:bg-white/5"
variant="outline"
>
<a href={modelUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" />
打开3D文件
</a>
</Button>
</div>
);
}
return (
<ViewerErrorBoundary
fallback={
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3 overflow-hidden bg-zinc-950 p-4 text-zinc-300">
{previewImageUrl ? (
<img
alt="3D 模型静态预览"
className="absolute inset-0 h-full w-full object-contain opacity-80"
src={previewImageUrl}
/>
) : null}
<div className="relative flex max-w-xs flex-col items-center gap-3 rounded-lg bg-black/70 px-4 py-3 text-center backdrop-blur-sm">
<AlertCircle className="h-7 w-7 text-amber-300" />
<p className="text-sm">{previewImageUrl ? "3D 预览加载失败,可查看静态预览或下载模型" : "3D 预览加载失败,请重试或下载模型"}</p>
<Button
asChild
className="gap-2 border-white/10 text-zinc-300 hover:bg-white/5"
variant="outline"
>
<a href={modelUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" />
打开3D文件
</a>
</Button>
</div>
</div>
}
>
<div className="relative h-full w-full overflow-hidden">
<Canvas
camera={{
position:
CAMERA_PRESET_POSITIONS[settings.cameraPreset] ||
CAMERA_PRESET_POSITIONS.iso,
fov: 45,
}}
className="bg-gradient-to-b from-zinc-900 to-zinc-950"
gl={{ preserveDrawingBuffer: true }}
onCreated={({ gl }) => {
gl.outputColorSpace = THREE.SRGBColorSpace;
gl.toneMapping = THREE.ACESFilmicToneMapping;
gl.toneMappingExposure = 1;
}}
>
<ViewerCaptureBridge onCaptureReady={onCaptureReady} />
<CameraPresetController
controlsRef={controlsRef}
preset={settings.cameraPreset}
resetToken={resetViewToken}
/>
<ModelMovementControls
enabled={showMovementControls}
movementStateRef={movementStateRef}
orbitControlsRef={controlsRef}
/>
<ambientLight intensity={0.5 * settings.lightIntensity} />
<directionalLight
intensity={1.2 * settings.lightIntensity}
position={[5, 8, 5]}
shadow-bias={-0.0001}
/>
{settings.showGrid ? (
<Grid
args={[20, 20]}
cellColor="#3f3f46"
cellSize={0.5}
fadeDistance={30}
fadeStrength={1}
position={[0, gridY, 0]}
sectionColor="#52525b"
sectionSize={2}
/>
) : null}
{localStudioEnvironment ? <LocalStudioEnvironment /> : null}
<Suspense
fallback={
localStudioEnvironment ? <ModelLoadingFallback /> :
<mesh>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="#27272a" wireframe />
</mesh>
}
>
<group position={[0, 0, 0]}>
<ModelScene
autoFitModel={autoFitModel}
layerVisibility={layerVisibility}
modelRotation={modelRotation}
onGroundYChange={handleGroundYChange}
onLayersChange={onLayersChange}
onStatsChange={onStatsChange}
settings={settings}
url={resolvedModelUrl}
/>
<ContactShadows
blur={2}
color="#000000"
far={4}
opacity={0.4}
position={[0, groundY, 0]}
resolution={256}
scale={10}
/>
</group>
</Suspense>
{!localStudioEnvironment ? (
<Suspense fallback={null}>
<Environment preset={settings.envPreset} />
</Suspense>
) : null}
<OrbitControls
autoRotate={settings.autoRotate}
dampingFactor={0.08}
enableDamping
enablePan
enableRotate
enableZoom
makeDefault
maxPolarAngle={Math.PI / 1.75}
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN,
}}
panSpeed={0.8}
screenSpacePanning
minPolarAngle={0}
ref={controlsRef}
touches={{
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
}}
/>
</Canvas>
{showMovementControls ? <ModelMovementPad movementStateRef={movementStateRef} /> : null}
</div>
</ViewerErrorBoundary>
);
}