Selaa lähdekoodia

feat(loader)!: refactor useLoader to a true composable (#959)

* feat(loader): introduce useLoader composable and documentation

- 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.

* feat(loader)!: introduce useLoader composable and documentation

BREAKING CHANGE: 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
Alvaro Saburido 1 kuukausi sitten
vanhempi
commit
430837f1ae
32 muutettua tiedostoa jossa 1290 lisäystä ja 241 poistoa
  1. 8 1
      docs/.vitepress/config/en.ts
  2. 1 0
      docs/components.d.ts
  3. 58 0
      docs/composables/use-graph.md
  4. 182 0
      docs/composables/use-loader.md
  5. 16 18
      docs/cookbook/load-models.md
  6. 3 3
      docs/team.md
  7. 0 14
      playground/vue/src/composables/useFBX.ts
  8. 0 82
      playground/vue/src/composables/useGLTF.ts
  9. 57 0
      playground/vue/src/composables/usePocGLTF.ts
  10. 4 5
      playground/vue/src/pages/loaders/componentDemo.vue
  11. 2 4
      playground/vue/src/pages/loaders/fbx-loader/TheExperience.vue
  12. 39 4
      playground/vue/src/pages/loaders/fbx-loader/TheModel.vue
  13. 27 3
      playground/vue/src/pages/loaders/fbx-loader/index.vue
  14. 13 0
      playground/vue/src/pages/loaders/gltf-loader/ManualUseGLTF.vue
  15. 2 4
      playground/vue/src/pages/loaders/gltf-loader/TheExperience.vue
  16. 71 4
      playground/vue/src/pages/loaders/gltf-loader/TheModel.vue
  17. 27 3
      playground/vue/src/pages/loaders/gltf-loader/index.vue
  18. 62 0
      playground/vue/src/pages/loaders/multiple-models/TheExperience.vue
  19. 35 0
      playground/vue/src/pages/loaders/multiple-models/index.vue
  20. 50 0
      playground/vue/src/pages/loaders/texture-loader/TheExperience.vue
  21. 35 0
      playground/vue/src/pages/loaders/texture-loader/index.vue
  22. 32 0
      playground/vue/src/pages/misc/use-graph/index.vue
  23. 10 0
      playground/vue/src/router/routes/loaders.ts
  24. 5 0
      playground/vue/src/router/routes/misc.ts
  25. 1 0
      src/composables/index.ts
  26. 9 0
      src/composables/useGraph/index.ts
  27. 36 6
      src/composables/useLoader/component.vue
  28. 109 79
      src/composables/useLoader/index.ts
  29. 279 11
      src/composables/useLoader/useLoader.test.ts
  30. 1 0
      src/index.ts
  31. 96 0
      src/utils/graph.test.ts
  32. 20 0
      src/utils/graph.ts

+ 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',
         text: 'Advanced',
 
 

+ 1 - 0
docs/components.d.ts

@@ -2,6 +2,7 @@
 // @ts-nocheck
 // @ts-nocheck
 // Generated by unplugin-vue-components
 // Generated by unplugin-vue-components
 // Read more: https://github.com/vuejs/core/pull/3399
 // Read more: https://github.com/vuejs/core/pull/3399
+// biome-ignore lint: disable
 export {}
 export {}
 
 
 /* prettier-ignore */
 /* prettier-ignore */

+ 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:
 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`
 ## 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.
 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 { useLoader } from '@tresjs/core'
 import { GLTFLoader } from 'three/addons/loaders/GLTFLoader'
 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:
 Then you can pass the model scene to a TresJS [`primitive`](/advanced/primitive) component to render it:
 
 
 ```html
 ```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.
 > 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
 ```ts
 import { useGLTF } from '@tresjs/cientos'
 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.
 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
 ```ts
 import { useGLTF } from '@tresjs/cientos'
 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.
 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">
 <script setup lang="ts">
 import { useGLTF } from '@tresjs/cientos'
 import { useGLTF } from '@tresjs/cientos'
 
 
-const { nodes } = await useGLTF('/models/AkuAku.gltf', { draco: true })
+const { state } = useGLTF('/models/AkuAku.gltf', { draco: true })
 </script>
 </script>
 
 
 <template>
 <template>
-  <primitive :object="nodes.AkuAku" />
+  <primitive :object="state.nodes.AkuAku" />
 </template>
 </template>
 ```
 ```
 :::
 :::
@@ -112,9 +114,7 @@ import { OrbitControls, GLTFModel } from '@tresjs/cientos'
   <TresCanvas clear-color="#82DBC5" shadows alpha>
   <TresCanvas clear-color="#82DBC5" shadows alpha>
     <TresPerspectiveCamera :position="[11, 11, 11]" />
     <TresPerspectiveCamera :position="[11, 11, 11]" />
     <OrbitControls />
     <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 />
     <TresDirectionalLight :position="[-4, 8, 4]" :intensity="1.5" cast-shadow />
   </TresCanvas>
   </TresCanvas>
 </template>
 </template>
@@ -129,7 +129,7 @@ The `useFBX` composable is available from the [@tresjs/cientos](https://github.c
 ```ts
 ```ts
 import { useFBX } from '@tresjs/cientos'
 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:
 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>
   <TresCanvas clear-color="#82DBC5" shadows alpha>
     <TresPerspectiveCamera :position="[11, 11, 11]" />
     <TresPerspectiveCamera :position="[11, 11, 11]" />
     <OrbitControls />
     <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>
   </TresCanvas>
 </template>
 </template>
 ```
 ```

+ 3 - 3
docs/team.md

@@ -26,13 +26,13 @@ import { core, maintainers, alumni } from './_data/team'
     <template #members>
     <template #members>
       <VPTeamMembers :members="core" />
       <VPTeamMembers :members="core" />
     </template>
     </template>
-  </VPTeamPageSection> 
+  </VPTeamPageSection>
   <VPTeamPageSection>
   <VPTeamPageSection>
     <template #title>Maintainers</template>
     <template #title>Maintainers</template>
     <template #members>
     <template #members>
       <VPTeamMembers :members="maintainers" />
       <VPTeamMembers :members="maintainers" />
     </template>
     </template>
-  </VPTeamPageSection> 
+  </VPTeamPageSection>
   <VPTeamPageSection>
   <VPTeamPageSection>
     <template #title>Alumni</template>
     <template #title>Alumni</template>
     <template #lead>
     <template #lead>
@@ -42,5 +42,5 @@ import { core, maintainers, alumni } from './_data/team'
     <template #members>
     <template #members>
       <VPTeamMembers size="small" :members="alumni" />
       <VPTeamMembers size="small" :members="alumni" />
     </template>
     </template>
-  </VPTeamPageSection> 
+  </VPTeamPageSection>
 </VPTeamPage>
 </VPTeamPage>

+ 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>
+}

+ 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">
   <TresCanvas window-size clear-color="#333">
     <TresPerspectiveCamera :position="[0, 3, 7]" :look-at="[0, 0, 0]" />
     <TresPerspectiveCamera :position="[0, 3, 7]" :look-at="[0, 0, 0]" />
     <OrbitControls />
     <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" />
     <TresDirectionalLight :position="[0, 10, 0]" :intensity="1" />
     <TresAmbientLight :intensity="0.5" />
     <TresAmbientLight :intensity="0.5" />
+    <TresGridHelper />
   </TresCanvas>
   </TresCanvas>
 </template>
 </template>

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

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

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

@@ -1,10 +1,45 @@
 <script setup lang="ts">
 <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>
 </script>
 
 
 <template>
 <template>
-  <primitive :object="scene" />
+  <primitive v-if="model" :object="model" :scale="0.01" />
 </template>
 </template>

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

@@ -2,10 +2,34 @@
 import { TresCanvas } from '@tresjs/core'
 import { TresCanvas } from '@tresjs/core'
 
 
 import TheExperience from './TheExperience.vue'
 import TheExperience from './TheExperience.vue'
+
+const state = reactive({
+  hasFinishLoading: false,
+  progress: 0,
+})
+
+provide('gltf-loader-state', state)
 </script>
 </script>
 
 
 <template>
 <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>
 </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>
 </script>
 
 
 <template>
 <template>
-  <TresPerspectiveCamera :position="[3, 3, 3]" />
+  <TresPerspectiveCamera :position="[8, 8, 8]" />
   <OrbitControls />
   <OrbitControls />
   <TresGridHelper />
   <TresGridHelper />
   <TresAmbientLight :intensity="1" />
   <TresAmbientLight :intensity="1" />
-  <Suspense>
-    <TheModel />
-  </Suspense>
+  <TheModel />
 </template>
 </template>

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

@@ -1,10 +1,77 @@
 <script setup lang="ts">
 <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>
 </script>
 
 
 <template>
 <template>
-  <primitive :object="model" />
+  <primitive v-if="model?.scene" ref="modelRef" :position="[0, 2, 0]" :object="model.scene" />
 </template>
 </template>

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

@@ -2,10 +2,34 @@
 import { TresCanvas } from '@tresjs/core'
 import { TresCanvas } from '@tresjs/core'
 
 
 import TheExperience from './TheExperience.vue'
 import TheExperience from './TheExperience.vue'
+
+const state = reactive({
+  hasFinishLoading: false,
+  progress: 0,
+})
+
+provide('gltf-loader-state', state)
 </script>
 </script>
 
 
 <template>
 <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>
 </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',
     name: 'FBX Loader',
     component: () => import('../../pages/loaders/fbx-loader/index.vue'),
     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',
     path: '/loader/component',
     name: 'Loader Component',
     name: 'Loader Component',
     component: () => import('../../pages/loaders/componentDemo.vue'),
     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

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

+ 1 - 0
src/composables/index.ts

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

+ 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 type { LoaderProto } from './index'
-import { reactive } from 'vue'
 import { useLoader } from './index'
 import { useLoader } from './index'
+import { defineEmits, defineProps } from 'vue'
+import { whenever } from '@vueuse/core'
+import type { TresObjectMap } from '../../utils/graph'
 
 
 const props = defineProps<{
 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>
 </script>
 
 
 <template>
 <template>
-  <slot :data="data"></slot>
+  <slot
+    :state="state"
+    :is-loading="isLoading"
+    :error="error"
+  ></slot>
 </template>
 </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: (
   load: (
-    url: string | string[],
+    url: string,
     onLoad: (result: T) => void,
     onLoad: (result: T) => void,
-    onProgress?: (event: ProgressEvent) => void,
-    onError?: (event: ErrorEvent) => void
+    onProgress?: (event: ProgressEvent<EventTarget>) => void,
+    onError?: (err: unknown) => void
   ) => void
   ) => void
   loadAsync: (url: string, onProgress?: (event: ProgressEvent) => void) => Promise<T>
   loadAsync: (url: string, onProgress?: (event: ProgressEvent) => void) => Promise<T>
 }
 }
 
 
 export type LoaderProto<T> = new (manager?: LoadingManager) => TresLoader<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>,
   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 { 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', () => {
 describe('useLoader', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
   it('is defined', () => {
   it('is defined', () => {
     expect(useLoader).toBeDefined()
     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
 // TODO: find a way to test this

+ 1 - 0
src/index.ts

@@ -10,6 +10,7 @@ export * from './core/catalogue'
 export * from './core/loop'
 export * from './core/loop'
 export * from './directives'
 export * from './directives'
 export * from './types'
 export * from './types'
+export * from './utils/graph'
 export * from './utils/logger'
 export * from './utils/logger'
 
 
 export interface TresOptions {
 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
+}