nodeOps.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import type { TresContext } from '../composables'
  2. import type { DisposeType, LocalState, TresInstance, TresObject, TresObject3D, TresPrimitive, WithMathProps } from '../types'
  3. import { BufferAttribute, Object3D } from 'three'
  4. import { isRef, type RendererOptions } from 'vue'
  5. import { attach, deepArrayEqual, doRemoveDeregister, doRemoveDetach, invalidateInstance, isHTMLTag, kebabToCamel, noop, prepareTresInstance, setPrimitiveObject, unboxTresPrimitive } from '../utils'
  6. import { logError } from '../utils/logger'
  7. import { isArray, isCamera, isFunction, isObject, isObject3D, isScene, isTresInstance, isUndefined } from '../utils/is'
  8. import { createRetargetingProxy } from '../utils/primitive/createRetargetingProxy'
  9. import { catalogue } from './catalogue'
  10. const supportedPointerEvents = [
  11. 'onClick',
  12. 'onContextMenu',
  13. 'onPointerMove',
  14. 'onPointerEnter',
  15. 'onPointerLeave',
  16. 'onPointerOver',
  17. 'onPointerOut',
  18. 'onDoubleClick',
  19. 'onPointerDown',
  20. 'onPointerUp',
  21. 'onPointerCancel',
  22. 'onPointerMissed',
  23. 'onLostPointerCapture',
  24. 'onWheel',
  25. ]
  26. export const nodeOps: (context: TresContext) => RendererOptions<TresObject, TresObject | null> = (context) => {
  27. const scene = context.scene.value
  28. function createElement(tag: string, _isSVG: undefined, _anchor: any, props: Partial<WithMathProps<TresObject>> | null): TresObject | null {
  29. if (!props) { props = {} }
  30. if (!props.args) {
  31. props.args = []
  32. }
  33. if (tag === 'template') { return null }
  34. if (isHTMLTag(tag)) { return null }
  35. let name = tag.replace('Tres', '')
  36. let obj: TresObject | null
  37. if (tag === 'primitive') {
  38. if (!isObject(props.object) || isRef(props.object)) {
  39. logError(
  40. 'Tres primitives need an \'object\' prop, whose value is an object or shallowRef<object>',
  41. )
  42. }
  43. name = props.object.type
  44. const __tres = {}
  45. const primitive = createRetargetingProxy(
  46. props.object,
  47. {
  48. object: t => t,
  49. isPrimitive: () => true,
  50. __tres: () => __tres,
  51. },
  52. {
  53. object: (object: TresObject, _: unknown, primitive: TresPrimitive, setTarget: (nextObject: TresObject) => void) => {
  54. setPrimitiveObject(object, primitive, setTarget, { patchProp, remove, insert }, context)
  55. },
  56. __tres: (t: LocalState) => { Object.assign(__tres, t) },
  57. },
  58. )
  59. obj = primitive
  60. }
  61. else {
  62. const target = catalogue.value[name]
  63. if (!target) {
  64. logError(
  65. `${name} is not defined on the THREE namespace. Use extend to add it to the catalog.`,
  66. )
  67. }
  68. // eslint-disable-next-line new-cap
  69. obj = new target(...props.args) as TresObject
  70. }
  71. if (!obj) { return null }
  72. // Opinionated default to avoid user issue not seeing anything if camera is on origin
  73. if (isCamera(obj)) {
  74. if (!props?.position) {
  75. obj.position.set(3, 3, 3)
  76. }
  77. if (!props?.lookAt) {
  78. obj.lookAt(0, 0, 0)
  79. }
  80. }
  81. obj = prepareTresInstance(obj, {
  82. ...(isTresInstance(obj) ? obj.__tres : {}),
  83. type: name,
  84. memoizedProps: props,
  85. eventCount: 0,
  86. primitive: tag === 'primitive',
  87. attach: props.attach,
  88. }, context)
  89. return obj as TresObject
  90. }
  91. function insert(child: TresObject, parent: TresObject) {
  92. if (!child) { return }
  93. // TODO: Investigate and eventually remove `scene` fallback.
  94. // According to the signature, `parent` should always be
  95. // truthy. If it is not truthy, it may be due to a bug
  96. // elsewhere in Tres.
  97. parent = parent || scene
  98. const childInstance: TresInstance = (child.__tres ? child as TresInstance : prepareTresInstance(child, {}, context))
  99. const parentInstance: TresInstance = (parent.__tres ? parent as TresInstance : prepareTresInstance(parent, {}, context))
  100. child = unboxTresPrimitive(childInstance)
  101. parent = unboxTresPrimitive(parentInstance)
  102. if (child.__tres && child.__tres?.eventCount > 0) {
  103. context.eventManager?.registerObject(child)
  104. }
  105. if (isCamera(child)) {
  106. context.camera?.registerCamera(child)
  107. }
  108. // NOTE: Track onPointerMissed objects separate from the scene
  109. context.eventManager?.registerPointerMissedObject(child)
  110. if (childInstance.__tres.attach) {
  111. attach(parentInstance, childInstance, childInstance.__tres.attach)
  112. }
  113. else if (isObject3D(child) && isObject3D(parentInstance)) {
  114. parentInstance.add(child)
  115. child.dispatchEvent({ type: 'added' })
  116. }
  117. // NOTE: Update __tres parent/objects graph
  118. childInstance.__tres.parent = parentInstance
  119. if (parentInstance.__tres.objects && !parentInstance.__tres.objects.includes(childInstance)) {
  120. parentInstance.__tres.objects.push(childInstance)
  121. }
  122. }
  123. /**
  124. * @param node – the node root to remove
  125. * @param dispose – the disposal type
  126. */
  127. function remove(node: TresObject | null, dispose?: DisposeType) {
  128. // NOTE: `remove` is initially called by Vue only on
  129. // the root `node` of the tree to be removed. We will
  130. // recursively call the function on children, if necessary.
  131. // NOTE: Vue does not pass a `dispose` argument; it is
  132. // used by the recursive calls.
  133. if (!node) { return }
  134. // Remove from event manager if necessary
  135. if (node?.__tres && node.__tres?.eventCount > 0) {
  136. context.eventManager?.deregisterObject(node)
  137. }
  138. // NOTE: Derive `dispose` value for this `remove` call and
  139. // recursive remove calls.
  140. dispose = isUndefined(dispose) ? 'default' : dispose
  141. const userDispose = node.__tres?.dispose
  142. if (!isUndefined(userDispose)) {
  143. if (userDispose === null) {
  144. // NOTE: Treat as `false` to act like R3F
  145. dispose = false
  146. }
  147. else {
  148. // NOTE: Otherwise, if the user has defined a `dispose`, use it
  149. dispose = userDispose
  150. }
  151. }
  152. // NOTE: Create a `shouldDispose` boolean for readable predicates below.
  153. // 1) If `dispose` is "default", then:
  154. // - dispose declarative components, e.g., <TresMeshNormalMaterial />
  155. // - do *not* dispose primitives or their non-declarative children
  156. // 2) Otherwise, follow `dispose`
  157. const isPrimitive = node.__tres?.primitive
  158. const shouldDispose = dispose === 'default' ? !isPrimitive : !!(dispose)
  159. // NOTE: This function has 5 stages:
  160. // 1) Recursively remove `node`'s children
  161. // 2) Detach `node` from its parent
  162. // 3) Deregister `node` with `context` and invalidate
  163. // 4) Dispose `node`
  164. // 5) Remove `node`'s `LocalState`
  165. // NOTE: 1) Recursively remove `node`'s children
  166. // NOTE: Remove declarative children.
  167. if (node.__tres && 'objects' in node.__tres) {
  168. // NOTE: In the recursive `remove` calls, the array elements
  169. // will remove themselves from the array, resulting in skipped
  170. // elements. Make a shallow copy of the array.
  171. [...node.__tres.objects].forEach(obj => remove(obj, dispose))
  172. }
  173. // NOTE: Remove remaining THREE children.
  174. // On primitives, we do not remove THREE children unless disposing.
  175. // Otherwise we would alter the user's `:object`.
  176. if (shouldDispose) {
  177. // NOTE: In the recursive `remove` calls, the array elements
  178. // will remove themselves from the array, resulting in skipped
  179. // elements. Make a shallow copy of the array.
  180. if (node.children) {
  181. [...node.children].forEach(child => remove(child, dispose))
  182. }
  183. }
  184. // NOTE: 2) Detach `node` from its parent
  185. doRemoveDetach(node, context)
  186. // NOTE: 3) Deregister `node` THREE.Object3D children and invalidate `node`
  187. doRemoveDeregister(node, context)
  188. // NOTE: 4) Dispose `node`
  189. if (shouldDispose && !isScene(node)) {
  190. if (isFunction(dispose)) {
  191. dispose(node as TresInstance)
  192. }
  193. else if (isFunction(node.dispose)) {
  194. try {
  195. node.dispose()
  196. }
  197. // eslint-disable-next-line unused-imports/no-unused-vars
  198. catch (e) {
  199. // NOTE: We must try/catch here. We want to remove/dispose
  200. // Vue/THREE children in bottom-up order. But THREE objects
  201. // will e.g., call `this.material.dispose` without checking
  202. // if the material exists, leading to an error.
  203. // See issue #721:
  204. // https://github.com/Tresjs/tres/issues/721
  205. // Cannot read properties of undefined (reading 'dispose') - GridHelper
  206. }
  207. }
  208. }
  209. // NOTE: 5) Remove `LocalState`
  210. if ('__tres' in node) {
  211. delete node.__tres
  212. }
  213. }
  214. function patchProp(node: TresObject, prop: string, prevValue: any, nextValue: any) {
  215. if (!node) { return }
  216. let root = node
  217. let key = prop
  218. // NOTE: Update memoizedProps with the new value
  219. if (node.__tres) { node.__tres.memoizedProps[prop] = nextValue }
  220. if (prop === 'attach') {
  221. // NOTE: `attach` is not a field on a TresObject.
  222. // `nextValue` is a string representing how Tres
  223. // should attach `node` to its parent – if the
  224. // parent exists.
  225. const maybeParent = node.__tres?.parent || node.parent
  226. remove(node)
  227. prepareTresInstance(node, { attach: nextValue }, context)
  228. if (maybeParent) { insert(node, maybeParent) }
  229. return
  230. }
  231. if (prop === 'dispose') {
  232. // NOTE: Add node.__tres, if necessary.
  233. if (!node.__tres) { node = prepareTresInstance(node, {}, context) }
  234. node.__tres!.dispose = nextValue
  235. return
  236. }
  237. if (isObject3D(node) && key === 'blocks-pointer-events') {
  238. if (nextValue || nextValue === '') { node[key] = nextValue }
  239. else { delete node[key] }
  240. return
  241. }
  242. // Has events
  243. if (supportedPointerEvents.includes(prop) && node.__tres) {
  244. node.__tres.eventCount += 1
  245. }
  246. let finalKey = kebabToCamel(key)
  247. let target = root?.[finalKey]
  248. if (key === 'args') {
  249. const prevNode = node as TresObject3D
  250. const prevArgs = prevValue ?? []
  251. const args = nextValue ?? []
  252. const instanceName = node.__tres?.type || node.type
  253. if (
  254. instanceName
  255. && prevArgs.length
  256. && !deepArrayEqual(prevArgs, args)
  257. ) {
  258. // Create a new instance
  259. const newInstance = new catalogue.value[instanceName](...nextValue)
  260. // Get all property descriptors of the new instance
  261. const descriptors = Object.getOwnPropertyDescriptors(newInstance)
  262. // Only copy properties that are not readonly
  263. Object.entries(descriptors).forEach(([key, descriptor]) => {
  264. if (!descriptor.writable && !descriptor.set) {
  265. return // Skip readonly properties
  266. }
  267. // Copy the value from new instance to previous node
  268. if (key in prevNode) {
  269. try {
  270. (prevNode as unknown as Record<string, unknown>)[key] = newInstance[key]
  271. }
  272. catch (e) {
  273. // Skip if property can't be set
  274. console.warn(`Could not set property ${key} on ${instanceName}:`, e)
  275. }
  276. }
  277. })
  278. root = prevNode
  279. }
  280. return
  281. }
  282. if (root.type === 'BufferGeometry') {
  283. if (key === 'args') { return }
  284. root.setAttribute(
  285. kebabToCamel(key),
  286. new BufferAttribute(...(nextValue as ConstructorParameters<typeof BufferAttribute>)),
  287. )
  288. return
  289. }
  290. // Traverse pierced props (e.g. foo-bar=value => foo.bar = value)
  291. if (key.includes('-') && target === undefined) {
  292. // TODO: A standalone function called `resolve` is
  293. // available in /src/utils/index.ts. It's covered by tests.
  294. // Refactor below to DRY.
  295. const chain = key.split('-')
  296. target = chain.reduce((acc, key) => acc[kebabToCamel(key)], root)
  297. key = chain.pop() as string
  298. finalKey = key
  299. if (!target?.set) { root = chain.reduce((acc, key) => acc[kebabToCamel(key)], root) }
  300. }
  301. let value = nextValue
  302. if (value === '') { value = true }
  303. // Set prop, prefer atomic methods if applicable
  304. if (isFunction(target)) {
  305. // don't call pointer event callback functions
  306. if (!supportedPointerEvents.includes(prop)) {
  307. if (isArray(value)) { node[finalKey](...value) }
  308. else { node[finalKey](value) }
  309. }
  310. // NOTE: Set on* callbacks
  311. // Issue: https://github.com/Tresjs/tres/issues/360
  312. if (finalKey.startsWith('on') && isFunction(value)) {
  313. root[finalKey] = value
  314. }
  315. return
  316. }
  317. if (!target?.set && !isFunction(target)) { root[finalKey] = value }
  318. else if (target.constructor === value.constructor && target?.copy) { target?.copy(value) }
  319. else if (isArray(value)) { target.set(...value) }
  320. else if (!target.isColor && target.setScalar) { target.setScalar(value) }
  321. else { target.set(value) }
  322. if (isCamera(node)) {
  323. node.updateProjectionMatrix()
  324. }
  325. invalidateInstance(node as TresObject)
  326. }
  327. function parentNode(node: TresObject): TresObject | null {
  328. return node?.__tres?.parent || null
  329. }
  330. /**
  331. * createComment
  332. *
  333. * Creates a comment object that can be used to represent a commented out string in a vue template
  334. * Used by Vue's internal runtime as a placeholder for v-if'd elements
  335. *
  336. * @param comment Any commented out string contaiend in a vue template, typically this is `v-if`
  337. * @returns TresObject
  338. */
  339. function createComment(comment: string): TresObject {
  340. // TODO: Add a custom type for comments instead of reusing Object3D. Comments should be light weight and not exist in the scene graph
  341. const commentObj = prepareTresInstance(new Object3D(), { type: 'Comment' }, context)
  342. commentObj.name = comment
  343. return commentObj
  344. }
  345. // nextSibling - Returns the next sibling of a TresObject
  346. function nextSibling(node: TresObject) {
  347. const parent = parentNode(node)
  348. const siblings = parent?.__tres?.objects || []
  349. const index = siblings.indexOf(node)
  350. // NOTE: If not found OR this is the last of the siblings ...
  351. if (index < 0 || index >= siblings.length - 1) { return null }
  352. return siblings[index + 1]
  353. }
  354. return {
  355. insert,
  356. remove,
  357. createElement,
  358. patchProp,
  359. parentNode,
  360. createText: () => noop('createText'),
  361. createComment,
  362. setText: () => noop('setText'),
  363. setElementText: () => noop('setElementText'),
  364. nextSibling,
  365. querySelector: () => noop('querySelector'),
  366. setScopeId: () => noop('setScopeId'),
  367. cloneNode: () => noop('cloneNode'),
  368. insertStaticContent: () => noop('insertStaticContent'),
  369. }
  370. }