1
0
Эх сурвалжийг харах

feat: 1012 add context inspector devtools (#1013)

* feat: get the context object tree with max 4 leves deep

* feat: enhance context node creation and graph building

- Updated `createContextNode` to include an optional `parentKey` parameter for better context chaining.
- Modified `buildContextGraph` to utilize the new `parentKey` for constructing chained keys during recursion.
- Added handling for context nodes in the inspector state, allowing for dynamic traversal of context objects based on chained keys.
- Improved readability and maintainability of the context graph logic.

* fix: improve scene object handling in Tres Devtools

- Enhanced the logic for extracting UUIDs from scene node IDs to ensure proper handling of scene objects.
- Updated the inspector state editing to reflect the new UUID extraction method, improving reliability when editing scene objects.
- Set the `editable` property to false for certain inspector values to prevent unintended modifications.

* fix: lint issue with fonts

* feat: implement inspector handlers for Tres Devtools

- Added `inspectorHandlers.ts` to manage inspector tree and state updates, enhancing the interaction with the Tres context.
- Introduced functions for creating nodes in the inspector tree, building graphs for scene and context objects, and handling state edits.
- Updated `TresCanvas.vue` to ensure proper context handling when registering Tres Devtools.
- Refactored `plugin.ts` to utilize the new inspector handlers, improving code organization and maintainability.
- Created type definitions in `types.ts` for better clarity and type safety in inspector-related functionalities.

* refactor: remove uuid from TresContext and update UUID generation logic

- Removed the `uuid` property from the `TresContext` interface as it is no longer necessary.
- Updated the UUID generation in `buildContextGraph` to safely access the scene's UUID, ensuring compatibility with the new context structure.

* refactor: remove unused import from useTresContextProvider

- Removed the unused `MathUtils` import from `useTresContextProvider/index.ts` to clean up the code and improve maintainability.
Alvaro Saburido 3 долоо хоног өмнө
parent
commit
ff6723cc7d

+ 1 - 1
src/components/TresCanvas.vue

@@ -145,7 +145,7 @@ const createInternalComponent = (context: TresContext, empty = false) =>
       provide('useTres', context)
       provide('extend', extend)
 
-      if (typeof window !== 'undefined') {
+      if (typeof window !== 'undefined' && ctx?.app) {
         registerTresDevtools(ctx?.app, context)
       }
       return () => h(Fragment, null, !empty ? slots.default() : [])

+ 382 - 0
src/devtools/inspectorHandlers.ts

@@ -0,0 +1,382 @@
+import type { Mesh } from 'three'
+import { Color } from 'three'
+import type { TresObject } from '../types'
+import { bytesToKB, calculateMemoryUsage } from '../utils/perf'
+import { isLight } from '../utils/is'
+import type { SceneGraphObject } from './types'
+import { isRef } from 'vue'
+import type { TresContext } from '../composables/useTresContextProvider'
+import { INSPECTOR_ID } from './plugin'
+import { createHighlightMesh, editSceneObject } from '../utils'
+
+/**
+ * Creates a node representation of a Three.js object for the inspector tree
+ * @param object - The Three.js object to create a node for
+ * @returns A SceneGraphObject representing the Three.js object with relevant metadata
+ */
+export const createNode = (object: TresObject): SceneGraphObject => {
+  const node: SceneGraphObject = {
+    id: `scene-${object.uuid}`,
+    label: object.type,
+    children: [],
+    tags: [],
+  }
+  if (object.name !== '') {
+    node.tags.push({
+      label: object.name,
+      textColor: 0x57BF65,
+      backgroundColor: 0xF0FCF3,
+    })
+  }
+  const memory = calculateMemoryUsage(object)
+  if (memory > 0) {
+    node.tags.push({
+      label: `${bytesToKB(memory)} KB`,
+      textColor: 0xEFAC35,
+      backgroundColor: 0xFFF9DC,
+      tooltip: 'Memory usage',
+    })
+  }
+
+  if (object.type.includes('Light')) {
+    if (isLight(object)) {
+      node.tags.push({
+        label: `${object.intensity}`,
+        textColor: 0x9499A6,
+        backgroundColor: 0xF8F9FA,
+        tooltip: 'Intensity',
+      })
+    }
+    node.tags.push({
+      label: `#${new Color(object.color).getHexString()}`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Color',
+    })
+  }
+
+  if (object.type.includes('Camera')) {
+    node.tags.push({
+      label: `${object.fov}°`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Field of view',
+    })
+    node.tags.push({
+
+      label: `x: ${Math.round(object.position.x)} y: ${Math.round(object.position.y)} z: ${Math.round(object.position.z)}`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Position',
+    })
+  }
+  return node
+}
+
+/**
+ * Creates a context node for the inspector tree
+ * @param key - The key identifier for the context node
+ * @param uuid - The unique identifier for the context
+ * @param parentKey - Optional parent key for nested context nodes
+ * @returns A SceneGraphObject representing the context node
+ */
+export function createContextNode(key: string, uuid: string, parentKey = ''): SceneGraphObject {
+  const chainedKey = parentKey ? `${parentKey}.${key}` : key
+  return {
+    id: `context-${uuid}-${chainedKey}`,
+    label: key,
+    children: [],
+    tags: [],
+  }
+}
+
+/**
+ * Recursively builds a graph representation of Three.js objects for the inspector
+ * @param object - The root Three.js object to build the graph from
+ * @param node - The current node in the graph being built
+ * @param filter - Optional filter string to filter objects by type or name
+ */
+export function buildGraph(object: TresObject, node: SceneGraphObject, filter: string = '') {
+  object.children.forEach((child: TresObject) => {
+    if (child.type === 'HightlightMesh') { return }
+    if (filter && !child.type.includes(filter) && !child.name.includes(filter)) { return }
+
+    const childNode = createNode(child)
+    node.children.push(childNode)
+    buildGraph(child, childNode, filter)
+  })
+}
+
+/**
+ * Recursively builds a graph representation of context objects for the inspector
+ * @param object - The root object to build the context graph from
+ * @param node - The current node in the graph being built
+ * @param visited - WeakSet to track visited objects and prevent circular references
+ * @param depth - Current depth in the object tree
+ * @param maxDepth - Maximum depth to traverse
+ * @param contextUuid - Optional UUID for the context
+ * @param parentKey - Optional parent key for nested objects
+ */
+export function buildContextGraph(
+  object: any,
+  node: SceneGraphObject,
+  visited = new WeakSet(),
+  depth = 0,
+  maxDepth = 4,
+  contextUuid?: string,
+  parentKey = '',
+) {
+  // Prevent infinite recursion
+  if (depth >= maxDepth || !object || visited.has(object)) {
+    return
+  }
+
+  // Generate UUID only on the first call (for TresContext)
+  const uuid = depth === 0 ? (object?.scene?.value?.uuid || Math.random().toString(36).slice(2, 11)) : contextUuid
+
+  visited.add(object)
+
+  Object.entries(object).forEach(([key, value]) => {
+    // Skip internal Vue properties and functions
+    if (key.startsWith('_') || typeof value === 'function') {
+      return
+    }
+
+    const chainedKey = parentKey ? `${parentKey}.${key}` : key
+    const childNode = createContextNode(key, uuid!, parentKey)
+
+    if (key === 'scene') {
+      return
+    }
+
+    // Handle Vue refs
+    if (isRef(value)) {
+      childNode.tags.push({
+        label: `Ref<${typeof value.value}>`,
+        textColor: 0x42B883,
+        backgroundColor: 0xF0FCF3,
+      })
+      // If ref value is an object, continue recursion with its value
+      if (value.value && typeof value.value === 'object') {
+        buildContextGraph(value.value, childNode, visited, depth + 1, maxDepth, uuid, chainedKey)
+      }
+      else {
+        // For primitive ref values, show them in the label
+        childNode.label = `${key}: ${JSON.stringify(value.value)}`
+      }
+    }
+    // Handle regular objects (but avoid circular references)
+    else if (value && typeof value === 'object' && !Array.isArray(value)) {
+      // Check if object has enumerable properties
+      const hasProperties = Object.keys(value).length > 0
+      if (hasProperties) {
+        if (visited.has(value)) {
+          childNode.tags.push({
+            label: 'Circular',
+            textColor: 0xFF0000,
+            backgroundColor: 0xFFF0F0,
+          })
+        }
+        else {
+          buildContextGraph(value, childNode, visited, depth + 1, maxDepth, uuid, chainedKey)
+        }
+      }
+      else {
+        childNode.label = `${key}: {}`
+      }
+    }
+    // Handle arrays
+    else if (Array.isArray(value)) {
+      childNode.label = `${key}: Array(${value.length})`
+      childNode.tags.push({
+        label: `length: ${value.length}`,
+        textColor: 0x9499A6,
+        backgroundColor: 0xF8F9FA,
+      })
+    }
+    // Handle primitive values
+    else {
+      childNode.label = `${key}: ${JSON.stringify(value)}`
+    }
+
+    node.children.push(childNode)
+  })
+}
+
+/**
+ * Handler for inspector tree updates
+ * @param tres - The TresContext instance
+ * @returns A function that handles inspector tree payload updates
+ */
+export const inspectorTreeHandler = (tres: TresContext) => (payload: any) => {
+  if (payload.inspectorId === INSPECTOR_ID) {
+    // Scene Graph
+    const root = createNode(tres.scene.value as unknown as TresObject)
+    buildGraph(tres.scene.value as unknown as TresObject, root, payload.filter)
+
+    // Context Graph
+    const rootContext = {
+      id: 'context-root',
+      label: 'Context',
+      children: [],
+      tags: [],
+    }
+    buildContextGraph(tres, rootContext)
+    payload.rootNodes = [root, rootContext]
+  }
+}
+
+/**
+ * Handler for inspector state updates
+ * @param tres - The TresContext instance
+ * @param options - Options for the handler
+ * @param options.highlightMesh - The currently highlighted mesh
+ * @param options.prevInstance - The previously selected instance
+ * @returns A function that handles inspector state payload updates
+ */
+export const inspectorStateHandler = (tres: TresContext, { highlightMesh, prevInstance }: { highlightMesh: Mesh | null, prevInstance: TresObject | null }) => (payload: any) => {
+  if (payload.inspectorId === INSPECTOR_ID) {
+    if (payload.nodeId.includes('scene')) {
+      // Extract UUID from scene-uuid format
+      const match = payload.nodeId.match(/^scene-(.+)$/)
+      const uuid = match ? match[1] : null
+      if (!uuid) { return }
+
+      const [instance] = tres.scene.value.getObjectsByProperty('uuid', uuid) as TresObject[]
+      if (!instance) { return }
+
+      if (prevInstance && highlightMesh && highlightMesh.parent) {
+        prevInstance.remove(highlightMesh)
+      }
+
+      if (instance.isMesh) {
+        const newHighlightMesh = createHighlightMesh(instance)
+        instance.add(newHighlightMesh)
+
+        highlightMesh = newHighlightMesh
+        prevInstance = instance
+      }
+
+      payload.state = {
+        object: Object.entries(instance)
+          .map(([key, value]) => {
+            if (key === 'children') {
+              return { key, value: value.filter((child: { type: string }) => child.type !== 'HightlightMesh') }
+            }
+            return { key, value, editable: true }
+          })
+          .filter(({ key }) => {
+            return key !== 'parent'
+          }),
+      }
+
+      if (instance.isScene) {
+        payload.state = {
+          ...payload.state,
+          state: [
+            {
+              key: 'Scene Info',
+              value: {
+                objects: instance.children.length,
+                memory: calculateMemoryUsage(instance),
+                calls: tres.renderer.instance.value.info.render.calls,
+                triangles: tres.renderer.instance.value.info.render.triangles,
+                points: tres.renderer.instance.value.info.render.points,
+                lines: tres.renderer.instance.value.info.render.lines,
+              },
+            },
+            {
+              key: 'Programs',
+              value: tres.renderer.instance.value.info.programs?.map(program => ({
+                ...program,
+                programName: program.name,
+              })) || [],
+            },
+          ],
+        }
+      }
+    }
+    else if (payload.nodeId.includes('context')) {
+      // Format is: context-uuid-chainedKey
+      // Use regex to match: 'context-' followed by UUID (which may contain dashes) followed by '-' and the chainedKey
+      const match = payload.nodeId.match(/^context-([^-]+(?:-[^-]+)*)-(.+)$/)
+      const chainedKey = match ? match[2] : 'context'
+
+      if (!chainedKey || chainedKey === 'context') {
+        // Root context node
+        payload.state = {
+          object: Object.entries(tres)
+            .filter(([key]) => !key.startsWith('_') && key !== 'parent')
+            .map(([key, value]) => ({
+              key,
+              value: isRef(value) ? value.value : value,
+              editable: false,
+            })),
+        }
+        return
+      }
+
+      // Traverse the object path
+      const parts = chainedKey.split('.')
+      let value = tres as Record<string, any>
+      for (const part of parts) {
+        if (!value || typeof value !== 'object') { break }
+        value = isRef(value[part]) ? value[part].value : value[part]
+      }
+
+      if (value !== undefined) {
+        payload.state = {
+          object: Object.entries(value)
+            .filter(([key]) => !key.startsWith('_') && key !== 'parent')
+            .map(([key, val]) => {
+              if (isRef(val)) {
+                return {
+                  key,
+                  value: val.value,
+                  editable: false,
+                }
+              }
+              if (typeof val === 'function') {
+                return {
+                  key,
+                  value: 'ƒ()',
+                  editable: false,
+                }
+              }
+              if (val && typeof val === 'object') {
+                return {
+                  key,
+                  value: Array.isArray(val) ? `Array(${val.length})` : 'Object',
+                  editable: false,
+                }
+              }
+              return {
+                key,
+                value: val,
+                editable: false,
+              }
+            }),
+        }
+      }
+    }
+  }
+}
+
+/**
+ * Handler for inspector state edits
+ * @param tres - The TresContext instance
+ * @returns A function that handles inspector state edit payload updates
+ */
+export const inspectorEditStateHandler = (tres: TresContext) => (payload: any) => {
+  if (payload.inspectorId === INSPECTOR_ID) {
+    if (payload.nodeId.includes('scene')) {
+      // Extract UUID from scene-uuid format
+      const match = payload.nodeId.match(/^scene-(.+)$/)
+      const uuid = match ? match[1] : null
+      if (!uuid) { return }
+
+      // Handle scene object editing
+      editSceneObject(tres.scene.value, uuid, payload.path, payload.state.value)
+    }
+  }
+}

+ 21 - 170
src/devtools/plugin.ts

@@ -3,14 +3,11 @@ import type { TresObject } from './../types'
 import {
   setupDevtoolsPlugin,
 } from '@vue/devtools-api'
-import { Color, type Mesh } from 'three'
-import { reactive } from 'vue'
-import { createHighlightMesh, editSceneObject } from '../utils'
-import { bytesToKB, calculateMemoryUsage } from '../utils/perf'
+import type { Mesh } from 'three'
+import type { App } from 'vue'
 import { toastMessage } from './utils'
-
-import { isLight } from '../utils/is'
 import { setupTresDevtools } from './setupDevtools'
+import { inspectorEditStateHandler, inspectorStateHandler, inspectorTreeHandler } from './inspectorHandlers'
 
 export interface Tags {
   label: string
@@ -19,102 +16,23 @@ export interface Tags {
   tooltip?: string
 }
 
-export interface SceneGraphObject {
-  id: string
-  label: string
-  children: SceneGraphObject[]
-  tags: Tags[]
-}
+export const INSPECTOR_ID = 'tres:inspector'
 
-const createNode = (object: TresObject): SceneGraphObject => {
-  const node: SceneGraphObject = {
-    id: object.uuid,
-    label: object.type,
-    children: [],
-    tags: [],
-  }
-  if (object.name !== '') {
-    node.tags.push({
-      label: object.name,
-      textColor: 0x57BF65,
-      backgroundColor: 0xF0FCF3,
-    })
+export function registerTresDevtools(app: App, tres: TresContext) {
+  const pluginDescriptor = {
+    id: 'dev.esm.tres',
+    label: 'TresJS 🪐',
+    logo: 'https://raw.githubusercontent.com/Tresjs/tres/main/public/favicon.svg',
+    packageName: 'tresjs',
+    homepage: 'https://docs.tresjs.org',
+    app,
   }
-  const memory = calculateMemoryUsage(object)
-  if (memory > 0) {
-    node.tags.push({
-      label: `${bytesToKB(memory)} KB`,
-      textColor: 0xEFAC35,
-      backgroundColor: 0xFFF9DC,
-      tooltip: 'Memory usage',
-    })
-  }
-
-  if (object.type.includes('Light')) {
-    if (isLight(object)) {
-      node.tags.push({
-        label: `${object.intensity}`,
-        textColor: 0x9499A6,
-        backgroundColor: 0xF8F9FA,
-        tooltip: 'Intensity',
-      })
-    }
-    node.tags.push({
-      label: `#${new Color(object.color).getHexString()}`,
-      textColor: 0x9499A6,
-      backgroundColor: 0xF8F9FA,
-      tooltip: 'Color',
-    })
-  }
-
-  if (object.type.includes('Camera')) {
-    node.tags.push({
-      label: `${object.fov}°`,
-      textColor: 0x9499A6,
-      backgroundColor: 0xF8F9FA,
-      tooltip: 'Field of view',
-    })
-    node.tags.push({
-
-      label: `x: ${Math.round(object.position.x)} y: ${Math.round(object.position.y)} z: ${Math.round(object.position.z)}`,
-      textColor: 0x9499A6,
-      backgroundColor: 0xF8F9FA,
-      tooltip: 'Position',
-    })
-  }
-  return node
-}
-
-function buildGraph(object: TresObject, node: SceneGraphObject, filter: string = '') {
-  object.children.forEach((child: TresObject) => {
-    if (child.type === 'HightlightMesh') { return }
-    if (filter && !child.type.includes(filter) && !child.name.includes(filter)) { return }
+  const highlightMesh: Mesh | null = null
+  const prevInstance: TresObject | null = null
 
-    const childNode = createNode(child)
-    node.children.push(childNode)
-    buildGraph(child, childNode, filter)
-  })
-}
-
-const componentStateTypes: string[] = []
-const INSPECTOR_ID = 'tres:inspector'
-
-const state = reactive({
-  sceneGraph: null as SceneGraphObject | null,
-})
-
-export function registerTresDevtools(app: any, tres: TresContext) {
   setupTresDevtools(tres)
   setupDevtoolsPlugin(
-    {
-      id: 'dev.esm.tres',
-      label: 'TresJS 🪐',
-      logo: 'https://raw.githubusercontent.com/Tresjs/tres/main/public/favicon.svg',
-      packageName: 'tresjs',
-      homepage: 'https://docs.tresjs.org',
-      componentStateTypes,
-      app,
-    },
+    pluginDescriptor,
     (api) => {
       if (typeof api.now !== 'function') {
         toastMessage(
@@ -137,81 +55,14 @@ export function registerTresDevtools(app: any, tres: TresContext) {
         api.notifyComponentUpdate()
       }, 5000)
 
-      api.on.getInspectorTree((payload) => {
-        if (payload.inspectorId === INSPECTOR_ID) {
-          // Your logic here
-          const root = createNode(tres.scene.value as unknown as TresObject)
-          buildGraph(tres.scene.value as unknown as TresObject, root, payload.filter)
-          state.sceneGraph = root
-          payload.rootNodes = [root]
-        }
-      })
-      let highlightMesh: Mesh | null = null
-      let prevInstance: TresObject | null = null
-
-      api.on.getInspectorState((payload) => {
-        if (payload.inspectorId === INSPECTOR_ID) {
-          // Your logic here
-          const [instance] = tres.scene.value.getObjectsByProperty('uuid', payload.nodeId) as TresObject[]
-          if (!instance) { return }
-          if (prevInstance && highlightMesh && highlightMesh.parent) {
-            prevInstance.remove(highlightMesh)
-          }
-
-          if (instance.isMesh) {
-            const newHighlightMesh = createHighlightMesh(instance)
-            instance.add(newHighlightMesh)
-
-            highlightMesh = newHighlightMesh
-            prevInstance = instance
-          }
+      api.on.getInspectorTree(inspectorTreeHandler(tres))
 
-          payload.state = {
-            object: Object.entries(instance)
-              .map(([key, value]) => {
-                if (key === 'children') {
-                  return { key, value: value.filter((child: { type: string }) => child.type !== 'HightlightMesh') }
-                }
-                return { key, value, editable: true }
-              })
-              .filter(({ key }) => {
-                return key !== 'parent'
-              }),
-          }
+      api.on.getInspectorState(inspectorStateHandler(tres, {
+        highlightMesh,
+        prevInstance,
+      }))
 
-          if (instance.isScene) {
-            payload.state = {
-              ...payload.state,
-              state: [
-                {
-                  key: 'Scene Info',
-                  value: {
-                    objects: instance.children.length,
-                    memory: calculateMemoryUsage(instance),
-                    calls: tres.renderer.instance.value.info.render.calls,
-                    triangles: tres.renderer.instance.value.info.render.triangles,
-                    points: tres.renderer.instance.value.info.render.points,
-                    lines: tres.renderer.instance.value.info.render.lines,
-                  },
-                },
-                {
-                  key: 'Programs',
-                  value: tres.renderer.instance.value.info.programs?.map(program => ({
-                    ...program,
-                    programName: program.name,
-                  })) || [],
-                },
-              ],
-            }
-          }
-        }
-      })
-
-      api.on.editInspectorState((payload) => {
-        if (payload.inspectorId === INSPECTOR_ID) {
-          editSceneObject(tres.scene.value, payload.nodeId, payload.path, payload.state.value)
-        }
-      })
+      api.on.editInspectorState(inspectorEditStateHandler(tres))
     },
   )
 }

+ 96 - 0
src/devtools/types.ts

@@ -0,0 +1,96 @@
+/**
+ * Represents a tag that can be displayed in the inspector UI
+ * @interface Tags
+ */
+export interface Tags {
+  /** The text to display in the tag */
+  label: string
+  /** The color of the text in hexadecimal format (e.g., 0xFFFFFF) */
+  textColor: number
+  /** The background color of the tag in hexadecimal format (e.g., 0x000000) */
+  backgroundColor: number
+  /** Optional tooltip text to show on hover */
+  tooltip?: string
+}
+
+/**
+ * Represents a node in the scene graph that can be displayed in the inspector
+ * @interface SceneGraphObject
+ */
+export interface SceneGraphObject {
+  /** Unique identifier for the node */
+  id: string
+  /** Display name of the node */
+  label: string
+  /** Array of child nodes */
+  children: SceneGraphObject[]
+  /** Array of tags associated with the node */
+  tags: Tags[]
+}
+
+/**
+ * Represents memory usage statistics for Three.js resources
+ * @interface MemoryStats
+ */
+export interface MemoryStats {
+  /** Map of geometry names to their memory usage in bytes */
+  geometries: Map<string, number>
+  /** Map of material names to their memory usage in bytes */
+  materials: Map<string, number>
+  /** Map of texture names to their memory usage in bytes */
+  textures: Map<string, number>
+}
+
+/**
+ * Represents a property in the inspector state
+ * @interface InspectorStateProperty
+ */
+export interface InspectorStateProperty {
+  /** The name of the property */
+  key: string
+  /** The value of the property */
+  value: unknown
+  /** Whether the property can be edited in the inspector */
+  editable: boolean
+}
+
+/**
+ * Represents the state of an inspector panel
+ * @interface InspectorState
+ */
+export interface InspectorState {
+  /** Array of properties to display in the inspector */
+  object: InspectorStateProperty[]
+}
+
+/**
+ * Represents the internal state of the devtools
+ * @interface DevtoolsState
+ */
+export interface DevtoolsState {
+  /** The current scene graph representation */
+  sceneGraph: SceneGraphObject | null
+  /** Timestamp of the last update */
+  lastUpdate: number
+  /** Minimum time between updates in milliseconds */
+  updateThreshold: number
+  /** Current memory usage statistics */
+  memoryStats: MemoryStats
+}
+
+/**
+ * Represents a payload sent to or received from the devtools
+ * @interface DevtoolsPayload
+ */
+export interface DevtoolsPayload {
+  /** The ID of the inspector panel */
+  inspectorId: string
+  /** The ID of the node being inspected */
+  nodeId: string
+  /** Optional filter string for searching */
+  filter?: string
+  /** Optional state to update */
+  state?: InspectorState
+  /** Optional path to the property being edited */
+  path?: string[]
+}