From 30ff9826dcacf8185f1e22cc5fdff99d1ba35fc9 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 13 May 2026 17:41:54 +0200 Subject: [PATCH] feat: add model hierarchy panel --- components/ModelViewer.tsx | 166 +++++++++++++++++++++++++++++++------ components/SceneViewer.tsx | 48 ++++++++++- 2 files changed, 185 insertions(+), 29 deletions(-) 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 ( - +