Make @theatre/r3f play well with different vs of react,three,r3f #177

This commit is contained in:
Aria 2022-05-27 21:59:51 +02:00 committed by GitHub
parent e8c440f357
commit 9b4aa4b0e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 796 additions and 145 deletions

View file

@ -0,0 +1,11 @@
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: `ImportDeclaration[importKind!='type'][source.value=/\\u002Fextension\\u002F/]`,
message: `The main bundle should not be able to import the internals of extension.`,
},
],
},
}

View file

@ -0,0 +1,28 @@
import React, {useEffect} from 'react'
import {refreshSnapshot} from './utils'
/**
* Putting this element in a suspense tree makes sure the snapshot editor
* gets refreshed once the tree renders.
*
* Alternatively you can use {@link refreshSnapshot}
*
* @example
* Usage
* ```jsx
* <Suspense fallback={null}>
* <RefreshSnapshot />
* <Model url={sceneGLB} />
* </Suspense>
* ```
*/
const RefreshSnapshot: React.FC<{}> = () => {
useEffect(() => {
setTimeout(() => {
refreshSnapshot()
})
}, [])
return <></>
}
export default RefreshSnapshot

View file

@ -0,0 +1,45 @@
import React, {
createContext,
useContext,
useEffect,
useLayoutEffect,
} from 'react'
import {useThree} from '@react-three/fiber'
import type {ISheet} from '@theatre/core'
import {bindToCanvas} from './store'
const ctx = createContext<{sheet: ISheet}>(undefined!)
const useWrapperContext = (): {sheet: ISheet} => {
const val = useContext(ctx)
if (!val) {
throw new Error(
`No sheet found. You need to add a <SheetProvider> higher up in the tree. https://docs.theatrejs.com/r3f.html#sheetprovider`,
)
}
return val
}
export const useCurrentSheet = (): ISheet | undefined => {
return useWrapperContext().sheet
}
const SheetProvider: React.FC<{
sheet: ISheet
}> = ({sheet, children}) => {
const {scene, gl} = useThree((s) => ({scene: s.scene, gl: s.gl}))
useEffect(() => {
if (!sheet || sheet.type !== 'Theatre_Sheet_PublicAPI') {
throw new Error(`sheet in <Wrapper sheet={sheet}> has an invalid value`)
}
}, [sheet])
useLayoutEffect(() => {
bindToCanvas({gl, scene})
}, [scene, gl])
return <ctx.Provider value={{sheet}}>{children}</ctx.Provider>
}
export default SheetProvider

View file

@ -0,0 +1,119 @@
import type {EditableFactoryConfig} from './editableFactoryConfigUtils'
import {
createColorPropConfig,
createNumberPropConfig,
createVector,
createVectorPropConfig,
extendObjectProps,
} from './editableFactoryConfigUtils'
import type {
DirectionalLight,
Object3D,
OrthographicCamera,
PerspectiveCamera,
PointLight,
SpotLight,
} from 'three'
import {
BoxHelper,
CameraHelper,
DirectionalLightHelper,
PointLightHelper,
SpotLightHelper,
} from 'three'
const baseObjectConfig = {
props: {
position: createVectorPropConfig('position'),
rotation: createVectorPropConfig('rotation'),
scale: createVectorPropConfig('scale', createVector([1, 1, 1])),
},
useTransformControls: true,
icon: 'cube' as const,
createHelper: (object: Object3D) => new BoxHelper(object, selectionColor),
}
const baseLightConfig = {
...extendObjectProps(baseObjectConfig, {
intensity: createNumberPropConfig('intensity', 1),
distance: createNumberPropConfig('distance'),
decay: createNumberPropConfig('decay'),
}),
dimensionless: true,
}
const baseCameraConfig = {
...extendObjectProps(baseObjectConfig, {
near: createNumberPropConfig('near', 0.1, {nudgeMultiplier: 0.1}),
far: createNumberPropConfig('far', 2000, {nudgeMultiplier: 0.1}),
}),
updateObject: (camera: PerspectiveCamera | OrthographicCamera) => {
camera.updateProjectionMatrix()
},
icon: 'camera' as const,
dimensionless: true,
createHelper: (camera: PerspectiveCamera) => new CameraHelper(camera),
}
const selectionColor = '#40AAA4'
const defaultEditableFactoryConfig = {
group: {
...baseObjectConfig,
icon: 'collection' as const,
createHelper: (object: Object3D) => new BoxHelper(object, selectionColor),
},
mesh: {
...baseObjectConfig,
icon: 'cube' as const,
createHelper: (object: Object3D) => new BoxHelper(object, selectionColor),
},
spotLight: {
...extendObjectProps(baseLightConfig, {
angle: createNumberPropConfig('angle', 0, {nudgeMultiplier: 0.001}),
penumbra: createNumberPropConfig('penumbra', 0, {nudgeMultiplier: 0.001}),
}),
icon: 'spotLight' as const,
createHelper: (light: SpotLight) =>
new SpotLightHelper(light, selectionColor),
},
directionalLight: {
...extendObjectProps(baseObjectConfig, {
intensity: createNumberPropConfig('intensity', 1),
}),
icon: 'sun' as const,
dimensionless: true,
createHelper: (light: DirectionalLight) =>
new DirectionalLightHelper(light, 1, selectionColor),
},
pointLight: {
...baseLightConfig,
icon: 'lightBulb' as const,
createHelper: (light: PointLight) =>
new PointLightHelper(light, 1, selectionColor),
},
perspectiveCamera: extendObjectProps(baseCameraConfig, {
fov: createNumberPropConfig('fov', 50, {nudgeMultiplier: 0.1}),
zoom: createNumberPropConfig('zoom', 1),
}),
orthographicCamera: baseCameraConfig,
points: baseObjectConfig,
line: baseObjectConfig,
lineLoop: baseObjectConfig,
lineSegments: baseObjectConfig,
fog: {
props: {
color: createColorPropConfig('color'),
near: createNumberPropConfig('near', 1, {nudgeMultiplier: 0.1}),
far: createNumberPropConfig('far', 1000, {nudgeMultiplier: 0.1}),
},
useTransformControls: false,
icon: 'cloud' as const,
},
}
// Assert that the config is indeed of EditableFactoryConfig without actually
// forcing it to that type so that we can pass the real type to the editable factory
defaultEditableFactoryConfig as EditableFactoryConfig
export default defaultEditableFactoryConfig

View file

@ -0,0 +1,180 @@
import type {ComponentProps, ComponentType, RefAttributes} from 'react'
import React, {forwardRef, useLayoutEffect, useRef, useState} from 'react'
import {allRegisteredObjects, editorStore} from './store'
import mergeRefs from 'react-merge-refs'
import type {ISheetObject} from '@theatre/core'
import useInvalidate from './useInvalidate'
import {useCurrentSheet} from './SheetProvider'
import defaultEditableFactoryConfig from './defaultEditableFactoryConfig'
import type {EditableFactoryConfig} from './editableFactoryConfigUtils'
import {makeStoreKey} from './utils'
import type {$FixMe} from '../types'
const createEditable = <Keys extends keyof JSX.IntrinsicElements>(
config: EditableFactoryConfig,
) => {
const editable = <
T extends ComponentType<any> | Keys | 'primitive',
U extends T extends Keys ? T : Keys,
>(
Component: T,
type: T extends 'primitive' ? null : U,
) => {
type Props = Omit<ComponentProps<T>, 'visible'> & {
uniqueName: string
visible?: boolean | 'editor'
additionalProps?: $FixMe
objRef?: $FixMe
} & (T extends 'primitive'
? {
editableType: U
}
: {}) &
RefAttributes<JSX.IntrinsicElements[U]>
return forwardRef(
(
{
uniqueName,
visible,
editableType,
additionalProps,
objRef,
...props
}: Props,
ref,
) => {
const actualType = type ?? editableType
const objectRef = useRef<JSX.IntrinsicElements[U]>()
const sheet = useCurrentSheet()!
const storeKey = makeStoreKey(sheet, uniqueName)
const [sheetObject, setSheetObject] = useState<
undefined | ISheetObject<$FixMe>
>(undefined)
const invalidate = useInvalidate()
useLayoutEffect(() => {
if (!sheet) return
const sheetObject = sheet.object(
uniqueName,
Object.assign(
{
...additionalProps,
},
// @ts-ignore
...Object.values(config[actualType].props).map(
// @ts-ignore
(value) => value.type,
),
),
)
allRegisteredObjects.add(sheetObject)
setSheetObject(sheetObject)
if (objRef) objRef!.current = sheetObject
editorStore.getState().addEditable(storeKey, {
type: actualType,
sheetObject,
visibleOnlyInEditor: visible === 'editor',
// @ts-ignore
objectConfig: config[actualType],
})
}, [sheet, storeKey])
// store initial values of props
useLayoutEffect(() => {
if (!sheetObject) return
sheetObject!.initialValue = Object.fromEntries(
// @ts-ignore
Object.entries(config[actualType].props).map(
// @ts-ignore
([key, value]) => [key, value.parse(props)],
),
)
}, [
sheetObject,
// @ts-ignore
...Object.keys(config[actualType].props).map(
// @ts-ignore
(key) => props[key],
),
])
// subscribe to prop changes from theatre
useLayoutEffect(() => {
if (!sheetObject) return
const object = objectRef.current!
const setFromTheatre = (newValues: any) => {
// @ts-ignore
Object.entries(config[actualType].props).forEach(
// @ts-ignore
([key, value]) => value.apply(newValues[key], object),
)
// @ts-ignore
config[actualType].updateObject?.(object)
invalidate()
}
setFromTheatre(sheetObject.value)
const untap = sheetObject.onValuesChange(setFromTheatre)
return () => {
untap()
}
}, [sheetObject])
return (
// @ts-ignore
<Component
ref={mergeRefs([objectRef, ref])}
{...props}
visible={visible !== 'editor' && visible}
userData={{
__editable: true,
__storeKey: storeKey,
}}
/>
)
},
)
}
const extensions = {
...Object.fromEntries(
Object.keys(config).map((key) => [
key,
// @ts-ignore
editable(key, key),
]),
),
primitive: editable('primitive', null),
} as unknown as {
[Property in Keys]: React.ForwardRefExoticComponent<
React.PropsWithoutRef<
Omit<JSX.IntrinsicElements[Property], 'visible'> & {
uniqueName: string
visible?: boolean | 'editor'
additionalProps?: $FixMe
objRef?: $FixMe
} & React.RefAttributes<JSX.IntrinsicElements[Property]>
>
>
}
return Object.assign(editable, extensions)
}
const editable = createEditable<keyof typeof defaultEditableFactoryConfig>(
defaultEditableFactoryConfig,
)
export default editable

View file

@ -0,0 +1,121 @@
import type {UnknownShorthandCompoundProps} from '@theatre/core'
import {types} from '@theatre/core'
import type {Object3D} from 'three'
import type {IconID} from '../extension/icons'
import {Color} from 'three'
export type Helper = Object3D & {
update?: () => void
}
type PropConfig<T> = {
parse: (props: Record<string, any>) => T
apply: (value: T, object: any) => void
type: UnknownShorthandCompoundProps
}
type Props = Record<string, PropConfig<any>>
type Meta<T> = {
useTransformControls: boolean
updateObject?: (object: T) => void
icon: IconID
dimensionless?: boolean
createHelper?: (object: T) => Helper
}
export type ObjectConfig<T> = {props: Props} & Meta<T>
export type EditableFactoryConfig = Partial<
Record<keyof JSX.IntrinsicElements, ObjectConfig<any>>
>
type Vector3 = {
x: number
y: number
z: number
}
export const createVector = (components?: [number, number, number]) => {
return components
? {x: components[0], y: components[1], z: components[2]}
: {
x: 0,
y: 0,
z: 0,
}
}
export const createVectorPropConfig = (
key: string,
defaultValue = createVector(),
{nudgeMultiplier = 0.01} = {},
): PropConfig<Vector3> => ({
parse: (props) => {
const vector = props[key]
? Array.isArray(props[key])
? createVector(props[key] as any)
: {
x: props[key].x,
y: props[key].y,
z: props[key].z,
}
: defaultValue
;(['x', 'y', 'z'] as const).forEach((axis) => {
if (props[`${key}-${axis}` as any])
vector[axis] = props[`${key}-${axis}` as any]
})
return vector
},
apply: (value, object) => {
object[key].set(value.x, value.y, value.z)
},
type: {
[key]: {
x: types.number(defaultValue.x, {nudgeMultiplier}),
y: types.number(defaultValue.y, {nudgeMultiplier}),
z: types.number(defaultValue.z, {nudgeMultiplier}),
},
},
})
export const createNumberPropConfig = (
key: string,
defaultValue: number = 0,
{nudgeMultiplier = 0.01} = {},
): PropConfig<number> => ({
parse: (props) => {
return props[key] ?? defaultValue
},
apply: (value, object) => {
object[key] = value
},
type: {
[key]: types.number(defaultValue, {nudgeMultiplier}),
},
})
export type Rgba = {
r: number
g: number
b: number
a: number
}
export const createColorPropConfig = (
key: string,
defaultValue = new Color(0, 0, 0),
): PropConfig<Rgba> => ({
parse: (props) => {
return {...(props[key] ?? defaultValue), a: 1}
},
apply: (value, object) => {
object[key].setRGB(value.r, value.g, value.b)
},
type: {
[key]: types.rgba({...defaultValue, a: 1}),
},
})
export const extendObjectProps = <T extends {props: {}}>(
objectConfig: T,
extension: Props,
) => ({
...objectConfig,
props: {...objectConfig.props, ...extension},
})

View file

@ -0,0 +1,116 @@
import type {StateCreator} from 'zustand'
import create from 'zustand/vanilla'
import type {Object3D, Scene, WebGLRenderer} from 'three'
import {Group} from 'three'
import type {ISheetObject} from '@theatre/core'
import type {ObjectConfig} from './editableFactoryConfigUtils'
export type TransformControlsMode = 'translate' | 'rotate' | 'scale'
export type TransformControlsSpace = 'world' | 'local'
export type ViewportShading = 'wireframe' | 'flat' | 'solid' | 'rendered'
export type BaseSheetObjectType = ISheetObject<any>
export const allRegisteredObjects = new WeakSet<BaseSheetObjectType>()
export interface Editable<T> {
type: string
sheetObject: ISheetObject<any>
objectConfig: ObjectConfig<T>
visibleOnlyInEditor: boolean
}
export type EditableSnapshot<T extends Editable<any> = Editable<any>> = {
proxyObject?: Object3D | null
} & T
export interface SerializedEditable {
type: string
}
export interface EditableState {
editables: Record<string, SerializedEditable>
}
export type EditorStore = {
scene: Scene | null
gl: WebGLRenderer | null
helpersRoot: Group
editables: Record<string, Editable<any>>
// this will come in handy when we start supporting multiple canvases
canvasName: string
sceneSnapshot: Scene | null
editablesSnapshot: Record<string, EditableSnapshot> | null
init: (scene: Scene, gl: WebGLRenderer) => void
addEditable: (uniqueName: string, editable: Editable<any>) => void
createSnapshot: () => void
setSnapshotProxyObject: (
proxyObject: Object3D | null,
uniqueName: string,
) => void
}
const config: StateCreator<EditorStore> = (set) => {
return {
sheet: null,
editorObject: null,
scene: null,
gl: null,
helpersRoot: new Group(),
editables: {},
canvasName: 'default',
sceneSnapshot: null,
editablesSnapshot: null,
initialEditorCamera: {},
init: (scene, gl) => {
set({
scene,
gl,
})
},
addEditable: (uniqueName, editable) => {
set((state) => ({
editables: {
...state.editables,
[uniqueName]: editable,
},
}))
},
createSnapshot: () => {
set((state) => ({
sceneSnapshot: state.scene?.clone() ?? null,
editablesSnapshot: state.editables,
}))
},
setSnapshotProxyObject: (proxyObject, uniqueName) => {
set((state) => ({
editablesSnapshot: {
...state.editablesSnapshot,
[uniqueName]: {
...state.editablesSnapshot![uniqueName],
proxyObject,
},
},
}))
},
}
}
export const editorStore = create<EditorStore>(config)
export type BindFunction = (options: {
allowImplicitInstancing?: boolean
gl: WebGLRenderer
scene: Scene
}) => void
export const bindToCanvas: BindFunction = ({gl, scene}) => {
const init = editorStore.getState().init
init(scene, gl)
}

View file

@ -0,0 +1,5 @@
import {useThree} from '@react-three/fiber'
export default function useInvalidate() {
return useThree(({invalidate}) => invalidate)
}

View file

@ -0,0 +1,7 @@
import {editorStore} from './store'
import type {ISheet} from '@theatre/core'
export const refreshSnapshot = editorStore.getState().createSnapshot
export const makeStoreKey = (sheet: ISheet, name: string) =>
`${sheet.address.sheetId}:${sheet.address.sheetInstanceId}:${name}`