Renamed @theatre/plugin-r3f to @theatre/r3f

This commit is contained in:
Aria Minaei 2021-09-06 10:19:10 +02:00
parent 03a2f26686
commit 4f66d57cf8
42 changed files with 19 additions and 19 deletions

1
packages/r3f/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/dist

203
packages/r3f/LICENSE Normal file
View file

@ -0,0 +1,203 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

7
packages/r3f/README.md Normal file
View file

@ -0,0 +1,7 @@
# @theatre/r3f
A [Theatre.js](https://github.com/AriaMinaei/theatre) extension for [THREE.js](https://threejs.org/) with [React Three Fiber](https://github.com/pmndrs/react-three-fiber).
## Documentation
Docs and video tutorials are [here](https://docs.theatrejs.com/r3f/).

View file

@ -0,0 +1,77 @@
import * as path from 'path'
import {build} from 'esbuild'
import type {Plugin} from 'esbuild'
import {existsSync, mkdirSync, writeFileSync} from 'fs'
const externalPlugin = (patterns: RegExp[]): Plugin => {
return {
name: `external`,
setup(build) {
build.onResolve({filter: /.*/}, (args) => {
const external = patterns.some((p) => {
return p.test(args.path)
})
if (external) {
return {path: args.path, external}
}
})
},
}
}
const definedGlobals = {
global: 'window',
}
function createBundles(watch: boolean) {
const pathToPackage = path.join(__dirname, '../')
const esbuildConfig: Parameters<typeof build>[0] = {
entryPoints: [path.join(pathToPackage, 'src/index.tsx')],
bundle: true,
sourcemap: true,
define: definedGlobals,
watch,
platform: 'neutral',
mainFields: ['browser', 'module', 'main'],
target: ['firefox57', 'chrome58'],
conditions: ['browser', 'node'],
// every dependency is considered external
plugins: [externalPlugin([/^[\@a-zA-Z]+/])],
}
build({
...esbuildConfig,
define: {...definedGlobals, 'process.env.NODE_ENV': '"production"'},
outfile: path.join(pathToPackage, 'dist/index.production.js'),
format: 'cjs',
})
build({
...esbuildConfig,
define: {...definedGlobals, 'process.env.NODE_ENV': '"development"'},
outfile: path.join(pathToPackage, 'dist/index.development.js'),
format: 'cjs',
})
if (!existsSync(path.join(pathToPackage, 'dist')))
mkdirSync(path.join(pathToPackage, 'dist'))
writeFileSync(
path.join(pathToPackage, 'dist/index.js'),
`module.exports =
process.env.NODE_ENV === "production"
? require("./index.production.js")
: require("./index.development.js")`,
{encoding: 'utf-8'},
)
// build({
// ...esbuildConfig,
// outfile: path.join(pathToPackage, 'dist/index.mjs'),
// format: 'esm',
// })
}
createBundles(false)

View file

@ -0,0 +1,3 @@
{
}

66
packages/r3f/package.json Normal file
View file

@ -0,0 +1,66 @@
{
"name": "@theatre/r3f",
"version": "0.4.0-dev.14",
"license": "Apache-2.0",
"authors": [
{
"name": "Andrew Prifer",
"email": "andrew.prifer@gmail.com",
"url": "https://github.com/AndrewPrifer"
},
{
"name": "Aria Minaei",
"email": "aria@theatrejs.com",
"url": "https://github.com/AriaMinaei"
}
],
"repository": {
"type": "git",
"url": "https://github.com/AriaMinaei/theatre",
"directory": "packages/r3f"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"files": [
"dist/**/*"
],
"scripts": {
"prepack": "node ../../devEnv/ensurePublishing.js",
"typecheck": "yarn run build",
"build": "run-s build:ts build:js",
"build:ts": "tsc --build ./tsconfig.json",
"build:js": "node -r esbuild-register ./devEnv/build.ts",
"prepublish": "node ../../devEnv/ensurePublishing.js"
},
"devDependencies": {
"@types/jest": "^26.0.23",
"@types/lodash-es": "^4.17.4",
"@types/node": "^15.6.2",
"@types/react": "^17.0.9",
"@types/styled-components": "^5.1.9",
"npm-run-all": "^4.1.5",
"typescript": "^4.4.2"
},
"dependencies": {
"@react-three/drei": "^7.3.1",
"@theatre/react": "workspace:*",
"lodash-es": "^4.17.21",
"polished": "^4.1.3",
"react-icons": "^4.2.0",
"react-merge-refs": "^1.1.0",
"react-shadow": "^19.0.2",
"react-use-measure": "^2.0.4",
"reakit": "^1.3.8",
"styled-components": "^5.3.0",
"zustand": "^3.5.1"
},
"peerDependencies": {
"@react-three/fiber": "^7.0.6",
"@theatre/core": "*",
"@theatre/studio": "*",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"three": "^0.131.3"
}
}

View file

@ -0,0 +1,11 @@
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: `ImportDeclaration[source.value=/@theatre\\u002F(studio|core)\\u002F/]`,
message: `Importing Theatre's submodules would not work in the production build.`,
},
],
},
}

View file

@ -0,0 +1,47 @@
import React, {
createContext,
useContext,
useLayoutEffect,
useState,
} from 'react'
import {useThree} from '@react-three/fiber'
import type {ISheet} from '@theatre/core'
import {bindToCanvas} from './store'
const ctx = createContext<{sheet: ISheet | undefined} | undefined>(undefined)
const useWrapperContext = (): {sheet: ISheet | undefined} => {
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<{
getSheet: () => ISheet
}> = (props) => {
const {scene, gl} = useThree((s) => ({scene: s.scene, gl: s.gl}))
const [sheet, setSheet] = useState<ISheet | undefined>(undefined)
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})
}, [scene, gl])
return <ctx.Provider value={{sheet}}>{props.children}</ctx.Provider>
}
export default SheetProvider

View file

@ -0,0 +1,224 @@
import type {Object3D} from 'three'
import {
BoxHelper,
CameraHelper,
DirectionalLightHelper,
PointLightHelper,
SpotLightHelper,
} from 'three'
import type {ReactElement, VFC} from 'react'
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'
import {useHelper, Sphere, Html} from '@react-three/drei'
import type {EditableType} from '../store'
import {useEditorStore} from '../store'
import shallow from 'zustand/shallow'
import {
BiSun,
BsCameraVideoFill,
BsFillCollectionFill,
GiCube,
GiLightBulb,
GiLightProjector,
} from 'react-icons/all'
import type {IconType} from 'react-icons'
import studio from '@theatre/studio'
import {useSelected} from './useSelected'
import {useVal} from '@theatre/react'
import {getEditorSheetObject} from './editorStuff'
export interface EditableProxyProps {
editableName: string
editableType: EditableType
object: Object3D
onChange?: () => void
}
const EditableProxy: VFC<EditableProxyProps> = ({
editableName: uniqueName,
editableType,
object,
}) => {
const editorObject = getEditorSheetObject()
const setSnapshotProxyObject = useEditorStore(
(state) => state.setSnapshotProxyObject,
shallow,
)
const selected = useSelected()
const showOverlayIcons =
useVal(editorObject?.props.viewport.showOverlayIcons) ?? false
useEffect(() => {
setSnapshotProxyObject(object, uniqueName)
return () => setSnapshotProxyObject(null, uniqueName)
}, [uniqueName, object, setSnapshotProxyObject])
useLayoutEffect(() => {
const originalVisibility = object.visible
if (object.userData.__visibleOnlyInEditor) {
object.visible = true
}
return () => {
// this has absolutely no effect, __visibleOnlyInEditor of the snapshot never changes, I'm just doing it because it looks right 🤷‍️
object.visible = originalVisibility
}
}, [object.userData.__visibleOnlyInEditor, object.visible])
// set up helper
let Helper:
| typeof SpotLightHelper
| typeof DirectionalLightHelper
| typeof PointLightHelper
| typeof BoxHelper
| typeof CameraHelper
switch (editableType) {
case 'spotLight':
Helper = SpotLightHelper
break
case 'directionalLight':
Helper = DirectionalLightHelper
break
case 'pointLight':
Helper = PointLightHelper
break
case 'perspectiveCamera':
case 'orthographicCamera':
Helper = CameraHelper
break
case 'group':
case 'mesh':
Helper = BoxHelper
}
let helperArgs: [string] | [number, string] | []
const size = 1
const color = 'darkblue'
switch (editableType) {
case 'directionalLight':
case 'pointLight':
helperArgs = [size, color]
break
case 'group':
case 'mesh':
case 'spotLight':
helperArgs = [color]
break
case 'perspectiveCamera':
case 'orthographicCamera':
helperArgs = []
}
let icon: ReactElement<IconType>
switch (editableType) {
case 'group':
icon = <BsFillCollectionFill />
break
case 'mesh':
icon = <GiCube />
break
case 'pointLight':
icon = <GiLightBulb />
break
case 'spotLight':
icon = <GiLightProjector />
break
case 'directionalLight':
icon = <BiSun />
break
case 'perspectiveCamera':
case 'orthographicCamera':
icon = <BsCameraVideoFill />
}
const objectRef = useRef(object)
useLayoutEffect(() => {
objectRef.current = object
}, [object])
const dimensionless = [
'spotLight',
'pointLight',
'directionalLight',
'perspectiveCamera',
'orthographicCamera',
]
const [hovered, setHovered] = useState(false)
useHelper(
objectRef,
selected === uniqueName || dimensionless.includes(editableType) || hovered
? Helper
: null,
...helperArgs,
)
return (
<>
<group
onClick={(e) => {
if (e.delta < 2) {
e.stopPropagation()
const theatreObject =
useEditorStore.getState().sheetObjects[uniqueName]
if (!theatreObject) {
console.log('no theatre object for', uniqueName)
} else {
studio.setSelection([theatreObject])
}
}
}}
onPointerOver={(e) => {
e.stopPropagation()
setHovered(true)
}}
onPointerOut={(e) => {
e.stopPropagation()
setHovered(false)
}}
>
<primitive object={object}>
{showOverlayIcons && (
<Html
center
className="pointer-events-none p-1 rounded bg-white bg-opacity-70 shadow text-gray-700"
>
{icon}
</Html>
)}
{dimensionless.includes(editableType) && (
<Sphere
args={[2, 4, 2]}
onClick={(e) => {
if (e.delta < 2) {
e.stopPropagation()
const theatreObject =
useEditorStore.getState().sheetObjects[uniqueName]
if (!theatreObject) {
console.log('no theatre object for', uniqueName)
} else {
studio.setSelection([theatreObject])
}
}
}}
userData={{helper: true}}
>
<meshBasicMaterial visible={false} />
</Sphere>
)}
</primitive>
</group>
</>
)
}
export default EditableProxy

View file

@ -0,0 +1,23 @@
import type {ComponentProps, ElementType} from 'react'
import React from 'react'
import {useEditorStore} from '../store'
import {createPortal} from '@react-three/fiber'
export type EditorHelperProps<T extends ElementType> = {
component: T
} & ComponentProps<T>
const EditorHelper = <T extends ElementType>({
component: Component,
...props
}: EditorHelperProps<T>) => {
if (process.env.NODE_ENV === 'development') {
const helpersRoot = useEditorStore((state) => state.helpersRoot)
return <>{createPortal(<Component {...props} />, helpersRoot)}</>
} else {
return null
}
}
export default EditorHelper

View file

@ -0,0 +1,268 @@
import type {VFC} from 'react'
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import {useEditorStore} from '../store'
import {createPortal} from '@react-three/fiber'
import EditableProxy from './EditableProxy'
import type {OrbitControls} from 'three-stdlib'
import TransformControls from './TransformControls'
import shallow from 'zustand/shallow'
import type {Material, Mesh, Object3D} from 'three'
import {MeshBasicMaterial, MeshPhongMaterial} from 'three'
import studio from '@theatre/studio'
import type {ISheetObject} from '@theatre/core'
import type {$FixMe} from '../types'
import {useSelected} from './useSelected'
import {useVal} from '@theatre/react'
import useInvalidate from './useInvalidate'
import {getEditorSheetObject} from './editorStuff'
export interface ProxyManagerProps {
orbitControlsRef: React.MutableRefObject<OrbitControls | null>
}
type IEditableProxy = {
portal: ReturnType<typeof createPortal>
object: Object3D
sheetObject: ISheetObject<$FixMe>
}
const ProxyManager: VFC<ProxyManagerProps> = ({orbitControlsRef}) => {
const isBeingEdited = useRef(false)
const editorObject = getEditorSheetObject()
const [sceneSnapshot, sheetObjects] = useEditorStore(
(state) => [state.sceneSnapshot, state.sheetObjects],
shallow,
)
const transformControlsMode =
useVal(editorObject?.props.transformControls.mode) ?? 'translate'
const transformControlsSpace =
useVal(editorObject?.props.transformControls.space) ?? 'world'
const viewportShading =
useVal(editorObject?.props.viewport.shading) ?? 'rendered'
const sceneProxy = useMemo(() => sceneSnapshot?.clone(), [sceneSnapshot])
const [editableProxies, setEditableProxies] = useState<
{
[name in string]?: IEditableProxy
}
>({})
const invalidate = useInvalidate()
// set up scene proxies
useLayoutEffect(() => {
if (!sceneProxy) {
return
}
const editableProxies: {[name: string]: IEditableProxy} = {}
sceneProxy.traverse((object) => {
if (object.userData.__editable) {
// there are duplicate uniqueNames in the scene, only display one instance in the editor
if (editableProxies[object.userData.__editableName]) {
object.parent!.remove(object)
} else {
const uniqueName = object.userData.__editableName
editableProxies[uniqueName] = {
portal: createPortal(
<EditableProxy
editableName={object.userData.__editableName}
editableType={object.userData.__editableType}
object={object}
/>,
object.parent!,
),
object: object,
sheetObject: sheetObjects[uniqueName]!,
}
}
}
})
setEditableProxies(editableProxies)
}, [orbitControlsRef, sceneProxy])
const selected = useSelected()
const editableProxyOfSelected = selected && editableProxies[selected]
// subscribe to external changes
useEffect(() => {
if (!editableProxyOfSelected) return
const object = editableProxyOfSelected.object
const sheetObject = editableProxyOfSelected.sheetObject
const setFromTheatre = (newValues: any) => {
object.position.set(
newValues.position.x,
newValues.position.y,
newValues.position.z,
)
object.rotation.set(
newValues.rotation.x,
newValues.rotation.y,
newValues.rotation.z,
)
object.scale.set(newValues.scale.x, newValues.scale.y, newValues.scale.z)
invalidate()
}
setFromTheatre(sheetObject.value)
const untap = sheetObject.onValuesChange(setFromTheatre)
return () => {
untap()
}
}, [editableProxyOfSelected, selected])
// set up viewport shading modes
const [renderMaterials, setRenderMaterials] = useState<{
[id: string]: Material | Material[]
}>({})
useLayoutEffect(() => {
if (!sceneProxy) {
return
}
const renderMaterials: {
[id: string]: Material | Material[]
} = {}
sceneProxy.traverse((object) => {
const mesh = object as Mesh
if (mesh.isMesh && !mesh.userData.helper) {
renderMaterials[mesh.id] = mesh.material
}
})
setRenderMaterials(renderMaterials)
return () => {
// @todo do we need this cleanup?
// Object.entries(renderMaterials).forEach(([id, material]) => {
// ;(sceneProxy.getObjectById(Number.parseInt(id)) as Mesh).material =
// material
// })
}
}, [sceneProxy])
useLayoutEffect(() => {
if (!sceneProxy) {
return
}
sceneProxy.traverse((object) => {
const mesh = object as Mesh
if (mesh.isMesh && !mesh.userData.helper) {
let material
switch (viewportShading) {
case 'wireframe':
mesh.material = new MeshBasicMaterial({
wireframe: true,
color: 'black',
})
break
case 'flat':
// it is possible that renderMaterials hasn't updated yet
if (!renderMaterials[mesh.id]) {
return
}
material = new MeshBasicMaterial()
if (renderMaterials[mesh.id].hasOwnProperty('color')) {
material.color = (renderMaterials[mesh.id] as any).color
}
if (renderMaterials[mesh.id].hasOwnProperty('map')) {
material.map = (renderMaterials[mesh.id] as any).map
}
if (renderMaterials[mesh.id].hasOwnProperty('vertexColors')) {
material.vertexColors = (
renderMaterials[mesh.id] as any
).vertexColors
}
mesh.material = material
break
case 'solid':
// it is possible that renderMaterials hasn't updated yet
if (!renderMaterials[mesh.id]) {
return
}
material = new MeshPhongMaterial()
if (renderMaterials[mesh.id].hasOwnProperty('color')) {
material.color = (renderMaterials[mesh.id] as any).color
}
if (renderMaterials[mesh.id].hasOwnProperty('map')) {
material.map = (renderMaterials[mesh.id] as any).map
}
if (renderMaterials[mesh.id].hasOwnProperty('vertexColors')) {
material.vertexColors = (
renderMaterials[mesh.id] as any
).vertexColors
}
mesh.material = material
break
case 'rendered':
mesh.material = renderMaterials[mesh.id]
}
}
})
}, [viewportShading, renderMaterials, sceneProxy])
if (!sceneProxy) {
return null
}
return (
<>
<primitive object={sceneProxy} />
{selected && editableProxyOfSelected && (
<TransformControls
mode={transformControlsMode}
space={transformControlsSpace}
orbitControlsRef={orbitControlsRef}
object={editableProxyOfSelected.object}
onObjectChange={() => {
const sheetObject = editableProxyOfSelected.sheetObject
const obj = editableProxyOfSelected.object
studio.transaction(({set}) => {
set(sheetObject.props, {
position: {
x: obj.position.x,
y: obj.position.y,
z: obj.position.z,
},
rotation: {
x: obj.rotation.x,
y: obj.rotation.y,
z: obj.rotation.z,
},
scale: {
x: obj.scale.x,
y: obj.scale.y,
z: obj.scale.z,
},
})
})
}}
onDraggingChange={(event) => (isBeingEdited.current = event.value)}
/>
)}
{Object.values(editableProxies).map(
(editableProxy) => editableProxy!.portal,
)}
</>
)
}
export default ProxyManager

View file

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

View file

@ -0,0 +1,188 @@
import {useCallback, useLayoutEffect} from 'react'
import React from 'react'
import {Canvas} from '@react-three/fiber'
import type {BaseSheetObjectType} from '../store'
import {allRegisteredObjects, useEditorStore} from '../store'
import shallow from 'zustand/shallow'
import root from 'react-shadow/styled-components'
import ProxyManager from './ProxyManager'
import studio, {ToolbarIconButton} from '@theatre/studio'
import {useVal} from '@theatre/react'
import styled, {createGlobalStyle, StyleSheetManager} from 'styled-components'
import {IoCameraReverseOutline} from 'react-icons/all'
import type {ISheet} from '@theatre/core'
import useSnapshotEditorCamera from './useSnapshotEditorCamera'
import {getEditorSheet, getEditorSheetObject} from './editorStuff'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
const GlobalStyle = createGlobalStyle`
:host {
contain: strict;
all: initial;
color: white;
font: 11px -apple-system, BlinkMacSystemFont, Segoe WPC, Segoe Editor,
HelveticaNeue-Light, Ubuntu, Droid Sans, sans-serif;
}
* {
padding: 0;
margin: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
list-style: none;
}
`
const EditorScene: React.FC<{snapshotEditorSheet: ISheet; paneId: string}> = ({
snapshotEditorSheet,
paneId,
}) => {
const [editorCamera, orbitControlsRef] = useSnapshotEditorCamera(
snapshotEditorSheet,
paneId,
)
const editorObject = getEditorSheetObject()
const helpersRoot = useEditorStore((state) => state.helpersRoot, shallow)
const showGrid = useVal(editorObject?.props.viewport.showGrid) ?? true
const showAxes = useVal(editorObject?.props.viewport.showAxes) ?? true
return (
<>
{showGrid && <gridHelper args={[20, 20, '#6e6e6e', '#4a4b4b']} />}
{showAxes && <axesHelper args={[500]} />}
{editorCamera}
<primitive object={helpersRoot}></primitive>
<ProxyManager orbitControlsRef={orbitControlsRef} />
<color attach="background" args={[0.24, 0.24, 0.24]} />
</>
)
}
const Wrapper = styled.div`
tab-size: 4;
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
margin: 0;
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
`
const CanvasWrapper = styled.div`
display: relative;
z-index: 0;
height: 100%;
`
const Overlay = styled.div`
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
`
const Tools = styled.div`
position: absolute;
left: 8px;
top: 6px;
pointer-events: auto;
`
const SnapshotEditor: React.FC<{paneId: string}> = (props) => {
const snapshotEditorSheet = getEditorSheet()
const paneId = props.paneId
const editorObject = getEditorSheetObject()
const [sceneSnapshot, createSnapshot] = useEditorStore(
(state) => [state.sceneSnapshot, state.createSnapshot],
shallow,
)
const editorOpen = true
useLayoutEffect(() => {
let timeout: NodeJS.Timeout | undefined
if (editorOpen) {
// a hack to make sure all the scene's props are
// applied before we take a snapshot
timeout = setTimeout(createSnapshot, 100)
}
return () => {
if (timeout !== undefined) {
clearTimeout(timeout)
}
}
}, [editorOpen])
const onPointerMissed = useCallback(() => {
// This callback runs when the user clicks in an empty space inside a SnapshotEditor.
// We'll try to set the current selection to the nearest sheet _if_ at least one object
// belonging to R3F was selected previously.
const obj: undefined | BaseSheetObjectType = studio.selection.find(
(sheetOrObject) =>
allRegisteredObjects.has(sheetOrObject as $IntentionalAny),
) as $IntentionalAny
if (obj) {
studio.setSelection([obj.sheet])
}
}, [])
if (!editorObject) return <></>
return (
<root.div>
<StyleSheetManager disableVendorPrefixes>
<>
<GlobalStyle />
<Wrapper>
<Overlay>
<Tools>
<ToolbarIconButton
title="Refresh Snapshot"
onClick={createSnapshot}
>
<IoCameraReverseOutline />
</ToolbarIconButton>
</Tools>
</Overlay>
{sceneSnapshot ? (
<>
<CanvasWrapper>
<Canvas
// @ts-ignore
colorManagement
onCreated={({gl}) => {
gl.setClearColor('white')
}}
shadowMap
dpr={[1, 2]}
fog={'red'}
frameloop="demand"
onPointerMissed={onPointerMissed}
>
<EditorScene
snapshotEditorSheet={snapshotEditorSheet}
paneId={paneId}
/>
</Canvas>
</CanvasWrapper>
</>
) : null}
</Wrapper>
{/* </PortalContext.Provider> */}
</>
</StyleSheetManager>
</root.div>
)
}
export default SnapshotEditor

View file

@ -0,0 +1,118 @@
import type {VFC} from 'react'
import React from 'react'
import {IoCameraOutline} from 'react-icons/all'
import studio, {ToolbarIconButton} from '@theatre/studio'
import {useVal} from '@theatre/react'
import TransformControlsModeSelect from './TransformControlsModeSelect'
import ViewportShadingSelect from './ViewportShadingSelect'
import TransformControlsSpaceSelect from './TransformControlsSpaceSelect'
import {getEditorSheetObject} from '../editorStuff'
const Toolbar: VFC = () => {
const editorObject = getEditorSheetObject()
const transformControlsMode =
useVal(editorObject?.props.transformControls.mode) ?? 'translate'
const transformControlsSpace =
useVal(editorObject?.props.transformControls.space) ?? 'world'
const viewportShading =
useVal(editorObject?.props.viewport.shading) ?? 'rendered'
if (!editorObject) return <></>
return (
<>
<ToolbarIconButton
onClick={() => {
studio.createPane('snapshot')
}}
title="Create snapshot"
>
<IoCameraOutline />
</ToolbarIconButton>
<TransformControlsModeSelect
value={transformControlsMode}
onChange={(value) =>
studio.transaction(({set}) =>
set(editorObject!.props.transformControls.mode, value),
)
}
/>
<TransformControlsSpaceSelect
value={transformControlsSpace}
onChange={(space) => {
studio.transaction(({set}) => {
set(editorObject.props.transformControls.space, space)
})
}}
/>
<ViewportShadingSelect
value={viewportShading}
onChange={(shading) => {
studio.transaction(({set}) => {
set(editorObject.props.viewport.shading, shading)
})
}}
/>
{/* <ToolbarIconButton
label="Focus on selected"
icon={<RiFocus3Line />}
onClick={() => {
const orbitControls =
useEditorStore.getState().orbitControlsRef?.current
const selected = getSelected()
let focusObject
if (selected) {
focusObject =
useEditorStore.getState().editablesSnapshot![selected].proxyObject
}
if (orbitControls && focusObject) {
focusObject.getWorldPosition(
// @ts-ignore TODO
orbitControls.target as Vector3,
)
}
}}
/> */}
{/* <ToolbarIconButton
label="Align object to view"
icon={<GiPocketBow />}
onClick={() => {
const camera = (
useEditorStore.getState().orbitControlsRef?.current as $FixMe
)?.object
const selected = getSelected()
let proxyObject
if (selected) {
proxyObject =
useEditorStore.getState().editablesSnapshot![selected].proxyObject
if (proxyObject && camera) {
const direction = new Vector3()
const position = camera.position.clone()
camera.getWorldDirection(direction)
proxyObject.position.set(0, 0, 0)
proxyObject.lookAt(direction)
proxyObject.parent!.worldToLocal(position)
proxyObject.position.copy(position)
proxyObject.updateMatrix()
}
}
}}
/> */}
</>
)
}
export default Toolbar

View file

@ -0,0 +1,41 @@
import {ToolbarSwitchSelect} from '@theatre/studio'
import type {VFC} from 'react'
import React from 'react'
import {GiClockwiseRotation, GiMove, GiResize} from 'react-icons/all'
import type {TransformControlsMode} from '../../store'
export interface TransformControlsModeSelectProps {
value: TransformControlsMode
onChange: (value: string) => void
}
const TransformControlsModeSelect: VFC<TransformControlsModeSelectProps> = ({
value,
onChange,
}) => {
return (
<ToolbarSwitchSelect
value={value}
onChange={onChange}
options={[
{
value: 'translate',
label: 'Tool: Translate',
icon: <GiMove />,
},
{
value: 'rotate',
label: 'Tool: Rotate',
icon: <GiClockwiseRotation />,
},
{
value: 'scale',
label: 'Tool: Scale',
icon: <GiResize />,
},
]}
/>
)
}
export default TransformControlsModeSelect

View file

@ -0,0 +1,34 @@
import type {VFC} from 'react'
import React from 'react'
import type {TransformControlsSpace} from '../../store'
import {BiCube, BiGlobe} from 'react-icons/all'
import {ToolbarSwitchSelect} from '@theatre/studio'
export interface TransformControlsSpaceSelectProps {
value: TransformControlsSpace
onChange: (value: TransformControlsSpace) => void
}
const TransformControlsSpaceSelect: VFC<TransformControlsSpaceSelectProps> = ({
value,
onChange,
}) => (
<ToolbarSwitchSelect
value={value}
onChange={onChange}
options={[
{
value: 'world',
label: 'Space: World',
icon: <BiGlobe />,
},
{
value: 'local',
label: 'Space: Local',
icon: <BiCube />,
},
]}
/>
)
export default TransformControlsSpaceSelect

View file

@ -0,0 +1,44 @@
import type {VFC} from 'react'
import React from 'react'
import type {ViewportShading} from '../../store'
import {FaCube, GiCube, GiIceCube, BiCube} from 'react-icons/all'
import {ToolbarSwitchSelect} from '@theatre/studio'
export interface ViewportShadingSelectProps {
value: ViewportShading
onChange: (value: ViewportShading) => void
}
const ViewportShadingSelect: VFC<ViewportShadingSelectProps> = ({
value,
onChange,
}) => (
<ToolbarSwitchSelect
value={value}
onChange={onChange}
options={[
{
value: 'wireframe',
label: 'Display: Wireframe',
icon: <BiCube />,
},
{
value: 'flat',
label: 'Display: Flat',
icon: <GiCube />,
},
{
value: 'solid',
label: 'Display: Solid',
icon: <FaCube />,
},
{
value: 'rendered',
label: 'Display: Rendered',
icon: <GiIceCube />,
},
]}
/>
)
export default ViewportShadingSelect

View file

@ -0,0 +1,97 @@
import type {Object3D, Event} from 'three'
import React, {forwardRef, useLayoutEffect, useEffect, useMemo} from 'react'
import type {ReactThreeFiber, Overwrite} from '@react-three/fiber'
import {useThree} from '@react-three/fiber'
import {TransformControls as TransformControlsImpl} from 'three/examples/jsm/controls/TransformControls'
import type {OrbitControls} from 'three-stdlib'
type R3fTransformControls = Overwrite<
ReactThreeFiber.Object3DNode<
TransformControlsImpl,
typeof TransformControlsImpl
>,
{target?: ReactThreeFiber.Vector3}
>
export interface TransformControlsProps extends R3fTransformControls {
object: Object3D
orbitControlsRef?: React.MutableRefObject<OrbitControls | null>
onObjectChange?: (event: Event) => void
onDraggingChange?: (event: Event) => void
}
const TransformControls = forwardRef(
(
{
children,
object,
orbitControlsRef,
onObjectChange,
onDraggingChange,
...props
}: TransformControlsProps,
ref,
) => {
const {camera, gl, invalidate} = useThree()
const controls = useMemo(
() => new TransformControlsImpl(camera, gl.domElement),
[camera, gl.domElement],
)
useLayoutEffect(() => {
controls.attach(object)
return () => void controls.detach()
}, [object, controls])
useEffect(() => {
controls?.addEventListener?.('change', invalidate)
return () => controls?.removeEventListener?.('change', invalidate)
}, [controls, invalidate])
useEffect(() => {
const callback = (event: Event) => {
if (orbitControlsRef && orbitControlsRef.current) {
// @ts-ignore TODO
orbitControlsRef.current.enabled = !event.value
}
}
if (controls) {
controls.addEventListener!('dragging-changed', callback)
}
return () => {
controls.removeEventListener!('dragging-changed', callback)
}
}, [controls, orbitControlsRef])
useEffect(() => {
if (onObjectChange) {
controls.addEventListener('objectChange', onObjectChange)
}
return () => {
if (onObjectChange) {
controls.removeEventListener('objectChange', onObjectChange)
}
}
}, [onObjectChange, controls])
useEffect(() => {
if (onDraggingChange) {
controls.addEventListener('dragging-changed', onDraggingChange)
}
return () => {
if (onDraggingChange) {
controls.removeEventListener('dragging-changed', onDraggingChange)
}
}
}, [controls, onDraggingChange])
return <primitive dispose={null} object={controls} ref={ref} {...props} />
},
)
export default TransformControls

View file

@ -0,0 +1,205 @@
import type {ComponentProps, ComponentType, RefAttributes} from 'react'
import React, {forwardRef, useLayoutEffect, useRef, useState} from 'react'
import type {
DirectionalLight,
Group,
Mesh,
OrthographicCamera,
PerspectiveCamera,
PointLight,
SpotLight,
} from 'three'
import {Vector3} from 'three'
import type {EditableType} from '../store'
import {allRegisteredObjects} from '../store'
import {baseSheetObjectType} from '../store'
import {useEditorStore} from '../store'
import mergeRefs from 'react-merge-refs'
import type {$FixMe} from '@theatre/shared/utils/types'
import type {ISheetObject} from '@theatre/core'
import useInvalidate from './useInvalidate'
import {useCurrentSheet} from '../SheetProvider'
interface Elements {
group: Group
mesh: Mesh
spotLight: SpotLight
directionalLight: DirectionalLight
perspectiveCamera: PerspectiveCamera
orthographicCamera: OrthographicCamera
pointLight: PointLight
}
const editable = <
T extends ComponentType<any> | EditableType | 'primitive',
U extends T extends EditableType ? T : EditableType,
>(
Component: T,
type: T extends 'primitive' ? null : U,
) => {
type Props = Omit<ComponentProps<T>, 'visible'> & {
uniqueName: string
visible?: boolean | 'editor'
} & (T extends 'primitive'
? {
editableType: U
}
: {}) &
RefAttributes<Elements[U]>
return forwardRef(
({uniqueName, visible, editableType, ...props}: Props, ref) => {
const objectRef = useRef<Elements[U]>()
const sheet = useCurrentSheet()
const [sheetObject, setSheetObject] = useState<
undefined | ISheetObject<$FixMe>
>(undefined)
const invalidate = useInvalidate()
useLayoutEffect(() => {
if (!sheet) return
const sheetObject = sheet.object(uniqueName, baseSheetObjectType)
allRegisteredObjects.add(sheetObject)
setSheetObject(sheetObject)
useEditorStore
.getState()
.setSheetObject(uniqueName, sheetObject as $FixMe)
}, [sheet, uniqueName])
const transformDeps: string[] = []
;['x', 'y', 'z'].forEach((axis) => {
transformDeps.push(
props[`position-${axis}` as any],
props[`rotation-${axis}` as any],
props[`scale-${axis}` as any],
)
})
// store initial values of props
useLayoutEffect(() => {
if (!sheetObject) return
// calculate initial properties before adding the editable
const position: Vector3 = props.position
? Array.isArray(props.position)
? new Vector3(...(props.position as any))
: props.position
: new Vector3()
const rotation: Vector3 = props.rotation
? Array.isArray(props.rotation)
? new Vector3(...(props.rotation as any))
: props.rotation
: new Vector3()
const scale: Vector3 = props.scale
? Array.isArray(props.scale)
? new Vector3(...(props.scale as any))
: props.scale
: new Vector3(1, 1, 1)
;['x', 'y', 'z'].forEach((axis, index) => {
if (props[`position-${axis}` as any])
position.setComponent(index, props[`position-${axis}` as any])
if (props[`rotation-${axis}` as any])
rotation.setComponent(index, props[`rotation-${axis}` as any])
if (props[`scale-${axis}` as any])
scale.setComponent(index, props[`scale-${axis}` as any])
})
const initial = {
position: {
x: position.x,
y: position.y,
z: position.z,
},
rotation: {
x: rotation.x,
y: rotation.y,
z: rotation.z,
},
scale: {
x: scale.x,
y: scale.y,
z: scale.z,
},
}
sheetObject!.initialValue = initial
}, [
uniqueName,
sheetObject,
props.position,
props.rotation,
props.scale,
...transformDeps,
])
// subscribe to prop changes from theatre
useLayoutEffect(() => {
if (!sheetObject) return
const object = objectRef.current!
const setFromTheatre = (newValues: any) => {
object.position.set(
newValues.position.x,
newValues.position.y,
newValues.position.z,
)
object.rotation.set(
newValues.rotation.x,
newValues.rotation.y,
newValues.rotation.z,
)
object.scale.set(
newValues.scale.x,
newValues.scale.y,
newValues.scale.z,
)
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,
__editableName: uniqueName,
__editableType: type ?? editableType,
__visibleOnlyInEditor: visible === 'editor',
}}
/>
)
},
)
}
const createEditable = <T extends EditableType>(type: T) =>
// @ts-ignore
editable(type, type)
editable.primitive = editable('primitive', null)
editable.group = createEditable('group')
editable.mesh = createEditable('mesh')
editable.spotLight = createEditable('spotLight')
editable.directionalLight = createEditable('directionalLight')
editable.pointLight = createEditable('pointLight')
editable.perspectiveCamera = createEditable('perspectiveCamera')
editable.orthographicCamera = createEditable('orthographicCamera')
export default editable

View file

@ -0,0 +1,67 @@
import type {ISheet, ISheetObject} from '@theatre/core'
import {types} from '@theatre/core'
import studio from '@theatre/studio'
let sheet: ISheet | undefined = undefined
let sheetObject: ISheetObject<typeof editorSheetObjectConfig> | undefined =
undefined
const editorSheetObjectConfig = types.compound({
viewport: types.compound(
{
showAxes: types.boolean(true, {label: 'Axes'}),
showGrid: types.boolean(true, {label: 'Grid'}),
showOverlayIcons: types.boolean(false, {label: 'Overlay Icons'}),
shading: types.stringLiteral(
'rendered',
{
flat: 'Flat',
rendered: 'Rendered',
solid: 'Solid',
wireframe: 'Wireframe',
},
{as: 'menu', label: 'Shading'},
),
},
{label: 'Viewport Config'},
),
transformControls: types.compound(
{
mode: types.stringLiteral(
'translate',
{
translate: 'Translate',
rotate: 'Rotate',
scale: 'Scale',
},
{as: 'switch', label: 'Mode'},
),
space: types.stringLiteral(
'world',
{
local: 'Local',
world: 'World',
},
{as: 'switch', label: 'Space'},
),
},
{label: 'Transform Controls'},
),
})
export function getEditorSheet(): ISheet {
if (!sheet) {
sheet = studio.getStudioProject().sheet('R3F UI')
}
return sheet
}
export function getEditorSheetObject(): ISheetObject<
typeof editorSheetObjectConfig
> | null {
if (!sheetObject) {
sheetObject =
getEditorSheet().object('Editor', editorSheetObjectConfig) || null
}
return sheetObject
}

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,37 @@
import type {MutableRefObject} from 'react'
import {useMemo, useState} from 'react'
/**
* Combines useRef() and useState().
*
* @example
* ```typescript
* const [ref, val] = useRefAndState<HTMLDivElement | null>(null)
*
* useEffect(() => {
* val.addEventListener(...)
* }, [val])
*
* return <div ref={ref}></div>
* ```
*/
export default function useRefAndState<T>(
initialValue: T,
): [ref: MutableRefObject<T>, state: T] {
const ref = useMemo(() => {
let current = initialValue
return {
get current() {
return current
},
set current(v: T) {
current = v
setState(v)
},
}
}, [])
const [state, setState] = useState<T>(() => initialValue)
return [ref, state]
}

View file

@ -0,0 +1,11 @@
import {useCallback} from 'react'
import {useEditorStore} from '../store'
/**
* Returns a function that can be called to refresh the snapshot in the snapshot editor.
*/
export default function useRefreshSnapshot() {
return useCallback(() => {
useEditorStore.getState().createSnapshot()
}, [])
}

View file

@ -0,0 +1,41 @@
import {useLayoutEffect, useRef, useState} from 'react'
import {allRegisteredObjects} from '../store'
import studio from '@theatre/studio'
import type {ISheetObject} from '@theatre/core'
export function useSelected(): undefined | string {
const [state, set] = useState<string | undefined>(undefined)
const stateRef = useRef(state)
stateRef.current = state
useLayoutEffect(() => {
const setFromStudio = (selection: typeof studio.selection) => {
const item = selection.find(
(s): s is ISheetObject =>
s.type === 'Theatre_SheetObject_PublicAPI' &&
allRegisteredObjects.has(s),
)
if (!item) {
set(undefined)
} else {
set(item.address.objectKey)
}
}
setFromStudio(studio.selection)
return studio.onSelectionChange(setFromStudio)
}, [])
return state
}
export function getSelected(): undefined | string {
const item = studio.selection.find(
(s): s is ISheetObject =>
s.type === 'Theatre_SheetObject_PublicAPI' && allRegisteredObjects.has(s),
)
if (!item) {
return undefined
} else {
return item.address.objectKey
}
}

View file

@ -0,0 +1,168 @@
import {OrbitControls, PerspectiveCamera} from '@react-three/drei'
import type {OrbitControls as OrbitControlsImpl} from 'three-stdlib'
import type {MutableRefObject} from 'react'
import {useLayoutEffect, useRef} from 'react'
import React from 'react'
import useRefAndState from './useRefAndState'
import studio from '@theatre/studio'
import type {PerspectiveCamera as PerspectiveCameraImpl} from 'three'
import type {ISheet} from '@theatre/core'
import {types} from '@theatre/core'
import type {ISheetObject} from '@theatre/core'
import {useThree} from '@react-three/fiber'
const camConf = types.compound({
transform: types.compound({
position: types.compound({
x: types.number(10),
y: types.number(10),
z: types.number(0),
}),
target: types.compound({
x: types.number(0),
y: types.number(0),
z: types.number(0),
}),
}),
lens: types.compound({
zoom: types.number(1, {range: [0.0001, 10]}),
fov: types.number(50, {range: [1, 1000]}),
near: types.number(0.1, {range: [0, Infinity]}),
far: types.number(2000, {range: [0, Infinity]}),
focus: types.number(10, {range: [0, Infinity]}),
filmGauge: types.number(35, {range: [0, Infinity]}),
filmOffset: types.number(0, {range: [0, Infinity]}),
}),
})
export default function useSnapshotEditorCamera(
snapshotEditorSheet: ISheet,
paneId: string,
): [
node: React.ReactNode,
orbitControlsRef: MutableRefObject<OrbitControlsImpl | null>,
] {
// OrbitControls and Cam might change later on, so we use useRefAndState()
// instead of useRef() to catch those changes.
const [orbitControlsRef, orbitControls] =
useRefAndState<OrbitControlsImpl | null>(null)
const [camRef, cam] = useRefAndState<PerspectiveCameraImpl | undefined>(
undefined,
)
const objRef = useRef<ISheetObject<typeof camConf> | null>(null)
useLayoutEffect(() => {
if (!objRef.current) {
objRef.current = snapshotEditorSheet.object(
`Editor Camera ${paneId}`,
camConf,
)
}
}, [paneId])
usePassValuesFromTheatreToCamera(cam, orbitControls, objRef)
usePassValuesFromOrbitControlsToTheatre(cam, orbitControls, objRef)
const node = (
<>
<PerspectiveCamera makeDefault ref={camRef} position={[0, 102, 0]} />
<OrbitControls
makeDefault
ref={orbitControlsRef}
camera={cam}
enableDamping={false}
/>
</>
)
return [node, orbitControlsRef]
}
function usePassValuesFromOrbitControlsToTheatre(
cam: PerspectiveCameraImpl | undefined,
orbitControls: OrbitControlsImpl | null,
objRef: MutableRefObject<ISheetObject<typeof camConf> | null>,
) {
useLayoutEffect(() => {
if (!cam || orbitControls == null) return
let currentScrub: undefined | ReturnType<typeof studio['debouncedScrub']>
let started = false
const onStart = () => {
started = true
if (!currentScrub) {
currentScrub = studio.debouncedScrub(600)
}
}
const onEnd = () => {
started = false
}
const onChange = () => {
if (!started) return
const p = cam!.position
const position = {x: p.x, y: p.y, z: p.z}
const t = orbitControls!.target
const target = {x: t.x, y: t.y, z: t.z}
const transform = {
position,
target,
}
currentScrub!.capture(({set}) => {
set(objRef.current!.props.transform, transform)
})
}
orbitControls.addEventListener('start', onStart)
orbitControls.addEventListener('end', onEnd)
orbitControls.addEventListener('change', onChange)
return () => {
orbitControls.removeEventListener('start', onStart)
orbitControls.removeEventListener('end', onEnd)
orbitControls.removeEventListener('change', onChange)
}
}, [cam, orbitControls])
}
function usePassValuesFromTheatreToCamera(
cam: PerspectiveCameraImpl | undefined,
orbitControls: OrbitControlsImpl | null,
objRef: MutableRefObject<ISheetObject<typeof camConf> | null>,
) {
const invalidate = useThree(({invalidate}) => invalidate)
useLayoutEffect(() => {
if (!cam || orbitControls === null) return
const obj = objRef.current!
const setFromTheatre = (props: typeof camConf['valueType']): void => {
const {position, target} = props.transform
cam.zoom = props.lens.zoom
cam.fov = props.lens.fov
cam.near = props.lens.near
cam.far = props.lens.far
cam.focus = props.lens.focus
cam.filmGauge = props.lens.filmGauge
cam.filmOffset = props.lens.filmOffset
cam.position.set(position.x, position.y, position.z)
cam.updateProjectionMatrix()
orbitControls.target.set(target.x, target.y, target.z)
orbitControls.update()
invalidate()
}
const unsub = obj.onValuesChange(setFromTheatre)
setFromTheatre(obj.value)
return unsub
}, [cam, orbitControls, objRef, invalidate])
}

View file

@ -0,0 +1,18 @@
import SnapshotEditor from './components/SnapshotEditor'
import type {IExtension} from '@theatre/studio'
import Toolbar from './components/Toolbar/Toolbar'
const r3fExtension: IExtension = {
id: '@theatre/r3f',
globalToolbar: {
component: Toolbar,
},
panes: [
{
class: 'snapshot',
component: SnapshotEditor,
},
],
}
export default r3fExtension

3
packages/r3f/src/globals.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare module '*.txt' {
export default string
}

View file

@ -0,0 +1,8 @@
export {default as extension} from './extension'
export {default as EditorHelper} from './components/EditorHelper'
export type {EditorHelperProps} from './components/EditorHelper'
export {default as editable} from './components/editable'
export type {EditableState, BindFunction} from './store'
export {default as SheetProvider, useCurrentSheet} from './SheetProvider'
export {default as useRefreshSnapshot} from './components/useRefreshSnapshot'
export {default as RefreshSnapshot} from './components/RefreshSnapshot'

277
packages/r3f/src/store.ts Normal file
View file

@ -0,0 +1,277 @@
import type {StateCreator} from 'zustand'
import create from 'zustand'
import type {Object3D, Scene, WebGLRenderer} from 'three'
import {Group} from 'three'
import type {ISheetObject} from '@theatre/core'
import {types} from '@theatre/core'
export type EditableType =
| 'group'
| 'mesh'
| 'spotLight'
| 'directionalLight'
| 'pointLight'
| 'perspectiveCamera'
| 'orthographicCamera'
export type TransformControlsMode = 'translate' | 'rotate' | 'scale'
export type TransformControlsSpace = 'world' | 'local'
export type ViewportShading = 'wireframe' | 'flat' | 'solid' | 'rendered'
const positionComp = types.number(1, {nudgeMultiplier: 0.1})
const rotationComp = types.number(1, {nudgeMultiplier: 0.02})
const scaleComp = types.number(1, {nudgeMultiplier: 0.1})
export const baseSheetObjectType = types.compound({
position: types.compound({
x: positionComp,
y: positionComp,
z: positionComp,
}),
rotation: types.compound({
x: rotationComp,
y: rotationComp,
z: rotationComp,
}),
scale: types.compound({
x: scaleComp,
y: scaleComp,
z: scaleComp,
}),
})
export type BaseSheetObjectType = ISheetObject<typeof baseSheetObjectType>
export const allRegisteredObjects = new WeakSet<BaseSheetObjectType>()
export interface AbstractEditable<T extends EditableType> {
type: T
role: 'active' | 'removed'
sheetObject?: ISheetObject<any>
}
// all these identical types are to prepare for a future in which different object types have different properties
export interface EditableGroup extends AbstractEditable<'group'> {
sheetObject?: BaseSheetObjectType
}
export interface EditableMesh extends AbstractEditable<'mesh'> {
sheetObject?: BaseSheetObjectType
}
export interface EditableSpotLight extends AbstractEditable<'spotLight'> {
sheetObject?: BaseSheetObjectType
}
export interface EditableDirectionalLight
extends AbstractEditable<'directionalLight'> {
sheetObject?: BaseSheetObjectType
}
export interface EditablePointLight extends AbstractEditable<'pointLight'> {
sheetObject?: BaseSheetObjectType
}
export interface EditablePerspectiveCamera
extends AbstractEditable<'perspectiveCamera'> {
sheetObject?: BaseSheetObjectType
}
export interface EditableOrthographicCamera
extends AbstractEditable<'orthographicCamera'> {
sheetObject?: BaseSheetObjectType
}
export type Editable =
| EditableGroup
| EditableMesh
| EditableSpotLight
| EditableDirectionalLight
| EditablePointLight
| EditablePerspectiveCamera
| EditableOrthographicCamera
export type EditableSnapshot<T extends Editable = Editable> = {
proxyObject?: Object3D | null
} & T
export interface AbstractSerializedEditable<T extends EditableType> {
type: T
}
export interface SerializedEditableGroup
extends AbstractSerializedEditable<'group'> {}
export interface SerializedEditableMesh
extends AbstractSerializedEditable<'mesh'> {}
export interface SerializedEditableSpotLight
extends AbstractSerializedEditable<'spotLight'> {}
export interface SerializedEditableDirectionalLight
extends AbstractSerializedEditable<'directionalLight'> {}
export interface SerializedEditablePointLight
extends AbstractSerializedEditable<'pointLight'> {}
export interface SerializedEditablePerspectiveCamera
extends AbstractSerializedEditable<'perspectiveCamera'> {}
export interface SerializedEditableOrthographicCamera
extends AbstractSerializedEditable<'orthographicCamera'> {}
export type SerializedEditable =
| SerializedEditableGroup
| SerializedEditableMesh
| SerializedEditableSpotLight
| SerializedEditableDirectionalLight
| SerializedEditablePointLight
| SerializedEditablePerspectiveCamera
| SerializedEditableOrthographicCamera
export interface EditableState {
editables: Record<string, SerializedEditable>
}
export type EditorStore = {
sheetObjects: {[uniqueName in string]?: BaseSheetObjectType}
scene: Scene | null
gl: WebGLRenderer | null
allowImplicitInstancing: boolean
helpersRoot: Group
editables: Record<string, Editable>
// 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,
allowImplicitInstancing: boolean,
) => void
addEditable: <T extends EditableType>(type: T, uniqueName: string) => void
removeEditable: (uniqueName: string) => void
createSnapshot: () => void
setSheetObject: (uniqueName: string, sheetObject: BaseSheetObjectType) => void
setSnapshotProxyObject: (
proxyObject: Object3D | null,
uniqueName: string,
) => void
}
const config: StateCreator<EditorStore> = (set, get) => {
return {
sheet: null,
editorObject: null,
sheetObjects: {},
scene: null,
gl: null,
allowImplicitInstancing: false,
helpersRoot: new Group(),
editables: {},
canvasName: 'default',
sceneSnapshot: null,
editablesSnapshot: null,
initialEditorCamera: {},
init: (scene, gl, allowImplicitInstancing) => {
set({
scene,
gl,
allowImplicitInstancing,
})
},
addEditable: (type, uniqueName) =>
set((state) => {
if (state.editables[uniqueName]) {
if (
state.editables[uniqueName].type !== type &&
process.env.NODE_ENV === 'development'
) {
console.error(`Warning: There is a mismatch between the serialized type of ${uniqueName} and the one set when adding it to the scene.
Serialized: ${state.editables[uniqueName].type}.
Current: ${type}.
This might have happened either because you changed the type of an object, in which case a re-export will solve the issue, or because you re-used the uniqueName for an object of a different type, which is an error.`)
}
if (
state.editables[uniqueName].role === 'active' &&
!state.allowImplicitInstancing
) {
throw Error(
`Scene already has an editable object named ${uniqueName}.
If this is intentional, please set the allowImplicitInstancing prop of EditableManager to true.`,
)
} else {
}
}
return {
editables: {
...state.editables,
[uniqueName]: {
type: type as EditableType,
role: 'active',
},
},
}
}),
removeEditable: (name) =>
set((state) => {
const {[name]: removed, ...rest} = state.editables
return {
editables: {
...rest,
[name]: {...removed, role: 'removed'},
},
}
}),
setSheetObject: (uniqueName, sheetObject) => {
set((state) => ({
sheetObjects: {
...state.sheetObjects,
[uniqueName]: sheetObject,
},
}))
},
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 useEditorStore = create<EditorStore>(config)
export type BindFunction = (options: {
allowImplicitInstancing?: boolean
gl: WebGLRenderer
scene: Scene
}) => void
export const bindToCanvas: BindFunction = ({
allowImplicitInstancing = false,
gl,
scene,
}) => {
const init = useEditorStore.getState().init
init(scene, gl, allowImplicitInstancing)
}

View file

@ -0,0 +1,2 @@
export type $FixMe = any
export type $IntentionalAny = any

View file

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"lib": ["ESNext", "DOM"],
"rootDir": "src",
"types": ["jest", "node"],
"emitDeclarationOnly": true,
"composite": true
},
"references": [{"path": "../../theatre"}],
"include": ["./src/**/*"]
}