diff --git a/components/ModelViewer.tsx b/components/ModelViewer.tsx
index 18d33dc..0b379c3 100644
--- a/components/ModelViewer.tsx
+++ b/components/ModelViewer.tsx
@@ -2,7 +2,7 @@
import { Component, useEffect, useState } from 'react'
import type { ComponentType, ReactNode } from 'react'
-import type { ModelStats } from './SceneViewer'
+import type { ModelHierarchyNode, ModelStats } from './SceneViewer'
interface ModelViewerProps {
url: string
@@ -28,6 +28,95 @@ function PreviewFallback({ message }: { message?: string }) {
)
}
+function formatTransformValue(value: number) {
+ return Number.isInteger(value) ? String(value) : value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')
+}
+
+function formatTransform(values: [number, number, number]) {
+ return `[${values.map(formatTransformValue).join(', ')}]`
+}
+
+function countHierarchyNodes(node: ModelHierarchyNode): number {
+ return 1 + node.children.reduce((count, child) => count + countHierarchyNodes(child), 0)
+}
+
+function HierarchyTree({
+ node,
+ depth = 0,
+}: {
+ node: ModelHierarchyNode
+ depth?: number
+}) {
+ const hasChildren = node.children.length > 0
+
+ return (
+
+
+
+
+ {hasChildren ? 'v' : '-'}
+ {node.name}
+ {node.type}
+
+
+ pos {formatTransform(node.position)}
+ rot {formatTransform(node.rotation)}
+
+
+
+ {node.visible ? 'Visible' : 'Hidden'}
+
+
+ {hasChildren && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function HierarchyPanel({
+ hierarchy,
+ onClose,
+}: {
+ hierarchy: ModelHierarchyNode
+ onClose: () => void
+}) {
+ const nodeCount = countHierarchyNodes(hierarchy)
+
+ return (
+
+
+
+
Hierarchy
+
{nodeCount} nodes
+
+
+
+
+
+
+
+ )
+}
+
class PreviewErrorBoundary extends Component<
{ children: ReactNode },
{ message: string | null }
@@ -54,11 +143,14 @@ class PreviewErrorBoundary extends Component<
export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) {
const canPreview = filename.toLowerCase().endsWith('.gltf')
const [stats, setStats] = useState(null)
+ const [hierarchy, setHierarchy] = useState(null)
+ const [hierarchyOpen, setHierarchyOpen] = useState(false)
const [sceneError, setSceneError] = useState(null)
const [Scene, setScene] = useState
onStatsReady: (stats: ModelStats) => void
+ onHierarchyReady: (hierarchy: ModelHierarchyNode) => void
}> | null>(null)
useEffect(() => {
@@ -67,6 +159,8 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
let cancel = false
setSceneError(null)
setStats(null)
+ setHierarchy(null)
+ setHierarchyOpen(false)
import('./SceneViewer')
.then((mod) => {
@@ -108,38 +202,56 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
{stats && (
-
-
- Draw calls
- {stats.drawCalls}
-
-
- Children
- {stats.childObjects}
-
-
- Meshes
- {stats.meshes}
-
-
- Triangles
- {stats.triangles.toLocaleString('fr-FR')}
-
-
- Materials
- {stats.materials}
-
-
- Textures
- {stats.textures}
-
+
+
+
+ Draw calls
+ {stats.drawCalls}
+
+
+ Children
+ {stats.childObjects}
+
+
+ Meshes
+ {stats.meshes}
+
+
+ Triangles
+ {stats.triangles.toLocaleString('fr-FR')}
+
+
+ Materials
+ {stats.materials}
+
+
+ Textures
+ {stats.textures}
+
+
+
)}
+ {hierarchy && hierarchyOpen && (
+
setHierarchyOpen(false)} />
+ )}
{sceneError ? (
) : (
-
+
)}
diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx
index 158a358..bd6936b 100644
--- a/components/SceneViewer.tsx
+++ b/components/SceneViewer.tsx
@@ -18,6 +18,16 @@ export interface ModelStats {
triangles: number
}
+export interface ModelHierarchyNode {
+ children: ModelHierarchyNode[]
+ id: string
+ name: string
+ position: [number, number, number]
+ rotation: [number, number, number]
+ type: string
+ visible: boolean
+}
+
interface OpacityMapEntry {
target: string
url: string
@@ -207,6 +217,30 @@ function getModelStats(scene: Object3D, assetUrls: Record): Mode
}
}
+function roundTransformValue(value: number) {
+ return Number(value.toFixed(4))
+}
+
+function getObjectHierarchy(object: Object3D): ModelHierarchyNode {
+ return {
+ children: object.children.map(getObjectHierarchy),
+ id: object.uuid,
+ name: object.name || object.type,
+ position: [
+ roundTransformValue(object.position.x),
+ roundTransformValue(object.position.y),
+ roundTransformValue(object.position.z),
+ ],
+ rotation: [
+ roundTransformValue(object.rotation.x),
+ roundTransformValue(object.rotation.y),
+ roundTransformValue(object.rotation.z),
+ ],
+ type: object.type,
+ visible: object.visible,
+ }
+}
+
function pickOpacityMap(
mesh: Mesh,
material: Material,
@@ -234,10 +268,12 @@ function Model({
url,
assetUrls,
onStatsReady,
+ onHierarchyReady,
}: {
url: string
assetUrls: Record
onStatsReady: (stats: ModelStats) => void
+ onHierarchyReady: (hierarchy: ModelHierarchyNode) => void
}) {
const { scene } = useLoader(GLTFLoader, url, (loader) => {
loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls))
@@ -247,7 +283,8 @@ function Model({
useEffect(() => {
onStatsReady(getModelStats(scene, assetUrls))
- }, [assetUrls, onStatsReady, scene])
+ onHierarchyReady(getObjectHierarchy(scene))
+ }, [assetUrls, onHierarchyReady, onStatsReady, scene])
useEffect(() => {
if (opacityMapEntries.length === 0) return
@@ -270,16 +307,23 @@ export default function SceneViewer({
url,
assetUrls,
onStatsReady,
+ onHierarchyReady,
}: {
url: string
assetUrls: Record
onStatsReady: (stats: ModelStats) => void
+ onHierarchyReady: (hierarchy: ModelHierarchyNode) => void
}) {
return (