diff --git a/optuna_dashboard/ts/components/ModelViewer.tsx b/optuna_dashboard/ts/components/ModelViewer.tsx
new file mode 100644
index 00000000..db5caee2
--- /dev/null
+++ b/optuna_dashboard/ts/components/ModelViewer.tsx
@@ -0,0 +1,81 @@
+import * as THREE from "three"
+import React, { useState } from "react"
+import { Canvas } from "@react-three/fiber"
+import { GizmoHelper, GizmoViewport, OrbitControls } from "@react-three/drei"
+import { STLLoader } from "three/examples/jsm/loaders/STLLoader"
+import { PerspectiveCamera } from "three"
+
+interface ModelViewerProps {
+ src: string
+ alt: string
+ width: string
+ height: string
+ hasGizmo: boolean
+}
+
+function CustomGizmoHelper(): JSX.Element {
+ return (
+
+
+
+ )
+}
+
+export function ModelViewer(props: ModelViewerProps): JSX.Element {
+ const [geometry, setGeometry] = useState()
+ const [modelSize, setModelSize] = useState()
+
+ React.useEffect(() => {
+ const loader = new STLLoader()
+ loader.load(props.src, (geometry: THREE.BufferGeometry) => {
+ if (geometry) {
+ setGeometry(geometry)
+ geometry.computeBoundingBox()
+ if (geometry.boundingBox === null) {
+ setModelSize(new THREE.Vector3(10, 10, 10))
+ } else {
+ const size = geometry.boundingBox.getSize(new THREE.Vector3())
+ setModelSize(size)
+ }
+ }
+ })
+ }, [])
+ const cameraPosition = modelSize
+ ? [modelSize.x * 1.5, modelSize.y * 1.5, modelSize.z * 1.5]
+ : [10, 10, 10]
+ const cameraSettings: PerspectiveCamera = {
+ fov: modelSize
+ ? Math.min(45, Math.atan(modelSize.y / modelSize.z) * (180 / Math.PI) * 2)
+ : 45,
+ aspect: window.innerWidth / window.innerHeight,
+ near: 0.1,
+ far: 1000,
+ position: cameraPosition,
+ }
+
+ const viewerWidth = `${parseInt(props.width) * 2}px`
+
+ return (
+
+ )
+}
diff --git a/optuna_dashboard/ts/components/TrialList.tsx b/optuna_dashboard/ts/components/TrialList.tsx
index c2c75dd3..165fe9d0 100644
--- a/optuna_dashboard/ts/components/TrialList.tsx
+++ b/optuna_dashboard/ts/components/TrialList.tsx
@@ -45,6 +45,7 @@ import { artifactIsAvailable } from "../state"
import { actionCreator } from "../action"
import { useDeleteArtifactDialog } from "./DeleteArtifactDialog"
import { TrialFormWidgets } from "./TrialFormWidgets"
+import { ModelViewer } from "./ModelViewer"
const states: TrialState[] = [
"Complete",
@@ -437,6 +438,80 @@ const TrialArtifact: FC<{ trial: Trial }> = ({ trial }) => {
)
+ } else if (a.filename.endsWith(".stl")) {
+ return (
+
+
+
+
+
+
+ {a.filename}
+
+ {
+ openDeleteArtifactDialog(
+ trial.study_id,
+ trial.trial_id,
+ a
+ )
+ }}
+ >
+
+
+
+
+
+
+
+ )
} else if (a.mimetype.startsWith("audio")) {
return (