Browse Source

feat: 474 vue chrome devtools plugin (#479)

* feat: vue chrome devtools

* feat: editable scenes from devtools

* chore(lint): fix lint errors

* feat: highlight material

* chore(lint): fix
Alvaro Saburido 1 year ago
parent
commit
224ab06a44

+ 3 - 3
docs/package.json

@@ -8,11 +8,11 @@
     "build": "vitepress build",
     "preview": "vitepress preview"
   },
+  "dependencies": {
+    "@tresjs/core": "workspace:3.6.0"
+  },
   "devDependencies": {
     "unocss": "^0.58.0",
     "vite-svg-loader": "^5.1.0"
-  },
-  "dependencies": {
-    "@tresjs/core": "workspace:3.6.0-next.0"
   }
 }

+ 2 - 1
playground/package.json

@@ -10,15 +10,16 @@
   },
   "dependencies": {
     "@tresjs/cientos": "3.6.0",
+    "@tresjs/core": "workspace:^",
     "vue-router": "^4.2.5"
   },
   "devDependencies": {
     "@tresjs/leches": "0.15.0-next.3",
     "@tweakpane/plugin-essentials": "^0.2.0",
-    "vite-plugin-vue-devtools": "1.0.0-rc.6",
     "unplugin-auto-import": "^0.17.2",
     "vite-plugin-glsl": "^1.2.1",
     "vite-plugin-qrcode": "^0.2.3",
+    "vite-plugin-vue-devtools": "1.0.0-rc.6",
     "vue-tsc": "^1.8.25"
   }
 }

+ 19 - 18
playground/src/components/TheExperience.vue

@@ -3,6 +3,8 @@ import { ref, watchEffect } from 'vue'
 import { BasicShadowMap, SRGBColorSpace, NoToneMapping } from 'three'
 import { TresCanvas } from '@tresjs/core'
 import { OrbitControls } from '@tresjs/cientos'
+import { TresLeches, useControls } from '@tresjs/leches'
+import '@tresjs/leches/styles'
 import TheSphere from './TheSphere.vue'
 
 const gl = {
@@ -17,21 +19,21 @@ const gl = {
 const wireframe = ref(true)
 
 const canvas = ref()
+const meshRef = ref()
+
+const { isVisible } = useControls({
+  isVisible: true,
+})
 
 watchEffect(() => {
-  if (canvas.value) {
-    console.log(canvas.value.context)
+  if (meshRef.value) {
+    console.log(meshRef.value)
   }
 })
 </script>
 
 <template>
-  <div>
-    <button @click="wireframe = !wireframe">
-      Click
-    </button>
-  </div>
-  <pre>{{ wireframe }}</pre>
+  <TresLeches />
   <TresCanvas
     v-bind="gl"
     ref="canvas"
@@ -44,14 +46,10 @@ watchEffect(() => {
       :look-at="[0, 4, 0]"
     />
     <OrbitControls />
-    <TresFog
-      :color="gl.clearColor"
-      :near="5"
-      :far="15"
-    />
     <TresMesh
       :position="[-2, 6, 0]"
       :rotation="[0, Math.PI, 0]"
+      name="cone"
       cast-shadow
     >
       <TresConeGeometry :args="[1, 1.5, 3]" />
@@ -68,19 +66,22 @@ watchEffect(() => {
       />
     </TresMesh>
     <TresMesh
-      :rotation="[-Math.PI / 2, 0, 0]"
+      ref="meshRef"
+      :rotation="[-Math.PI / 2, 0, Math.PI / 2]"
+      name="floor"
       receive-shadow
     >
-      <TresPlaneGeometry :args="[10, 10, 10, 10]" />
-      <TresMeshToonMaterial color="#D3FC8A" />
+      <TresPlaneGeometry :args="[20, 20, 20]" />
+      <TresMeshToonMaterial
+        color="#D3FC8A"
+      />
     </TresMesh>
-    <TheSphere />
+    <TheSphere v-if="isVisible" />
     <TresAxesHelper :args="[1]" />
     <TresDirectionalLight
       :position="[0, 2, 4]"
       :intensity="2"
       cast-shadow
     />
-    <TresOrthographicCamera />
   </TresCanvas>
 </template>

+ 1 - 0
playground/src/components/TheSphere.vue

@@ -3,6 +3,7 @@
 <template>
   <TresMesh
     :position="[2, 2, 0]"
+    name="sphere"
     cast-shadow
   >
     <TresSphereGeometry />

File diff suppressed because it is too large
+ 154 - 417
pnpm-lock.yaml


+ 5 - 0
src/components/TresCanvas.vue

@@ -33,6 +33,7 @@ import { render } from '../core/renderer'
 
 import type { RendererPresetsType } from '../composables/useRenderer/const'
 import type { TresCamera, TresObject } from '../types/'
+import { registerTresDevtools } from '../devtools'
 
 export interface TresCanvasProps
   extends Omit<WebGLRendererParameters, 'canvas'> {
@@ -92,6 +93,10 @@ const createInternalComponent = (context: TresContext) =>
       if (ctx) ctx.app = instance as App
       provide('useTres', context)
       provide('extend', extend)
+
+      if (typeof window !== 'undefined') {
+        registerTresDevtools(ctx.app, context)
+      }
       return () => h(Fragment, null, slots?.default ? slots.default() : [])
     },
   })

+ 24 - 0
src/devtools/highlight.ts

@@ -0,0 +1,24 @@
+import * as THREE from 'three'
+
+export class HightlightMesh extends THREE.Mesh {
+  type = 'HightlightMesh'
+  createTime: number
+  constructor(...args: THREE.Mesh['args']) {
+    super(...args)
+    this.createTime = Date.now()
+  }
+
+  onBeforeRender() {
+    const currentTime = Date.now()
+    const time = (currentTime - this.createTime) / 1000
+    // Pulsing effect parameters
+    const scaleAmplitude = 0.07 // Amplitude of the scale pulsation
+    const pulseSpeed = 2.5 // Speed of the pulsation
+
+    // Calculate the scale factor with a sine function for pulsing effect
+    const scaleFactor = 1 + scaleAmplitude * Math.sin(pulseSpeed * time)
+
+    // Apply the scale factor
+    this.scale.set(scaleFactor, scaleFactor, scaleFactor)
+  }
+}

+ 1 - 0
src/devtools/index.ts

@@ -0,0 +1 @@
+export { registerTresDevtools } from './plugin'

+ 292 - 0
src/devtools/plugin.ts

@@ -0,0 +1,292 @@
+import type {
+  App as DevtoolsApp } from '@vue/devtools-api'
+import {
+  setupDevtoolsPlugin,
+} from '@vue/devtools-api'
+import { reactive } from 'vue'
+import type { Mesh, Object3D } from 'three'
+import { createHighlightMesh, editSceneObject } from '../utils'
+import { bytesToKB, calculateMemoryUsage } from '../utils/perf'
+import type { TresContext } from '../composables'
+import type { TresObject } from './../types'
+import { toastMessage } from './utils'
+
+export interface Tags {
+  label: string
+  textColor: number
+  backgroundColor: number
+  tooltip?: string
+}
+
+export interface SceneGraphObject {
+  id: string
+  label: string
+  children: SceneGraphObject[]
+  tags: Tags[]
+}
+
+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,
+    })
+  }
+  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')) {
+    node.tags.push({
+      label: `${object.intensity}`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Intensity',
+    })
+    node.tags.push({
+      label: `#${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({
+      // eslint-disable-next-line max-len
+      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',
+    })
+  }
+  /* if (object.position) {
+    node.tags.push({
+      label: `x: ${object.position.x} y: ${object.position.y} z: ${object.position.z}`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Position',
+    })
+  } */
+  return node
+}
+
+function buildGraph(object: TresObject, node: SceneGraphObject) {
+  object.children.forEach((child: TresObject) => {
+    if (child.type === 'HightlightMesh') return
+    const childNode = createNode(child)
+    node.children.push(childNode)
+    buildGraph(child, childNode)
+  })
+}
+
+const componentStateTypes: string[] = []
+const INSPECTOR_ID = 'tres:inspector'
+
+const state = reactive({
+  sceneGraph: null as SceneGraphObject | null,
+})
+export function registerTresDevtools(app: DevtoolsApp, 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',
+      componentStateTypes,
+      app,
+    },
+    (api) => {
+      if (typeof api.now !== 'function') {
+        toastMessage(
+          // eslint-disable-next-line max-len
+          'You seem to be using an outdated version of Vue Devtools. Are you still using the Beta release instead of the stable one? You can find the links at https://devtools.vuejs.org/guide/installation.html.',
+        )
+      }
+
+      api.addInspector({
+        id: INSPECTOR_ID,
+        label: 'TresJS 🪐',
+        icon: 'account_tree',
+        treeFilterPlaceholder: 'Search instances',
+      })
+
+      setInterval(() => {
+        api.sendInspectorTree(INSPECTOR_ID)
+      }, 1000)
+
+      setInterval(() => {
+        api.notifyComponentUpdate()
+      }, 5000)
+
+      api.on.getInspectorTree((payload) => {
+        if (payload.inspectorId === INSPECTOR_ID) {
+          // Your logic here
+          const root = createNode(tres.scene.value)
+          buildGraph(tres.scene.value, root)
+          state.sceneGraph = root
+          payload.rootNodes = [root]
+          /*  payload.rootNodes = [
+            {
+              id: 'root',
+              label: 'Root ',
+              children: [
+                {
+                  id: 'child',
+                  label: `Child ${payload.filter}`,
+                  tags: [
+                    {
+                      label: 'active',
+                      textColor: 0x000000,
+                      backgroundColor: 0xFF984F,
+                    },
+                    {
+                      label: 'test',
+                      textColor: 0xffffff,
+                      backgroundColor: 0x000000,
+                    },
+                  ],
+                },
+              ],
+            },
+          ] */
+        }
+      })
+      let highlightMesh: Mesh | null = null
+      let prevInstance: Object3D | null = null
+      
+      api.on.getInspectorState((payload) => {
+        if (payload.inspectorId === INSPECTOR_ID) {
+          // Your logic here
+          const [instance] = tres.scene.value.getObjectsByProperty('uuid', payload.nodeId)
+          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: [
+              {
+                key: 'uuid',
+                editable: true,
+                value: instance.uuid,
+              },
+              {
+                key: 'name',
+                editable: true,
+                value: instance.name,
+              },
+              {
+                key: 'type',
+                editable: true,
+                value: instance.type,
+              },
+              {
+                key: 'position',
+                editable: true,
+                value: instance.position,
+              },
+              {
+                key: 'rotation',
+                editable: true,
+                value: instance.rotation,
+              },
+              {
+                key: 'scale',
+                editable: true,
+                value: instance.scale,
+              },
+              {
+                key: 'geometry',
+                value: instance.geometry,
+              },
+              {
+                key: 'material',
+                value: instance.material,
+              },
+              {
+                key: 'color',
+                editable: true,
+                value: instance.color,
+              },
+              {
+                key: 'intensity',
+                editable: true,
+                value: instance.intensity,
+              },
+              {
+                key: 'castShadow',
+                editable: true,
+                value: instance.castShadow,
+              },
+              {
+                key: 'receiveShadow',
+                editable: true,
+                value: instance.receiveShadow,
+              },
+              {
+                key: 'frustumCulled',
+                editable: true,
+                value: instance.frustumCulled,
+              },
+              {
+                key: 'matrixAutoUpdate',
+                editable: true,
+                value: instance.matrixAutoUpdate,
+              },
+              {
+                key: 'matrixWorldNeedsUpdate',
+                editable: true,
+                value: instance.matrixWorldNeedsUpdate,
+              },
+              {
+                key: 'matrixWorld',
+                value: instance.matrixWorld,
+              },
+                
+              {
+                key: 'visible',
+                editable: true,
+                value: instance.visible,
+              },
+            ],
+          }
+        }
+      })
+
+      api.on.editInspectorState((payload) => {
+        if (payload.inspectorId === INSPECTOR_ID) {
+          editSceneObject(tres.scene.value, payload.nodeId, payload.path, payload.state.value)
+        }
+      })
+    },
+  )
+}

+ 27 - 0
src/devtools/utils.ts

@@ -0,0 +1,27 @@
+/**
+ * Shows a toast or console.log
+ *
+ * @param message - message to log
+ * @param type - different color of the tooltip
+ */
+export function toastMessage(
+  message: string,
+  type?: 'normal' | 'error' | 'warn' | undefined,
+) {
+  const tresMessage = `▲ ■ ●${message}`
+
+  if (typeof __VUE_DEVTOOLS_TOAST__ === 'function') {
+    // No longer available :(
+    __VUE_DEVTOOLS_TOAST__(tresMessage, type)
+  }
+  else if (type === 'error') {
+    console.error(tresMessage)
+  }
+  else if (type === 'warn') {
+    console.warn(tresMessage)
+  }
+  else {
+    // eslint-disable-next-line no-console
+    console.log(tresMessage)
+  }
+}

+ 100 - 0
src/utils/index.ts

@@ -1,3 +1,7 @@
+import THREE, { MeshBasicMaterial, DoubleSide } from 'three'
+import type { Mesh, type Scene, type Object3D } from 'three'
+import { HightlightMesh } from '../devtools/highlight'
+
 export function toSetMethodName(key: string) {
   return `set${key[0].toUpperCase()}${key.slice(1)}`
 }
@@ -127,3 +131,99 @@ export function deepArrayEqual(arr1: any[], arr2: any[]): boolean {
  * 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 => {
+    if (node.uuid === objectUuid) {
+      return node
+    }
+
+    for (const child of node.children) {
+      const found = findObjectByUuid(child)
+      if (found) {
+        return found
+      }
+    }
+
+    return undefined
+  }
+
+  // Find the target object
+  const targetObject = findObjectByUuid(scene)
+  if (!targetObject) {
+    console.warn('Object with UUID not found in the scene.')
+    return
+  }
+
+  // Traverse the property path to get to the desired property
+  let currentProperty: any = targetObject
+  for (let i = 0; i < propertyPath.length - 1; i++) {
+    if (currentProperty[propertyPath[i]] !== undefined) {
+      currentProperty = currentProperty[propertyPath[i]]
+    }
+    else {
+      console.warn(`Property path is not valid: ${propertyPath.join('.')}`)
+      return
+    }
+  }
+
+  // Set the new value
+  const lastProperty = propertyPath[propertyPath.length - 1]
+  if (currentProperty[lastProperty] !== undefined) {
+    currentProperty[lastProperty] = value
+  }
+  else {
+    console.warn(`Property path is not valid: ${propertyPath.join('.')}`)
+  }
+}
+
+export function createHighlightMaterial(): MeshBasicMaterial {
+  return new MeshBasicMaterial({
+    color: 0xA7E6D7, // Highlight color, e.g., yellow
+    transparent: true,
+    opacity: 0.2,
+    depthTest: false, // So the highlight is always visible
+    side: DoubleSide, // To ensure the highlight is visible from all angles
+  })
+}
+let animationFrameId: number | null = null
+export function animateHighlight(highlightMesh: Mesh, startTime: number): void {
+  const currentTime = Date.now()
+  const time = (currentTime - startTime) / 1000 // convert to seconds
+
+  // Pulsing effect parameters
+  const scaleAmplitude = 0.07 // Amplitude of the scale pulsation
+  const pulseSpeed = 2.5 // Speed of the pulsation
+
+  // Calculate the scale factor with a sine function for pulsing effect
+  const scaleFactor = 1 + scaleAmplitude * Math.sin(pulseSpeed * time)
+
+  // Apply the scale factor
+  highlightMesh.scale.set(scaleFactor, scaleFactor, scaleFactor)
+
+  // Update the animation frame ID
+  animationFrameId = requestAnimationFrame(() => animateHighlight(highlightMesh, startTime))
+}
+
+export function stopHighlightAnimation(): void {
+  if (animationFrameId !== null) {
+    cancelAnimationFrame(animationFrameId)
+    animationFrameId = null
+  }
+}
+
+export function createHighlightMesh(object: Object3D): Mesh {
+  const highlightMaterial = new MeshBasicMaterial({
+    color: 0xa7e6d7, // Highlight color, e.g., yellow
+    transparent: true,
+    opacity: 0.2,
+    depthTest: false, // So the highlight is always visible
+    side: DoubleSide, // To e
+  })
+  // Clone the geometry of the object. You might need a more complex approach 
+  // if the object's geometry is not straightforward.
+  const highlightMesh = new HightlightMesh(object.geometry.clone(), highlightMaterial)
+
+  return highlightMesh
+}

+ 1 - 1
src/utils/perf.ts

@@ -18,7 +18,7 @@ export function calculateMemoryUsage(object: TresObject | Scene) {
     }
   })
 
-  return totalMemory
+  return totalMemory // In bytes
 }
 
 export function bytesToKB(bytes: number): string {

Some files were not shown because too many files changed in this diff