Support multiple sheet instances (#153)

* Support multiple/nested sheets and sheet instances

* Add playground for instances

* Fix playground and example

* Change r3f's objectKey to storeKey to avoid confusion

* Update all editable/uniqueName variables used as store key to storeKey too

* Fix lint warnings
This commit is contained in:
Andrew Prifer 2022-05-16 12:43:45 +02:00 committed by GitHub
parent 10b4954ee2
commit fc9df7c346
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 195 additions and 88 deletions

View file

@ -92,7 +92,7 @@ function App() {
shadowMap shadowMap
> >
<SheetProvider <SheetProvider
getSheet={() => getProject('Playground - R3F').sheet('R3F-Canvas')} sheet={getProject('Playground - R3F').sheet('R3F-Canvas')}
> >
{/* @ts-ignore */} {/* @ts-ignore */}
<e.perspectiveCamera makeDefault uniqueName="Camera" /> <e.perspectiveCamera makeDefault uniqueName="Camera" />

View file

@ -0,0 +1,109 @@
import {editable as e, RefreshSnapshot, SheetProvider} from '@theatre/r3f'
import {Stars} from '@react-three/drei'
import {getProject} from '@theatre/core'
import React, {Suspense, useState} from 'react'
import {Canvas} from '@react-three/fiber'
import {useGLTF, PerspectiveCamera} from '@react-three/drei'
import sceneGLB from './scene.glb'
document.body.style.backgroundColor = '#171717'
const EditableCamera = e(PerspectiveCamera, 'perspectiveCamera')
function Model({
url,
instance,
...props
}: {url: string; instance?: string} & JSX.IntrinsicElements['group']) {
const {nodes} = useGLTF(url) as any
return (
<e.group
uniqueName={`Transforms for Rocket: ${instance ?? 'default'}`}
{...props}
>
<SheetProvider sheet={getProject('Space').sheet('Rocket', instance)}>
<group rotation={[-Math.PI / 2, 0, 0]} position={[0, -7, 0]} scale={7}>
<group rotation={[Math.PI / 13.5, -Math.PI / 5.8, Math.PI / 5.6]}>
<e.mesh
uniqueName="Thingy"
receiveShadow
castShadow
geometry={nodes.planet001.geometry}
material={nodes.planet001.material}
/>
<e.mesh
uniqueName="Debris 2"
receiveShadow
castShadow
geometry={nodes.planet002.geometry}
material={nodes.planet002.material}
/>
<e.mesh
uniqueName="Debris 1"
geometry={nodes.planet003.geometry}
material={nodes.planet003.material}
/>
</group>
</group>
</SheetProvider>
</e.group>
)
}
function App() {
const bgs = ['#272730', '#b7c5d1']
const [bgIndex, setBgIndex] = useState(0)
const bg = bgs[bgIndex]
return (
<div
onClick={() => {
// return setBgIndex((bgIndex) => (bgIndex + 1) % bgs.length)
}}
style={{
height: '100vh',
}}
>
<Canvas dpr={[1.5, 2]} linear shadows frameloop="demand">
<SheetProvider sheet={getProject('Space').sheet('Scene')}>
<fog attach="fog" args={[bg, 16, 70]} />
<color attach="background" args={[bg]} />
<ambientLight intensity={0.75} />
<EditableCamera
uniqueName="Camera"
makeDefault
position={[0, 0, 0]}
fov={75}
near={20}
far={70}
>
<e.pointLight
uniqueName="Light 1"
intensity={1}
position={[-10, -25, -10]}
/>
<e.spotLight
uniqueName="Light 2"
castShadow
intensity={2.25}
angle={0.2}
penumbra={1}
position={[-25, 20, -15]}
shadow-mapSize={[1024, 1024]}
shadow-bias={-0.0001}
/>
<e.directionalLight uniqueName="Light 3" />
</EditableCamera>
<Suspense fallback={null}>
<RefreshSnapshot />
<Model url={sceneGLB} instance="Apollo" position={[18, 5, -42]} />
<Model url={sceneGLB} instance="Sputnik" position={[-18, 5, -42]} />
</Suspense>
<Stars radius={500} depth={50} count={1000} factor={10} />
</SheetProvider>
</Canvas>
</div>
)
}
export default App

View file

@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import studio from '@theatre/studio'
import {extension} from '@theatre/r3f'
studio.extend(extension)
studio.initialize()
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root'),
)

Binary file not shown.

View file

@ -1,11 +1,10 @@
import {editable as e, RefreshSnapshot, SheetProvider} from '@theatre/r3f' import {editable as e, RefreshSnapshot, SheetProvider} from '@theatre/r3f'
import {Stars} from '@react-three/drei' import {Stars} from '@react-three/drei'
import {getProject} from '@theatre/core' import {getProject} from '@theatre/core'
import React, {Suspense, useMemo, useState} from 'react' import React, {Suspense, useState} from 'react'
import {Canvas} from '@react-three/fiber' import {Canvas} from '@react-three/fiber'
import {useGLTF, PerspectiveCamera} from '@react-three/drei' import {useGLTF, PerspectiveCamera} from '@react-three/drei'
import sceneGLB from './scene.glb' import sceneGLB from './scene.glb'
import {Color} from 'three'
document.body.style.backgroundColor = '#171717' document.body.style.backgroundColor = '#171717'
@ -55,14 +54,8 @@ function App() {
}} }}
> >
<Canvas dpr={[1.5, 2]} linear shadows frameloop="demand"> <Canvas dpr={[1.5, 2]} linear shadows frameloop="demand">
<SheetProvider getSheet={() => getProject('Space').sheet('Scene')}> <SheetProvider sheet={getProject('Space').sheet('Scene')}>
<e.fog <fog attach="fog" args={[bg, 16, 30]} />
attach="fog"
color={useMemo(() => new Color(bg), [bg])}
near={16}
far={30}
uniqueName="Fog"
/>
<color attach="background" args={[bg]} /> <color attach="background" args={[bg]} />
<ambientLight intensity={0.75} /> <ambientLight intensity={0.75} />
<EditableCamera <EditableCamera

View file

@ -1,16 +1,16 @@
import React, { import React, {
createContext, createContext,
useContext, useContext,
useEffect,
useLayoutEffect, useLayoutEffect,
useState,
} from 'react' } from 'react'
import {useThree} from '@react-three/fiber' import {useThree} from '@react-three/fiber'
import type {ISheet} from '@theatre/core' import type {ISheet} from '@theatre/core'
import {bindToCanvas} from './store' import {bindToCanvas} from './store'
const ctx = createContext<{sheet: ISheet | undefined} | undefined>(undefined) const ctx = createContext<{sheet: ISheet}>(undefined!)
const useWrapperContext = (): {sheet: ISheet | undefined} => { const useWrapperContext = (): {sheet: ISheet} => {
const val = useContext(ctx) const val = useContext(ctx)
if (!val) { if (!val) {
throw new Error( throw new Error(
@ -25,23 +25,21 @@ export const useCurrentSheet = (): ISheet | undefined => {
} }
const SheetProvider: React.FC<{ const SheetProvider: React.FC<{
getSheet: () => ISheet sheet: ISheet
}> = (props) => { }> = ({sheet, children}) => {
const {scene, gl} = useThree((s) => ({scene: s.scene, gl: s.gl})) const {scene, gl} = useThree((s) => ({scene: s.scene, gl: s.gl}))
const [sheet, setSheet] = useState<ISheet | undefined>(undefined)
useEffect(() => {
if (!sheet || sheet.type !== 'Theatre_Sheet_PublicAPI') {
throw new Error(`sheet in <Wrapper sheet={sheet}> has an invalid value`)
}
}, [sheet])
useLayoutEffect(() => { useLayoutEffect(() => {
const sheet = props.getSheet()
if (!sheet || sheet.type !== 'Theatre_Sheet_PublicAPI') {
throw new Error(
`getSheet() in <Wrapper getSheet={getSheet}> has returned an invalid value`,
)
}
setSheet(sheet)
bindToCanvas({gl, scene}) bindToCanvas({gl, scene})
}, [scene, gl]) }, [scene, gl])
return <ctx.Provider value={{sheet}}>{props.children}</ctx.Provider> return <ctx.Provider value={{sheet}}>{children}</ctx.Provider>
} }
export default SheetProvider export default SheetProvider

View file

@ -15,14 +15,11 @@ import {invalidate, useFrame, useThree} from '@react-three/fiber'
import {useDragDetector} from './DragDetector' import {useDragDetector} from './DragDetector'
export interface EditableProxyProps { export interface EditableProxyProps {
editableName: string storeKey: string
object: Object3D object: Object3D
} }
const EditableProxy: VFC<EditableProxyProps> = ({ const EditableProxy: VFC<EditableProxyProps> = ({storeKey, object}) => {
editableName: uniqueName,
object,
}) => {
const editorObject = getEditorSheetObject() const editorObject = getEditorSheetObject()
const [setSnapshotProxyObject, editables] = useEditorStore( const [setSnapshotProxyObject, editables] = useEditorStore(
(state) => [state.setSnapshotProxyObject, state.editables], (state) => [state.setSnapshotProxyObject, state.editables],
@ -31,17 +28,17 @@ const EditableProxy: VFC<EditableProxyProps> = ({
const dragging = useDragDetector() const dragging = useDragDetector()
const editable = editables[uniqueName] const editable = editables[storeKey]
const selected = useSelected() const selected = useSelected()
const showOverlayIcons = const showOverlayIcons =
useVal(editorObject?.props.viewport.showOverlayIcons) ?? false useVal(editorObject?.props.viewport.showOverlayIcons) ?? false
useEffect(() => { useEffect(() => {
setSnapshotProxyObject(object, uniqueName) setSnapshotProxyObject(object, storeKey)
return () => setSnapshotProxyObject(null, uniqueName) return () => setSnapshotProxyObject(null, storeKey)
}, [uniqueName, object, setSnapshotProxyObject]) }, [storeKey, object, setSnapshotProxyObject])
useLayoutEffect(() => { useLayoutEffect(() => {
const originalVisibility = object.visible const originalVisibility = object.visible
@ -68,7 +65,7 @@ const EditableProxy: VFC<EditableProxyProps> = ({
return return
} }
if (selected === uniqueName || hovered) { if (selected === storeKey || hovered) {
scene.add(helper) scene.add(helper)
invalidate() invalidate()
} }
@ -93,6 +90,30 @@ const EditableProxy: VFC<EditableProxyProps> = ({
} }
}, [dragging]) }, [dragging])
// subscribe to external changes
useEffect(() => {
const sheetObject = editable.sheetObject
const objectConfig = editable.objectConfig
const setFromTheatre = (newValues: any) => {
// @ts-ignore
Object.entries(objectConfig.props).forEach(([key, value]) => {
// @ts-ignore
return value.apply(newValues[key], object)
})
objectConfig.updateObject?.(object)
invalidate()
}
setFromTheatre(sheetObject.value)
const untap = sheetObject.onValuesChange(setFromTheatre)
return () => {
untap()
}
}, [editable])
return ( return (
<> <>
<group <group
@ -101,10 +122,10 @@ const EditableProxy: VFC<EditableProxyProps> = ({
e.stopPropagation() e.stopPropagation()
const theatreObject = const theatreObject =
useEditorStore.getState().editables[uniqueName].sheetObject useEditorStore.getState().editables[storeKey].sheetObject
if (!theatreObject) { if (!theatreObject) {
console.log('no theatre object for', uniqueName) console.log('no theatre object for', storeKey)
} else { } else {
studio.setSelection([theatreObject]) studio.setSelection([theatreObject])
} }
@ -129,8 +150,7 @@ const EditableProxy: VFC<EditableProxyProps> = ({
> >
<primitive object={object}> <primitive object={object}>
{(showOverlayIcons || {(showOverlayIcons ||
(editable.objectConfig.dimensionless && (editable.objectConfig.dimensionless && selected !== storeKey)) && (
selected !== uniqueName)) && (
<Html <Html
center center
style={{ style={{
@ -149,10 +169,10 @@ const EditableProxy: VFC<EditableProxyProps> = ({
if (e.delta < 2) { if (e.delta < 2) {
e.stopPropagation() e.stopPropagation()
const theatreObject = const theatreObject =
useEditorStore.getState().editables[uniqueName].sheetObject useEditorStore.getState().editables[storeKey].sheetObject
if (!theatreObject) { if (!theatreObject) {
console.log('no theatre object for', uniqueName) console.log('no theatre object for', storeKey)
} else { } else {
studio.setSelection([theatreObject]) studio.setSelection([theatreObject])
} }

View file

@ -1,11 +1,5 @@
import type {VFC} from 'react' import type {VFC} from 'react'
import React, { import React, {useLayoutEffect, useMemo, useRef, useState} from 'react'
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import type {Editable} from '../store' import type {Editable} from '../store'
import {useEditorStore} from '../store' import {useEditorStore} from '../store'
import {createPortal} from '@react-three/fiber' import {createPortal} from '@react-three/fiber'
@ -15,11 +9,10 @@ import TransformControls from './TransformControls'
import shallow from 'zustand/shallow' import shallow from 'zustand/shallow'
import type {Material, Mesh, Object3D} from 'three' import type {Material, Mesh, Object3D} from 'three'
import {MeshBasicMaterial, MeshPhongMaterial} from 'three' import {MeshBasicMaterial, MeshPhongMaterial} from 'three'
import type {IScrub} from '@theatre/studio'; import type {IScrub} from '@theatre/studio'
import studio from '@theatre/studio' import studio from '@theatre/studio'
import {useSelected} from './useSelected' import {useSelected} from './useSelected'
import {useVal} from '@theatre/react' import {useVal} from '@theatre/react'
import useInvalidate from './useInvalidate'
import {getEditorSheetObject} from './editorStuff' import {getEditorSheetObject} from './editorStuff'
export interface ProxyManagerProps { export interface ProxyManagerProps {
@ -55,8 +48,6 @@ const ProxyManager: VFC<ProxyManagerProps> = ({orbitControlsRef}) => {
} }
>({}) >({})
const invalidate = useInvalidate()
// set up scene proxies // set up scene proxies
useLayoutEffect(() => { useLayoutEffect(() => {
if (!sceneProxy) { if (!sceneProxy) {
@ -68,15 +59,15 @@ const ProxyManager: VFC<ProxyManagerProps> = ({orbitControlsRef}) => {
sceneProxy.traverse((object) => { sceneProxy.traverse((object) => {
if (object.userData.__editable) { if (object.userData.__editable) {
// there are duplicate uniqueNames in the scene, only display one instance in the editor // there are duplicate uniqueNames in the scene, only display one instance in the editor
if (editableProxies[object.userData.__editableName]) { if (editableProxies[object.userData.__storeKey]) {
object.parent!.remove(object) object.parent!.remove(object)
} else { } else {
const uniqueName = object.userData.__editableName const uniqueName = object.userData.__storeKey
editableProxies[uniqueName] = { editableProxies[uniqueName] = {
portal: createPortal( portal: createPortal(
<EditableProxy <EditableProxy
editableName={object.userData.__editableName} storeKey={object.userData.__storeKey}
object={object} object={object}
/>, />,
object.parent!, object.parent!,
@ -95,32 +86,6 @@ const ProxyManager: VFC<ProxyManagerProps> = ({orbitControlsRef}) => {
const editableProxyOfSelected = selected && editableProxies[selected] const editableProxyOfSelected = selected && editableProxies[selected]
const editable = selected ? editables[selected] : undefined const editable = selected ? editables[selected] : undefined
// subscribe to external changes
useEffect(() => {
if (!editableProxyOfSelected || !editable) return
const object = editableProxyOfSelected.object
const sheetObject = editableProxyOfSelected.editable.sheetObject
const objectConfig = editable.objectConfig
const setFromTheatre = (newValues: any) => {
// @ts-ignore
Object.entries(objectConfig.props).forEach(([key, value]) => {
// @ts-ignore
return value.apply(newValues[key], object)
})
objectConfig.updateObject?.(object)
invalidate()
}
setFromTheatre(sheetObject.value)
const untap = sheetObject.onValuesChange(setFromTheatre)
return () => {
untap()
}
}, [editableProxyOfSelected, selected])
// set up viewport shading modes // set up viewport shading modes
const [renderMaterials, setRenderMaterials] = useState<{ const [renderMaterials, setRenderMaterials] = useState<{
[id: string]: Material | Material[] [id: string]: Material | Material[]

View file

@ -8,6 +8,7 @@ import useInvalidate from './useInvalidate'
import {useCurrentSheet} from '../SheetProvider' import {useCurrentSheet} from '../SheetProvider'
import defaultEditableFactoryConfig from '../defaultEditableFactoryConfig' import defaultEditableFactoryConfig from '../defaultEditableFactoryConfig'
import type {EditableFactoryConfig} from '../editableFactoryConfigUtils' import type {EditableFactoryConfig} from '../editableFactoryConfigUtils'
import {makeStoreKey} from '../utils'
const createEditable = <Keys extends keyof JSX.IntrinsicElements>( const createEditable = <Keys extends keyof JSX.IntrinsicElements>(
config: EditableFactoryConfig, config: EditableFactoryConfig,
@ -47,7 +48,9 @@ const createEditable = <Keys extends keyof JSX.IntrinsicElements>(
const objectRef = useRef<JSX.IntrinsicElements[U]>() const objectRef = useRef<JSX.IntrinsicElements[U]>()
const sheet = useCurrentSheet() const sheet = useCurrentSheet()!
const storeKey = makeStoreKey(sheet, uniqueName)
const [sheetObject, setSheetObject] = useState< const [sheetObject, setSheetObject] = useState<
undefined | ISheetObject<$FixMe> undefined | ISheetObject<$FixMe>
@ -75,14 +78,14 @@ const createEditable = <Keys extends keyof JSX.IntrinsicElements>(
if (objRef) objRef!.current = sheetObject if (objRef) objRef!.current = sheetObject
useEditorStore.getState().addEditable(uniqueName, { useEditorStore.getState().addEditable(storeKey, {
type: actualType, type: actualType,
sheetObject, sheetObject,
visibleOnlyInEditor: visible === 'editor', visibleOnlyInEditor: visible === 'editor',
// @ts-ignore // @ts-ignore
objectConfig: config[actualType], objectConfig: config[actualType],
}) })
}, [sheet, uniqueName]) }, [sheet, storeKey])
// store initial values of props // store initial values of props
useLayoutEffect(() => { useLayoutEffect(() => {
@ -95,7 +98,6 @@ const createEditable = <Keys extends keyof JSX.IntrinsicElements>(
), ),
) )
}, [ }, [
uniqueName,
sheetObject, sheetObject,
// @ts-ignore // @ts-ignore
...Object.keys(config[actualType].props).map( ...Object.keys(config[actualType].props).map(
@ -138,7 +140,7 @@ const createEditable = <Keys extends keyof JSX.IntrinsicElements>(
visible={visible !== 'editor' && visible} visible={visible !== 'editor' && visible}
userData={{ userData={{
__editable: true, __editable: true,
__editableName: uniqueName, __storeKey: storeKey,
}} }}
/> />
) )

View file

@ -3,6 +3,7 @@ import {allRegisteredObjects} from '../store'
import studio from '@theatre/studio' import studio from '@theatre/studio'
import type {ISheetObject} from '@theatre/core' import type {ISheetObject} from '@theatre/core'
import type {$IntentionalAny} from '../types' import type {$IntentionalAny} from '../types'
import {makeStoreKey} from '../utils'
export function useSelected(): undefined | string { export function useSelected(): undefined | string {
const [state, set] = useState<string | undefined>(undefined) const [state, set] = useState<string | undefined>(undefined)
@ -19,7 +20,7 @@ export function useSelected(): undefined | string {
if (!item) { if (!item) {
set(undefined) set(undefined)
} else { } else {
set(item.address.objectKey) set(makeStoreKey(item.sheet, item.address.objectKey))
} }
} }
setFromStudio(studio.selection) setFromStudio(studio.selection)
@ -38,6 +39,6 @@ export function getSelected(): undefined | string {
if (!item) { if (!item) {
return undefined return undefined
} else { } else {
return item.address.objectKey return makeStoreKey(item.sheet, item.address.objectKey)
} }
} }

View file

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