Add Client-Side Gizmo (#2354)
* draft #2279
Add client side gizmo #2279, work in progress
* draft #2279
unreliableSubscriptions
* draft #2279
nice Gizmo
* blue ring
give the canvas a round shape and a border, wrapping rounded div element around the canvas
* Refactor Gizmo Component
Extracted reusable constants
Modularized the code
Simplified the useEffect logic
Added TypeScript type annotations
Improved overall code structure and readability
* remove old gizmo
* fmt
* styling and relocation
Add className "pointer-events-none" to gizmo wrapper div (for now to prevent context menu)
Make LowerRightControls container element have these classNames: flex flex-col items-end gap-3
Move gizmo into LowerRightControls.tsx as the first child of the section element
Remove the fixed styling from the gizmo div so it flows in flexbox
* fmt
* fix camera up problem
* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)
* up tweak
* Revert "up tweak"
This reverts commit a53a0ef240
.
* test tweak
* tweak test
---------
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Frank Noirot <frank@kittycad.io>
This commit is contained in:
@ -20,6 +20,7 @@ import {
|
||||
EngineCommand,
|
||||
Subscription,
|
||||
EngineCommandManager,
|
||||
UnreliableSubscription,
|
||||
} from 'lang/std/engineConnection'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { deg2Rad } from 'lib/utils2d'
|
||||
@ -232,9 +233,18 @@ export class CameraControls {
|
||||
this.update()
|
||||
this._usePerspectiveCamera()
|
||||
|
||||
const cb: Subscription<
|
||||
'default_camera_zoom' | 'camera_drag_end' | 'default_camera_get_settings'
|
||||
>['callback'] = ({ data, type }) => {
|
||||
type CallBackParam = Parameters<
|
||||
(
|
||||
| Subscription<
|
||||
| 'default_camera_zoom'
|
||||
| 'camera_drag_end'
|
||||
| 'default_camera_get_settings'
|
||||
>
|
||||
| UnreliableSubscription<'camera_drag_move'>
|
||||
)['callback']
|
||||
>[0]
|
||||
|
||||
const cb = ({ data, type }: CallBackParam) => {
|
||||
const camSettings = data.settings
|
||||
this.camera.position.set(
|
||||
camSettings.pos.x,
|
||||
@ -246,7 +256,13 @@ export class CameraControls {
|
||||
camSettings.center.y,
|
||||
camSettings.center.z
|
||||
)
|
||||
this.camera.up.set(camSettings.up.x, camSettings.up.y, camSettings.up.z)
|
||||
const quat = new Quaternion(
|
||||
camSettings.orientation.x,
|
||||
camSettings.orientation.y,
|
||||
camSettings.orientation.z,
|
||||
camSettings.orientation.w
|
||||
).invert()
|
||||
this.camera.up.copy(new Vector3(0, 1, 0).applyQuaternion(quat))
|
||||
if (this.camera instanceof PerspectiveCamera && camSettings.ortho) {
|
||||
this.useOrthographicCamera()
|
||||
}
|
||||
@ -287,6 +303,10 @@ export class CameraControls {
|
||||
event: 'default_camera_get_settings',
|
||||
callback: cb,
|
||||
})
|
||||
this.engineCommandManager.subscribeToUnreliable({
|
||||
event: 'camera_drag_move',
|
||||
callback: cb,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
146
src/components/Gizmo.tsx
Normal file
146
src/components/Gizmo.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { sceneInfra } from 'lib/singletons'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
WebGLRenderer,
|
||||
Scene,
|
||||
OrthographicCamera,
|
||||
BoxGeometry,
|
||||
SphereGeometry,
|
||||
MeshBasicMaterial,
|
||||
Color,
|
||||
Mesh,
|
||||
Clock,
|
||||
Quaternion,
|
||||
ColorRepresentation,
|
||||
} from 'three'
|
||||
|
||||
const CANVAS_SIZE = 80
|
||||
const FRUSTUM_SIZE = 0.5
|
||||
const AXIS_LENGTH = 0.35
|
||||
const AXIS_WIDTH = 0.02
|
||||
const AXIS_COLORS = {
|
||||
x: '#fa6668',
|
||||
y: '#11eb6b',
|
||||
z: '#6689ef',
|
||||
gray: '#c6c7c2',
|
||||
}
|
||||
|
||||
export default function Gizmo() {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true })
|
||||
renderer.setSize(CANVAS_SIZE, CANVAS_SIZE)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
|
||||
const scene = new Scene()
|
||||
const camera = createCamera()
|
||||
const { gizmoAxes, gizmoAxisHeads } = createGizmo()
|
||||
scene.add(...gizmoAxes, ...gizmoAxisHeads)
|
||||
|
||||
const clock = new Clock()
|
||||
const clientCamera = sceneInfra.camControls.camera
|
||||
let currentQuaternion = new Quaternion().copy(clientCamera.quaternion)
|
||||
|
||||
const animate = () => {
|
||||
requestAnimationFrame(animate)
|
||||
updateCameraOrientation(
|
||||
camera,
|
||||
currentQuaternion,
|
||||
sceneInfra.camControls.camera.quaternion,
|
||||
clock.getDelta()
|
||||
)
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
renderer.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-none">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const createCamera = () => {
|
||||
return new OrthographicCamera(
|
||||
-FRUSTUM_SIZE,
|
||||
FRUSTUM_SIZE,
|
||||
FRUSTUM_SIZE,
|
||||
-FRUSTUM_SIZE,
|
||||
0.5,
|
||||
3
|
||||
)
|
||||
}
|
||||
|
||||
const createGizmo = () => {
|
||||
const gizmoAxes = [
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.x, 0, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.y, Math.PI / 2, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.z, -Math.PI / 2, 'y'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI / 2, 'y'),
|
||||
]
|
||||
|
||||
const gizmoAxisHeads = [
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.x, 0, 'z'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.y, Math.PI / 2, 'z'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.z, -Math.PI / 2, 'y'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI, 'z'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI / 2, 'y'),
|
||||
]
|
||||
|
||||
return { gizmoAxes, gizmoAxisHeads }
|
||||
}
|
||||
|
||||
const createAxis = (
|
||||
length: number,
|
||||
width: number,
|
||||
color: ColorRepresentation,
|
||||
rotation = 0,
|
||||
axis = 'x'
|
||||
) => {
|
||||
const geometry = new BoxGeometry(length, width, width).translate(
|
||||
length / 2,
|
||||
0,
|
||||
0
|
||||
)
|
||||
const material = new MeshBasicMaterial({ color: new Color(color) })
|
||||
const mesh = new Mesh(geometry, material)
|
||||
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
|
||||
return mesh
|
||||
}
|
||||
|
||||
const createAxisHead = (
|
||||
length: number,
|
||||
color: ColorRepresentation,
|
||||
rotation = 0,
|
||||
axis = 'x'
|
||||
) => {
|
||||
const geometry = new SphereGeometry(0.065, 16, 8).translate(length, 0, 0)
|
||||
const material = new MeshBasicMaterial({ color: new Color(color) })
|
||||
const mesh = new Mesh(geometry, material)
|
||||
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
|
||||
return mesh
|
||||
}
|
||||
|
||||
const updateCameraOrientation = (
|
||||
camera: OrthographicCamera,
|
||||
currentQuaternion: Quaternion,
|
||||
targetQuaternion: Quaternion,
|
||||
deltaTime: number
|
||||
) => {
|
||||
const slerpFactor = 1 - Math.exp(-30 * deltaTime)
|
||||
currentQuaternion.slerp(targetQuaternion, slerpFactor).normalize()
|
||||
camera.position.set(0, 0, 1).applyQuaternion(currentQuaternion)
|
||||
camera.quaternion.copy(currentQuaternion)
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { APP_VERSION } from 'routes/Settings'
|
||||
import { CustomIcon } from 'components/CustomIcon'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import Gizmo from 'components/Gizmo'
|
||||
import { paths } from 'lib/paths'
|
||||
import { NetworkHealthIndicator } from 'components/NetworkHealthIndicator'
|
||||
import { HelpMenu } from './HelpMenu'
|
||||
@ -14,8 +15,9 @@ export function LowerRightControls(props: React.PropsWithChildren) {
|
||||
'!text-chalkboard-70 hover:!text-chalkboard-80 dark:!text-chalkboard-40 dark:hover:!text-chalkboard-30'
|
||||
|
||||
return (
|
||||
<section className="fixed bottom-2 right-2">
|
||||
<section className="fixed bottom-2 right-2 flex flex-col items-end gap-3">
|
||||
{props.children}
|
||||
<Gizmo />
|
||||
<menu className="flex items-center justify-end gap-3">
|
||||
<a
|
||||
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
|
||||
|
@ -590,6 +590,8 @@ class EngineConnection {
|
||||
) {
|
||||
this.engineCommandManager.inSequence = result.data.sequence
|
||||
callback(result)
|
||||
} else if (result.type !== 'highlight_set_entity') {
|
||||
callback(result)
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -907,7 +909,7 @@ type UnreliableResponses = Extract<
|
||||
Models['OkModelingCmdResponse_type'],
|
||||
{ type: 'highlight_set_entity' | 'camera_drag_move' }
|
||||
>
|
||||
interface UnreliableSubscription<T extends UnreliableResponses['type']> {
|
||||
export interface UnreliableSubscription<T extends UnreliableResponses['type']> {
|
||||
event: T
|
||||
callback: (data: Extract<UnreliableResponses, { type: T }>) => void
|
||||
}
|
||||
@ -1119,24 +1121,6 @@ export class EngineCommandManager {
|
||||
},
|
||||
})
|
||||
|
||||
// Make the axis gizmo.
|
||||
// We do this after the connection opened to avoid a race condition.
|
||||
// Connected opened is the last thing that happens when the stream
|
||||
// is ready.
|
||||
// We also do this here because we want to ensure we create the gizmo
|
||||
// and execute the code everytime the stream is restarted.
|
||||
const gizmoId = uuidv4()
|
||||
void this.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: gizmoId,
|
||||
cmd: {
|
||||
type: 'make_axes_gizmo',
|
||||
clobber: false,
|
||||
// If true, axes gizmo will be placed in the corner of the screen.
|
||||
// If false, it will be placed at the origin of the scene.
|
||||
gizmo_mode: true,
|
||||
},
|
||||
})
|
||||
this._camControlsCameraChange()
|
||||
this.sendSceneCommand({
|
||||
// CameraControls subscribes to default_camera_get_settings response events
|
||||
@ -1420,17 +1404,6 @@ export class EngineCommandManager {
|
||||
this.lastArtifactMap = this.artifactMap
|
||||
this.artifactMap = {}
|
||||
await this.initPlanes()
|
||||
await this.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'make_axes_gizmo',
|
||||
clobber: false,
|
||||
// If true, axes gizmo will be placed in the corner of the screen.
|
||||
// If false, it will be placed at the origin of the scene.
|
||||
gizmo_mode: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
subscribeTo<T extends ModelTypes>({
|
||||
event,
|
||||
|
Reference in New Issue
Block a user