2022-12-03 06:26:17 +00:00
|
|
|
import { CSSProperties, ReactNode, useEffect, useMemo, useState } from "react";
|
|
|
|
|
import "@google/model-viewer";
|
|
|
|
|
import type { ModelViewerElement } from "@google/model-viewer";
|
|
|
|
|
import { ModelViewerContext } from "./useModelViewer";
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
namespace JSX {
|
|
|
|
|
interface IntrinsicElements {
|
|
|
|
|
"model-viewer": ModelViewerAttributes;
|
|
|
|
|
}
|
|
|
|
|
interface ModelViewerAttributes {
|
|
|
|
|
alt: string;
|
|
|
|
|
src: string;
|
|
|
|
|
ref: (modelViewer: ModelViewerElement | null) => void;
|
2024-01-27 09:06:54 +00:00
|
|
|
exposure: number;
|
2022-12-03 06:26:17 +00:00
|
|
|
autoplay: "true" | "false";
|
|
|
|
|
scale?: string;
|
|
|
|
|
style: CSSProperties;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-26 19:48:36 +00:00
|
|
|
function useTimeScale(
|
|
|
|
|
modelViewer: ModelViewerElement | null,
|
|
|
|
|
timeScale: number
|
|
|
|
|
) {
|
2022-12-03 06:26:17 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (modelViewer) {
|
2025-10-19 16:15:21 +00:00
|
|
|
// eslint-disable-next-line react-hooks/immutability
|
2024-08-26 19:48:36 +00:00
|
|
|
modelViewer.timeScale = timeScale;
|
2022-12-03 06:26:17 +00:00
|
|
|
}
|
2024-08-26 19:48:36 +00:00
|
|
|
}, [modelViewer, timeScale]);
|
2022-12-03 06:26:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ModelViewerProps {
|
|
|
|
|
modelUrl: string;
|
|
|
|
|
environmentImageUrl: string | null;
|
2024-01-27 08:43:44 +00:00
|
|
|
showEnvironment?: boolean;
|
2024-01-27 09:06:54 +00:00
|
|
|
exposure?: number;
|
2022-12-03 06:26:17 +00:00
|
|
|
colorImageUrl?: string;
|
|
|
|
|
metallicImageUrl?: string;
|
|
|
|
|
animationName: string | null;
|
|
|
|
|
animationPaused?: boolean;
|
2024-08-26 19:48:36 +00:00
|
|
|
timeScale?: number;
|
2022-12-03 06:26:17 +00:00
|
|
|
cameraOrbit?: string;
|
|
|
|
|
cameraTarget?: string;
|
|
|
|
|
fieldOfView?: string;
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ModelViewerKeyedByModel({
|
|
|
|
|
modelUrl,
|
|
|
|
|
environmentImageUrl,
|
2024-01-27 08:43:44 +00:00
|
|
|
showEnvironment = false,
|
2024-01-27 09:06:54 +00:00
|
|
|
exposure = 1,
|
2022-12-03 06:26:17 +00:00
|
|
|
animationName,
|
|
|
|
|
animationPaused = false,
|
2024-08-26 19:48:36 +00:00
|
|
|
timeScale = 1,
|
2022-12-03 06:26:17 +00:00
|
|
|
cameraOrbit,
|
|
|
|
|
cameraTarget,
|
|
|
|
|
fieldOfView,
|
|
|
|
|
children,
|
|
|
|
|
}: ModelViewerProps) {
|
|
|
|
|
const [modelViewer, setModelViewer] = useState<ModelViewerElement | null>(
|
|
|
|
|
null
|
|
|
|
|
);
|
|
|
|
|
const [isLoaded, setLoaded] = useState(false);
|
|
|
|
|
|
|
|
|
|
const context = useMemo(() => {
|
|
|
|
|
if (!modelViewer || !isLoaded || !modelViewer.model) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
modelViewer,
|
|
|
|
|
model: modelViewer.model,
|
|
|
|
|
isLoaded,
|
|
|
|
|
};
|
|
|
|
|
}, [modelViewer, isLoaded]);
|
|
|
|
|
|
2024-08-26 19:48:36 +00:00
|
|
|
useTimeScale(modelViewer, timeScale);
|
2022-12-03 06:26:17 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!modelViewer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let stale = false;
|
|
|
|
|
|
|
|
|
|
const handleLoad = () => {
|
|
|
|
|
if (!stale) {
|
|
|
|
|
setLoaded(true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
modelViewer.addEventListener("load", handleLoad);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
stale = true;
|
|
|
|
|
modelViewer.removeEventListener("load", handleLoad);
|
|
|
|
|
};
|
|
|
|
|
}, [modelViewer, modelUrl]);
|
|
|
|
|
|
2025-10-19 16:15:21 +00:00
|
|
|
if (!isLoaded && modelViewer && modelViewer.loaded) {
|
|
|
|
|
setLoaded(true);
|
|
|
|
|
}
|
2022-12-03 06:26:17 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!modelViewer || !isLoaded) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (animationPaused) {
|
|
|
|
|
modelViewer.pause();
|
|
|
|
|
} else {
|
|
|
|
|
modelViewer.play();
|
|
|
|
|
}
|
|
|
|
|
}, [modelViewer, isLoaded, animationPaused]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (modelViewer && isLoaded && fieldOfView) {
|
|
|
|
|
modelViewer.setAttribute("field-of-view", fieldOfView);
|
|
|
|
|
}
|
|
|
|
|
}, [modelViewer, isLoaded, fieldOfView]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<model-viewer
|
|
|
|
|
ref={setModelViewer}
|
|
|
|
|
alt="Tribes 2 Model"
|
|
|
|
|
src={modelUrl}
|
|
|
|
|
camera-controls
|
|
|
|
|
camera-orbit={cameraOrbit}
|
2024-01-27 08:43:44 +00:00
|
|
|
max-camera-orbit={
|
|
|
|
|
environmentImageUrl && showEnvironment ? "auto 90deg auto" : undefined
|
|
|
|
|
}
|
2022-12-03 06:26:17 +00:00
|
|
|
camera-target={cameraTarget}
|
|
|
|
|
min-field-of-view="10deg"
|
2024-01-27 08:43:44 +00:00
|
|
|
max-field-of-view="45deg"
|
2022-12-03 06:26:17 +00:00
|
|
|
animation-name={animationName ?? undefined}
|
2025-10-19 16:15:21 +00:00
|
|
|
autoplay={animationName != null}
|
2022-12-03 06:26:17 +00:00
|
|
|
touch-action="pan-y"
|
2024-01-27 09:06:54 +00:00
|
|
|
exposure={exposure}
|
2022-12-03 06:26:17 +00:00
|
|
|
environment-image={environmentImageUrl ?? undefined}
|
2024-01-27 08:43:44 +00:00
|
|
|
skybox-image={
|
|
|
|
|
environmentImageUrl && showEnvironment
|
|
|
|
|
? environmentImageUrl
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
skybox-height="1.5m"
|
|
|
|
|
shadow-intensity={environmentImageUrl && showEnvironment ? 1 : 0}
|
2022-12-03 06:26:17 +00:00
|
|
|
style={{ width: "100%", height: "100%" }}
|
|
|
|
|
/>
|
|
|
|
|
{isLoaded ? (
|
|
|
|
|
<ModelViewerContext.Provider value={context}>
|
|
|
|
|
{children}
|
|
|
|
|
</ModelViewerContext.Provider>
|
|
|
|
|
) : null}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ModelViewer(props: ModelViewerProps) {
|
|
|
|
|
return <ModelViewerKeyedByModel key={props.modelUrl} {...props} />;
|
|
|
|
|
}
|