Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented GizmoHelper, ViewportGizmo and ViewcubeGizmo #303

Merged
merged 12 commits into from
Feb 27, 2021
53 changes: 53 additions & 0 deletions .storybook/stories/BlenderViewportGizmo.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { color, number, select, withKnobs } from '@storybook/addon-knobs'
import * as React from 'react'
import { extend } from 'react-three-fiber'
import { Vector3 } from 'three'
import { BlenderViewportGizmo, GizmoHelper, OrbitControls, PerspectiveCamera, useGLTF } from '../../src'
import { Setup } from '../Setup'

extend({ OrbitControls })
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved

export default {
title: 'Gizmos/BlenderViewportGizmo',
component: BlenderViewportGizmo,
decorators: [
(storyFn) => (
<Setup controls={false} cameraPosition={new Vector3(0, 0, 10)}>
{storyFn()}
</Setup>
),
withKnobs,
],
}

const BlenderViewportGizmoStory = () => {
const controlsRef = React.useRef<OrbitControls>()
const { scene } = useGLTF('LittlestTokyo.glb')

return (
<>
<PerspectiveCamera makeDefault position={[0, 0, 10]} />
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
<React.Suspense fallback={null}>
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
<primitive object={scene} scale={[0.01, 0.01, 0.01]} />
</React.Suspense>
<GizmoHelper
alignment={select('alignment', ['top-left', 'top-right', 'bottom-right', 'bottom-left'], 'bottom-right')}
margin={[number('marginX', 80), number('marginY', 80)]}
onTarget={() => controlsRef?.current?.target as Vector3}
onUpdate={() => controlsRef.current?.update!()}
>
<BlenderViewportGizmo
axisColors={[color('colorX', 'red'), color('colorY', 'green'), color('colorZ', 'blue')]}
labelColor={color('labelColor', 'black')}
/>
</GizmoHelper>

<OrbitControls ref={controlsRef} />
</>
)
}

export const BlenderViewportGizmoStorySt = () => <BlenderViewportGizmoStory />
BlenderViewportGizmoStorySt.story = {
name: 'Default',
}
50 changes: 50 additions & 0 deletions .storybook/stories/ViewCubeGizmo.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { number, select, withKnobs } from '@storybook/addon-knobs'
import * as React from 'react'
import { extend } from 'react-three-fiber'
import { Vector3 } from 'three'
import { GizmoHelper, OrbitControls, PerspectiveCamera, useGLTF, ViewCubeGizmo } from '../../src'
import { Setup } from '../Setup'

extend({ OrbitControls })

export default {
title: 'Gizmos/ViewCubeGizmo',
component: ViewCubeGizmo,
decorators: [
(storyFn) => (
<Setup controls={false} cameraPosition={new Vector3(0, 0, 10)}>
{storyFn()}
</Setup>
),
withKnobs,
],
}

const ViewCubeGizmoStory = () => {
const controlsRef = React.useRef<OrbitControls>()
const { scene } = useGLTF('LittlestTokyo.glb')

return (
<>
<PerspectiveCamera makeDefault position={[0, 0, 10]} />
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
<React.Suspense fallback={null}>
<primitive object={scene} scale={[0.01, 0.01, 0.01]} />
</React.Suspense>
<GizmoHelper
alignment={select('alignment', ['top-left', 'top-right', 'bottom-right', 'bottom-left'], 'bottom-right')}
margin={[number('marginX', 80), number('marginY', 80)]}
onTarget={() => controlsRef?.current?.target as Vector3}
onUpdate={() => controlsRef.current?.update!()}
>
<ViewCubeGizmo />
</GizmoHelper>

<OrbitControls ref={controlsRef} />
</>
)
}

export const ViewCubeGizmoStorySt = () => <ViewCubeGizmoStory />
ViewCubeGizmoStorySt.story = {
name: 'Default',
}
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ The `native` route of the library **does not** export `Html` or `Loader`. The de
<li><a href="#line">Line</a></li>
<li><a href="#positionalaudio">PositionalAudio</a></li>
<li><a href="#billboard">Billboard</a></li>
<li><a href="#gizmoHelper">GizmoHelper</a></li>
<li><a href="#environment">Environment</a></li>
<li><a href="#effects">Effects</a></li>
<li><a href="#useanimations">useAnimations</a></li>
Expand Down Expand Up @@ -277,6 +278,24 @@ Adds a `<Plane />` that always faces the camera.
/>
```

#### GizmoHelper

Used by widgets that visualize and control camera position.

Two example gizmos are included: BlenderViewportGizmo and ViewCubeGizmo, and `useGizmoHelper` makes it easy to create your own.

```jsx
<GizmoHelper
alignment="bottom-right" // widget alignment within scene
margin={[80, 80]} // widget margins (X, Y)
onUpdate={/* called during camera animation */}
onTarget={/* return current camera target (e.g. from orbit controls) to center animation */}
>
<BlenderViewportGizmo />
{/* alternative: <ViewCubeGizmo /> */}
</GizmoHelper>
```

#### Environment

[![](https://img.shields.io/badge/-storybook-%23ff69b4)](https://drei.react-spring.io/?path=/story/abstractions-environment--environment-st)
Expand Down
84 changes: 84 additions & 0 deletions src/core/BlenderViewportGizmo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as React from 'react'
import { BoxGeometry, CanvasTexture, Event } from 'three'
import { useGizmoHelper } from './GizmoHelper'

function Axis({ color, rotation }: JSX.IntrinsicElements['mesh'] & { color: string }) {
const geometry = React.useMemo(() => new BoxGeometry(0.8, 0.05, 0.05).translate(0.4, 0, 0), [])
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
return (
<mesh geometry={geometry} rotation={rotation}>
<meshBasicMaterial color={color} toneMapped={false} />
</mesh>
)
}

function AxisHead({
arcStyle,
label,
labelColor,
...props
}: JSX.IntrinsicElements['sprite'] & { arcStyle: string; label?: string; labelColor: string }) {
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
const texture = React.useMemo(() => {
const canvas = document.createElement('canvas')
canvas.width = 64
canvas.height = 64

const context = canvas.getContext('2d')!
context.beginPath()
context.arc(32, 32, 16, 0, 2 * Math.PI)
context.closePath()
context.fillStyle = arcStyle
context.fill()

if (label) {
context.font = '24px Arial'
context.textAlign = 'center'
context.fillStyle = labelColor
context.fillText(label, 32, 41)
}
return new CanvasTexture(canvas)
}, [arcStyle, label, labelColor])

const [active, setActive] = React.useState(false)
const scale = (label ? 1 : 0.75) * (active ? 1.2 : 1)
const pointerOver = (e: Event) => void (setActive(true), e.stopPropagation())
const pointerOut = (e: Event) => void (setActive(false), e.stopPropagation())
return (
<sprite scale={[scale, scale, scale]} onPointerOver={pointerOver} onPointerOut={pointerOut} {...props}>
<spriteMaterial map={texture} toneMapped={false} />
</sprite>
)
}

type BlenderViewportGizmoProps = {
axisColors?: [string, string, string]
labelColor?: string
}

export const BlenderViewportGizmo = ({
axisColors = ['#ff3653', '#8adb00', '#2c8fff'],
labelColor = '#000',
...props
}: BlenderViewportGizmoProps) => {
const [colorX, colorY, colorZ] = axisColors
const { tweenCamera, raycast } = useGizmoHelper()
const axisHeadProps = {
labelColor,
onPointerDown: (e: Event) => void (tweenCamera(e.object.position), e.stopPropagation()),
raycast,
}
return (
<group scale={[40, 40, 40]} {...props}>
<Axis color={colorX} rotation={[0, 0, 0]} />
<Axis color={colorY} rotation={[0, 0, Math.PI / 2]} />
<Axis color={colorZ} rotation={[0, -Math.PI / 2, 0]} />
<AxisHead arcStyle={colorX} position={[1, 0, 0]} label="X" {...axisHeadProps} />
<AxisHead arcStyle={colorY} position={[0, 1, 0]} label="Y" {...axisHeadProps} />
<AxisHead arcStyle={colorZ} position={[0, 0, 1]} label="Z" {...axisHeadProps} />
<AxisHead arcStyle={colorX} position={[-1, 0, 0]} {...axisHeadProps} />
<AxisHead arcStyle={colorY} position={[0, -1, 0]} {...axisHeadProps} />
<AxisHead arcStyle={colorZ} position={[0, 0, -1]} {...axisHeadProps} />
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} intensity={0.5} />
</group>
)
}
121 changes: 121 additions & 0 deletions src/core/GizmoHelper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import * as React from 'react'
import { createPortal, useFrame, useThree } from 'react-three-fiber'
import { Group, Intersection, Matrix4, Object3D, Quaternion, Raycaster, Scene, Vector3 } from 'three'
import { OrthographicCamera } from './OrthographicCamera'
import { useCamera } from './useCamera'

type GizmoHelperContext = {
tweenCamera: (direction: Vector3) => void
raycast: (raycaster: Raycaster, intersects: Intersection[]) => void
}

const Context = React.createContext<GizmoHelperContext>({} as GizmoHelperContext)

export const useGizmoHelper = () => {
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
return React.useContext<GizmoHelperContext>(Context)
}

const turnRate = 2 * Math.PI // turn rate in angles per second
const dummy = new Object3D()
const matrix = new Matrix4()
const [q1, q2] = [new Quaternion(), new Quaternion()]
const target = new Vector3()
const targetPosition = new Vector3()
const targetQuaternion = new Quaternion()

export type GizmoHelperProps = JSX.IntrinsicElements['group'] & {
alignment?: 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left'
margin?: [number, number]
onUpdate: () => void // update controls during animation
onTarget: () => Vector3 // return the target to rotate around
}

export const GizmoHelper = ({
alignment = 'bottom-right',
margin = [80, 80],
onUpdate,
onTarget,
children: GizmoHelperComponent,
}: GizmoHelperProps): any => {
const { gl, scene, camera: mainCamera, size } = useThree()
const gizmoRef = React.useRef<Group>()
const virtualCam = React.useRef<any>()
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
const virtualScene = React.useMemo(() => new Scene(), [])
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
const [animating, setAnimating] = React.useState(false)
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
const [radius, setRadius] = React.useState(0)
const [focusPoint, setFocusPoint] = React.useState(new Vector3(0, 0, 0))

function tweenCamera(direction: Vector3) {
const radius = mainCamera.position.distanceTo(target)
setRadius(radius)
setFocusPoint(onTarget())
setAnimating(true)

// Rotate from current camera orientation
dummy.position.copy(target)
dummy.lookAt(mainCamera.position)
q1.copy(dummy.quaternion)

// To new current camera orientation
targetPosition.copy(direction).multiplyScalar(radius).add(target)
dummy.lookAt(targetPosition)
q2.copy(dummy.quaternion)
}

function animateStep(delta: number): void {
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved
if (!animating) return

const step = delta * turnRate

// animate position by doing a slerp and then scaling the position on the unit sphere
q1.rotateTowards(q2, step)
mainCamera.position.set(0, 0, 1).applyQuaternion(q1).multiplyScalar(radius).add(focusPoint)

// animate orientation
mainCamera.quaternion.rotateTowards(targetQuaternion, step)
mainCamera.updateProjectionMatrix()
onUpdate && onUpdate()

if (q1.angleTo(q2) === 0) {
setAnimating(false)
}
}

useFrame((_, delta) => {
if (virtualCam.current && gizmoRef.current) {
animateStep(delta)

// Sync gizmo with main camera orientation
matrix.copy(mainCamera.matrix).invert()
gizmoRef.current.quaternion.setFromRotationMatrix(matrix)

// Render main scene
gl.autoClear = true
gl.render(scene, mainCamera)

// Render gizmo
gl.autoClear = false
gl.clearDepth()
gl.render(virtualScene, virtualCam.current)
}
}, 1)
joshuaellis marked this conversation as resolved.
Show resolved Hide resolved

const gizmoHelperContext = {
tweenCamera,
raycast: useCamera(virtualCam),
} as GizmoHelperContext
w3dot0 marked this conversation as resolved.
Show resolved Hide resolved

// Position gizmo component within scene
const [marginX, marginY] = margin
const x = alignment.endsWith('-left') ? -size.width / 2 + marginX : size.width / 2 - marginX
const y = alignment.startsWith('top-') ? size.height / 2 - marginY : -size.height / 2 + marginY
return createPortal(
<Context.Provider value={gizmoHelperContext}>
<OrthographicCamera ref={virtualCam} makeDefault={false} position={[0, 0, 100]} />
<group ref={gizmoRef} position={[x, y, 0]}>
{GizmoHelperComponent}
</group>
</Context.Provider>,
virtualScene
)
}
Loading