-
-
Notifications
You must be signed in to change notification settings - Fork 8
/
utils.ts
292 lines (250 loc) · 9.23 KB
/
utils.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import * as React from 'react'
import * as OGL from 'ogl'
import type { Fiber } from 'react-reconciler'
import { RESERVED_PROPS, INSTANCE_PROPS, POINTER_EVENTS } from './constants'
import { useIsomorphicLayoutEffect } from './hooks'
import { ConstructorRepresentation, DPR, EventHandlers, Instance, RootState, RootStore } from './types'
/**
* Converts camelCase primitives to PascalCase.
*/
export const toPascalCase = (str: string) => str.charAt(0).toUpperCase() + str.substring(1)
/**
* Checks for inheritance between two classes.
*/
export const classExtends = (a: any, b: any) => (Object.prototype.isPrototypeOf.call(a, b) as boolean) || a === b
/**
* Interpolates DPR from [min, max] based on device capabilities.
*/
export const calculateDpr = (dpr: DPR) =>
Array.isArray(dpr) ? Math.min(Math.max(dpr[0], window.devicePixelRatio), dpr[1]) : dpr
/**
* Returns only instance props from reconciler fibers.
*/
export function getInstanceProps<T = any>(queue: Fiber['pendingProps']): Instance<T>['props'] {
const props: Instance<T>['props'] = {}
for (const key in queue) {
if (!RESERVED_PROPS.includes(key)) props[key] = queue[key]
}
return props
}
/**
* Prepares an object, returning an instance descriptor.
*/
export function prepare<T>(target: T, root: RootStore, type: string, props: Instance<T>['props']): Instance<T> {
const object = (target as unknown as Instance['object']) ?? {}
// Create instance descriptor
let instance = object.__ogl
if (!instance) {
instance = {
root,
parent: null,
children: [],
type,
props: getInstanceProps(props),
object,
isHidden: false,
}
object.__ogl = instance
}
return instance
}
/**
* Resolves a potentially pierced key type against an object.
*/
export function resolve(root: any, key: string) {
let target = root[key]
if (!key.includes('-')) return { root, key, target }
// Resolve pierced target
const chain = key.split('-')
target = chain.reduce((acc, key) => acc[key], root)
key = chain.pop()!
// Switch root if atomic
if (!target?.set) root = chain.reduce((acc, key) => acc[key], root)
return { root, key, target }
}
// Checks if a dash-cased string ends with an integer
const INDEX_REGEX = /-\d+$/
/**
* Attaches an instance to a parent via its `attach` prop.
*/
export function attach(parent: Instance, child: Instance) {
if (typeof child.props.attach === 'string') {
// If attaching into an array (foo-0), create one
if (INDEX_REGEX.test(child.props.attach)) {
const target = child.props.attach.replace(INDEX_REGEX, '')
const { root, key } = resolve(parent.object, target)
if (!Array.isArray(root[key])) root[key] = []
}
const { root, key } = resolve(parent.object, child.props.attach)
child.object.__previousAttach = root[key]
root[key] = child.object
child.object.__currentAttach = parent.object.__currentAttach = root[key]
} else if (typeof child.props.attach === 'function') {
child.object.__previousAttach = child.props.attach(parent.object, child.object)
}
}
/**
* Removes an instance from a parent via its `attach` prop.
*/
export function detach(parent: Instance, child: Instance) {
if (typeof child.props.attach === 'string') {
// Reset parent key if last attached
if (parent.object.__currentAttach === child.object.__currentAttach) {
const { root, key } = resolve(parent.object, child.props.attach)
root[key] = child.object.__previousAttach
}
} else {
child.object.__previousAttach(parent.object, child.object)
}
delete child.object.__previousAttach
delete child.object.__currentAttach
delete parent.object.__currentAttach
}
/**
* Safely mutates an OGL element, respecting special JSX syntax.
*/
export function applyProps<T extends ConstructorRepresentation = any>(
object: Instance<T>['object'],
newProps: Instance<T>['props'],
oldProps?: Instance<T>['props'],
): void {
// Mutate our OGL element
for (const prop in newProps) {
// Don't mutate reserved keys
if (RESERVED_PROPS.includes(prop as typeof RESERVED_PROPS[number])) continue
if (INSTANCE_PROPS.includes(prop as typeof INSTANCE_PROPS[number])) continue
// Don't mutate unchanged keys
if (newProps[prop] === oldProps?.[prop]) continue
// Collect event handlers
const isHandler = POINTER_EVENTS.includes(prop as typeof POINTER_EVENTS[number])
if (isHandler) {
object.__handlers = { ...object.__handlers, [prop]: newProps[prop] }
continue
}
const value = newProps[prop]
const { root, key, target } = resolve(object, prop)
// Prefer to use properties' copy and set methods
// otherwise, mutate the property directly
const isMathClass = typeof target?.set === 'function' && typeof target?.copy === 'function'
if (!ArrayBuffer.isView(value) && isMathClass) {
if (target.constructor === (value as ConstructorRepresentation).constructor) {
target.copy(value)
} else if (Array.isArray(value)) {
target.set(...value)
} else {
// Support shorthand scalar syntax like scale={1}
const scalar = new Array(target.length).fill(value)
target.set(...scalar)
}
} else {
// Allow shorthand values for uniforms
const uniformList = value as any
if (key === 'uniforms') {
for (const uniform in uniformList) {
// @ts-ignore
let uniformValue = uniformList[uniform]?.value ?? uniformList[uniform]
// Handle uniforms shorthand
if (typeof uniformValue === 'string') {
// Uniform is a string, convert it into a color
uniformValue = new OGL.Color(uniformValue)
} else if (
uniformValue?.constructor === Array &&
(uniformValue as any[]).every((v: any) => typeof v === 'number')
) {
// @ts-ignore Uniform is an array, convert it into a vector
uniformValue = new OGL[`Vec${uniformValue.length}`](...uniformValue)
}
root.uniforms[uniform] = { value: uniformValue }
}
} else {
// Mutate the property directly
root[key] = value
}
}
}
}
/**
* Creates event handlers, returning an event handler method.
*/
export function createEvents(state: RootState) {
const handleEvent = (event: PointerEvent, type: keyof EventHandlers) => {
// Convert mouse coordinates
state.mouse!.x = (event.offsetX / state.size.width) * 2 - 1
state.mouse!.y = -(event.offsetY / state.size.height) * 2 + 1
// Filter to interactive meshes
const interactive: OGL.Mesh[] = []
state.scene.traverse((node: OGL.Transform) => {
// Mesh has registered events and a defined volume
if (
node instanceof OGL.Mesh &&
(node as Instance<OGL.Mesh>['object']).__handlers &&
node.geometry?.attributes?.position
)
interactive.push(node)
})
// Get elements that intersect with our pointer
state.raycaster!.castMouse(state.camera, state.mouse)
const intersects: OGL.Mesh[] = state.raycaster!.intersectMeshes(interactive)
// Used to discern between generic events and custom hover events.
// We hijack the pointermove event to handle hover state
const isHoverEvent = type === 'onPointerMove'
// Trigger events for hovered elements
for (const entry of intersects) {
// Bail if object doesn't have handlers (managed externally)
if (!(entry as unknown as any).__handlers) continue
const object = entry as Instance<OGL.Mesh>['object']
const handlers = object.__handlers
if (isHoverEvent && !state.hovered!.get(object.id)) {
// Mark object as hovered and fire its hover events
state.hovered!.set(object.id, object)
// Fire hover events
handlers.onPointerMove?.({ ...object.hit, nativeEvent: event })
handlers.onPointerOver?.({ ...object.hit, nativeEvent: event })
} else {
// Otherwise, fire its generic event
handlers[type]?.({ ...object.hit, nativeEvent: event })
}
}
// Cleanup stale hover events
if (isHoverEvent || type === 'onPointerDown') {
state.hovered!.forEach((object) => {
const handlers = object.__handlers
if (!intersects.length || !intersects.find((i) => i === object)) {
// Reset hover state
state.hovered!.delete(object.id)
// Fire unhover event
if (handlers?.onPointerOut) handlers.onPointerOut({ ...object.hit, nativeEvent: event })
}
})
}
return intersects
}
return { handleEvent }
}
export type SetBlock = false | Promise<null> | null
/**
* Used to block rendering via its `set` prop. Useful for suspenseful effects.
*/
export function Block({ set }: { set: React.Dispatch<React.SetStateAction<SetBlock>> }) {
useIsomorphicLayoutEffect(() => {
set(new Promise(() => null))
return () => set(false)
}, [])
return null
}
/**
* Generic error boundary. Calls its `set` prop on error.
*/
export class ErrorBoundary extends React.Component<
{ set: React.Dispatch<any>; children: React.ReactNode },
{ error: boolean }
> {
state = { error: false }
static getDerivedStateFromError = () => ({ error: true })
componentDidCatch(error: any) {
this.props.set(error)
}
render() {
return this.state.error ? null : this.props.children
}
}