瀏覽代碼

Merge branch 'next' into feature/962-createrenderer-composable

alvarosabu 1 月之前
父節點
當前提交
0b306bc938
共有 49 個文件被更改,包括 1838 次插入431 次删除
  1. 76 0
      CHANGELOG.md
  2. 8 1
      docs/.vitepress/config/en.ts
  3. 38 6
      docs/_data/team.js
  4. 58 0
      docs/composables/use-graph.md
  5. 182 0
      docs/composables/use-loader.md
  6. 16 18
      docs/cookbook/load-models.md
  7. 6 6
      docs/fr/guide/index.md
  8. 19 8
      docs/team.md
  9. 2 2
      package.json
  10. 0 14
      playground/vue/src/composables/useFBX.ts
  11. 0 82
      playground/vue/src/composables/useGLTF.ts
  12. 57 0
      playground/vue/src/composables/usePocGLTF.ts
  13. 2 0
      playground/vue/src/pages/events/index.vue
  14. 4 5
      playground/vue/src/pages/loaders/componentDemo.vue
  15. 2 4
      playground/vue/src/pages/loaders/fbx-loader/TheExperience.vue
  16. 39 4
      playground/vue/src/pages/loaders/fbx-loader/TheModel.vue
  17. 27 3
      playground/vue/src/pages/loaders/fbx-loader/index.vue
  18. 13 0
      playground/vue/src/pages/loaders/gltf-loader/ManualUseGLTF.vue
  19. 2 4
      playground/vue/src/pages/loaders/gltf-loader/TheExperience.vue
  20. 71 4
      playground/vue/src/pages/loaders/gltf-loader/TheModel.vue
  21. 27 3
      playground/vue/src/pages/loaders/gltf-loader/index.vue
  22. 62 0
      playground/vue/src/pages/loaders/multiple-models/TheExperience.vue
  23. 35 0
      playground/vue/src/pages/loaders/multiple-models/index.vue
  24. 50 0
      playground/vue/src/pages/loaders/texture-loader/TheExperience.vue
  25. 35 0
      playground/vue/src/pages/loaders/texture-loader/index.vue
  26. 32 0
      playground/vue/src/pages/misc/use-graph/index.vue
  27. 10 0
      playground/vue/src/router/routes/loaders.ts
  28. 5 0
      playground/vue/src/router/routes/misc.ts
  29. 3 2
      playground/vue/vite.config.ts
  30. 10 10
      pnpm-lock.yaml
  31. 1 0
      src/composables/index.ts
  32. 1 1
      src/composables/useCamera/index.ts
  33. 9 0
      src/composables/useGraph/index.ts
  34. 36 6
      src/composables/useLoader/component.vue
  35. 109 79
      src/composables/useLoader/index.ts
  36. 279 11
      src/composables/useLoader/useLoader.test.ts
  37. 1 1
      src/composables/useTexture/index.ts
  38. 3 67
      src/composables/useTresContextProvider/index.ts
  39. 5 5
      src/composables/useTresEventManager/index.ts
  40. 14 14
      src/core/nodeOps.ts
  41. 6 7
      src/devtools/plugin.ts
  42. 69 0
      src/devtools/setupDevtools.ts
  43. 1 0
      src/index.ts
  44. 96 0
      src/utils/graph.test.ts
  45. 20 0
      src/utils/graph.ts
  46. 11 15
      src/utils/index.ts
  47. 16 17
      src/utils/is.test.ts
  48. 265 32
      src/utils/is.ts
  49. 5 0
      src/utils/perf.ts

+ 76 - 0
CHANGELOG.md

@@ -1,5 +1,81 @@
 # Changelog
 
+## [5.0.0-next.0](https://github.com/Tresjs/tres/compare/4.3.3...5.0.0-next.0) (2025-04-12)
+
+### ⚠ BREAKING CHANGES
+
+* **loader:** useLoader no longer returns the plain obj, it now returns and object with reactive data (resource|s), isLoading, error) and a load method, can be used both sync and async (suspense)
+
+- Added `useLoader` composable for loading resources with THREE.js, supporting single and multiple resource loading, loading state tracking, and error handling.
+- Created comprehensive documentation for `useLoader`, detailing its features, usage examples, and API reference.
+- Updated various playground components to utilize the new `useLoader` composable for loading GLTF and FBX models.
+- Added new demo pages for loading multiple models and using the `UseLoader` component in templates.
+
+* chore(loader): simplify useLoader implementation and clean up imports
+
+- Updated the `UseLoader` component in documentation to remove unnecessary destructuring of the slot props.
+- Cleaned up imports in `BlenderCube.vue`, `Suzanne.vue`, and `TheExperience.vue` by removing unused types.
+- Added eslint-disable comments for console logging in `Suzanne.vue` and `TheExperience.vue` to improve code readability while debugging.
+
+* feat: useGraph to generate named object material collections
+
+* feat: useAsyncState for useLoader
+
+- Added documentation for the `useLoader` composable, detailing its features, usage examples, and API reference.
+- Updated the navigation in the VitePress configuration to include a link to the new `useLoader` documentation.
+- Enhanced the `useLoader` composable to support better type safety and resource management.
+- Removed the `Suzanne.vue` component as part of the cleanup process.
+
+* feat: enhance useLoader and useGraph composables
+
+- Updated the `useLoader` composable to improve type safety and support loading textures alongside models.
+- Refactored the `useGraph` composable to accept both Object3D and TresObject types, enhancing its flexibility.
+- Added new examples and documentation for loading multiple models and textures, including progress tracking.
+- Cleaned up and organized playground components to demonstrate the new features effectively.
+
+* refactor(useLoader.test): clean up imports by removing unused `nextTick` import
+
+- Removed the unused `nextTick` import from the `useLoader.test.ts` file to streamline the code and improve readability.
+
+* docs: remove trailing spaces in team.md for consistency
+
+- Cleaned up trailing spaces in the `team.md` file to improve code consistency and readability.
+
+* fix(graph): export also types from graph utils
+
+* refactor(index.ts): remove unused export of buildGraph
+
+- Removed the unused `buildGraph` export from `index.ts` to streamline the code and improve maintainability.
+
+* feat: enhance resources loading with progress tracking
+
+- Enhanced the `useLoader` composable to return progress information, allowing for better user feedback during model loading.
+- Updated `TheModel.vue` to utilize the new progress tracking feature from the `useLoader` composable.
+- Removed the unused `LoadingManager` and integrated progress updates directly into the component's state.
+- Updated docs
+
+* feat: added tests to load and progress
+* Tres is now ESM only
+
+- Removed UMD build configuration from package.json and adjusted exports to only include ES module.
+- Updated vite.config.ts to specify the output format as ES, enhancing compatibility with modern module systems.
+
+### Features
+
+* 974-createsetup-devtools ([#975](https://github.com/Tresjs/tres/issues/975)) ([1d83f7b](https://github.com/Tresjs/tres/commit/1d83f7b96e0f0c6b20a3c42a03599f86211e051f))
+* **deps:** update @vue/devtools-api and @tresjs/cientos dependencies ([#977](https://github.com/Tresjs/tres/issues/977)) ([4793f6b](https://github.com/Tresjs/tres/commit/4793f6b0cabd0a233e9008bd3b45fcef6c133945))
+* **loader:** refactor useLoader to a true composable ([#959](https://github.com/Tresjs/tres/issues/959)) ([430837f](https://github.com/Tresjs/tres/commit/430837f1aeeddb4f17620efb13975c23e0f67d6c))
+* update package.json and vite.config.ts for build configuration ([#960](https://github.com/Tresjs/tres/issues/960)) ([96a96f4](https://github.com/Tresjs/tres/commit/96a96f48a4b0097c34b2edb36babf745e97a9a6f))
+
+### Bug Fixes
+
+* export logger utility from utils in index.ts ([#966](https://github.com/Tresjs/tres/issues/966)) ([bb0b9e2](https://github.com/Tresjs/tres/commit/bb0b9e2f3843d2bd27cd46cfe982f433dca013b4))
+* revert improve type safety in retargeting proxy setter ([#930](https://github.com/Tresjs/tres/issues/930)) ([0a95764](https://github.com/Tresjs/tres/commit/0a95764ac47b93d58fd0668327658fe4aae53783))
+
+### Reverts
+
+* Revert "fix: improve typing pixel ratio handling in setPixelRatio utility" (#929) ([9e76010](https://github.com/Tresjs/tres/commit/9e76010d46e1b70a6af6f1cb3eb07a4b888873fc)), closes [#929](https://github.com/Tresjs/tres/issues/929)
+
 ## [4.3.3](https://github.com/Tresjs/tres/compare/4.3.2...4.3.3) (2025-02-06)
 
 ### Bug Fixes

+ 8 - 1
docs/.vitepress/config/en.ts

@@ -37,7 +37,14 @@ export const enConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
           },
         ],
       },
-
+      {
+        text: 'Composables',
+        link: '/api/composables',
+        items: [
+          { text: 'useLoader', link: '/composables/use-loader' },
+          { text: 'useGraph', link: '/composables/use-graph' },
+        ],
+      },
       {
         text: 'Advanced',
 

+ 38 - 6
docs/_data/team.js

@@ -9,15 +9,49 @@ export const core = [
     links: [
       { icon: 'github', link: 'https://github.com/alvarosabu' },
       { icon: 'twitter', link: 'https://twitter.com/alvarosabu' },
+      { icon: 'bluesky', link: 'https://bsky.app/profile/alvarosaburido.dev' },
       { icon: 'youtube', link: 'https://www.youtube.com/channel/UC6D2KveNVcuuPqOKp0YWO3w' },
     ],
     sponsor: 'https://github.com/sponsors/alvarosabu',
   },
+  {
+    avatar: 'https://github.com/garrlker.png',
+    name: 'Garrett Walker',
+    title: 'Frontend Engineer',
+    links: [
+      { icon: 'github', link: 'https://github.com/garrlker' },
+      { icon: 'twitter', link: 'https://twitter.com/garrlker' },
+    ],
+  },
+  {
+    avatar: 'https://www.github.com/Tinoooo.png',
+    name: 'Tino Koch',
+    title: 'Frontend Engineer',
+    links: [
+      { icon: 'github', link: 'https://github.com/Tinoooo' },
+      { icon: 'twitter', link: 'https://twitter.com/@ichbintino' },
+      { icon: 'bluesky', link: 'https://bsky.app/profile/iamtino.bsky.social' },
+    ],
+    sponsor: 'https://github.com/sponsors/Tinoooo',
+  },
+]
+export const maintainers = [
+  {
+    avatar: 'https://www.github.com/damienmontastier.png',
+    name: 'Damien Montastier',
+    title: 'Freelance Frontend Developer',
+    links: [
+      { icon: 'github', link: 'https://github.com/damienmontastier' },
+      { icon: 'twitter', link: 'https://twitter.com/dammontastier' },
+      { icon: 'bluesky', link: 'https://bsky.app/profile/dammontastier.bsky.social' },
+    ],
+  },
+]
+export const alumni = [
   {
     avatar: 'https://www.github.com/JaimeTorrealba.png',
     name: 'Jaime Torrealba',
     title: 'Frontend Engineer',
-    desc: 'A passionate creative-developer',
     links: [
       { icon: 'github', link: 'https://github.com/JaimeTorrealba' },
       { icon: 'twitter', link: 'https://twitter.com/jaimebboyjt' },
@@ -25,13 +59,11 @@ export const core = [
     sponsor: 'https://github.com/sponsors/JaimeTorrealba',
   },
   {
-    avatar: 'https://www.github.com/Tinoooo.png',
-    name: 'Tino Koch',
+    avatar: 'https://www.github.com/andretchen0.png',
+    name: 'Andretchen0',
     title: 'Frontend Engineer',
-    desc: 'A passionate creative-developer',
     links: [
-      { icon: 'github', link: 'https://github.com/Tinoooo' },
-      { icon: 'twitter', link: 'https://twitter.com/@ichbintino' },
+      { icon: 'github', link: 'https://github.com/andretchen0' },
     ],
   },
 ]

+ 58 - 0
docs/composables/use-graph.md

@@ -0,0 +1,58 @@
+# useGraph
+
+A composable that creates a named object/material collection from any `Object3D`
+
+## Usage
+
+```ts
+import { useGraph } from '@tresjs/core'
+
+const { nodes, materials, meshes } = useGraph(object3D)
+```
+
+## Return Value
+
+Returns a computed ref containing a `TresObjectMap` with the following structure:
+
+```ts
+interface TresObjectMap {
+  nodes: { [name: string]: Object3D }
+  materials: { [name: string]: Material }
+  meshes: { [name: string]: Mesh }
+}
+```
+
+## Why use useGraph?
+
+The `useGraph` composable is particularly useful when working with complex 3D models (like GLTF) because it:
+
+1. **Simplifies Access**: Provides direct access to objects by name instead of traversing the scene graph manually
+2. **Organizes Resources**: Automatically categorizes objects into nodes, materials, and meshes
+3. **Improves Performance**: Caches the graph structure in a computed ref, preventing unnecessary recalculations
+4. **Enables Easy Manipulation**: Makes it easy to find and modify specific parts of your 3D model
+
+## Example
+
+```vue
+<script setup lang="ts">
+import { useGraph, useLoader } from '@tresjs/core'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
+
+const { state: model } = await useLoader(GLTFLoader, '/path/to/model.gltf')
+const graph = useGraph(computed(() => model.value?.scene))
+
+watch(graph, ({ nodes, materials }) => {
+  const carBody = nodes.carBody
+  const paintMaterial = materials.paint
+})
+</script>
+```
+
+## Implementation Details
+
+The composable uses an util function `buildGraph` internally to traverse the object hierarchy and create a map of:
+- All named objects in the `nodes` object
+- All unique materials in the `materials` object
+- All meshes in the `meshes` object
+
+This structure is particularly useful when working with models exported from 3D modeling software, as it maintains the naming conventions used in the original model.

+ 182 - 0
docs/composables/use-loader.md

@@ -0,0 +1,182 @@
+# useLoader
+
+The `useLoader` composable provides a unified way to load external resources like models and textures in TresJS using any [Three.js loader](https://threejs.org/docs/#api/en/loaders/Loader). It handles loading states and properly manages resource disposal.
+
+## Basic Usage
+
+```ts
+import { useLoader } from '@tresjs/core'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
+
+const { state, isLoading } = useLoader(GLTFLoader, '/path/to/model.gltf')
+```
+
+For multiple models, you can create multiple loader instances:
+
+```ts
+const models = [
+  '/path/to/model1.gltf',
+  '/path/to/model2.gltf'
+].map(path => useLoader(GLTFLoader, path))
+```
+
+You can also use the `useLoader` composable to load textures:
+
+```ts
+import { useLoader } from '@tresjs/core'
+import { TextureLoader } from 'three'
+
+const { state: texture } = useLoader(TextureLoader, '/path/to/texture.jpg')
+```
+
+## Features
+
+- 🔄 Reactive loading states
+- 🎯 Type-safe loader handling
+- 🧹 Automatic resource cleanup
+- 🔌 Extensible loader configuration
+- 🎮 Progress tracking support
+
+## API
+
+### Type Parameters
+
+- `T extends TresObjectMap`: The type of the loaded resource (use `TresGLTF` for GLTF models)
+- `Shallow extends boolean = false`: Whether to use shallow reactive state
+
+### Arguments
+
+| Name | Type | Description |
+|------|------|-------------|
+| `Loader` | `LoaderProto<T>` | The Three.js loader constructor to use |
+| `path` | `MaybeRef<string>` | Path to the resource |
+| `options?` | `TresLoaderOptions<T, Shallow>` | Optional configuration |
+
+### Options
+
+```ts
+interface TresLoaderOptions<T extends TresObjectMap, Shallow extends boolean> {
+  manager?: LoadingManager
+  extensions?: (loader: TresLoader<T>) => void
+  asyncOptions?: UseAsyncStateOptions<Shallow, any | null>
+}
+```
+
+### Returns
+
+Returns a `UseLoaderReturn` object containing:
+- `state`: The loaded resource
+- `isLoading`: Whether the resource is currently loading
+- `error`: Any error that occurred during loading
+- `execute`: Function to reload the resource
+- `load`: Function to load a new resource from a given path
+- `progress`: Object containing loading progress information:
+  - `loaded`: Number of bytes loaded
+  - `total`: Total number of bytes to load
+  - `percentage`: Loading progress as a percentage (0-100)
+
+## Component Usage
+
+You can use the `UseLoader` component to load a resource and use it in your template directly.
+
+```vue
+<script setup lang="ts">
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
+
+const url = 'https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/blender-cube.glb'
+</script>
+
+<template>
+  <UseLoader v-slot="{ state }" :loader="GLTFLoader" :path="url">
+    <primitive v-if="state?.scene" :object="state.scene" />
+  </UseLoader>
+</template>
+```
+
+## Advanced Examples
+
+### Using a Loading Manager
+
+```ts
+import { useLoader } from '@tresjs/core'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
+import { LoadingManager } from 'three'
+import type { TresGLTF } from '@tresjs/core'
+
+const manager = new LoadingManager()
+manager.onProgress = (url, loaded, total) => {
+  console.log(`Loading file: ${url}. Loaded ${loaded} of ${total} files.`)
+}
+
+const { state } = useLoader<TresGLTF>(GLTFLoader, '/path/to/model.gltf', { manager })
+```
+
+### Loading Multiple Resources
+
+```ts
+import { useLoader } from '@tresjs/core'
+import { TextureLoader } from 'three'
+
+const models = [
+  '/path/to/model1.gltf',
+  '/path/to/model2.gltf'
+].map(path => useLoader(GLTFLoader, path))
+
+// Check if all models are loaded
+const allLoaded = computed(() =>
+  models.every(({ isLoading }) => !isLoading.value)
+)
+
+// Track loading progress
+const totalProgress = computed(() => {
+  const progress = models.reduce((acc, { progress }) => acc + progress.percentage, 0)
+  return progress / models.length
+})
+```
+
+## Best Practices
+
+1. **Resource Cleanup**: The composable automatically handles resource disposal when the component is unmounted.
+
+2. **Error Handling**: Always handle potential loading errors in production:
+```ts
+const { error, state } = useLoader(GLTFLoader, '/model.gltf')
+
+watch(error, (err) => {
+  if (err) { console.error('Failed to load model:', err) }
+})
+```
+
+3. **Loading States**: Use the `isLoading` state to show loading indicators:
+```vue
+<template>
+  <primitive v-if="!isLoading" :object="state.scene" />
+</template>
+```
+
+4. **Type Safety**: Always use proper types for better type inference:
+```ts
+// For GLTF models
+useLoader<GLTF>(GLTFLoader, '/model.gltf')
+
+// For textures
+useLoader<Texture>(TextureLoader, '/texture.jpg')
+```
+
+5. **Progress Tracking**: Use the built-in progress tracking to show loading progress:
+```ts
+const { progress } = useLoader(GLTFLoader, '/model.gltf')
+
+// Watch for progress updates
+watch(progress, ({ percentage }) => {
+  console.log(`Loading: ${percentage.toFixed(2)}%`)
+})
+```
+
+6. **Dynamic Loading**: Use the `load` method to change the loaded resource:
+```ts
+const { load, state } = useLoader(GLTFLoader, '/initial-model.gltf')
+
+// Later in your code, load a different model
+load('/new-model.gltf')
+```

+ 16 - 18
docs/cookbook/load-models.md

@@ -18,10 +18,6 @@ For this guide we are going to focus on loading gLTF (GL Transmission Format) mo
 
 There are several ways to load models on TresJS:
 
-::: warning
-Please note that in the examples below use top level `await`. Make sure to wrap such code with a Vue's [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) component.
-:::
-
 ## Using `useLoader`
 
 The `useLoader` composable allows you to pass any type of three.js loader and a URL to load the resource from. It returns a `Promise` with the loaded resource.
@@ -32,13 +28,13 @@ For a detailed explanation of how to use `useLoader`, check out the [useLoader](
 import { useLoader } from '@tresjs/core'
 import { GLTFLoader } from 'three/addons/loaders/GLTFLoader'
 
-const { scene } = await useLoader(GLTFLoader, '/models/AkuAku.gltf')
+const { state: model } = useLoader(GLTFLoader, '/models/AkuAku.gltf')
 ```
 
 Then you can pass the model scene to a TresJS [`primitive`](/advanced/primitive) component to render it:
 
 ```html
-<primitive :object="scene" />
+<primitive :object="model" />
 ```
 
 > The `<primitive />` component is not a standalone component in the Tres source code. Instead, it's a part of the Tres core functionality. When you use `<primitive>`, it is translated to a `createElement` call, which creates the appropriate three.js object based on the provided "object" prop.
@@ -52,7 +48,13 @@ A more convenient way of loading models is using the `useGLTF` composable availa
 ```ts
 import { useGLTF } from '@tresjs/cientos'
 
-const { scene, nodes, animations, materials } = await useGLTF('/models/AkuAku.gltf')
+const { state: model } = useGLTF('/models/AkuAku.gltf')
+```
+
+Then you can pass the model scene to a TresJS [`primitive`](/advanced/primitive) component to render it:
+
+```html
+<primitive :object="model.scene" />
 ```
 
 An advantage of using `useGLTF`is that you can pass a `draco` prop to enable [Draco compression](https://threejs.org/docs/index.html?q=drac#examples/en/loaders/DRACOLoader) for the model. This will reduce the size of the model and improve performance.
@@ -60,7 +62,7 @@ An advantage of using `useGLTF`is that you can pass a `draco` prop to enable [Dr
 ```ts
 import { useGLTF } from '@tresjs/cientos'
 
-const { scene, nodes, animations, materials } = await useGLTF('/models/AkuAku.gltf', { draco: true })
+const { state } = await useGLTF('/models/AkuAku.gltf', { draco: true })
 ```
 
 Alternatively you can easily select objects inside the model using the `nodes` property.
@@ -91,11 +93,11 @@ import Model from './Model.vue'
 <script setup lang="ts">
 import { useGLTF } from '@tresjs/cientos'
 
-const { nodes } = await useGLTF('/models/AkuAku.gltf', { draco: true })
+const { state } = useGLTF('/models/AkuAku.gltf', { draco: true })
 </script>
 
 <template>
-  <primitive :object="nodes.AkuAku" />
+  <primitive :object="state.nodes.AkuAku" />
 </template>
 ```
 :::
@@ -112,9 +114,7 @@ import { OrbitControls, GLTFModel } from '@tresjs/cientos'
   <TresCanvas clear-color="#82DBC5" shadows alpha>
     <TresPerspectiveCamera :position="[11, 11, 11]" />
     <OrbitControls />
-    <Suspense>
-      <GLTFModel path="/models/AkuAku.gltf" draco />
-    </Suspense>
+    <GLTFModel path="/models/AkuAku.gltf" draco />
     <TresDirectionalLight :position="[-4, 8, 4]" :intensity="1.5" cast-shadow />
   </TresCanvas>
 </template>
@@ -129,7 +129,7 @@ The `useFBX` composable is available from the [@tresjs/cientos](https://github.c
 ```ts
 import { useFBX } from '@tresjs/cientos'
 
-const model = await useFBX('/models/AkuAku.fbx')
+const { state: model } = useFBX('/models/AkuAku.fbx')
 ```
 
 Then is as straightforward as adding the scene to your scene:
@@ -150,10 +150,8 @@ import { OrbitControls, FBXModel } from '@tresjs/cientos'
   <TresCanvas clear-color="#82DBC5" shadows alpha>
     <TresPerspectiveCamera :position="[11, 11, 11]" />
     <OrbitControls />
-      <Suspense>
-        <FBXModel path="/models/AkuAku.fbx" />
-      </Suspense>
-      <TresDirectionalLight :position="[-4, 8, 4]" :intensity="1.5" cast-shadow />
+    <FBXModel path="/models/AkuAku.fbx" />
+    <TresDirectionalLight :position="[-4, 8, 4]" :intensity="1.5" cast-shadow />
   </TresCanvas>
 </template>
 ```

+ 6 - 6
docs/fr/guide/index.md

@@ -44,7 +44,7 @@ pnpm add @types/three -D
 
 ## Vite
 
-Si vous utilisez Vite, vous devez ajoutez ceci à votre `vite.config.ts`:
+Si vous utilisez Vite, vous devez ajouter ceci à votre `vite.config.ts`:
 
 ```ts [vite.config.ts]
 import { templateCompilerOptions } from '@tresjs/core'
@@ -79,17 +79,17 @@ Nous avons un nouveau starter [StackBlitz](https://stackblitz.com/) afin d'essay
 
 ## Labs
 
-Nous avons aussi un playground où vous pouvez essayer TresJS en ligne. Testez [ici](https://play.tresjs.org/).
+Nous avons un laboratoire de démonstration d'exemples créés avec TresJS. Consultez-le [ici](https://play.tresjs.org/).
 
-<iframe src="https://play.tresjs.org/" class="w-full rounded shadow-lg outline-none border-none aspect-4/3"></iframe>
+![](/tresjs-lab.png)
 
 ## Motivation
 
 [ThreeJS](https://threejs.org/) est une merveilleuse bibliothèque pour faire de superbes sites en 3D avec WebGL. C'est aussi une bibliothèque en perpétuelle évolution, ce qui rend complèxe le maintien de wrapper comme [TroisJS](https://troisjs.github.io/) afin de le garder constamment à jour.
 
-L'écosytème React possède un impréssionnant **moteur de rendu personnalisé** appellé [React-three-fiber](https://docs.pmnd.rs/react-three-fiber) qui permet de créer des scènes 3D, de manière déclarative, avec des composants réutilisables et autonomes qui réagissent aux états.
+L'écosystème React possède un impréssionnant **moteur de rendu personnalisé** appellé [React-three-fiber](https://docs.pmnd.rs/react-three-fiber) qui permet de créer des scènes 3D, de manière déclarative, avec des composants réutilisables et autonomes qui réagissent aux états.
 
-Dans ma recherche d'un outil similaire dans l'écosytème VueJS j'ai trouvé cette incroyable bibliothèque nommée [Lunchbox](https://github.com/breakfast-studio/lunchboxjs) qui fonctionne de la même manière que R3F, il propose un [moteur de rendu personnalisé Vue3](https://vuejs.org/api/custom-renderer.html). Je contribue aussi à améliorer cette bibliothèque afin qu'elle devienne aussi mature et complète que R3F.
+Dans ma recherche d'un outil similaire dans l'écosystème VueJS j'ai trouvé cette incroyable bibliothèque nommée [Lunchbox](https://github.com/breakfast-studio/lunchboxjs) qui fonctionne de la même manière que R3F, il propose un [moteur de rendu personnalisé Vue3](https://vuejs.org/api/custom-renderer.html). Je contribue aussi à améliorer cette bibliothèque afin qu'elle devienne aussi mature et complète que R3F.
 
 Le seul problème concerne le mélange des compilateurs et des moteurs de rendu en Vue3. C'est un sujet sur lequel la communauté Vue travaille encore. Pour plus d'informations voir [ici](https://github.com/vuejs/vue-loader/pull/1645).
 
@@ -110,4 +110,4 @@ const lunchboxApp = createLunchboxApp(LunchboxApp)
 lunchboxApp.mount('#lunchbox')
 ```
 
-Ces deux bibliothèques m'ont donc inspiré pour créer un moteur de rendu personnalisé Vue pour ThreeJS. Il s'appel **TresJS v2**.
+Ces deux bibliothèques m'ont donc inspiré pour créer un moteur de rendu personnalisé Vue pour ThreeJS. Il s'appelle **TresJS v2**.

+ 19 - 8
docs/team.md

@@ -1,7 +1,7 @@
 ---
 layout: page
 title: Meet the Team
-description: The TresJS ecosystem is develop and maintain by a global team..
+description: The TresJS ecosystem is developed and maintained by a global team.
 ---
 
 <script setup>
@@ -11,25 +11,36 @@ import {
   VPTeamPageSection,
   VPTeamMembers
 } from 'vitepress/theme'
-import { core } from './_data/team'
+import { core, maintainers, alumni } from './_data/team'
 </script>
 
 <VPTeamPage>
   <VPTeamPageTitle>
     <template #title>Meet the Team</template>
     <template #lead>
-      The TresJS ecosystem is develop and maintain by a global team.
+      The TresJS ecosystem is developed and maintained by a global team.
     </template>
   </VPTeamPageTitle>
-  <VPTeamMembers :members="core" />
-  <!-- <VPTeamPageSection>
-    <template #title>Team Emeriti</template>
+  <VPTeamPageSection>
+    <template #title>Core team</template>
+    <template #members>
+      <VPTeamMembers :members="core" />
+    </template>
+  </VPTeamPageSection>
+  <VPTeamPageSection>
+    <template #title>Maintainers</template>
+    <template #members>
+      <VPTeamMembers :members="maintainers" />
+    </template>
+  </VPTeamPageSection>
+  <VPTeamPageSection>
+    <template #title>Alumni</template>
     <template #lead>
       Here we honor some no-longer-active team members who have made valuable
       contributions in the past.
     </template>
     <template #members>
-      <VPTeamMembers size="small" :members="emeriti" />
+      <VPTeamMembers size="small" :members="alumni" />
     </template>
-  </VPTeamPageSection> -->
+  </VPTeamPageSection>
 </VPTeamPage>

+ 2 - 2
package.json

@@ -1,7 +1,7 @@
 {
   "name": "@tresjs/core",
   "type": "module",
-  "version": "4.3.3",
+  "version": "5.0.0-next.0",
   "packageManager": "pnpm@10.6.3",
   "description": "Declarative ThreeJS using Vue Components",
   "author": "Alvaro Saburido <hola@alvarosaburido.dev> (https://github.com/alvarosabu/)",
@@ -70,7 +70,7 @@
   },
   "dependencies": {
     "@alvarosabu/utils": "^3.2.0",
-    "@vue/devtools-api": "^6.6.3",
+    "@vue/devtools-api": "^7.7.2",
     "@vueuse/core": "^12.5.0"
   },
   "devDependencies": {

+ 0 - 14
playground/vue/src/composables/useFBX.ts

@@ -1,14 +0,0 @@
-import type { Object3D } from 'three'
-import { useLoader } from '@tresjs/core'
-import { FBXLoader } from 'three-stdlib'
-
-/**
- * Loads an FBX file and returns a THREE.Object3D.
- *
- * @export
- * @param {(string | string[])} path
- * @return {*}  {Promise<Object3D>}
- */
-export async function useFBX(path: string | string[]): Promise<Object3D> {
-  return (await useLoader(FBXLoader, path)) as unknown as Object3D
-}

+ 0 - 82
playground/vue/src/composables/useGLTF.ts

@@ -1,82 +0,0 @@
-import type { AnimationClip, Material, Scene } from 'three'
-import type { GLTF } from 'three-stdlib'
-import { type TresLoader, type TresObject3D, useLoader } from '@tresjs/core'
-import { DRACOLoader, GLTFLoader } from 'three-stdlib'
-
-export interface GLTFLoaderOptions {
-  /**
-   * Whether to use Draco compression.
-   *
-   * @type {boolean}
-   * @memberof GLTFLoaderOptions
-   */
-  draco?: boolean
-  /**
-   * The path to the Draco decoder.
-   *
-   * @type {string}
-   * @memberof GLTFLoaderOptions
-   */
-  decoderPath?: string
-}
-
-export interface GLTFResult {
-  animations: Array<AnimationClip>
-  nodes: Record<string, TresObject3D>
-  materials: Record<string, Material>
-  scene: Scene
-}
-
-let dracoLoader: DRACOLoader | null = null
-
-export interface TresGLTFLoader extends TresLoader<GLTF> {
-  setDRACOLoader?: (dracoLoader: DRACOLoader) => void
-}
-
-/**
- * Sets the extensions for the GLTFLoader.
- *
- * @param {GLTFLoaderOptions} options
- * @param {(loader: TresGLTFLoader) => void} [extendLoader]
- * @return {*}
- */
-function setExtensions(options: GLTFLoaderOptions, extendLoader?: (loader: TresGLTFLoader) => void) {
-  return (loader: TresGLTFLoader) => {
-    if (extendLoader) {
-      extendLoader(loader)
-    }
-    if (options.draco) {
-      if (!dracoLoader) {
-        dracoLoader = new DRACOLoader()
-      }
-      dracoLoader.setDecoderPath(options.decoderPath || 'https://www.gstatic.com/draco/versioned/decoders/1.4.3/')
-      if (loader.setDRACOLoader) {
-        loader.setDRACOLoader(dracoLoader)
-      }
-    }
-  }
-}
-
-/**
- * Loads a GLTF file and returns a THREE.Object3D.
- *
- * @export
- * @param {(string | string[])} path
- * @param {GLTFLoaderOptions} [options]
- *
- *
- * @param {(loader: GLTFLoader) => void} [extendLoader]
- * @return {*}  {Promise<GLTFResult>}
- */
-export async function useGLTF<T extends string | string[]>(
-  path: T,
-  options: GLTFLoaderOptions = {
-    draco: false,
-  },
-  extendLoader?: (loader: TresGLTFLoader) => void,
-): Promise<T extends string[] ? GLTFResult[] : GLTFResult> {
-  const gltfModel = (await useLoader<GLTF>(GLTFLoader, path, setExtensions(options, extendLoader))) as unknown as GLTFResult
-  dracoLoader?.dispose()
-  dracoLoader = null
-  return gltfModel as T extends string[] ? GLTFResult[] : GLTFResult
-}

+ 57 - 0
playground/vue/src/composables/usePocGLTF.ts

@@ -0,0 +1,57 @@
+import type { TresObject } from '@tresjs/core'
+import { buildGraph } from '@tresjs/core'
+import type { UseAsyncStateOptions, UseAsyncStateReturn } from '@vueuse/core'
+import { useAsyncState } from '@vueuse/core'
+import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import type { TresObjectMap } from '../../../../src/utils/graph'
+import { DRACOLoader } from 'three/examples/jsm/Addons.js'
+import type { LoadingManager } from 'three'
+
+/* export function useGLTF(path: MaybeRef<string>, options: any) {
+  const { state: model, isLoading, execute } = useLoader(GLTFLoader, path, options)
+
+  const state = computed(() => ({
+    ...model.value,
+    ...useGraph(model.value?.scene).value,
+  }))
+
+  return { state, isLoading, execute }
+} */
+
+export function usePocGLTF(path: MaybeRef<string>, options?: {
+  draco?: boolean
+  manager?: LoadingManager
+  asyncOptions?: UseAsyncStateOptions<true, any | null>
+}) {
+  const loader = new GLTFLoader(options?.manager)
+  if (options?.draco) {
+    const dracoLoader = new DRACOLoader()
+    dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/')
+    loader.setDRACOLoader(dracoLoader)
+  }
+
+  const initialPath = toValue(path)
+
+  const result = useAsyncState(
+    (path?: string) => new Promise((resolve, reject) => {
+      loader.load(path || initialPath || '', (result: GLTF) => {
+        const loadedData = result
+        if (loadedData.scene) {
+          const graph = buildGraph(loadedData.scene)
+          Object.assign(loadedData, graph.value)
+        }
+        resolve(loadedData as unknown as TresObject)
+      }, undefined, (err: unknown) => {
+        reject(err)
+      })
+    }),
+    null,
+    {
+      ...options?.asyncOptions,
+      immediate: options?.asyncOptions?.immediate ?? true,
+    },
+  )
+
+  return result as UseAsyncStateReturn<GLTF & TresObjectMap, [string], true>
+}

+ 2 - 0
playground/vue/src/pages/events/index.vue

@@ -11,6 +11,8 @@ const gl = {
   shadows: true,
 }
 
+useControls('fpsgraph')
+
 const { stopPropagation } = useControls({
   stopPropagation: false,
 })

+ 4 - 5
playground/vue/src/pages/loaders/componentDemo.vue

@@ -10,12 +10,11 @@ const url = 'https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/bl
   <TresCanvas window-size clear-color="#333">
     <TresPerspectiveCamera :position="[0, 3, 7]" :look-at="[0, 0, 0]" />
     <OrbitControls />
-    <Suspense>
-      <UseLoader v-slot="{ data }" :loader="GLTFLoader" :url="url">
-        <primitive :object="data.scene" />
-      </useloader>
-    </Suspense>
+    <UseLoader v-slot="{ state }" :loader="GLTFLoader" :path="url">
+      <primitive v-if="state?.scene" :object="state.scene" />
+    </UseLoader>
     <TresDirectionalLight :position="[0, 10, 0]" :intensity="1" />
     <TresAmbientLight :intensity="0.5" />
+    <TresGridHelper />
   </TresCanvas>
 </template>

+ 2 - 4
playground/vue/src/pages/loaders/fbx-loader/TheExperience.vue

@@ -4,11 +4,9 @@ import TheModel from './TheModel.vue'
 </script>
 
 <template>
-  <TresPerspectiveCamera :position="[3, 3, 3]" />
+  <TresPerspectiveCamera :position="[8, 8, 8]" />
   <OrbitControls />
   <TresGridHelper />
   <TresAmbientLight :intensity="1" />
-  <Suspense>
-    <TheModel />
-  </Suspense>
+  <TheModel />
 </template>

+ 39 - 4
playground/vue/src/pages/loaders/fbx-loader/TheModel.vue

@@ -1,10 +1,45 @@
 <script setup lang="ts">
-import { useFBX } from '../../../composables/useFBX'
+/* eslint-disable no-console */
+import { LoadingManager } from 'three'
 
-const scene = await useFBX('https://raw.githubusercontent.com/Tresjs/assets/main/models/fbx/low-poly-truck/Jeep_done.fbx')
-scene.scale.set(0.01, 0.01, 0.01)
+import { useLoader } from '@tresjs/core'
+import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'
+
+const state = inject<{
+  hasFinishLoading: boolean
+  progress: number
+}>('gltf-loader-state')!
+
+const manager = new LoadingManager()
+manager.onProgress = (url, loaded, total) => {
+  const progress = (loaded / total) * 100
+  state.progress = progress
+}
+
+const { state: model, isLoading } = useLoader(
+  FBXLoader,
+  'https://raw.githubusercontent.com/Tresjs/assets/main/models/fbx/low-poly-truck/Jeep_done.fbx',
+  {
+    manager,
+  },
+)
+
+watch(isLoading, (newIsLoading) => {
+  console.log('isLoading', newIsLoading)
+  if (newIsLoading) {
+    state.hasFinishLoading = false
+  }
+})
+
+watch(model, (newModel) => {
+  console.log('model', newModel)
+  setTimeout(() => {
+    state.hasFinishLoading = true
+  }, 1000)
+})
+/* eslint-enable no-console */
 </script>
 
 <template>
-  <primitive :object="scene" />
+  <primitive v-if="model" :object="model" :scale="0.01" />
 </template>

+ 27 - 3
playground/vue/src/pages/loaders/fbx-loader/index.vue

@@ -2,10 +2,34 @@
 import { TresCanvas } from '@tresjs/core'
 
 import TheExperience from './TheExperience.vue'
+
+const state = reactive({
+  hasFinishLoading: false,
+  progress: 0,
+})
+
+provide('gltf-loader-state', state)
 </script>
 
 <template>
-  <TresCanvas clear-color="#C0ffee">
-    <TheExperience />
-  </TresCanvas>
+  <div class="relative h-full w-full">
+    <Transition
+      name="fade-overlay"
+      enter-active-class="opacity-1 transition-opacity duration-200"
+      leave-active-class="opacity-0 transition-opacity duration-200"
+    >
+      <div
+        v-show="!state.hasFinishLoading"
+        class="absolute bg-white t-0 l-0 w-full h-full z-20 flex justify-center items-center text-black font-mono"
+      >
+        <div class="w-200px">
+          Loading...
+          {{ state.progress }} %
+        </div>
+      </div>
+    </Transition>
+    <TresCanvas clear-color="#C0ffee">
+      <TheExperience />
+    </TresCanvas>
+  </div>
 </template>

+ 13 - 0
playground/vue/src/pages/loaders/gltf-loader/ManualUseGLTF.vue

@@ -0,0 +1,13 @@
+<script setup lang="ts">
+import { usePocGLTF } from '../../../composables/usePocGLTF'
+
+const modelPath = ref('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/blender-cube.glb')
+
+const { state } = usePocGLTF(modelPath, {
+  draco: true,
+})
+</script>
+
+<template>
+  <primitive v-if="state?.nodes.Cube" :object="state.nodes.Cube" />
+</template>

+ 2 - 4
playground/vue/src/pages/loaders/gltf-loader/TheExperience.vue

@@ -4,11 +4,9 @@ import TheModel from './TheModel.vue'
 </script>
 
 <template>
-  <TresPerspectiveCamera :position="[3, 3, 3]" />
+  <TresPerspectiveCamera :position="[8, 8, 8]" />
   <OrbitControls />
   <TresGridHelper />
   <TresAmbientLight :intensity="1" />
-  <Suspense>
-    <TheModel />
-  </Suspense>
+  <TheModel />
 </template>

+ 71 - 4
playground/vue/src/pages/loaders/gltf-loader/TheModel.vue

@@ -1,10 +1,77 @@
 <script setup lang="ts">
-import { useGLTF } from '../../../composables/useGLTF'
+/* eslint-disable no-console */
+import { useGraph, useLoader, useLoop } from '@tresjs/core'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
+import { inject, ref, watch } from 'vue'
+import type { Mesh } from 'three'
 
-const { nodes } = await useGLTF('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/blender-cube.glb', { draco: true })
-const model = nodes.Cube
+const state = inject<{
+  hasFinishLoading: boolean
+  progress: number
+}>('gltf-loader-state')!
+
+// Initialize DRACOLoader
+const dracoLoader = new DRACOLoader()
+// Set the path to the Draco decoder (you might want to use a CDN or local path)
+dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/')
+
+const modelPath = ref('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/blender-cube.glb')
+
+const { state: model, isLoading, _load, progress } = useLoader(
+  GLTFLoader,
+  modelPath,
+  {
+    extensions: (loader) => {
+      if (loader instanceof GLTFLoader) {
+        loader.setDRACOLoader(dracoLoader)
+      }
+    },
+  },
+)
+const scene = computed(() => model.value?.scene)
+const graph = useGraph(scene)
+
+watch(graph, (newGraph) => {
+  console.log('newGraph', newGraph)
+})
+
+/* setTimeout(() => {
+  load('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/low-poly/cloud.gltf')
+}, 2000) */
+
+watch(isLoading, (newIsLoading) => {
+  console.log('isLoading', newIsLoading)
+  if (newIsLoading) {
+    state.hasFinishLoading = false
+  }
+})
+
+watch(model, (newModel) => {
+  console.log('model', newModel)
+  setTimeout(() => {
+    state.hasFinishLoading = true
+  }, 1000)
+})
+
+watch(progress, (newProgress) => {
+  console.log('progress', newProgress)
+  state.progress = newProgress.percentage
+}, { immediate: true })
+
+const modelRef = ref<Mesh>()
+
+const { onBeforeRender } = useLoop()
+
+onBeforeRender(({ elapsed }) => {
+  if (modelRef.value) {
+    modelRef.value.position.y = Math.sin(elapsed) + 2
+  }
+})
+
+/* eslint-enable no-console */
 </script>
 
 <template>
-  <primitive :object="model" />
+  <primitive v-if="model?.scene" ref="modelRef" :position="[0, 2, 0]" :object="model.scene" />
 </template>

+ 27 - 3
playground/vue/src/pages/loaders/gltf-loader/index.vue

@@ -2,10 +2,34 @@
 import { TresCanvas } from '@tresjs/core'
 
 import TheExperience from './TheExperience.vue'
+
+const state = reactive({
+  hasFinishLoading: false,
+  progress: 0,
+})
+
+provide('gltf-loader-state', state)
 </script>
 
 <template>
-  <TresCanvas clear-color="#C0ffee">
-    <TheExperience />
-  </TresCanvas>
+  <div class="relative h-full w-full">
+    <Transition
+      name="fade-overlay"
+      enter-active-class="opacity-1 transition-opacity duration-200"
+      leave-active-class="opacity-0 transition-opacity duration-200"
+    >
+      <div
+        v-show="!state.hasFinishLoading"
+        class="absolute bg-white t-0 l-0 w-full h-full z-20 flex justify-center items-center text-black font-mono"
+      >
+        <div class="w-200px">
+          Loading...
+          {{ state.progress }} %
+        </div>
+      </div>
+    </Transition>
+    <TresCanvas clear-color="#C0ffee">
+      <TheExperience />
+    </TresCanvas>
+  </div>
 </template>

+ 62 - 0
playground/vue/src/pages/loaders/multiple-models/TheExperience.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { OrbitControls } from '@tresjs/cientos'
+import { useLoader } from '@tresjs/core'
+import { LoadingManager } from 'three'
+import type { GLTF } from 'three/examples/jsm/Addons.js'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
+
+const state = inject<{
+  hasFinishLoading: boolean
+  progress: number
+}>('gltf-loader-state')!
+
+const modelPaths = [
+  'https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/blender-cube.glb',
+  'https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/suzanne/suzanne.glb',
+]
+
+const manager = new LoadingManager()
+
+manager.onProgress = (url, loaded, total) => {
+  const progress = (loaded / total) * 100
+  state.progress = progress
+}
+
+// Create individual loaders for each model
+const models = ref(modelPaths.map(path => useLoader<GLTF>(GLTFLoader, path, {
+  manager,
+})))
+
+const computedIsLoading = computed(() => models.value.some(model => model.isLoading.value))
+
+// Check if all models have loaded successfully and their scenes are available
+const allModelsLoaded = computed(() => models.value.every(model =>
+  !model.isLoading && model.state?.scene,
+))
+
+watch(allModelsLoaded, () => {
+  console.log('allModelsLoaded', allModelsLoaded.value)
+}, { immediate: true })
+
+watch([computedIsLoading, allModelsLoaded], ([isLoading, loaded]) => {
+  // Only set hasFinishLoading to true when all models are loaded AND their scenes are available
+  setTimeout(() => {
+    state.hasFinishLoading = !isLoading && loaded
+  }, 1000)
+}, { immediate: true })
+</script>
+
+<template>
+  <TresPerspectiveCamera :position="[11, 11, 11]" />
+  <OrbitControls />
+  <TresGridHelper />
+  <TresAmbientLight :intensity="1" />
+  <template v-for="(model, index) in models" :key="index">
+    <primitive
+      v-if="model.state?.scene"
+      :object="model.state.scene"
+      :position="[index * 3, 0, 0]"
+    />
+  </template>
+</template>

+ 35 - 0
playground/vue/src/pages/loaders/multiple-models/index.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+
+import TheExperience from './TheExperience.vue'
+
+const state = reactive({
+  hasFinishLoading: false,
+  progress: 0,
+})
+
+provide('gltf-loader-state', state)
+</script>
+
+<template>
+  <div class="relative h-full w-full">
+    <Transition
+      name="fade-overlay"
+      enter-active-class="opacity-1 transition-opacity duration-200"
+      leave-active-class="opacity-0 transition-opacity duration-200"
+    >
+      <div
+        v-show="!state.hasFinishLoading"
+        class="absolute bg-white t-0 l-0 w-full h-full z-20 flex justify-center items-center text-black font-mono"
+      >
+        <div class="w-200px">
+          Loading...
+          {{ state.progress }} %
+        </div>
+      </div>
+    </Transition>
+    <TresCanvas clear-color="#C0ffee">
+      <TheExperience />
+    </TresCanvas>
+  </div>
+</template>

+ 50 - 0
playground/vue/src/pages/loaders/texture-loader/TheExperience.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { OrbitControls } from '@tresjs/cientos'
+import { useLoader } from '@tresjs/core'
+import { LoadingManager, TextureLoader } from 'three'
+
+const state = inject<{
+  hasFinishLoading: boolean
+  progress: number
+}>('gltf-loader-state')!
+
+const manager = new LoadingManager()
+manager.onProgress = (url, loaded, total) => {
+  const progress = (loaded / total) * 100
+  state.progress = progress
+}
+
+const { state: texture, isLoading } = useLoader(
+  TextureLoader,
+  'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg',
+  {
+    manager,
+  },
+)
+
+watch(isLoading, (newIsLoading) => {
+  console.log('isLoading', newIsLoading)
+  if (newIsLoading) {
+    state.hasFinishLoading = false
+  }
+})
+
+watch(texture, (newTexture) => {
+  console.log('texture', newTexture)
+  setTimeout(() => {
+    state.hasFinishLoading = true
+  }, 1000)
+})
+</script>
+
+<template>
+  <TresPerspectiveCamera :position="[3, 3, 3]" />
+  <OrbitControls />
+  <TresGridHelper />
+  <TresAmbientLight :intensity="1" />
+  <TresMesh>
+    <TresBoxGeometry />
+    <TresMeshStandardMaterial :map="texture" />
+  </TresMesh>
+</template>

+ 35 - 0
playground/vue/src/pages/loaders/texture-loader/index.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+
+import TheExperience from './TheExperience.vue'
+
+const state = reactive({
+  hasFinishLoading: false,
+  progress: 0,
+})
+
+provide('gltf-loader-state', state)
+</script>
+
+<template>
+  <div class="relative h-full w-full">
+    <Transition
+      name="fade-overlay"
+      enter-active-class="opacity-1 transition-opacity duration-200"
+      leave-active-class="opacity-0 transition-opacity duration-200"
+    >
+      <div
+        v-show="!state.hasFinishLoading"
+        class="absolute bg-white t-0 l-0 w-full h-full z-20 flex justify-center items-center text-black font-mono"
+      >
+        <div class="w-200px">
+          Loading...
+          {{ state.progress }} %
+        </div>
+      </div>
+    </Transition>
+    <TresCanvas clear-color="#C0ffee">
+      <TheExperience />
+    </TresCanvas>
+  </div>
+</template>

+ 32 - 0
playground/vue/src/pages/misc/use-graph/index.vue

@@ -0,0 +1,32 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { TresCanvas, useGraph } from '@tresjs/core'
+
+import { OrbitControls } from '@tresjs/cientos'
+import { BoxGeometry, Group, Mesh, MeshStandardMaterial } from 'three'
+
+const gl = {
+  clearColor: '#82DBC5',
+}
+
+const group = new Group()
+
+group.add(new Mesh(new BoxGeometry(1, 1, 1), new MeshStandardMaterial({ name: 'FancyMaterial', color: 'red' })))
+
+const { nodes, materials } = useGraph(group)
+
+console.log('nodes', nodes)
+console.log('materials', materials)
+
+materials.FancyMaterial.color.set('blue')
+</script>
+
+<template>
+  <TresCanvas v-bind="gl">
+    <TresPerspectiveCamera :position="[3, 3, 3]" />
+    <OrbitControls />
+    <TresGridHelper />
+    <TresAmbientLight :intensity="1" />
+    <primitive :object="group" />
+  </TresCanvas>
+</template>

+ 10 - 0
playground/vue/src/router/routes/loaders.ts

@@ -9,9 +9,19 @@ export const loaderRoutes = [
     name: 'FBX Loader',
     component: () => import('../../pages/loaders/fbx-loader/index.vue'),
   },
+  {
+    path: '/loaders/texture',
+    name: 'Texture Loader',
+    component: () => import('../../pages/loaders/texture-loader/index.vue'),
+  },
   {
     path: '/loader/component',
     name: 'Loader Component',
     component: () => import('../../pages/loaders/componentDemo.vue'),
   },
+  {
+    path: '/loaders/multiple-models',
+    name: 'Multiple Models',
+    component: () => import('../../pages/loaders/multiple-models/index.vue'),
+  },
 ]

+ 5 - 0
playground/vue/src/router/routes/misc.ts

@@ -14,4 +14,9 @@ export const miscRoutes = [
     name: 'Brownian Distribution',
     component: () => import('../../pages/misc/BrownianDistribution.vue'),
   },
+  {
+    path: '/misc/graph',
+    name: 'useGraph',
+    component: () => import('../../pages/misc/use-graph/index.vue'),
+  },
 ]

+ 3 - 2
playground/vue/vite.config.ts

@@ -7,7 +7,7 @@ import Components from 'unplugin-vue-components/vite'
 import { defineConfig } from 'vite'
 import glsl from 'vite-plugin-glsl'
 import { qrcode } from 'vite-plugin-qrcode'
-/* import VueDevTools from 'vite-plugin-vue-devtools' */
+import VueDevTools from 'vite-plugin-vue-devtools'
 
 // https://vitejs.dev/config/
 export default defineConfig({
@@ -38,7 +38,8 @@ export default defineConfig({
         },
       },
     }),
-    qrcode(), // only applies in dev mode
+    qrcode(), // only applies in dev mode,
+    VueDevTools(),
   ],
   resolve: {
     alias: {

+ 10 - 10
pnpm-lock.yaml

@@ -12,8 +12,8 @@ importers:
         specifier: ^3.2.0
         version: 3.2.0
       '@vue/devtools-api':
-        specifier: ^6.6.3
-        version: 6.6.4
+        specifier: ^7.7.2
+        version: 7.7.2
       '@vueuse/core':
         specifier: ^12.5.0
         version: 12.7.0(typescript@5.7.3)
@@ -26,7 +26,7 @@ importers:
         version: 1.11.0
       '@tresjs/cientos':
         specifier: 4.1.0
-        version: 4.1.0(@tresjs/core@4.3.3(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))(@types/three@0.173.0)(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
+        version: 4.1.0(@tresjs/core@5.0.0-next.0(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))(@types/three@0.173.0)(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
       '@tresjs/eslint-config':
         specifier: ^1.4.0
         version: 1.4.0(@typescript-eslint/utils@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.13)(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.5)
@@ -1674,8 +1674,8 @@ packages:
       three: '>=0.133'
       vue: '>=3.3'
 
-  '@tresjs/core@4.3.3':
-    resolution: {integrity: sha512-AIFP0u5Hp/9LjifndcFEQWkucWYI72vpUAvJzeOArMdrGN/slKXf8XYP/GKm0BMbPQCu6/eg/LqghZO5tOQ81A==}
+  '@tresjs/core@5.0.0-next.0':
+    resolution: {integrity: sha512-AQom0UlFudxhlVpKaSjivF+8OpgZUdHsy/LxQ4VwUMJyGhqaVQfkb/og/5PLG45tdu8bNiklk8noQfEGL6ba9g==}
     peerDependencies:
       three: '>=0.133'
       vue: '>=3.4'
@@ -6959,9 +6959,9 @@ snapshots:
 
   '@tootallnate/quickjs-emscripten@0.23.0': {}
 
-  '@tresjs/cientos@4.1.0(@tresjs/core@4.3.3(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))(@types/three@0.173.0)(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))':
+  '@tresjs/cientos@4.1.0(@tresjs/core@5.0.0-next.0(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))(@types/three@0.173.0)(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))':
     dependencies:
-      '@tresjs/core': 4.3.3(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
+      '@tresjs/core': 5.0.0-next.0(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
       '@vueuse/core': 12.7.0(typescript@5.7.3)
       camera-controls: 2.10.0(three@0.173.0)
       stats-gl: 2.4.2(@types/three@0.173.0)(three@0.173.0)
@@ -6979,7 +6979,7 @@ snapshots:
   '@tresjs/cientos@https://pkg.pr.new/@tresjs/cientos@d84eb13(@tresjs/core@)(@types/three@0.173.0)(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))':
     dependencies:
       '@tresjs/core': 'link:'
-      '@vueuse/core': 12.7.0(typescript@5.7.3)
+      '@vueuse/core': 12.8.2(typescript@5.7.3)
       camera-controls: 2.10.0(three@0.173.0)
       stats-gl: 2.4.2(@types/three@0.173.0)(three@0.173.0)
       stats.js: 0.17.0
@@ -6993,10 +6993,10 @@ snapshots:
       - react
       - typescript
 
-  '@tresjs/core@4.3.3(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))':
+  '@tresjs/core@5.0.0-next.0(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))':
     dependencies:
       '@alvarosabu/utils': 3.2.0
-      '@vue/devtools-api': 6.6.4
+      '@vue/devtools-api': 7.7.2
       '@vueuse/core': 12.8.2(typescript@5.7.3)
       three: 0.173.0
       vue: 3.5.13(typescript@5.7.3)

+ 1 - 0
src/composables/index.ts

@@ -2,6 +2,7 @@ import UseLoader from './useLoader/component.vue'
 import UseTexture from './useTexture/component.vue'
 
 export * from './useCamera/'
+export * from './useGraph'
 export * from './useLoader'
 export * from './useLoop'
 export * from './useRaycaster'

+ 1 - 1
src/composables/useCamera/index.ts

@@ -4,7 +4,7 @@ import type { TresContext } from '../useTresContextProvider'
 
 import { Camera, PerspectiveCamera } from 'three'
 import { computed, onUnmounted, ref, watchEffect } from 'vue'
-import { camera as isCamera } from '../../utils/is'
+import { isCamera } from '../../utils/is'
 
 export const useCamera = ({ sizes }: Pick<TresContext, 'sizes'> & { scene: TresScene }) => {
   // the computed does not trigger, when for example the camera position changes

+ 9 - 0
src/composables/useGraph/index.ts

@@ -0,0 +1,9 @@
+import type { Object3D } from 'three'
+import type { MaybeRef } from '@vueuse/core'
+import { buildGraph } from '../../utils/graph'
+import { computed, toValue } from 'vue'
+import type { TresObject } from '../../types'
+
+export function useGraph(object: MaybeRef<Object3D | TresObject>) {
+  return computed(() => buildGraph(toValue(object) as Object3D))
+}

+ 36 - 6
src/composables/useLoader/component.vue

@@ -1,16 +1,46 @@
-<script setup lang="ts">
+<script setup lang="ts" generic="T extends TresObjectMap">
+import type { LoadingManager } from 'three'
 import type { LoaderProto } from './index'
-import { reactive } from 'vue'
 import { useLoader } from './index'
+import { defineEmits, defineProps } from 'vue'
+import { whenever } from '@vueuse/core'
+import type { TresObjectMap } from '../../utils/graph'
 
 const props = defineProps<{
-  loader: LoaderProto<unknown>
-  url: string | string[]
+  /**
+   * The THREE.js loader to use
+   */
+  loader: LoaderProto<T>
+  /**
+   * Path to resource
+   */
+  path: string
+  /**
+   * Optional THREE.js LoadingManager
+   */
+  manager?: LoadingManager
 }>()
 
-const data = await reactive(useLoader(props.loader, props.url))
+const emit = defineEmits<{
+  loaded: [result: T]
+  error: [error: unknown]
+}>()
+
+const { state, isLoading, error } = useLoader(props.loader, props.path, { manager: props.manager })
+
+whenever(error, (err) => {
+  if (err) { emit('error', err) }
+})
+
+whenever(state, (value) => {
+  if (value) { emit('loaded', value as T) }
+})
 </script>
 
 <template>
-  <slot :data="data"></slot>
+  <slot
+    :state="state"
+    :is-loading="isLoading"
+    :error="error"
+  ></slot>
 </template>

+ 109 - 79
src/composables/useLoader/index.ts

@@ -1,100 +1,130 @@
-import type { Loader, LoadingManager, Object3D } from 'three'
-import type { TresObject } from '../../types'
-import { logError } from '../../utils/logger'
+import type { UseAsyncStateOptions, UseAsyncStateReturn } from '@vueuse/core'
+import { useAsyncState } from '@vueuse/core'
+import type { Loader, LoadingManager } from 'three'
+import type { MaybeRef } from 'vue'
+import { onUnmounted, reactive, toValue, watch } from 'vue'
 
-export interface TresLoader<T> extends Loader {
+import type { TresObject } from '../../../types'
+
+import { disposeObject3D } from '../../utils/'
+
+export interface LoaderMethods {
+  setDRACOLoader: (dracoLoader: any) => void
+  setMeshoptDecoder: (meshoptDecoder: any) => void
+  setKTX2Loader: (ktx2Loader: any) => void
+}
+
+export type TresLoader<T> = Loader & Partial<LoaderMethods> & {
   load: (
-    url: string | string[],
+    url: string,
     onLoad: (result: T) => void,
-    onProgress?: (event: ProgressEvent) => void,
-    onError?: (event: ErrorEvent) => void
+    onProgress?: (event: ProgressEvent<EventTarget>) => void,
+    onError?: (err: unknown) => void
   ) => void
   loadAsync: (url: string, onProgress?: (event: ProgressEvent) => void) => Promise<T>
 }
 
 export type LoaderProto<T> = new (manager?: LoadingManager) => TresLoader<T>
 
-export type LoaderReturnType<T, L extends LoaderProto<T>> = T extends unknown
-  ? Awaited<ReturnType<InstanceType<L>['loadAsync']>>
-  : T
+export interface TresLoaderOptions<T, Shallow extends boolean> {
+  manager?: LoadingManager
+  extensions?: (loader: TresLoader<T>) => void
+  asyncOptions?: UseAsyncStateOptions<Shallow, any | null>
+}
 
 /**
- * Traverse an object and return all the nodes and materials
- *
- * @export
- * @param {Object3D} object
- * @return { [key: string]: any }
+ * Return type for the useLoader composable
+ * @template T - The type of the loaded asset (e.g., GLTF, Texture, etc.)
+ * @template Shallow - Whether to use shallow reactivity for better performance
+ * @extends {UseAsyncStateReturn<T, [string], Shallow>} - Extends VueUse's useAsyncState return type
  */
-export function traverseObjects(object: Object3D) {
-  const data: { [key: string]: any } = { nodes: {}, materials: {} }
-  if (object) {
-    object.traverse((obj: any) => {
-      if (obj.name) {
-        data.nodes[obj.name] = obj
-      }
-      if (obj.material && !data.materials[obj.material.name]) {
-        data.materials[obj.material.name] = obj.material
-      }
-    })
+export type UseLoaderReturn<T, Shallow extends boolean> = UseAsyncStateReturn<T, [string], Shallow> & {
+  /**
+   * Loads a new asset from the given path
+   * @param path - The URL or path to the asset to load
+   */
+  load: (path: string) => void
+  /**
+   * Progress of the loading process
+   * @property loaded - The number of bytes loaded
+   * @property total - The total number of bytes to load
+   * @property percentage - The percentage of the loading process
+   */
+  progress: {
+    loaded: number
+    total: number
+    percentage: number
   }
-  return data
 }
 
-export type Extensions<T extends { prototype: LoaderProto<any> }> = (loader: T['prototype']) => void
-
 /**
- * Load resources using THREE loaders and return the result as a promise
- *
- * @see https://tresjs.org/api/composables.html#useloader
- * @see https://threejs.org/docs/index.html?q=loader#api/en/loaders/Loader
- *
- * ```ts
- * import { useLoader } from '@tresjs/core'
- * import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
- *
- * const { scene } = await useLoader(THREE.GLTFLoader, 'path/to/asset.gltf')
- * ```
- *
- * @export
- * @template LoaderProto<T>
- * @template string | string[],
- * @param {LoaderProto<T>} Loader
- * @param {string | string[],} url
- * @param {Extensions<TresLoader<T>>} [extensions]
- * @param {(event: ProgressEvent<EventTarget>) => void} [onProgress]
- * @param {(proto: TresLoader<T>) => void} [cb]
- * @return {*}
+ * Vue composable for loading 3D models using Three.js loaders
+ * @param Loader - The Three.js loader constructor
+ * @param path - The path to the model file
+ * @param options - Optional configuration for the loader
+ * @returns UseAsyncState composable with the loaded model
  */
-export async function useLoader<T>(
+export function useLoader<T, Shallow extends boolean = false>(
   Loader: LoaderProto<T>,
-  url: string | string[],
-  extensions?: (loader: TresLoader<T>) => void,
-  onProgress?: (event: ProgressEvent<EventTarget>) => void,
-  cb?: (proto: TresLoader<T>) => void,
-): Promise<T | T[]> {
-  const proto = new Loader()
-  if (cb) {
-    cb(proto)
-  }
-  if (extensions) {
-    extensions(proto)
+  path: MaybeRef<string>,
+  options?: TresLoaderOptions<T, Shallow>,
+): UseLoaderReturn<T, Shallow> {
+  const proto = new Loader(options?.manager)
+  const progress = reactive({
+    loaded: 0,
+    total: 0,
+    percentage: 0,
+  })
+  if (options?.extensions) {
+    options.extensions(proto)
   }
 
-  return await new Promise((resolve, reject) => {
-    proto.load(
-      url,
-      (result: T) => {
-        const data = result as unknown as TresObject
-        if (data.scene) {
-          Object.assign(data, traverseObjects(data.scene))
-        }
-        resolve(data as T | T[])
-      },
-      onProgress,
-      (error: ErrorEvent) => {
-        logError('[useLoader] - Failed to load resource', error as unknown as Error)
-        reject(error)
-      },
-    )
-  }) as T | T[]
+  const initialPath = toValue(path)
+  const result = useAsyncState(
+    (path?: string) => new Promise((resolve, reject) => {
+      proto.load(path || initialPath || '', (result: T) => {
+        resolve(result as unknown as TresObject)
+      }, (event: ProgressEvent<EventTarget>) => {
+        progress.loaded = event.loaded
+        progress.total = event.total
+        progress.percentage = ((progress.loaded / progress.total) * 100)
+      }, (err: unknown) => {
+        reject(err)
+      })
+    }),
+    null,
+    {
+      ...options?.asyncOptions,
+      immediate: options?.asyncOptions?.immediate ?? true,
+    },
+  )
+
+  // Watch for path changes and reload the model
+  const unsub = watch(() => toValue(path), (newPath) => {
+    if (newPath) {
+      const value = result.state.value
+      // Safely dispose the scene if it exists
+      if (value && typeof value === 'object' && 'scene' in value && value.scene) {
+        disposeObject3D(value.scene as unknown as TresObject)
+      }
+      result.execute(0, newPath)
+    }
+  })
+
+  // Cleanup on component unmount
+  onUnmounted(() => {
+    unsub()
+    const value = result.state.value
+    if (value && typeof value === 'object' && 'scene' in value && value.scene) {
+      disposeObject3D(value.scene as unknown as TresObject)
+    }
+  })
+
+  return {
+    ...result,
+    load: (path: string) => {
+      result.execute(0, path)
+    },
+    progress,
+  } as UseLoaderReturn<T, Shallow>
 }

+ 279 - 11
src/composables/useLoader/useLoader.test.ts

@@ -1,24 +1,292 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
 import { useLoader } from '.'
+import type { LoaderProto, TresLoaderOptions } from '@tresjs/core'
+import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
+import type { UseAsyncStateReturn } from '@vueuse/core'
+import { ref, watch } from 'vue'
+
+// Create a mock Material class
+class MockMaterial {
+  name: string
+
+  constructor(props: { name: string }) {
+    this.name = props.name
+  }
+
+  dispose = vi.fn()
+}
+
+// Create a mock Object3D-like structure
+class MockObject3D {
+  name?: string
+  type: string
+  material?: MockMaterial
+  children: MockObject3D[] = []
+  geometry?: { dispose: () => void }
+
+  constructor(props: {
+    name?: string
+    type: string
+    material?: MockMaterial
+    children?: MockObject3D[]
+  }) {
+    this.name = props.name
+    this.type = props.type
+    this.material = props.material
+    this.children = props.children || []
+  }
+
+  traverse(callback: (obj: MockObject3D) => void) {
+    callback(this)
+    this.children.forEach(child => child.traverse(callback))
+  }
+
+  // Add dispose method for cleanup
+  dispose() {
+    this.material?.dispose()
+    this.geometry?.dispose()
+    this.children.forEach(child => child.dispose())
+  }
+}
+
+// Mock scene structure
+const mockScene = new MockObject3D({
+  type: 'Scene',
+  children: [
+    new MockObject3D({
+      name: 'test',
+      type: 'Mesh',
+      material: new MockMaterial({ name: 'TestMaterial' }),
+    }),
+  ],
+})
+
+// Mock a basic loader class
+class MockLoader {
+  load(
+    _url: string,
+    onLoad: (result: { scene: MockObject3D }) => void,
+    onProgress?: (event: ProgressEvent) => void,
+    _onError?: (event: ErrorEvent) => void,
+  ) {
+    // Simulate async loading with progress events
+    let loaded = 0
+    const total = 100
+    const interval = setInterval(() => {
+      loaded += 25 // Increase step size to complete faster
+      if (onProgress) {
+        onProgress({ loaded, total } as ProgressEvent)
+      }
+      if (loaded >= total) {
+        clearInterval(interval)
+        onLoad({ scene: mockScene })
+      }
+    }, 5) // Reduce interval time
+  }
+
+  setPath(_path: string) {
+    // Mock setPath method
+  }
+}
+
+// Mock error loader
+class MockErrorLoader {
+  load(
+    _url: string,
+    onLoad: (result: any) => void,
+    _onProgress?: (event: ProgressEvent) => void,
+    onError?: (event: ErrorEvent) => void,
+  ) {
+    // Simulate async error
+    setTimeout(() => {
+      onError?.(new ErrorEvent('error', { message: 'Failed to load resource' }))
+    }, 0)
+  }
+}
 
 describe('useLoader', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
   it('is defined', () => {
     expect(useLoader).toBeDefined()
   })
-  /* test('loads a glTF file using GLTFLoader and returns the result', async () => {
-    const gltfUrl = 'https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/aku-aku/AkuAku.gltf'
-    const { scene } = await useLoader(GLTFLoader, gltfUrl)
-    expect(scene).toBeDefined()
+
+  it('loads a resource successfully', () => {
+    const TresMockLoader = MockLoader as unknown as LoaderProto<GLTF>
+    const result = useLoader(TresMockLoader, 'mock-url.glb') as UseAsyncStateReturn<GLTF, [string], false>
+
+    // Initial state
+    expect(result.isLoading.value).toBe(true)
+    expect(result.state.value).toBeNull()
+    expect(result.error.value).toBeUndefined()
+
+    // Watch for state changes
+    return new Promise<void>((resolve) => {
+      watch(result.state, (newState) => {
+        if (newState) {
+          const scene = newState.scene as unknown as MockObject3D
+          expect(scene).toBeDefined()
+          expect(scene.children[0].name).toBe('test')
+          expect(scene.children[0].material?.name).toBe('TestMaterial')
+          expect(result.isLoading.value).toBe(false)
+          resolve()
+        }
+      })
+    })
+  })
+
+  it('handles loading state correctly', () => {
+    const TresMockLoader = MockLoader as unknown as LoaderProto<GLTF>
+    const result = useLoader(TresMockLoader, 'mock-url.glb') as UseAsyncStateReturn<GLTF, [string], false>
+
+    // Initial state should be loading
+    expect(result.isLoading.value).toBe(true)
+
+    // Watch for loading state changes
+    return new Promise<void>((resolve) => {
+      watch(result.isLoading, (isLoading) => {
+        if (!isLoading) {
+          expect(result.state.value).toBeDefined()
+          resolve()
+        }
+      })
+    })
+  })
+
+  it('handles errors correctly', () => {
+    const TresMockErrorLoader = MockErrorLoader as unknown as LoaderProto<GLTF>
+    const result = useLoader(TresMockErrorLoader, 'mock-url.glb') as UseAsyncStateReturn<GLTF, [string], false>
+
+    // Initial state
+    expect(result.isLoading.value).toBe(true)
+    expect(result.error.value).toBeUndefined()
+
+    // Watch for error state changes
+    return new Promise<void>((resolve) => {
+      watch(result.error, (error) => {
+        if (error) {
+          // Cast error to Error type since we know it's an Error instance
+          const err = error as Error
+          expect(err.message).toContain('Failed to load resource')
+          expect(result.isLoading.value).toBe(false)
+          expect(result.state.value).toBeNull()
+          resolve()
+        }
+      })
+    })
   })
 
-  test('applies extensions to the loader before loading', async () => {
-    const gltfUrl = '/aku-aku/AkuAku.gltf'
-    const extensions = (loader: GLTFLoader) => {
-      loader.setPath('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf')
+  it('supports loader extensions', () => {
+    const setPathSpy = vi.fn()
+
+    class MockLoaderWithExtensions extends MockLoader {
+      setPath = setPathSpy
     }
-    const { scene } = await useLoader(GLTFLoader, gltfUrl, extensions)
 
-    expect(scene).toBeDefined()
-  }) */
+    const TresMockLoader = MockLoaderWithExtensions as unknown as LoaderProto<GLTF>
+    const options: TresLoaderOptions<GLTF, false> = {
+      extensions: (loader) => {
+        loader.setPath('https://example.com/models/')
+      },
+    }
+
+    useLoader(TresMockLoader, 'mock-url.glb', options)
+    expect(setPathSpy).toHaveBeenCalledWith('https://example.com/models/')
+  })
+
+  it('tracks loading progress correctly', () => {
+    const TresMockLoader = MockLoader as unknown as LoaderProto<GLTF>
+    const result = useLoader(TresMockLoader, 'mock-url.glb') as UseAsyncStateReturn<GLTF, [string], false> & { progress: { loaded: number, total: number, percentage: number } }
+
+    // Initial progress state
+    expect(result.progress.loaded).toBe(0)
+    expect(result.progress.total).toBe(0)
+    expect(result.progress.percentage).toBe(0)
+
+    // Watch for progress updates
+    return new Promise<void>((resolve) => {
+      watch(() => result.progress.percentage, (percentage) => {
+        if (percentage === 100) {
+          expect(result.progress.loaded).toBe(100)
+          expect(result.progress.total).toBe(100)
+          expect(result.progress.percentage).toBe(100)
+          resolve()
+        }
+      })
+    })
+  })
+
+  it('supports loading a new resource with the load method', async () => {
+    const TresMockLoader = MockLoader as unknown as LoaderProto<GLTF>
+    const result = useLoader(TresMockLoader, 'initial-url.glb') as UseAsyncStateReturn<GLTF, [string], false> & { load: (path: string) => void }
+
+    // Initial state
+    expect(result.isLoading.value).toBe(true)
+
+    // Wait for initial load to complete
+    await new Promise<void>((resolve) => {
+      watch([result.state, result.isLoading], ([state, isLoading]) => {
+        if (state && !isLoading) {
+          resolve()
+        }
+      })
+    })
+
+    // First load completed
+    const firstScene = result.state.value?.scene as unknown as MockObject3D
+    expect(firstScene).toBeDefined()
+
+    // Load a new resource
+    result.load('new-url.glb')
+    expect(result.isLoading.value).toBe(true)
+
+    // Wait for the new state to be loaded
+    await new Promise<void>((resolve) => {
+      watch([result.state, result.isLoading], ([newState, newIsLoading]) => {
+        if (newState && !newIsLoading) {
+          resolve()
+        }
+      })
+    })
+
+    // Verify new resource loaded
+    const newScene = result.state.value?.scene as unknown as MockObject3D
+    expect(newScene).toBeDefined()
+  }, 10000) // Increase timeout to 10 seconds
+
+  it('reacts to path changes', () => {
+    const TresMockLoader = MockLoader as unknown as LoaderProto<GLTF>
+    const path = ref('initial-url.glb')
+    const result = useLoader(TresMockLoader, path) as UseAsyncStateReturn<GLTF, [string], false>
+
+    // Initial state
+    expect(result.isLoading.value).toBe(true)
+
+    return new Promise<void>((resolve) => {
+      // Watch for state changes
+      watch([result.state, result.isLoading], ([state, isLoading]) => {
+        if (state && !isLoading) {
+          // First load completed
+          const firstScene = state.scene as unknown as MockObject3D
+          expect(firstScene).toBeDefined()
+
+          // Change path
+          path.value = 'new-url.glb'
+
+          // Watch for the new state to be loaded
+          watch([result.state, result.isLoading], ([newState, newIsLoading]) => {
+            if (newState && !newIsLoading) {
+              const newScene = newState.scene as unknown as MockObject3D
+              expect(newScene).toBeDefined()
+              resolve()
+            }
+          })
+        }
+      })
+    })
+  })
 })
 
 // TODO: find a way to test this

+ 1 - 1
src/composables/useTexture/index.ts

@@ -1,6 +1,6 @@
 import type { LoadingManager, Texture } from 'three'
 import { TextureLoader } from 'three'
-import { isArray } from '../../utils'
+import { isArray } from '../../utils/is'
 
 export interface PBRMaterialOptions {
   /**

+ 3 - 67
src/composables/useTresContextProvider/index.ts

@@ -3,11 +3,10 @@ import { Raycaster, WebGLRenderer } from 'three'
 import type { ComputedRef, DeepReadonly, MaybeRefOrGetter, Ref, ShallowRef } from 'vue'
 import type { RendererLoop } from '../../core/loop'
 import type { EmitEventFn, Renderer, TresControl, TresObject, TresScene } from '../../types'
-import { useFps, useMemory, useRafFn } from '@vueuse/core'
+
 import { computed, inject, onUnmounted, provide, readonly, ref, shallowRef } from 'vue'
 import { extend } from '../../core/catalogue'
 import { createRenderLoop } from '../../core/loop'
-import { calculateMemoryUsage } from '../../utils/perf'
 
 import { useCamera } from '../useCamera'
 import useSizes, { type SizesType } from '../useSizes'
@@ -16,6 +15,7 @@ import { useTresReady } from '../useTresReady'
 import { createRenderer } from '../../core/createRenderer'
 import { setupWebGLRenderer } from '../../core/setupRenderer'
 import type { TresCanvasProps } from '../../components/TresCanvas.vue'
+import { setupDevtools } from '../../devtools/setupDevtools'
 
 export interface InternalState {
   priority: Ref<number>
@@ -216,71 +216,7 @@ export async function useTresContextProvider({
     ctx.loop.stop()
   })
 
-  // Performance
-  const updateInterval = 100 // Update interval in milliseconds
-  const fps = useFps({ every: updateInterval })
-  const { isSupported, memory } = useMemory({ interval: updateInterval })
-  const maxFrames = 160
-  let lastUpdateTime = performance.now()
-
-  const updatePerformanceData = ({ timestamp }: { timestamp: number }) => {
-    // Update WebGL Memory Usage (Placeholder for actual logic)
-    // perf.memory.value = calculateMemoryUsage(gl)
-    if (ctx.scene.value) {
-      ctx.perf.memory.allocatedMem = calculateMemoryUsage(ctx.scene.value as unknown as TresObject)
-    }
-
-    // Update memory usage
-    if (timestamp - lastUpdateTime >= updateInterval) {
-      lastUpdateTime = timestamp
-
-      // Update FPS
-      ctx.perf.fps.accumulator.push(fps.value as never)
-
-      if (ctx.perf.fps.accumulator.length > maxFrames) {
-        ctx.perf.fps.accumulator.shift()
-      }
-
-      ctx.perf.fps.value = fps.value
-
-      // Update memory
-      if (isSupported.value && memory.value) {
-        ctx.perf.memory.accumulator.push(memory.value.usedJSHeapSize / 1024 / 1024 as never)
-
-        if (ctx.perf.memory.accumulator.length > maxFrames) {
-          ctx.perf.memory.accumulator.shift()
-        }
-
-        ctx.perf.memory.currentMem
-        = ctx.perf.memory.accumulator.reduce((a, b) => a + b, 0) / ctx.perf.memory.accumulator.length
-      }
-    }
-  }
-
-  // Devtools
-  let accumulatedTime = 0
-  const interval = 1 // Interval in milliseconds, e.g., 1000 ms = 1 second
-
-  const { pause } = useRafFn(({ delta }) => {
-    if (!window.__TRES__DEVTOOLS__) { return }
-
-    updatePerformanceData({ timestamp: performance.now() })
-
-    // Accumulate the delta time
-    accumulatedTime += delta
-
-    // Check if the accumulated time is greater than or equal to the interval
-    if (accumulatedTime >= interval) {
-      window.__TRES__DEVTOOLS__.cb(ctx)
-
-      // Reset the accumulated time
-      accumulatedTime = 0
-    }
-  }, { immediate: true })
-
-  onUnmounted(() => {
-    pause()
-  })
+  setupDevtools(ctx)
 
   return ctx
 }

+ 5 - 5
src/composables/useTresEventManager/index.ts

@@ -3,8 +3,8 @@ import type { Object3D, Object3DEventMap, Scene } from 'three'
 import type { TresContext } from '../useTresContextProvider'
 import { shallowRef } from 'vue'
 import { hyphenate } from '../../utils'
-import * as is from '../../utils/is'
 import { useRaycaster } from '../useRaycaster'
+import { isObject3D, isTresObject } from '../../utils/is'
 
 export interface TresEventManager {
   /**
@@ -183,13 +183,13 @@ export function useTresEventManager(
   })
 
   function registerObject(maybeTresObject: unknown) {
-    if (is.tresObject(maybeTresObject) && is.object3D(maybeTresObject)) {
+    if (isTresObject(maybeTresObject) && isObject3D(maybeTresObject)) {
       objectsWithEvents.value.push(maybeTresObject as TresInstance)
     }
   }
 
   function deregisterObject(maybeTresObject: unknown) {
-    if (is.tresObject(maybeTresObject) && is.object3D(maybeTresObject)) {
+    if (isTresObject(maybeTresObject) && isObject3D(maybeTresObject)) {
       const index = objectsWithEvents.value.indexOf(maybeTresObject as TresInstance)
       if (index > -1) {
         objectsWithEvents.value.splice(index, 1)
@@ -198,13 +198,13 @@ export function useTresEventManager(
   }
 
   function registerPointerMissedObject(maybeTresObject: unknown) {
-    if (is.tresObject(maybeTresObject) && is.object3D(maybeTresObject) && maybeTresObject.onPointerMissed) {
+    if (isTresObject(maybeTresObject) && isObject3D(maybeTresObject) && maybeTresObject.onPointerMissed) {
       pointerMissedObjects.push(maybeTresObject)
     }
   }
 
   function deregisterPointerMissedObject(maybeTresObject: unknown) {
-    if (is.tresObject(maybeTresObject) && is.object3D(maybeTresObject)) {
+    if (isTresObject(maybeTresObject) && isObject3D(maybeTresObject)) {
       const index = pointerMissedObjects.indexOf(maybeTresObject)
       if (index > -1) {
         pointerMissedObjects.splice(index, 1)

+ 14 - 14
src/core/nodeOps.ts

@@ -4,7 +4,7 @@ import { BufferAttribute, Object3D } from 'three'
 import { isRef, type RendererOptions } from 'vue'
 import { attach, deepArrayEqual, doRemoveDeregister, doRemoveDetach, invalidateInstance, isHTMLTag, kebabToCamel, noop, prepareTresInstance, setPrimitiveObject, unboxTresPrimitive } from '../utils'
 import { logError } from '../utils/logger'
-import * as is from '../utils/is'
+import { isArray, isFunction, isObject, isObject3D, isScene, isUndefined } from '../utils/is'
 import { createRetargetingProxy } from '../utils/primitive/createRetargetingProxy'
 import { catalogue } from './catalogue'
 
@@ -40,7 +40,7 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
     let obj: TresObject | null
 
     if (tag === 'primitive') {
-      if (!is.obj(props.object) || isRef(props.object)) {
+      if (!isObject(props.object) || isRef(props.object)) {
         logError(
           'Tres primitives need an \'object\' prop, whose value is an object or shallowRef<object>',
         )
@@ -121,7 +121,7 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
     if (childInstance.__tres.attach) {
       attach(parentInstance, childInstance, childInstance.__tres.attach)
     }
-    else if (is.object3D(child) && is.object3D(parentInstance)) {
+    else if (isObject3D(child) && isObject3D(parentInstance)) {
       parentInstance.add(child)
       child.dispatchEvent({ type: 'added' })
     }
@@ -153,9 +153,9 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
 
     // NOTE: Derive `dispose` value for this `remove` call and
     // recursive remove calls.
-    dispose = is.und(dispose) ? 'default' : dispose
+    dispose = isUndefined(dispose) ? 'default' : dispose
     const userDispose = node.__tres?.dispose
-    if (!is.und(userDispose)) {
+    if (!isUndefined(userDispose)) {
       if (userDispose === null) {
         // NOTE: Treat as `false` to act like R3F
         dispose = false
@@ -209,11 +209,11 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
     doRemoveDeregister(node, context)
 
     // NOTE: 4) Dispose `node`
-    if (shouldDispose && !is.scene(node)) {
-      if (is.fun(dispose)) {
+    if (shouldDispose && !isScene(node)) {
+      if (isFunction(dispose)) {
         dispose(node as TresInstance)
       }
-      else if (is.fun(node.dispose)) {
+      else if (isFunction(node.dispose)) {
         try {
           node.dispose()
         }
@@ -265,7 +265,7 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
       return
     }
 
-    if (is.object3D(node) && key === 'blocks-pointer-events') {
+    if (isObject3D(node) && key === 'blocks-pointer-events') {
       if (nextValue || nextValue === '') { node[key] = nextValue }
       else { delete node[key] }
       return
@@ -319,23 +319,23 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
     let value = nextValue
     if (value === '') { value = true }
     // Set prop, prefer atomic methods if applicable
-    if (is.fun(target)) {
+    if (isFunction(target)) {
       // don't call pointer event callback functions
 
       if (!supportedPointerEvents.includes(prop)) {
-        if (is.arr(value)) { node[finalKey](...value) }
+        if (isArray(value)) { node[finalKey](...value) }
         else { node[finalKey](value) }
       }
       // NOTE: Set on* callbacks
       // Issue: https://github.com/Tresjs/tres/issues/360
-      if (finalKey.startsWith('on') && is.fun(value)) {
+      if (finalKey.startsWith('on') && isFunction(value)) {
         root[finalKey] = value
       }
       return
     }
-    if (!target?.set && !is.fun(target)) { root[finalKey] = value }
+    if (!target?.set && !isFunction(target)) { root[finalKey] = value }
     else if (target.constructor === value.constructor && target?.copy) { target?.copy(value) }
-    else if (is.arr(value)) { target.set(...value) }
+    else if (isArray(value)) { target.set(...value) }
     else if (!target.isColor && target.setScalar) { target.setScalar(value) }
     else { target.set(value) }
 

+ 6 - 7
src/devtools/plugin.ts

@@ -1,6 +1,3 @@
-import type {
-  App as DevtoolsApp,
-} from '@vue/devtools-api'
 import type { TresContext } from '../composables'
 import type { TresObject } from './../types'
 import {
@@ -9,10 +6,11 @@ import {
 import { Color, type Mesh } from 'three'
 import { reactive } from 'vue'
 import { createHighlightMesh, editSceneObject } from '../utils'
-import * as is from '../utils/is'
 import { bytesToKB, calculateMemoryUsage } from '../utils/perf'
 import { toastMessage } from './utils'
 
+import { isLight } from '../utils/is'
+
 export interface Tags {
   label: string
   textColor: number
@@ -52,7 +50,7 @@ const createNode = (object: TresObject): SceneGraphObject => {
   }
 
   if (object.type.includes('Light')) {
-    if (is.light(object)) {
+    if (isLight(object)) {
       node.tags.push({
         label: `${object.intensity}`,
         textColor: 0x9499A6,
@@ -111,14 +109,15 @@ const INSPECTOR_ID = 'tres:inspector'
 const state = reactive({
   sceneGraph: null as SceneGraphObject | null,
 })
-export function registerTresDevtools(app: DevtoolsApp, tres: TresContext) {
+
+export function registerTresDevtools(app: any, tres: TresContext) {
   setupDevtoolsPlugin(
     {
       id: 'dev.esm.tres',
       label: 'TresJS 🪐',
       logo: 'https://raw.githubusercontent.com/Tresjs/tres/main/public/favicon.svg',
       packageName: 'tresjs',
-      homepage: 'https://tresjs.org',
+      homepage: 'https://docs.tresjs.org',
       componentStateTypes,
       app,
     },

+ 69 - 0
src/devtools/setupDevtools.ts

@@ -0,0 +1,69 @@
+import { useFps, useMemory, useRafFn } from '@vueuse/core'
+import { boundedPush, calculateMemoryUsage } from '../utils/perf'
+import type { TresContext } from '../composables'
+import type { TresObject } from '../types'
+import { onUnmounted } from 'vue'
+
+export function setupDevtools(ctx: TresContext) {
+  if (!ctx) { return }
+
+  // Performance
+  const updateInterval = 100 // Update interval in milliseconds
+  const fps = useFps({ every: updateInterval })
+  const { isSupported, memory } = useMemory({ interval: updateInterval })
+  const maxFrames = 160
+  let lastUpdateTime = performance.now()
+
+  // Devtools
+  let accumulatedTime = 0
+  const interval = 1 // Interval in milliseconds, e.g., 1000 ms = 1 second
+
+  const updatePerformanceData = ({ timestamp }: { timestamp: number }) => {
+    // Update WebGL Memory Usage (Placeholder for actual logic)
+    // perf.memory.value = calculateMemoryUsage(gl)
+    if (ctx.scene.value) {
+      ctx.perf.memory.allocatedMem = calculateMemoryUsage(ctx.scene.value as unknown as TresObject)
+    }
+
+    // Update memory usage
+    if (timestamp - lastUpdateTime >= updateInterval) {
+      lastUpdateTime = timestamp
+
+      // Update FPS
+      boundedPush(ctx.perf.fps.accumulator, fps.value as never, maxFrames)
+
+      ctx.perf.fps.value = fps.value
+
+      // Update memory
+      if (isSupported.value && memory.value?.usedJSHeapSize) {
+        boundedPush(ctx.perf.memory.accumulator, memory.value.usedJSHeapSize / 1024 / 1024 as never, maxFrames)
+
+        if (ctx.perf.memory.accumulator.length > 0) {
+          ctx.perf.memory.currentMem
+        = ctx.perf.memory.accumulator.reduce((a, b) => a + b, 0) / ctx.perf.memory.accumulator.length
+        }
+      }
+    }
+  }
+
+  const { pause } = useRafFn(({ delta }) => {
+    if (!window.__TRES__DEVTOOLS__) { return }
+
+    updatePerformanceData({ timestamp: performance.now() })
+
+    // Accumulate the delta time
+    accumulatedTime += delta
+
+    // Check if the accumulated time is greater than or equal to the interval
+    if (accumulatedTime >= interval) {
+      window.__TRES__DEVTOOLS__.cb(ctx)
+
+      // Reset the accumulated time
+      accumulatedTime = 0
+    }
+  }, { immediate: true })
+
+  onUnmounted(() => {
+    pause()
+  })
+}

+ 1 - 0
src/index.ts

@@ -10,6 +10,7 @@ export * from './core/catalogue'
 export * from './core/loop'
 export * from './directives'
 export * from './types'
+export * from './utils/graph'
 export * from './utils/logger'
 
 export interface TresOptions {

+ 96 - 0
src/utils/graph.test.ts

@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest'
+import { buildGraph } from './graph'
+import { Mesh, MeshBasicMaterial, Object3D } from 'three'
+
+describe('buildGraph', () => {
+  it('should return empty maps for null object', () => {
+    const result = buildGraph(null as unknown as Object3D)
+    expect(result).toEqual({
+      nodes: {},
+      materials: {},
+      meshes: {},
+    })
+  })
+
+  it('should build graph for a simple object hierarchy', () => {
+    // Create test objects
+    const parent = new Object3D()
+    parent.name = 'parent'
+
+    const child = new Object3D()
+    child.name = 'child'
+    parent.add(child)
+
+    const material = new MeshBasicMaterial()
+    material.name = 'testMaterial'
+
+    const mesh = new Mesh(undefined, material)
+    mesh.name = 'testMesh'
+    parent.add(mesh)
+
+    const result = buildGraph(parent)
+
+    // Test nodes
+    expect(result.nodes).toEqual({
+      parent,
+      child,
+      testMesh: mesh,
+    })
+
+    // Test materials
+    expect(result.materials).toEqual({
+      testMaterial: material,
+    })
+
+    // Test meshes
+    expect(result.meshes).toEqual({
+      testMesh: mesh,
+    })
+  })
+
+  it('should handle duplicate material names', () => {
+    const parent = new Object3D()
+    const material1 = new MeshBasicMaterial()
+    material1.name = 'duplicate'
+    const material2 = new MeshBasicMaterial()
+    material2.name = 'duplicate'
+
+    const mesh1 = new Mesh(undefined, material1)
+    const mesh2 = new Mesh(undefined, material2)
+    parent.add(mesh1)
+    parent.add(mesh2)
+
+    const result = buildGraph(parent)
+
+    // Should only keep the first material with the name
+    expect(Object.keys(result.materials)).toHaveLength(1)
+    expect(result.materials.duplicate).toBe(material1)
+  })
+
+  it('should handle duplicate mesh names', () => {
+    const parent = new Object3D()
+    const mesh1 = new Mesh()
+    mesh1.name = 'duplicate'
+    const mesh2 = new Mesh()
+    mesh2.name = 'duplicate'
+    parent.add(mesh1)
+    parent.add(mesh2)
+
+    const result = buildGraph(parent)
+
+    // Should only keep the first mesh with the name
+    expect(Object.keys(result.meshes)).toHaveLength(1)
+    expect(result.meshes.duplicate).toBe(mesh1)
+  })
+
+  it('should handle objects without names', () => {
+    const parent = new Object3D()
+    const unnamed = new Object3D()
+    parent.add(unnamed)
+
+    const result = buildGraph(parent)
+
+    // Unnamed objects should not appear in nodes
+    expect(Object.keys(result.nodes)).toHaveLength(0)
+  })
+})

+ 20 - 0
src/utils/graph.ts

@@ -0,0 +1,20 @@
+import type { Material, Mesh, Object3D, Scene } from 'three'
+
+export interface TresObjectMap {
+  nodes: { [name: string]: Object3D }
+  materials: { [name: string]: Material }
+  meshes: { [name: string]: Mesh }
+  scene?: Scene
+}
+
+export function buildGraph(object: Object3D): TresObjectMap {
+  const data: TresObjectMap = { nodes: {}, materials: {}, meshes: {} }
+  if (object) {
+    object.traverse((obj: any) => {
+      if (obj.name) { data.nodes[obj.name] = obj }
+      if (obj.material && !data.materials[obj.material.name]) { data.materials[obj.material.name] = obj.material }
+      if (obj.isMesh && !data.meshes[obj.name]) { data.meshes[obj.name] = obj }
+    })
+  }
+  return data
+}

+ 11 - 15
src/utils/index.ts

@@ -4,7 +4,7 @@ import type { Material, Mesh, Object3D, Texture } from 'three'
 import type { TresContext } from '../composables/useTresContextProvider'
 import { DoubleSide, MathUtils, MeshBasicMaterial, Scene, Vector3 } from 'three'
 import { HightlightMesh } from '../devtools/highlight'
-import * as is from './is'
+import { isFunction, isNumber, isString, isTresPrimitive, isUndefined } from './is'
 
 export * from './logger'
 export function toSetMethodName(key: string) {
@@ -163,11 +163,6 @@ export function deepArrayEqual(arr1: any[], arr2: any[]): boolean {
   return true
 }
 
-/**
- * TypeSafe version of Array.isArray
- */
-export const isArray = Array.isArray as (a: any) => a is any[] | readonly any[]
-
 export function editSceneObject(scene: Scene, objectUuid: string, propertyPath: string[], value: any): void {
   // Function to recursively find the object by UUID
   const findObjectByUuid = (node: Object3D): Object3D | undefined => {
@@ -361,7 +356,7 @@ function joinAsCamelCase(...strings: string[]): string {
 const INDEX_REGEX = /-\d+$/
 
 export function attach(parent: TresInstance, child: TresInstance, type: AttachType) {
-  if (is.str(type)) {
+  if (isString(type)) {
     // NOTE: If attaching into an array (foo-0), create one
     if (INDEX_REGEX.test(type)) {
       const typeWithoutTrailingIndex = type.replace(INDEX_REGEX, '')
@@ -374,7 +369,7 @@ export function attach(parent: TresInstance, child: TresInstance, type: AttachTy
         const previousAttach = target[key]
         const augmentedArray: any[] & { __tresDetach?: () => void } = []
         augmentedArray.__tresDetach = () => {
-          if (augmentedArray.every(v => is.und(v))) {
+          if (augmentedArray.every(v => isUndefined(v))) {
             target[key] = previousAttach
           }
         }
@@ -392,7 +387,7 @@ export function attach(parent: TresInstance, child: TresInstance, type: AttachTy
 }
 
 export function detach(parent: any, child: TresInstance, type: AttachType) {
-  if (is.str(type)) {
+  if (isString(type)) {
     const { target, key } = resolve(parent, type)
     const previous = child.__tres.previousAttach
     // When the previous value was undefined, it means the value was never set to begin with
@@ -456,15 +451,15 @@ export function noop(fn: string): any {
 export function setPixelRatio(renderer: { setPixelRatio?: (dpr: number) => void, getPixelRatio?: () => number }, systemDpr: number, userDpr?: number | [number, number]) {
   // NOTE: Optional `setPixelRatio` allows this function to accept
   // THREE renderers like SVGRenderer.
-  if (!is.fun(renderer.setPixelRatio)) { return }
+  if (!isFunction(renderer.setPixelRatio)) { return }
 
   let newDpr = 0
 
-  if (userDpr && is.arr(userDpr) && userDpr.length >= 2) {
+  if (userDpr && Array.isArray(userDpr) && userDpr.length >= 2) {
     const [min, max] = userDpr
     newDpr = MathUtils.clamp(systemDpr, min, max)
   }
-  else if (is.num(userDpr)) { newDpr = userDpr }
+  else if (isNumber(userDpr)) { newDpr = userDpr }
   else { newDpr = systemDpr }
 
   // NOTE: Don't call `setPixelRatio` unless both:
@@ -525,7 +520,7 @@ export function setPrimitiveObject(
 }
 
 export function unboxTresPrimitive<T>(maybePrimitive: T): T | TresInstance {
-  if (is.tresPrimitive(maybePrimitive)) {
+  if (isTresPrimitive(maybePrimitive)) {
     // NOTE:
     // `primitive` has-a THREE object. Multiple `primitive`s can have
     // the same THREE object. We want to allow the same THREE object
@@ -533,8 +528,9 @@ export function unboxTresPrimitive<T>(maybePrimitive: T): T | TresInstance {
     // that, e.g., materials and geometries.
     // But __tres (`LocalState`) only allows for a single parent.
     // So: copy `__tres` to the object when unboxing.
-    maybePrimitive.object.__tres = maybePrimitive.__tres
-    return maybePrimitive.object
+    const primitive = maybePrimitive as unknown as TresPrimitive
+    primitive.object.__tres = primitive.__tres
+    return primitive.object
   }
   else {
     return maybePrimitive

+ 16 - 17
src/utils/is.test.ts

@@ -1,6 +1,6 @@
 import type { Camera, Light, Material, Object3D } from 'three'
 import { AmbientLight, BufferGeometry, DirectionalLight, Fog, Group, Mesh, MeshBasicMaterial, MeshNormalMaterial, OrthographicCamera, PerspectiveCamera, PointLight, Scene } from 'three'
-import * as is from './is'
+import { isArray, isBoolean, isBufferGeometry, isCamera, isFog, isFunction, isLight, isMaterial, isNumber, isObject, isObject3D, isScene, isString, isTresObject, isUndefined } from './is'
 
 const NUMBERS: Record<string, number> = {
   '0': 0,
@@ -89,27 +89,26 @@ const BUFFER_GEOMETRIES: Record<string, BufferGeometry> = {
 
 const OBJECTS = Object.assign({}, { '{}': {}, '{ a: "a" }': { a: 'a' } }, FOGS, MATERIALS, OBJECT3DS, BUFFER_GEOMETRIES)
 
-// NOTE: See definition in is.ts
 const TRES_OBJECTS = Object.assign({}, MATERIALS, OBJECT3DS, BUFFER_GEOMETRIES, FOGS)
 
 const ALL = Object.assign({}, NUMBERS, BOOLEANS, STRINGS, NULL, UNDEFINED, ARRAYS, FUNCTIONS, OBJECTS)
 
 describe('is', () => {
-  describe('is.und(a: any)', () => { test(is.und, UNDEFINED) })
-  describe('is.arr(a: any)', () => { test(is.arr, ARRAYS) })
-  describe('is.num(a: any)', () => { test(is.num, NUMBERS) })
-  describe('is.str(a: any)', () => { test(is.str, STRINGS) })
-  describe('is.bool(a: any)', () => { test(is.bool, BOOLEANS) })
-  describe('is.fun(a: any)', () => { test(is.fun, FUNCTIONS) })
-  describe('is.obj(a: any)', () => { test(is.obj, OBJECTS) })
-  describe('is.object3D(a: any)', () => { test(is.object3D, OBJECT3DS) })
-  describe('is.camera(a: any)', () => { test(is.camera, CAMERAS) })
-  describe('is.bufferGeometry(a: any)', () => { test(is.bufferGeometry, BUFFER_GEOMETRIES) })
-  describe('is.material(a: any)', () => { test(is.material, MATERIALS) })
-  describe('is.light(a: any)', () => { test(is.light, LIGHTS) })
-  describe('is.fog(a: any)', () => { test(is.fog, FOGS) })
-  describe('is.scene(a: any)', () => { test(is.scene, SCENES) })
-  describe('is.tresObject(a: any)', () => { test(is.tresObject, TRES_OBJECTS) })
+  describe('isUndefined(a: any)', () => { test(isUndefined, UNDEFINED) })
+  describe('isArray(a: any)', () => { test(isArray, ARRAYS) })
+  describe('isNumber(a: any)', () => { test(isNumber, NUMBERS) })
+  describe('isString(a: any)', () => { test(isString, STRINGS) })
+  describe('isBoolean(a: any)', () => { test(isBoolean, BOOLEANS) })
+  describe('isFunction(a: any)', () => { test(isFunction, FUNCTIONS) })
+  describe('isObject(a: any)', () => { test(isObject, OBJECTS) })
+  describe('isObject3D(a: any)', () => { test(isObject3D, OBJECT3DS) })
+  describe('isCamera(a: any)', () => { test(isCamera, CAMERAS) })
+  describe('isBufferGeometry(a: any)', () => { test(isBufferGeometry, BUFFER_GEOMETRIES) })
+  describe('isMaterial(a: any)', () => { test(isMaterial, MATERIALS) })
+  describe('isLight(a: any)', () => { test(isLight, LIGHTS) })
+  describe('isFog(a: any)', () => { test(isFog, FOGS) })
+  describe('isScene(a: any)', () => { test(isScene, SCENES) })
+  describe('isTresObject(a: any)', () => { test(isTresObject, TRES_OBJECTS) })
 })
 
 /**

+ 265 - 32
src/utils/is.ts

@@ -1,68 +1,301 @@
 import type { TresObject, TresPrimitive } from 'src/types'
 import type { BufferGeometry, Camera, Fog, Light, Material, Object3D, Scene } from 'three'
 
-export function und(u: unknown): u is undefined {
-  return typeof u === 'undefined'
+/**
+ * Type guard to check if a value is undefined
+ * @param value - The value to check
+ * @returns True if the value is undefined, false otherwise
+ * @example
+ * ```ts
+ * const value = undefined
+ * if (isUndefined(value)) {
+ *   // TypeScript knows value is undefined here
+ * }
+ * ```
+ */
+export function isUndefined(value: unknown): value is undefined {
+  return typeof value === 'undefined'
 }
 
-export function arr(u: unknown): u is Array<unknown> {
-  return Array.isArray(u)
+/**
+ * Type guard to check if a value is an array
+ * @param value - The value to check
+ * @returns True if the value is an array, false otherwise
+ * @example
+ * ```ts
+ * const value = [1, 2, 3]
+ * if (isArray(value)) {
+ *   // TypeScript knows value is Array<unknown> here
+ *   value.length // OK
+ *   value.map(x => x) // OK
+ * }
+ * ```
+ */
+export function isArray(value: unknown): value is Array<unknown> {
+  return Array.isArray(value)
 }
 
-export function num(u: unknown): u is number {
-  return typeof u === 'number'
+/**
+ * Type guard to check if a value is a number
+ * @param value - The value to check
+ * @returns True if the value is a number (including NaN and Infinity), false otherwise
+ * @example
+ * ```ts
+ * const value = 42
+ * if (isNumber(value)) {
+ *   // TypeScript knows value is number here
+ *   value.toFixed(2) // OK
+ *   value * 2 // OK
+ * }
+ * ```
+ */
+export function isNumber(value: unknown): value is number {
+  return typeof value === 'number'
 }
 
-export function str(u: unknown): u is string {
-  return typeof u === 'string'
+/**
+ * Type guard to check if a value is a string
+ * @param value - The value to check
+ * @returns True if the value is a string, false otherwise
+ * @example
+ * ```ts
+ * const value = "hello"
+ * if (isString(value)) {
+ *   // TypeScript knows value is string here
+ *   value.length // OK
+ *   value.toUpperCase() // OK
+ * }
+ * ```
+ */
+export function isString(value: unknown): value is string {
+  return typeof value === 'string'
 }
 
-export function bool(u: unknown): u is boolean {
-  return u === true || u === false
+/**
+ * Type guard to check if a value is a boolean
+ * @param value - The value to check
+ * @returns True if the value is strictly true or false, false otherwise
+ * @example
+ * ```ts
+ * const value = true
+ * if (isBoolean(value)) {
+ *   // TypeScript knows value is boolean here
+ *   !value // OK
+ *   value && true // OK
+ * }
+ * ```
+ */
+export function isBoolean(value: unknown): value is boolean {
+  return value === true || value === false
 }
 
-export function fun(u: unknown): u is (...args: any[]) => any {
-  return typeof u === 'function'
+/**
+ * Type guard to check if a value is a function
+ * @param value - The value to check
+ * @returns True if the value is a callable function, false otherwise
+ * @example
+ * ```ts
+ * const value = () => {}
+ * if (isFunction(value)) {
+ *   // TypeScript knows value is (...args: any[]) => any here
+ *   value() // OK
+ *   value.call(null) // OK
+ * }
+ * ```
+ */
+export function isFunction(value: unknown): value is (...args: any[]) => any {
+  return typeof value === 'function'
 }
 
-export function obj(u: unknown): u is Record<string | number | symbol, unknown> {
-  return u === Object(u) && !arr(u) && !fun(u)
+/**
+ * Type guard to check if a value is a plain object
+ * @param value - The value to check
+ * @returns True if the value is a plain object (not null, array, or function), false otherwise
+ * @example
+ * ```ts
+ * const value = { key: 'value' }
+ * if (isObject(value)) {
+ *   // TypeScript knows value is Record<string | number | symbol, unknown> here
+ *   Object.keys(value) // OK
+ *   value.key // OK
+ * }
+ * ```
+ */
+export function isObject(value: unknown): value is Record<string | number | symbol, unknown> {
+  return value === Object(value) && !isArray(value) && !isFunction(value)
 }
 
-export function object3D(u: unknown): u is Object3D {
-  return obj(u) && !!(u.isObject3D)
+/**
+ * Type guard to check if a value is a Three.js Object3D
+ * @param value - The value to check
+ * @returns True if the value is a Three.js Object3D instance, false otherwise
+ * @example
+ * ```ts
+ * const value = new THREE.Object3D()
+ * if (isObject3D(value)) {
+ *   // TypeScript knows value is Object3D here
+ *   value.position // OK
+ *   value.rotation // OK
+ *   value.scale // OK
+ * }
+ * ```
+ */
+export function isObject3D(value: unknown): value is Object3D {
+  return isObject(value) && !!(value.isObject3D)
 }
 
-export function camera(u: unknown): u is Camera {
-  return obj(u) && !!(u.isCamera)
+/**
+ * Type guard to check if a value is a Three.js Camera
+ * @param value - The value to check
+ * @returns True if the value is a Three.js Camera instance, false otherwise
+ * @example
+ * ```ts
+ * const value = new THREE.PerspectiveCamera()
+ * if (isCamera(value)) {
+ *   // TypeScript knows value is Camera here
+ *   value.fov // OK
+ *   value.near // OK
+ *   value.far // OK
+ * }
+ * ```
+ */
+export function isCamera(value: unknown): value is Camera {
+  return isObject(value) && !!(value.isCamera)
 }
 
-export function bufferGeometry(u: unknown): u is BufferGeometry {
-  return obj(u) && !!(u.isBufferGeometry)
+/**
+ * Type guard to check if a value is a Three.js BufferGeometry
+ * @param value - The value to check
+ * @returns True if the value is a Three.js BufferGeometry instance, false otherwise
+ * @example
+ * ```ts
+ * const value = new THREE.BufferGeometry()
+ * if (isBufferGeometry(value)) {
+ *   // TypeScript knows value is BufferGeometry here
+ *   value.attributes // OK
+ *   value.index // OK
+ *   value.computeVertexNormals() // OK
+ * }
+ * ```
+ */
+export function isBufferGeometry(value: unknown): value is BufferGeometry {
+  return isObject(value) && !!(value.isBufferGeometry)
 }
 
-export function material(u: unknown): u is Material {
-  return obj(u) && !!(u.isMaterial)
+/**
+ * Type guard to check if a value is a Three.js Material
+ * @param value - The value to check
+ * @returns True if the value is a Three.js Material instance, false otherwise
+ * @example
+ * ```ts
+ * const value = new THREE.MeshStandardMaterial()
+ * if (isMaterial(value)) {
+ *   // TypeScript knows value is Material here
+ *   value.color // OK
+ *   value.metalness // OK
+ *   value.roughness // OK
+ * }
+ * ```
+ */
+export function isMaterial(value: unknown): value is Material {
+  return isObject(value) && !!(value.isMaterial)
 }
 
-export function light(u: unknown): u is Light {
-  return obj(u) && !!(u.isLight)
+/**
+ * Type guard to check if a value is a Three.js Light
+ * @param value - The value to check
+ * @returns True if the value is a Three.js Light instance, false otherwise
+ * @example
+ * ```ts
+ * const value = new THREE.DirectionalLight()
+ * if (isLight(value)) {
+ *   // TypeScript knows value is Light here
+ *   value.intensity // OK
+ *   value.color // OK
+ *   value.position // OK
+ * }
+ * ```
+ */
+export function isLight(value: unknown): value is Light {
+  return isObject(value) && !!(value.isLight)
 }
 
-export function fog(u: unknown): u is Fog {
-  return obj(u) && !!(u.isFog)
+/**
+ * Type guard to check if a value is a Three.js Fog
+ * @param value - The value to check
+ * @returns True if the value is a Three.js Fog instance, false otherwise
+ * @example
+ * ```ts
+ * const value = new THREE.Fog(0x000000, 1, 1000)
+ * if (isFog(value)) {
+ *   // TypeScript knows value is Fog here
+ *   value.color // OK
+ *   value.near // OK
+ *   value.far // OK
+ * }
+ * ```
+ */
+export function isFog(value: unknown): value is Fog {
+  return isObject(value) && !!(value.isFog)
 }
 
-export function scene(u: unknown): u is Scene {
-  return obj(u) && !!(u.isScene)
+/**
+ * Type guard to check if a value is a Three.js Scene
+ * @param value - The value to check
+ * @returns True if the value is a Three.js Scene instance, false otherwise
+ * @example
+ * ```ts
+ * const value = new THREE.Scene()
+ * if (isScene(value)) {
+ *   // TypeScript knows value is Scene here
+ *   value.children // OK
+ *   value.add(new THREE.Object3D()) // OK
+ *   value.remove(new THREE.Object3D()) // OK
+ * }
+ * ```
+ */
+export function isScene(value: unknown): value is Scene {
+  return isObject(value) && !!(value.isScene)
 }
 
-export function tresObject(u: unknown): u is TresObject {
+/**
+ * Type guard to check if a value is a TresObject
+ * @param value - The value to check
+ * @returns True if the value is a TresObject (Object3D | BufferGeometry | Material | Fog), false otherwise
+ * @example
+ * ```ts
+ * const value = new THREE.Mesh()
+ * if (isTresObject(value)) {
+ *   // TypeScript knows value is TresObject here
+ *   // You can use common properties and methods shared by all TresObjects
+ * }
+ * ```
+ * @remarks
+ * TresObject is a union type that represents the core Three.js objects that can be used in TresJS.
+ * This includes Object3D, BufferGeometry, Material, and Fog instances.
+ */
+export function isTresObject(value: unknown): value is TresObject {
   // NOTE: TresObject is currently defined as
   // TresObject3D | THREE.BufferGeometry | THREE.Material | THREE.Fog
-  return object3D(u) || bufferGeometry(u) || material(u) || fog(u)
+  return isObject3D(value) || isBufferGeometry(value) || isMaterial(value) || isFog(value)
 }
 
-export function tresPrimitive(u: unknown): u is TresPrimitive {
-  return obj(u) && !!(u.isPrimitive)
+/**
+ * Type guard to check if a value is a TresPrimitive
+ * @param value - The value to check
+ * @returns True if the value is a TresPrimitive instance, false otherwise
+ * @example
+ * ```ts
+ * const value = { isPrimitive: true }
+ * if (isTresPrimitive(value)) {
+ *   // TypeScript knows value is TresPrimitive here
+ *   // You can use properties and methods specific to TresPrimitives
+ * }
+ * ```
+ * @remarks
+ * TresPrimitive is a special type in TresJS that represents primitive objects
+ * that can be used directly in the scene without needing to be wrapped in a Three.js object.
+ */
+export function isTresPrimitive(value: unknown): value is TresPrimitive {
+  return isObject(value) && !!(value.isPrimitive)
 }

+ 5 - 0
src/utils/perf.ts

@@ -21,6 +21,11 @@ export function calculateMemoryUsage(object: TresObject | Scene) {
   return totalMemory // In bytes
 }
 
+export function boundedPush<T>(arr: T[], value: T, max: number) {
+  arr.push(value)
+  if (arr.length > max) { arr.shift() }
+}
+
 export function bytesToKB(bytes: number): string {
   return (bytes / 1024).toFixed(2)
 }