diff --git a/examples/src/examples/misc/gizmos.mjs b/examples/src/examples/misc/gizmos.mjs new file mode 100644 index 00000000000..b36d51596ec --- /dev/null +++ b/examples/src/examples/misc/gizmos.mjs @@ -0,0 +1,601 @@ +import * as pc from 'playcanvas'; + +/** + * @param {import('../../app/example.mjs').ControlOptions} options - The options. + * @returns {JSX.Element} The returned JSX Element. + */ +function controls({ observer, ReactPCUI, React, jsx, fragment }) { + const { BindingTwoWay, LabelGroup, Panel, BooleanInput, ColorPicker, SliderInput, SelectInput } = ReactPCUI; + + const [type, setType] = React.useState('translate'); + const [proj, setProj] = React.useState(pc.PROJECTION_PERSPECTIVE); + + window.setType = (value) => setType(value); + + return fragment( + jsx(Panel, { headerText: 'Transform' }, + jsx(LabelGroup, { text: 'Type' }, + jsx(SelectInput, { + options: [ + { v: 'translate', t: 'Translate' }, + { v: 'rotate', t: 'Rotate' }, + { v: 'scale', t: 'Scale' } + ], + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.type' }, + onSelect: this.setType + }) + ), + (type === 'translate' || type === 'rotate') && + jsx(LabelGroup, { text: 'Coord Space' }, + jsx(SelectInput, { + options: [ + { v: 'world', t: 'World' }, + { v: 'local', t: 'Local' } + ], + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.coordSpace' } + }) + ), + jsx(LabelGroup, { text: 'Size' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.size' }, + min: 0.1, + max: 2.0 + }) + ), + jsx(LabelGroup, { text: 'Snap Increment' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.snapIncrement' }, + min: 1, + max: 10, + precision: 0 + }) + ) + ), + jsx(Panel, { headerText: 'Color' }, + jsx(LabelGroup, { text: 'X Axis' }, + jsx(ColorPicker, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.xAxisColor' } + }) + ), + jsx(LabelGroup, { text: 'Y Axis' }, + jsx(ColorPicker, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.yAxisColor' } + }) + ), + jsx(LabelGroup, { text: 'Z Axis' }, + jsx(ColorPicker, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.zAxisColor' } + }) + ) + ), + jsx(Panel, { headerText: 'Intersection' }, + (type === 'translate' || type === 'scale') && + jsx(LabelGroup, { text: 'Line Tolerance' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisLineTolerance' }, + min: 0, + max: 0.5, + precision: 2 + }) + ), + type === 'scale' && + jsx(LabelGroup, { text: 'Center Tolerance' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisCenterTolerance' }, + min: 0, + max: 0.5, + precision: 2 + }) + ), + type === 'rotate' && + jsx(LabelGroup, { text: 'Ring Tolerance' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.ringTolerance' }, + min: 0, + max: 0.5, + precision: 2 + }) + ) + ), + jsx(Panel, { headerText: 'Render' }, + (type === 'translate' || type === 'scale') && + jsx(LabelGroup, { text: 'Gap' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisGap' } + }) + ), + (type === 'translate' || type === 'scale') && + jsx(LabelGroup, { text: 'Line Thickness' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisLineThickness' } + }) + ), + (type === 'translate' || type === 'scale') && + jsx(LabelGroup, { text: 'Line Length' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisLineLength' } + }) + ), + type === 'scale' && + jsx(LabelGroup, { text: 'Box Size' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisBoxSize' } + }) + ), + type === 'translate' && + jsx(LabelGroup, { text: 'Arrow Thickness' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisArrowThickness' } + }) + ), + type === 'translate' && + jsx(LabelGroup, { text: 'Arrow Length' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisArrowLength' } + }) + ), + (type === 'translate' || type === 'scale') && + jsx(LabelGroup, { text: 'Plane Size' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisPlaneSize' } + }) + ), + (type === 'translate' || type === 'scale') && + jsx(LabelGroup, { text: 'Plane Gap' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisPlaneGap' } + }) + ), + type === 'scale' && + jsx(LabelGroup, { text: 'Center Size' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.axisCenterSize' } + }) + ), + type === 'rotate' && + jsx(LabelGroup, { text: 'XYZ Tube Radius' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.xyzTubeRadius' } + }) + ), + type === 'rotate' && + jsx(LabelGroup, { text: 'XYZ Ring Radius' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.xyzRingRadius' } + }) + ), + type === 'rotate' && + jsx(LabelGroup, { text: 'Face Tube Radius' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.faceTubeRadius' } + }) + ), + type === 'rotate' && + jsx(LabelGroup, { text: 'Face Ring Radius' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'gizmo.faceRingRadius' }, + max: 2 + }) + ) + ), + jsx(Panel, { headerText: 'Camera' }, + jsx(LabelGroup, { text: 'Projection' }, + jsx(SelectInput, { + options: [ + { v: pc.PROJECTION_PERSPECTIVE + 1, t: 'Perspective' }, + { v: pc.PROJECTION_ORTHOGRAPHIC + 1, t: 'Orthographic' } + ], + binding: new BindingTwoWay(), + link: { observer, path: 'camera.proj' }, + onSelect: value => setProj(value - 1) + }) + ), + (proj === pc.PROJECTION_PERSPECTIVE) && + jsx(LabelGroup, { text: 'FOV' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'camera.fov' }, + min: 30, + max: 100 + }) + ) + ) + ); +} + +/** + * @param {import('../../options.mjs').ExampleOptions} options - The example options. + * @returns {Promise} The example application. + */ +async function example({ canvas, deviceType, data, glslangPath, twgslPath, scriptsPath }) { + // Class for handling gizmos + class GizmoHandler { + _type = 'translate'; + + _nodes = []; + + _ignorePicker = false; + + skipSetFire = false; + + + constructor(app, camera, layer) { + this._gizmos = { + translate: new pcx.GizmoTranslate(app, camera, layer), + rotate: new pcx.GizmoRotate(app, camera, layer), + scale: new pcx.GizmoScale(app, camera, layer) + }; + + for (const type in this._gizmos) { + const gizmo = this._gizmos[type]; + gizmo.on('pointer:down', (x, y, selection) => { + this._ignorePicker = !!selection; + }); + gizmo.on('pointer:up', () => { + this._ignorePicker = false; + }); + } + } + + get gizmo() { + return this._gizmos[this._type]; + } + + get ignorePicker() { + return this._ignorePicker; + } + + _updateData(type) { + const gizmo = this.gizmo; + this.skipSetFire = true; + data.set('gizmo', { + type: type, + size: gizmo.size, + snapIncrement: gizmo.snapIncrement, + xAxisColor: Object.values(gizmo.xAxisColor), + yAxisColor: Object.values(gizmo.yAxisColor), + zAxisColor: Object.values(gizmo.zAxisColor), + coordSpace: gizmo.coordSpace, + axisLineTolerance: gizmo.axisLineTolerance, + axisCenterTolerance: gizmo.axisCenterTolerance, + ringTolerance: gizmo.ringTolerance, + axisGap: gizmo.axisGap, + axisLineThickness: gizmo.axisLineThickness, + axisLineLength: gizmo.axisLineLength, + axisArrowThickness: gizmo.axisArrowThickness, + axisArrowLength: gizmo.axisArrowLength, + axisBoxSize: gizmo.axisBoxSize, + axisPlaneSize: gizmo.axisPlaneSize, + axisPlaneGap: gizmo.axisPlaneGap, + axisCenterSize: gizmo.axisCenterSize, + xyzTubeRadius: gizmo.xyzTubeRadius, + xyzRingRadius: gizmo.xyzRingRadius, + faceTubeRadius: gizmo.faceTubeRadius, + faceRingRadius: gizmo.faceRingRadius + }); + this.skipSetFire = false; + } + + add(node, clear = false) { + if (clear) { + this._nodes.length = 0; + } + if (this._nodes.indexOf(node) === -1) { + this._nodes.push(node); + } + this.gizmo.attach(this._nodes); + } + + clear() { + this._nodes.length = 0; + this.gizmo.detach(); + } + + switch(type) { + this.gizmo.detach(); + this._type = type ?? 'translate'; + this.gizmo.attach(this._nodes); + this._updateData(type); + } + + destroy() { + for (const type in this._gizmos) { + this._gizmos[type].destroy(); + } + } + } + + const gfxOptions = { + deviceTypes: [deviceType], + glslangUrl: glslangPath + 'glslang.js', + twgslUrl: twgslPath + 'twgsl.js' + }; + + const device = await pc.createGraphicsDevice(canvas, gfxOptions); + const createOptions = new pc.AppOptions(); + createOptions.graphicsDevice = device; + createOptions.mouse = new pc.Mouse(document.body); + createOptions.keyboard = new pc.Keyboard(window); + + createOptions.componentSystems = [ + pc.RenderComponentSystem, + pc.CameraComponentSystem, + pc.LightComponentSystem, + pc.ScriptComponentSystem + ]; + createOptions.resourceHandlers = [ + // @ts-ignore + pc.TextureHandler, + // @ts-ignore + pc.ContainerHandler, + // @ts-ignore + pc.ScriptHandler + ]; + + const app = new pc.AppBase(canvas); + app.init(createOptions); + + // Set the canvas to fill the window and automatically change resolution to be the same as the canvas size + app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); + app.setCanvasResolution(pc.RESOLUTION_AUTO); + + // Ensure canvas is resized when window changes size + const resize = () => app.resizeCanvas(); + window.addEventListener('resize', resize); + + // load assets + const assets = { + script: new pc.Asset('script', 'script', { url: scriptsPath + 'camera/orbit-camera.js' }) + }; + /** + * @param {pc.Asset[] | number[]} assetList - The asset list. + * @param {pc.AssetRegistry} assetRegistry - The asset registry. + * @returns {Promise} The promise. + */ + function loadAssets(assetList, assetRegistry) { + return new Promise((resolve) => { + const assetListLoader = new pc.AssetListLoader(assetList, assetRegistry); + assetListLoader.load(resolve); + }); + } + await loadAssets(Object.values(assets), app.assets); + + app.start(); + + // create entities + function createMaterial(color) { + const material = new pc.StandardMaterial(); + material.diffuse = color; + return material; + } + const box = new pc.Entity('box'); + box.addComponent('render', { + type: 'box', + material: createMaterial(new pc.Color(0.8, 1, 1)) + }); + box.setPosition(1, 0, 1); + app.root.addChild(box); + + const sphere = new pc.Entity('sphere'); + sphere.addComponent('render', { + type: 'sphere', + material: createMaterial(new pc.Color(1, 0.8, 1)) + }); + sphere.setPosition(-1, 0, 1); + app.root.addChild(sphere); + + const cone = new pc.Entity('cone'); + cone.addComponent('render', { + type: 'cone', + material: createMaterial(new pc.Color(1, 1, 0.8)) + }); + cone.setPosition(-1, 0, -1); + cone.setLocalScale(1.5, 2.25, 1.5); + app.root.addChild(cone); + + const capsule = new pc.Entity('capsule'); + capsule.addComponent('render', { + type: 'capsule', + material: createMaterial(new pc.Color(0.8, 0.8, 1)) + }); + capsule.setPosition(1, 0, -1); + app.root.addChild(capsule); + + // create camera entity + data.set('camera', { + proj: pc.PROJECTION_PERSPECTIVE + 1, + dist: 1, + fov: 45, + orthoHeight: 10 + }); + const camera = new pc.Entity('camera'); + camera.addComponent('camera', { + clearColor: new pc.Color(0.1, 0.1, 0.1) + }); + camera.addComponent("script"); + const orbitCamera = camera.script.create("orbitCamera"); + const orbitCameraInputMouse = camera.script.create("orbitCameraInputMouse"); + camera.script.create("orbitCameraInputTouch"); + camera.setPosition(1, 1, 1); + app.root.addChild(camera); + orbitCamera.distance = 14; + + // create 3-point lighting + const backLight = new pc.Entity('light'); + backLight.addComponent('light', { + intensity: 0.5 + }); + app.root.addChild(backLight); + backLight.setEulerAngles(-60, 0, 90); + + const fillLight = new pc.Entity('light'); + fillLight.addComponent('light', { + intensity: 0.5 + }); + app.root.addChild(fillLight); + fillLight.setEulerAngles(45, 0, 0); + + const keyLight = new pc.Entity('light'); + keyLight.addComponent('light', { + intensity: 1 + }); + app.root.addChild(keyLight); + keyLight.setEulerAngles(0, 0, -60); + + // create gizmoLayer + const gizmoLayer = new pc.Layer({ + name: 'Gizmo', + clearDepthBuffer: true, + opaqueSortMode: pc.SORTMODE_NONE, + transparentSortMode: pc.SORTMODE_NONE + }); + app.scene.layers.push(gizmoLayer); + camera.camera.layers = camera.camera.layers.concat(gizmoLayer.id); + + // create gizmo + const gizmoHandler = new GizmoHandler(app, camera.camera, gizmoLayer); + gizmoHandler.switch('translate'); + gizmoHandler.add(box); + this.focus(); + + // Change gizmo mode keybinds + const setType = (value) => { + data.set('gizmo.type', value); + + // call method from top context (same as controls) + window.top.setType(value); + }; + const keypress = (e) => { + switch (e.key) { + case 'x': + data.set('gizmo.coordSpace', data.get('gizmo.coordSpace') === 'world' ? 'local' : 'world'); + break; + case '1': + setType('translate'); + break; + case '2': + setType('rotate'); + break; + case '3': + setType('scale'); + break; + } + }; + window.addEventListener('keypress', keypress); + + // Gizmo and camera set handler + const tmpC = new pc.Color(); + data.on('*:set', (/** @type {string} */ path, value) => { + const pathArray = path.split('.'); + + switch (pathArray[0]) { + case 'camera': + switch (pathArray[1]) { + case 'proj': + camera.camera.projection = value - 1; + break; + case 'fov': + camera.camera.fov = value; + break; + } + return; + case 'gizmo': + if (gizmoHandler.skipSetFire) { + return; + } + switch (pathArray[1]) { + case 'type': + gizmoHandler.switch(value); + break; + case 'xAxisColor': + case 'yAxisColor': + case 'zAxisColor': + tmpC.set(...value); + gizmoHandler.gizmo[pathArray[1]] = tmpC; + break; + default: + gizmoHandler.gizmo[pathArray[1]] = value; + break; + } + break; + } + }); + + // Picker + const picker = new pc.Picker(app, canvas.clientWidth, canvas.clientHeight); + const worldLayer = app.scene.layers.getLayerByName("World"); + const pickerLayers = [worldLayer]; + + const onPointerDown = (e) => { + if (gizmoHandler.ignorePicker) { + return; + } + + if (picker) { + picker.resize(canvas.clientWidth, canvas.clientHeight); + picker.prepare(camera.camera, app.scene, pickerLayers); + } + + const selection = picker.getSelection(e.clientX - 1, e.clientY - 1, 2, 2); + if (!selection[0]) { + gizmoHandler.clear(); + return; + } + + gizmoHandler.add(selection[0].node, !e.ctrlKey && !e.metaKey); + }; + window.addEventListener('pointerdown', onPointerDown); + + const gridColor = new pc.Color(1, 1, 1, 0.5); + const gridHalfSize = 4; + const gridLines = []; + for (let i = 0; i < gridHalfSize * 2 + 1; i++) { + gridLines.push(new pc.Vec3(-gridHalfSize, 0, i - gridHalfSize), new pc.Vec3(gridHalfSize, 0, i - gridHalfSize)); + gridLines.push(new pc.Vec3(i - gridHalfSize, 0, -gridHalfSize), new pc.Vec3(i - gridHalfSize, 0, gridHalfSize)); + } + app.on('update', () => { + app.drawLines(gridLines, gridColor); + }); + + app.on('destroy', () => { + gizmoHandler.destroy(); + + window.removeEventListener('resize', resize); + window.removeEventListener('keypress', keypress); + window.removeEventListener('pointerdown', onPointerDown); + }); + + return app; +} + +class GizmosExample { + static CATEGORY = 'Misc'; + static WEBGPU_ENABLED = false; + static controls = controls; + static example = example; +} + +export { GizmosExample }; diff --git a/examples/src/examples/misc/index.mjs b/examples/src/examples/misc/index.mjs index 5e7f95bbbb0..c4922ec4d00 100644 --- a/examples/src/examples/misc/index.mjs +++ b/examples/src/examples/misc/index.mjs @@ -1,4 +1,5 @@ export * from "./hello-world.mjs"; export * from "./mini-stats.mjs"; export * from "./spineboy.mjs"; +export * from "./gizmos.mjs"; export * from "./multi-app.mjs"; diff --git a/extras/gizmo/axis-shapes.js b/extras/gizmo/axis-shapes.js new file mode 100644 index 00000000000..ae568c54543 --- /dev/null +++ b/extras/gizmo/axis-shapes.js @@ -0,0 +1,606 @@ +import { + createBox, + createCone, + createCylinder, + createPlane, + createMesh, + createTorus, + Color, + MeshInstance, + Entity, + Quat, + Vec3 +} from 'playcanvas'; + +import { MeshTriData } from './mesh-tri-data.js'; + +// constants +const SHADOW_DAMP_SCALE = 0.25; +const SHADOW_DAMP_OFFSET = 0.75; +const TORUS_RENDER_SEGMENTS = 80; +const TORUS_INTERSECT_SEGMENTS = 20; +const LIGHT_DIR = new Vec3(1, 2, 3); +const MESH_TEMPLATES = { + box: createBox, + cone: createCone, + cylinder: createCylinder, + plane: createPlane, + torus: createTorus +}; + +// temporary variables +const tmpV1 = new Vec3(); +const tmpV2 = new Vec3(); +const tmpQ1 = new Quat(); + +function createShadowMesh(device, entity, type, templateOpts = {}) { + const createTemplate = MESH_TEMPLATES[type]; + if (!createTemplate) { + throw new Error('Invalid primitive type.'); + } + + const mesh = createTemplate(device, templateOpts); + const options = { + positions: [], + normals: [], + indices: [], + colors: [] + }; + + mesh.getPositions(options.positions); + mesh.getNormals(options.normals); + mesh.getIndices(options.indices); + + const wtm = entity.getWorldTransform().clone().invert(); + tmpV1.copy(LIGHT_DIR); + wtm.transformVector(tmpV1, tmpV1); + tmpV1.normalize(); + const numVertices = mesh.vertexBuffer.numVertices; + calculateShadowColors(tmpV1, numVertices, options.normals, options.colors); + + return createMesh(device, options.positions, options); +} + +function calculateShadowColors(lightDir, numVertices, normals, colors = []) { + for (let i = 0; i < numVertices; i++) { + const x = normals[i * 3]; + const y = normals[i * 3 + 1]; + const z = normals[i * 3 + 2]; + tmpV2.set(x, y, z); + + const dot = lightDir.dot(tmpV2); + const shadow = dot * SHADOW_DAMP_SCALE + SHADOW_DAMP_OFFSET; + colors.push(shadow * 255, shadow * 255, shadow * 255, 1); + } + + return colors; +} + +class AxisShape { + _position; + + _rotation; + + _scale; + + _layers = []; + + _defaultColor; + + _hoverColor; + + device; + + axis; + + entity; + + meshTriDataList = []; + + meshInstances = []; + + constructor(device, options) { + this.device = device; + this.axis = options.axis ?? 'x'; + this._position = options.position ?? new Vec3(); + this._rotation = options.rotation ?? new Vec3(); + this._scale = options.scale ?? new Vec3(1, 1, 1); + + this._layers = options.layers ?? this._layers; + + this._defaultColor = options.defaultColor ?? Color.BLACK; + this._hoverColor = options.hoverColor ?? Color.WHITE; + } + + _createRoot(name) { + this.entity = new Entity(name + ':' + this.axis); + this._updateRootTransform(); + } + + _updateRootTransform() { + this.entity.setLocalPosition(this._position); + this.entity.setLocalEulerAngles(this._rotation); + this.entity.setLocalScale(this._scale); + } + + _addRenderMeshes(entity, meshes) { + const meshInstances = []; + for (let i = 0; i < meshes.length; i++) { + const mi = new MeshInstance(meshes[i], this._defaultColor); + meshInstances.push(mi); + this.meshInstances.push(mi); + } + entity.addComponent('render', { + meshInstances: meshInstances, + layers: this._layers, + castShadows: false + }); + } + + _addRenderShadowMesh(entity, type) { + const mesh = createShadowMesh(this.device, entity, type); + this._addRenderMeshes(entity, [mesh]); + } + + hover(state) { + const material = state ? this._hoverColor : this._defaultColor; + for (let i = 0; i < this.meshInstances.length; i++) { + this.meshInstances[i].material = material; + } + } + + destroy() { + this.entity.destroy(); + } +} + +class AxisArrow extends AxisShape { + _gap = 0; + + _lineThickness = 0.02; + + _lineLength = 0.5; + + _arrowThickness = 0.12; + + _arrowLength = 0.18; + + _tolerance = 0.1; + + constructor(device, options = {}) { + super(device, options); + + this.meshTriDataList = [ + new MeshTriData(createCone(this.device)), + new MeshTriData(createCylinder(this.device)) + ]; + + this._createArrow(); + } + + set gap(value) { + this._gap = value ?? 0; + this._updateHead(); + this._updateLine(); + } + + get gap() { + return this._gap; + } + + set lineThickness(value) { + this._lineThickness = value ?? 1; + this._updateHead(); + this._updateLine(); + } + + get lineThickness() { + return this._lineThickness; + } + + set lineLength(value) { + this._lineLength = value ?? 1; + this._updateHead(); + this._updateLine(); + } + + get lineLength() { + return this._lineLength; + } + + set arrowThickness(value) { + this._arrowThickness = value ?? 1; + this._updateHead(); + } + + get arrowThickness() { + return this._arrowThickness; + } + + set arrowLength(value) { + this._arrowLength = value ?? 1; + this._updateHead(); + } + + get arrowLength() { + return this._arrowLength; + } + + set tolerance(value) { + this._tolerance = value; + this._updateLine(); + } + + get tolerance() { + return this._tolerance; + } + + _createArrow() { + this._createRoot('arrow'); + + // head + this._head = new Entity('head:' + this.axis); + this.entity.addChild(this._head); + this._updateHead(); + this._addRenderShadowMesh(this._head, 'cone'); + + // line + this._line = new Entity('line:' + this.axis); + this.entity.addChild(this._line); + this._updateLine(); + this._addRenderShadowMesh(this._line, 'cylinder'); + } + + _updateHead() { + // intersect + tmpV1.set(0, this._gap + this._arrowLength * 0.5 + this._lineLength, 0); + tmpQ1.set(0, 0, 0, 1); + tmpV2.set(this._arrowThickness, this._arrowLength, this._arrowThickness); + this.meshTriDataList[0].setTransform(tmpV1, tmpQ1, tmpV2); + + this._head.setLocalPosition(0, this._gap + this._arrowLength * 0.5 + this._lineLength, 0); + this._head.setLocalScale(this._arrowThickness, this._arrowLength, this._arrowThickness); + } + + _updateLine() { + // intersect + tmpV1.set(0, this._gap + this._lineLength * 0.5, 0); + tmpQ1.set(0, 0, 0, 1); + tmpV2.set(this._lineThickness + this._tolerance, this._lineLength, this._lineThickness + this._tolerance); + this.meshTriDataList[1].setTransform(tmpV1, tmpQ1, tmpV2); + + // render + this._line.setLocalPosition(0, this._gap + this._lineLength * 0.5, 0); + this._line.setLocalScale(this._lineThickness, this._lineLength, this._lineThickness); + } +} + +class AxisBoxCenter extends AxisShape { + _size = 0.12; + + _tolerance = 0.05; + + constructor(device, options = {}) { + super(device, options); + + this.meshTriDataList = [ + new MeshTriData(createBox(this.device)) + ]; + + this._createCenter(); + } + + _createCenter() { + this._createRoot('boxCenter'); + this._updateTransform(); + + // box + this._addRenderShadowMesh(this.entity, 'box'); + } + + set size(value) { + this._size = value ?? 1; + this._updateTransform(); + } + + get size() { + return this._size; + } + + set tolerance(value) { + this._tolerance = value; + this._updateTransform(); + } + + get tolerance() { + return this._tolerance; + } + + _updateTransform() { + // intersect + const iSize = (this._size + this._tolerance) / this._size; + tmpV1.set(0, 0, 0); + tmpQ1.set(0, 0, 0, 1); + tmpV2.set(iSize, iSize, iSize); + this.meshTriDataList[0].setTransform(tmpV1, tmpQ1, tmpV2); + + // render + this.entity.setLocalScale(this._size, this._size, this._size); + } +} + +class AxisBoxLine extends AxisShape { + _gap = 0; + + _lineThickness = 0.02; + + _lineLength = 0.5; + + _boxSize = 0.12; + + _tolerance = 0.1; + + constructor(device, options = {}) { + super(device, options); + + this.meshTriDataList = [ + new MeshTriData(createBox(this.device)), + new MeshTriData(createCylinder(this.device)) + ]; + + this._createBoxLine(); + } + + set gap(value) { + this._gap = value ?? 0; + this._updateLine(); + this._updateBox(); + } + + get gap() { + return this._gap; + } + + set lineThickness(value) { + this._lineThickness = value ?? 1; + this._updateLine(); + this._updateBox(); + } + + get lineThickness() { + return this._lineThickness; + } + + set lineLength(value) { + this._lineLength = value ?? 1; + this._updateLine(); + this._updateBox(); + } + + get lineLength() { + return this._lineLength; + } + + set boxSize(value) { + this._boxSize = value ?? 1; + this._updateBox(); + } + + get boxSize() { + return this._boxSize; + } + + set tolerance(value) { + this._tolerance = value; + this._updateLine(); + } + + get tolerance() { + return this._tolerance; + } + + _createBoxLine() { + this._createRoot('boxLine'); + + // box + this._box = new Entity('box:' + this.axis); + this.entity.addChild(this._box); + this._updateBox(); + this._addRenderShadowMesh(this._box, 'box'); + + // line + this._line = new Entity('line:' + this.axis); + this.entity.addChild(this._line); + this._updateLine(); + this._addRenderShadowMesh(this._line, 'cylinder'); + + } + + _updateBox() { + // intersect + tmpV1.set(0, this._gap + this._boxSize * 0.5 + this._lineLength, 0); + tmpQ1.set(0, 0, 0, 1); + tmpV2.set(this._boxSize, this._boxSize, this._boxSize); + this.meshTriDataList[0].setTransform(tmpV1, tmpQ1, tmpV2); + + // render + this._box.setLocalPosition(0, this._gap + this._boxSize * 0.5 + this._lineLength, 0); + this._box.setLocalScale(this._boxSize, this._boxSize, this._boxSize); + } + + _updateLine() { + // intersect + tmpV1.set(0, this._gap + this._lineLength * 0.5, 0); + tmpQ1.set(0, 0, 0, 1); + tmpV2.set(this._lineThickness + this._tolerance, this._lineLength, this._lineThickness + this._tolerance); + this.meshTriDataList[1].setTransform(tmpV1, tmpQ1, tmpV2); + + // render + this._line.setLocalPosition(0, this._gap + this._lineLength * 0.5, 0); + this._line.setLocalScale(this._lineThickness, this._lineLength, this._lineThickness); + } +} + +class AxisDisk extends AxisShape { + _tubeRadius = 0.01; + + _ringRadius = 0.5; + + _sectorAngle; + + _lightDir; + + _tolerance = 0.05; + + constructor(device, options = {}) { + super(device, options); + + this._tubeRadius = options.tubeRadius ?? this._tubeRadius; + this._ringRadius = options.ringRadius ?? this._ringRadius; + this._sectorAngle = options.sectorAngle ?? this._sectorAngle; + + this.meshTriDataList = [ + new MeshTriData(this._createIntersectTorus()) + ]; + + this._createDisk(); + } + + _createIntersectTorus() { + return createTorus(this.device, { + tubeRadius: this._tubeRadius + this._tolerance, + ringRadius: this._ringRadius, + sectorAngle: this._sectorAngle, + segments: TORUS_INTERSECT_SEGMENTS + }); + } + + _createRenderTorus(sectorAngle) { + return createShadowMesh(this.device, this.entity, 'torus', { + tubeRadius: this._tubeRadius, + ringRadius: this._ringRadius, + sectorAngle: sectorAngle, + segments: TORUS_RENDER_SEGMENTS + }); + } + + _createDisk() { + this._createRoot('disk'); + + // arc/circle + this._addRenderMeshes(this.entity, [ + this._createRenderTorus(this._sectorAngle), + this._createRenderTorus(360) + ]); + this.drag(false); + } + + set tubeRadius(value) { + this._tubeRadius = value ?? 0.1; + this._updateTransform(); + } + + get tubeRadius() { + return this._tubeRadius; + } + + set ringRadius(value) { + this._ringRadius = value ?? 0.1; + this._updateTransform(); + } + + get ringRadius() { + return this._ringRadius; + } + + set tolerance(value) { + this._tolerance = value; + this._updateTransform(); + } + + get tolerance() { + return this._tolerance; + } + + _updateTransform() { + // intersect + this.meshTriDataList[0].setTris(this._createIntersectTorus()); + + // render + this.meshInstances[0].mesh = this._createRenderTorus(this._sectorAngle); + this.meshInstances[1].mesh = this._createRenderTorus(360); + } + + drag(state) { + this.meshInstances[0].visible = !state; + this.meshInstances[1].visible = state; + } + + hide(state) { + if (state) { + this.meshInstances[0].visible = false; + this.meshInstances[1].visible = false; + return; + } + + this.drag(false); + } +} + +class AxisPlane extends AxisShape { + _size = 0.2; + + _gap = 0.1; + + constructor(device, options = {}) { + super(device, options); + + this.meshTriDataList = [ + new MeshTriData(createPlane(this.device)) + ]; + + this._createPlane(); + } + + _getPosition() { + const offset = this._size / 2 + this._gap; + const position = new Vec3(offset, offset, offset); + position[this.axis] = 0; + return position; + } + + _createPlane() { + this._createRoot('plane'); + this._updateTransform(); + + // plane + this._addRenderShadowMesh(this.entity, 'plane'); + } + + set size(value) { + this._size = value ?? 1; + this._updateTransform(); + } + + get size() { + return this._size; + } + + set gap(value) { + this._gap = value ?? 0; + this._updateTransform(); + } + + get gap() { + return this._gap; + } + + _updateTransform() { + // intersect/render + this.entity.setLocalPosition(this._getPosition()); + this.entity.setLocalEulerAngles(this._rotation); + this.entity.setLocalScale(this._size, this._size, this._size); + } +} + +export { AxisShape, AxisArrow, AxisBoxCenter, AxisBoxLine, AxisDisk, AxisPlane }; diff --git a/extras/gizmo/gizmo-rotate.js b/extras/gizmo/gizmo-rotate.js new file mode 100644 index 00000000000..975fdb6c884 --- /dev/null +++ b/extras/gizmo/gizmo-rotate.js @@ -0,0 +1,326 @@ +import { + math, + Color, + Quat, + Mat4, + Vec3 +} from 'playcanvas'; + +import { AxisDisk } from './axis-shapes.js'; +import { LOCAL_COORD_SPACE } from './gizmo.js'; +import { GizmoTransform } from "./gizmo-transform.js"; + +// temporary variables +const tmpV1 = new Vec3(); +const tmpV2 = new Vec3(); +const tmpM1 = new Mat4(); +const tmpQ1 = new Quat(); +const tmpQ2 = new Quat(); + +/** + * Rotation gizmo. + * + * @augments GizmoTransform + */ +class GizmoRotate extends GizmoTransform { + _shapes = { + z: new AxisDisk(this.app.graphicsDevice, { + axis: 'z', + layers: [this.layer.id], + rotation: new Vec3(90, 0, 90), + defaultColor: this._materials.axis.z.cullBack, + hoverColor: this._materials.hover.z.cullBack, + sectorAngle: 180 + }), + x: new AxisDisk(this.app.graphicsDevice, { + axis: 'x', + layers: [this.layer.id], + rotation: new Vec3(0, 0, -90), + defaultColor: this._materials.axis.x.cullBack, + hoverColor: this._materials.hover.x.cullBack, + sectorAngle: 180 + }), + y: new AxisDisk(this.app.graphicsDevice, { + axis: 'y', + layers: [this.layer.id], + rotation: new Vec3(0, 0, 0), + defaultColor: this._materials.axis.y.cullBack, + hoverColor: this._materials.hover.y.cullBack, + sectorAngle: 180 + }), + face: new AxisDisk(this.app.graphicsDevice, { + axis: 'face', + layers: [this.layer.id], + rotation: this._getLookAtEulerAngles(this.camera.entity.getPosition()), + defaultColor: this._materials.axis.face, + hoverColor: this._materials.hover.face, + ringRadius: 0.55 + }) + }; + + _isRotation = true; + + /** + * Internal mapping from each attached node to their starting rotation in local space. + * + * @type {Map} + * @private + */ + _nodeLocalRotations = new Map(); + + /** + * Internal mapping from each attached node to their starting rotation in world space. + * + * @type {Map} + * @private + */ + _nodeRotations = new Map(); + + /** + * Internal mapping from each attached node to their offset position from the gizmo. + * + * @type {Map} + * @private + */ + _nodeOffsets = new Map(); + + /** + * Internal color for guide angle starting line. + * + * @type {Color} + * @private + */ + _guideAngleStartColor = new Color(0, 0, 0, 0.3); + + /** + * Internal vector for the start point of the guide line angle. + * + * @type {Vec3} + * @private + */ + _guideAngleStart = new Vec3(); + + /** + * Internal vector for the end point of the guide line angle. + * + * @type {Vec3} + * @private + */ + _guideAngleEnd = new Vec3(); + + snapIncrement = 5; + + /** + * Creates a new GizmoRotate object. + * + * @param {import('playcanvas').AppBase} app - The application instance. + * @param {import('playcanvas').CameraComponent} camera - The camera component. + * @param {import('playcanvas').Layer} layer - The render layer. + * @example + * const gizmo = new pcx.GizmoRotate(app, camera, layer); + */ + constructor(app, camera, layer) { + super(app, camera, layer); + + this._createTransform(); + + this.on('transform:start', () => { + const axis = this._selectedAxis; + const isFacing = axis === 'face'; + const scale = isFacing ? this.faceRingRadius : this.xyzRingRadius; + + this._storeNodeRotations(); + + // guide angle line start + this._guideAngleStart.copy(this._selectionStartPoint).normalize(); + this._guideAngleStart.scale(scale); + this._gizmoRotationStart.transformVector(this._guideAngleStart, this._guideAngleStart); + this._guideAngleEnd.copy(this._guideAngleStart); + + // drag handle for disk (arc <-> circle) + this._drag(true); + }); + + this.on('transform:move', (pointDelta, angleDelta) => { + const gizmoPos = this.gizmo.getPosition(); + const cameraPos = this.camera.entity.getPosition(); + const axis = this._selectedAxis; + const isFacing = axis === 'face'; + + if (this.snap) { + angleDelta = Math.round(angleDelta / this.snapIncrement) * this.snapIncrement; + } + this._setNodeRotations(axis, angleDelta); + + // guide angle line update rotation + tmpV1.set(0, 0, 0); + if (isFacing) { + tmpV1.copy(cameraPos).sub(gizmoPos).normalize(); + } else { + tmpV1[axis] = 1; + } + this._gizmoRotationStart.transformVector(tmpV1, tmpV1); + tmpQ1.setFromAxisAngle(tmpV1, angleDelta); + tmpQ1.transformVector(this._guideAngleStart, this._guideAngleEnd); + }); + + this.on('transform:end', () => { + this._drag(false); + }); + + this.on('nodes:detach', () => { + this._nodeLocalRotations.clear(); + this._nodeRotations.clear(); + this._nodeOffsets.clear(); + }); + + this.app.on('update', () => { + const cameraPos = this.camera.entity.getPosition(); + this._faceAxisLookAt(cameraPos); + this._xyzAxisLookAt(cameraPos); + + if (this._dragging) { + const gizmoPos = this.gizmo.getPosition(); + this._drawGuideAngleLine(gizmoPos, this._selectedAxis, + this._guideAngleStart, this._guideAngleStartColor); + this._drawGuideAngleLine(gizmoPos, this._selectedAxis, this._guideAngleEnd); + } + }); + } + + set xyzTubeRadius(value) { + this._setDiskProp('tubeRadius', value); + } + + get xyzTubeRadius() { + return this._shapes.x.tubeRadius; + } + + set xyzRingRadius(value) { + this._setDiskProp('ringRadius', value); + } + + get xyzRingRadius() { + return this._shapes.x.ringRadius; + } + + set faceTubeRadius(value) { + this._shapes.face.tubeRadius = value; + } + + get faceTubeRadius() { + return this._shapes.face.tubeRadius; + } + + set faceRingRadius(value) { + this._shapes.face.ringRadius = value; + } + + get faceRingRadius() { + return this._shapes.face.ringRadius; + } + + set ringTolerance(value) { + this._setDiskProp('tolerance', value); + this._shapes.face.tolerance = value; + } + + get ringTolerance() { + return this._shapes.x.tolerance; + } + + _setDiskProp(prop, value) { + this._shapes.x[prop] = value; + this._shapes.y[prop] = value; + this._shapes.z[prop] = value; + } + + _drawGuideAngleLine(pos, axis, point, color = this._guideColors[axis]) { + tmpV1.set(0, 0, 0); + tmpV2.copy(point).scale(this._scale); + this.app.drawLine(tmpV1.add(pos), tmpV2.add(pos), color, false, this.layer); + } + + _getLookAtEulerAngles(position) { + tmpV1.set(0, 0, 0); + tmpM1.setLookAt(tmpV1, position, Vec3.UP); + tmpQ1.setFromMat4(tmpM1); + tmpQ1.getEulerAngles(tmpV1); + tmpV1.x += 90; + return tmpV1; + } + + _faceAxisLookAt(position) { + this._shapes.face.entity.lookAt(position); + this._shapes.face.entity.rotateLocal(90, 0, 0); + } + + _xyzAxisLookAt(position) { + tmpV1.copy(position).sub(this.gizmo.getPosition()); + tmpQ1.copy(this.gizmo.getRotation()).invert().transformVector(tmpV1, tmpV1); + let angle = Math.atan2(tmpV1.z, tmpV1.y) * math.RAD_TO_DEG; + this._shapes.x.entity.setLocalEulerAngles(0, angle - 90, -90); + angle = Math.atan2(tmpV1.x, tmpV1.z) * math.RAD_TO_DEG; + this._shapes.y.entity.setLocalEulerAngles(0, angle, 0); + angle = Math.atan2(tmpV1.y, tmpV1.x) * math.RAD_TO_DEG; + this._shapes.z.entity.setLocalEulerAngles(90, 0, angle + 90); + } + + _drag(state) { + for (const axis in this._shapes) { + const shape = this._shapes[axis]; + if (axis === this._selectedAxis) { + shape.drag(state); + } else { + shape.hide(state); + } + } + } + + _storeNodeRotations() { + const gizmoPos = this.gizmo.getPosition(); + for (let i = 0; i < this.nodes.length; i++) { + const node = this.nodes[i]; + this._nodeLocalRotations.set(node, node.getLocalRotation().clone()); + this._nodeRotations.set(node, node.getRotation().clone()); + this._nodeOffsets.set(node, node.getPosition().clone().sub(gizmoPos)); + } + } + + _setNodeRotations(axis, angleDelta) { + const gizmoPos = this.gizmo.getPosition(); + const cameraPos = this.camera.entity.getPosition(); + const isFacing = axis === 'face'; + for (let i = 0; i < this.nodes.length; i++) { + const node = this.nodes[i]; + + if (isFacing) { + tmpV1.copy(cameraPos).sub(gizmoPos).normalize(); + } else { + tmpV1.set(0, 0, 0); + tmpV1[axis] = 1; + } + + tmpQ1.setFromAxisAngle(tmpV1, angleDelta); + + if (!isFacing && this._coordSpace === LOCAL_COORD_SPACE) { + tmpQ2.copy(this._nodeLocalRotations.get(node)).mul(tmpQ1); + node.setLocalRotation(tmpQ2); + } else { + tmpV1.copy(this._nodeOffsets.get(node)); + tmpQ1.transformVector(tmpV1, tmpV1); + tmpQ2.copy(tmpQ1).mul(this._nodeRotations.get(node)); + + // Fix: Rotation via quaternion when scale inverted causes scale warping? + node.setEulerAngles(tmpQ2.getEulerAngles()); + node.setPosition(tmpV1.add(gizmoPos)); + } + } + + if (this._coordSpace === LOCAL_COORD_SPACE) { + this._updateRotation(); + } + } +} + +export { GizmoRotate }; diff --git a/extras/gizmo/gizmo-scale.js b/extras/gizmo/gizmo-scale.js new file mode 100644 index 00000000000..3e77d690acf --- /dev/null +++ b/extras/gizmo/gizmo-scale.js @@ -0,0 +1,250 @@ +import { + Vec3 +} from 'playcanvas'; + +import { AxisBoxCenter, AxisBoxLine, AxisPlane } from './axis-shapes.js'; +import { LOCAL_COORD_SPACE } from './gizmo.js'; +import { GizmoTransform } from "./gizmo-transform.js"; + +// temporary variables +const tmpV1 = new Vec3(); + +/** + * Scaling gizmo. + * + * @augments GizmoTransform + */ +class GizmoScale extends GizmoTransform { + _shapes = { + xyz: new AxisBoxCenter(this.app.graphicsDevice, { + axis: 'xyz', + layers: [this.layer.id], + defaultColor: this._materials.axis.xyz, + hoverColor: this._materials.hover.xyz + }), + yz: new AxisPlane(this.app.graphicsDevice, { + axis: 'x', + flipAxis: 'y', + layers: [this.layer.id], + rotation: new Vec3(0, 0, -90), + defaultColor: this._materials.axis.x.cullNone, + hoverColor: this._materials.hover.x.cullNone + }), + xz: new AxisPlane(this.app.graphicsDevice, { + axis: 'y', + flipAxis: 'z', + layers: [this.layer.id], + rotation: new Vec3(0, 0, 0), + defaultColor: this._materials.axis.y.cullNone, + hoverColor: this._materials.hover.y.cullNone + }), + xy: new AxisPlane(this.app.graphicsDevice, { + axis: 'z', + flipAxis: 'x', + layers: [this.layer.id], + rotation: new Vec3(90, 0, 0), + defaultColor: this._materials.axis.z.cullNone, + hoverColor: this._materials.hover.z.cullNone + }), + x: new AxisBoxLine(this.app.graphicsDevice, { + axis: 'x', + layers: [this.layer.id], + rotation: new Vec3(0, 0, -90), + defaultColor: this._materials.axis.x.cullBack, + hoverColor: this._materials.hover.x.cullBack + }), + y: new AxisBoxLine(this.app.graphicsDevice, { + axis: 'y', + layers: [this.layer.id], + rotation: new Vec3(0, 0, 0), + defaultColor: this._materials.axis.y.cullBack, + hoverColor: this._materials.hover.y.cullBack + }), + z: new AxisBoxLine(this.app.graphicsDevice, { + axis: 'z', + layers: [this.layer.id], + rotation: new Vec3(90, 0, 0), + defaultColor: this._materials.axis.z.cullBack, + hoverColor: this._materials.hover.z.cullBack + }) + }; + + _coordSpace = LOCAL_COORD_SPACE; + + /** + * Internal mapping from each attached node to their starting scale. + * + * @type {Map} + * @private + */ + _nodeScales = new Map(); + + /** + * State for if uniform scaling is enabled for planes. Defaults to true. + * + * @type {boolean} + */ + uniform = true; + + snapIncrement = 1; + + /** + * Creates a new GizmoScale object. + * + * @param {import('playcanvas').AppBase} app - The application instance. + * @param {import('playcanvas').CameraComponent} camera - The camera component. + * @param {import('playcanvas').Layer} layer - The render layer. + * @example + * const gizmo = new pcx.GizmoScale(app, camera, layer); + */ + constructor(app, camera, layer) { + super(app, camera, layer); + + this._createTransform(); + + this.on('key:down', (key, shiftKey, ctrlKey) => { + this.uniform = ctrlKey; + }); + + this.on('key:up', () => { + this.uniform = false; + }); + + this.on('transform:start', () => { + this._selectionStartPoint.sub(Vec3.ONE); + this._storeNodeScales(); + }); + + this.on('transform:move', (pointDelta) => { + const axis = this._selectedAxis; + const isPlane = this._selectedIsPlane; + if (this.snap) { + pointDelta.scale(1 / this.snapIncrement); + pointDelta.round(); + pointDelta.scale(this.snapIncrement); + } + if (this.uniform && isPlane) { + tmpV1.set(Math.abs(pointDelta.x), Math.abs(pointDelta.y), Math.abs(pointDelta.z)); + tmpV1[axis] = 0; + const v = tmpV1.length(); + tmpV1.set(v * Math.sign(pointDelta.x), v * Math.sign(pointDelta.y), v * Math.sign(pointDelta.z)); + tmpV1[axis] = 1; + pointDelta.copy(tmpV1); + } + this._setNodeScales(pointDelta); + }); + + this.on('nodes:detach', () => { + this._nodeScales.clear(); + }); + } + + set coordSpace(value) { + // disallow changing coordSpace for scale + } + + get coordSpace() { + return this._coordSpace; + } + + set axisGap(value) { + this._setArrowProp('gap', value); + } + + get axisGap() { + return this._shapes.x.gap; + } + + set axisLineThickness(value) { + this._setArrowProp('lineThickness', value); + } + + get axisLineThickness() { + return this._shapes.x.lineThickness; + } + + set axisLineLength(value) { + this._setArrowProp('lineLength', value); + } + + get axisLineLength() { + return this._shapes.x.lineLength; + } + + set axisLineTolerance(value) { + this._setArrowProp('tolerance', value); + } + + get axisLineTolerance() { + return this._shapes.x.tolerance; + } + + set axisBoxSize(value) { + this._setArrowProp('boxSize', value); + } + + get axisBoxSize() { + return this._shapes.x.boxSize; + } + + set axisPlaneSize(value) { + this._setPlaneProp('size', value); + } + + get axisPlaneSize() { + return this._shapes.yz.size; + } + + set axisPlaneGap(value) { + this._setPlaneProp('gap', value); + } + + get axisPlaneGap() { + return this._shapes.yz.gap; + } + + set axisCenterSize(value) { + this._shapes.xyz.size = value; + } + + get axisCenterSize() { + return this._shapes.xyz.size; + } + + set axisCenterTolerance(value) { + this._shapes.xyz.tolerance = value; + } + + get axisCenterTolerance() { + return this._shapes.xyz.tolerance; + } + + + _setArrowProp(prop, value) { + this._shapes.x[prop] = value; + this._shapes.y[prop] = value; + this._shapes.z[prop] = value; + } + + _setPlaneProp(prop, value) { + this._shapes.yz[prop] = value; + this._shapes.xz[prop] = value; + this._shapes.xy[prop] = value; + } + + _storeNodeScales() { + for (let i = 0; i < this.nodes.length; i++) { + const node = this.nodes[i]; + this._nodeScales.set(node, node.getLocalScale().clone()); + } + } + + _setNodeScales(pointDelta) { + for (let i = 0; i < this.nodes.length; i++) { + const node = this.nodes[i]; + node.setLocalScale(this._nodeScales.get(node).clone().mul(pointDelta)); + } + } +} + +export { GizmoScale }; diff --git a/extras/gizmo/gizmo-transform.js b/extras/gizmo/gizmo-transform.js new file mode 100644 index 00000000000..7b2cbd76275 --- /dev/null +++ b/extras/gizmo/gizmo-transform.js @@ -0,0 +1,530 @@ +import { + math, + CULLFACE_NONE, + CULLFACE_BACK, + PROJECTION_PERSPECTIVE, + BLEND_NORMAL, + Color, + StandardMaterial, + Vec3, + Quat +} from 'playcanvas'; + +import { Gizmo } from "./gizmo.js"; + +// temporary variables +const tmpV1 = new Vec3(); +const tmpV2 = new Vec3(); +const tmpQ1 = new Quat(); + +const pointDelta = new Vec3(); + +// constants +const VEC3_AXES = Object.keys(tmpV1); +const FACING_EPSILON = 0.2; +const SPANLINE_SIZE = 1e3; +const ROTATE_SCALE = 900; + +const RED_COLOR = new Color(1, 0.3, 0.3); +const SEMI_RED_COLOR = new Color(1, 0.3, 0.3, 0.6); +const GREEN_COLOR = new Color(0.3, 1, 0.3); +const SEMI_GREEN_COLOR = new Color(0.3, 1, 0.3, 0.6); +const BLUE_COLOR = new Color(0.3, 0.3, 1); +const SEMI_BLUE_COLOR = new Color(0.3, 0.3, 1, 0.6); +const YELLOW_COLOR = new Color(1, 1, 0.5); +const WHITE_COLOR = new Color(1, 1, 1); +const SEMI_WHITE_COLOR = new Color(1, 1, 1, 0.6); + +/** + * The base class for all transform gizmos. + * + * @augments Gizmo + */ +class GizmoTransform extends Gizmo { + /** + * Internal material objects for mesh instances. + * + * @type {Object} + * @protected + */ + _materials = { + axis: { + x: { + cullBack: this._createMaterial(SEMI_RED_COLOR), + cullNone: this._createMaterial(SEMI_RED_COLOR, CULLFACE_NONE) + }, + y: { + cullBack: this._createMaterial(SEMI_GREEN_COLOR), + cullNone: this._createMaterial(SEMI_GREEN_COLOR, CULLFACE_NONE) + }, + z: { + cullBack: this._createMaterial(SEMI_BLUE_COLOR), + cullNone: this._createMaterial(SEMI_BLUE_COLOR, CULLFACE_NONE) + }, + face: this._createMaterial(SEMI_WHITE_COLOR), + xyz: this._createMaterial(SEMI_WHITE_COLOR) + }, + hover: { + x: { + cullBack: this._createMaterial(RED_COLOR), + cullNone: this._createMaterial(RED_COLOR, CULLFACE_NONE) + }, + y: { + cullBack: this._createMaterial(GREEN_COLOR), + cullNone: this._createMaterial(GREEN_COLOR, CULLFACE_NONE) + }, + z: { + cullBack: this._createMaterial(BLUE_COLOR), + cullNone: this._createMaterial(BLUE_COLOR, CULLFACE_NONE) + }, + face: this._createMaterial(YELLOW_COLOR), + xyz: this._createMaterial(WHITE_COLOR) + } + }; + + /** + * Internal version of the guide line color. + * + * @type {Object} + * @protected + */ + _guideColors = { + x: RED_COLOR, + y: GREEN_COLOR, + z: BLUE_COLOR, + face: YELLOW_COLOR + }; + + /** + * Internal gizmo starting rotation in world space. + * + * @type {Quat} + * @protected + */ + _gizmoRotationStart = new Quat(); + + /** + * Internal object containing the axis shapes to render. + * + * @type {Object.} + * @protected + */ + _shapes = {}; + + /** + * Internal mapping of mesh instances to axis shapes for hovering. + * + * @type {Map} + * @private + */ + _hoverShapeMap = new Map(); + + /** + * Internal currently hovered shape. + * + * @type {import('./axis-shapes.js').AxisShape} + * @private + */ + _hoverShape; + + /** + * Internal currently hovered axis. + * + * @type {string} + * @private + */ + _hoverAxis = ''; + + /** + * Internal state of if currently hovered shape is a plane. + * + * @type {boolean} + * @private + */ + _hoverIsPlane = false; + + /** + * Internal currently selected axis. + * + * @type {string} + * @protected + */ + _selectedAxis = ''; + + /** + * Internal state of if currently selected shape is a plane. + * + * @type {boolean} + * @protected + */ + _selectedIsPlane = false; + + /** + * Internal selection starting coordinates in world space. + * + * @type {Vec3} + * @protected + */ + _selectionStartPoint = new Vec3(); + + /** + * Internal selection starting angle in world space. + * + * @type {number} + * @protected + */ + _selectionStartAngle = 0; + + /** + * Internal state if transform is a rotation. + * + * @type {boolean} + * @protected + */ + _isRotation = false; + + /** + * Internal state for if the gizmo is being dragged. + * + * @type {boolean} + * @protected + */ + _dragging = false; + + /** + * State for if snapping is enabled. Defaults to false. + * + * @type {boolean} + */ + snap = false; + + /** + * Snapping increment. Defaults to 1. + * + * @type {number} + */ + snapIncrement = 1; + + /** + * Creates a new GizmoTransform object. + * + * @param {import('playcanvas').AppBase} app - The application instance. + * @param {import('playcanvas').CameraComponent} camera - The camera component. + * @param {import('playcanvas').Layer} layer - The render layer. + * @example + * const gizmo = new pcx.GizmoTransform(app, camera, layer); + */ + constructor(app, camera, layer) { + super(app, camera, layer); + + this.app.on('update', () => { + if (!this.gizmo.enabled) { + return; + } + this._drawGuideLines(); + }); + + this.on('pointer:down', (x, y, meshInstance) => { + if (this._dragging) { + return; + } + + if (meshInstance) { + this._selectedAxis = this._getAxis(meshInstance); + this._selectedIsPlane = this._getIsPlane(meshInstance); + this._gizmoRotationStart.copy(this.gizmo.getRotation()); + const pointInfo = this._calcPoint(x, y); + this._selectionStartPoint.copy(pointInfo.point); + this._selectionStartAngle = pointInfo.angle; + this._dragging = true; + this.fire('transform:start'); + } + }); + + this.on('pointer:move', (x, y, meshInstance) => { + this._hover(meshInstance); + + if (this._dragging) { + const pointInfo = this._calcPoint(x, y); + pointDelta.copy(pointInfo.point).sub(this._selectionStartPoint); + const angleDelta = pointInfo.angle - this._selectionStartAngle; + this.fire('transform:move', pointDelta, angleDelta); + this._hoverAxis = ''; + this._hoverIsPlane = false; + } + }); + + this.on('pointer:up', () => { + this._dragging = false; + this.fire('transform:end'); + + this._selectedAxis = ''; + this._selectedIsPlane = false; + }); + + this.on('key:down', (key, shiftKey) => { + this.snap = shiftKey; + }); + + this.on('key:up', () => { + this.snap = false; + }); + + this.on('nodes:detach', () => { + this._hoverAxis = ''; + this._hoverIsPlane = false; + this._hover(null); + this.fire('pointer:up'); + }); + } + + set xAxisColor(value) { + this._updateAxisColor('x', value); + } + + get xAxisColor() { + return this._materials.axis.x.cullBack.emissive; + } + + set yAxisColor(value) { + this._updateAxisColor('y', value); + } + + get yAxisColor() { + return this._materials.axis.y.cullBack.emissive; + } + + set zAxisColor(value) { + this._updateAxisColor('z', value); + } + + get zAxisColor() { + return this._materials.axis.z.cullBack.emissive; + } + + _updateAxisColor(axis, value) { + this._guideColors[axis].copy(value); + + this._materials.axis[axis].cullBack.emissive.copy(value); + this._materials.axis[axis].cullNone.emissive.copy(value); + this._materials.hover[axis].cullBack.emissive.copy(value); + this._materials.hover[axis].cullNone.emissive.copy(value); + + this._materials.axis[axis].cullBack.update(); + this._materials.axis[axis].cullNone.update(); + this._materials.hover[axis].cullBack.update(); + this._materials.hover[axis].cullNone.update(); + } + + _getAxis(meshInstance) { + if (!meshInstance) { + return ''; + } + return meshInstance.node.name.split(":")[1]; + } + + _getIsPlane(meshInstance) { + if (!meshInstance) { + return false; + } + return meshInstance.node.name.indexOf('plane') !== -1; + } + + _hover(meshInstance) { + if (this._dragging) { + return; + } + this._hoverAxis = this._getAxis(meshInstance); + this._hoverIsPlane = this._getIsPlane(meshInstance); + const shape = this._hoverShapeMap.get(meshInstance); + if (shape === this._hoverShape) { + return; + } + if (this._hoverShape) { + this._hoverShape.hover(false); + this._hoverShape = null; + } + if (shape) { + shape.hover(true); + this._hoverShape = shape; + } + } + + _calcPoint(x, y) { + const gizmoPos = this.gizmo.getPosition(); + const mouseWPos = this.camera.screenToWorld(x, y, 1); + const cameraRot = this.camera.entity.getRotation(); + const rayOrigin = this.camera.entity.getPosition(); + const rayDir = new Vec3(); + const planeNormal = new Vec3(); + const axis = this._selectedAxis; + const isPlane = this._selectedIsPlane; + const isRotation = this._isRotation; + const isAllAxes = axis === 'xyz'; + const isFacing = axis === 'face'; + + // calculate ray direction from mouse position + if (this.camera.projection === PROJECTION_PERSPECTIVE) { + rayDir.copy(mouseWPos).sub(rayOrigin).normalize(); + } else { + rayOrigin.add(mouseWPos); + this.camera.entity.getWorldTransform().transformVector(tmpV1.set(0, 0, -1), rayDir); + } + + if (isAllAxes || isFacing) { + // all axes so set normal to plane facing camera + planeNormal.copy(rayOrigin).sub(gizmoPos).normalize(); + } else { + // set plane normal based on axis + planeNormal[axis] = 1; + + // rotate plane normal by gizmo rotation + tmpQ1.copy(this._gizmoRotationStart).transformVector(planeNormal, planeNormal); + + if (!isPlane && !isRotation) { + tmpV1.copy(rayOrigin).sub(gizmoPos).normalize(); + planeNormal.copy(tmpV1.sub(planeNormal.scale(planeNormal.dot(tmpV1))).normalize()); + } + } + + // ray intersection with plane + const rayPlaneDot = planeNormal.dot(rayDir); + const planeDist = gizmoPos.dot(planeNormal); + const pointPlaneDist = (planeNormal.dot(rayOrigin) - planeDist) / rayPlaneDot; + const point = rayDir.scale(-pointPlaneDist).add(rayOrigin); + + if (isRotation) { + // point needs to be relative to gizmo for angle calculation + point.sub(gizmoPos); + } + + if (isAllAxes) { + // calculate point distance from gizmo + tmpV1.copy(point).sub(gizmoPos).normalize(); + tmpV2.copy(this.camera.entity.up).add(this.camera.entity.right).normalize(); + + const v = point.sub(gizmoPos).length() * tmpV1.dot(tmpV2); + point.set(v, v, v); + } else if (!isFacing) { + if (!isPlane && !isRotation) { + // reset normal based on axis and project position from plane onto normal + planeNormal.set(0, 0, 0); + planeNormal[axis] = 1; + tmpQ1.transformVector(planeNormal, planeNormal); + point.copy(planeNormal.scale(planeNormal.dot(point))); + } + + // rotate point back to world coords + tmpQ1.invert().transformVector(point, point); + + if (!isPlane && !isRotation) { + // set other axes to zero if not plane point + const v = point[axis]; + point.set(0, 0, 0); + point[axis] = v; + } + } + + // calculate angle + let angle = 0; + if (isRotation) { + let isAxisFacing = isFacing; + tmpV1.copy(rayOrigin).sub(gizmoPos).normalize(); + tmpV2.cross(planeNormal, tmpV1); + isAxisFacing ||= tmpV2.length() < FACING_EPSILON; + + if (isAxisFacing) { + switch (axis) { + case 'x': + angle = Math.atan2(point.z, point.y) * math.RAD_TO_DEG; + break; + case 'y': + angle = Math.atan2(point.x, point.z) * math.RAD_TO_DEG; + break; + case 'z': + angle = Math.atan2(point.y, point.x) * math.RAD_TO_DEG; + break; + case 'face': + cameraRot.invert().transformVector(point, tmpV1); + angle = Math.atan2(tmpV1.y, tmpV1.x) * math.RAD_TO_DEG; + break; + } + } else { + angle = mouseWPos.dot(tmpV2.normalize()) * ROTATE_SCALE; + } + } + + return { point, angle }; + } + + _drawGuideLines() { + const gizmoPos = this.gizmo.getPosition(); + const gizmoRot = tmpQ1.copy(this.gizmo.getRotation()); + const checkAxis = this._hoverAxis || this._selectedAxis; + const checkIsPlane = this._hoverIsPlane || this._selectedIsPlane; + for (let i = 0; i < VEC3_AXES.length; i++) { + const axis = VEC3_AXES[i]; + if (checkAxis === 'xyz') { + this._drawSpanLine(gizmoPos, gizmoRot, axis); + continue; + } + if (checkIsPlane) { + if (axis !== checkAxis) { + this._drawSpanLine(gizmoPos, gizmoRot, axis); + } + } else { + if (axis === checkAxis) { + this._drawSpanLine(gizmoPos, gizmoRot, axis); + } + } + } + } + + _drawSpanLine(pos, rot, axis) { + tmpV1.set(0, 0, 0); + tmpV1[axis] = 1; + tmpV1.scale(SPANLINE_SIZE); + tmpV2.copy(tmpV1).scale(-1); + rot.transformVector(tmpV1, tmpV1); + rot.transformVector(tmpV2, tmpV2); + this.app.drawLine(tmpV1.add(pos), tmpV2.add(pos), this._guideColors[axis], true); + } + + _createMaterial(color, cull = CULLFACE_BACK) { + const material = new StandardMaterial(); + material.emissive = color; + material.emissiveVertexColor = true; + material.cull = cull; + material.blendType = BLEND_NORMAL; + if (color.a !== 1) { + material.opacity = color.a; + } + return material; + } + + _createTransform() { + // shapes + for (const key in this._shapes) { + const shape = this._shapes[key]; + this.gizmo.addChild(shape.entity); + this.intersectData.push({ + meshTriDataList: shape.meshTriDataList, + parent: shape.entity, + meshInstances: shape.meshInstances + }); + for (let i = 0; i < shape.meshInstances.length; i++) { + this._hoverShapeMap.set(shape.meshInstances[i], shape); + } + } + } + + destroy() { + for (const key in this._shapes) { + this._shapes[key].destroy(); + } + + super.destroy(); + } +} + +export { GizmoTransform }; diff --git a/extras/gizmo/gizmo-translate.js b/extras/gizmo/gizmo-translate.js new file mode 100644 index 00000000000..2e02da1ebd8 --- /dev/null +++ b/extras/gizmo/gizmo-translate.js @@ -0,0 +1,225 @@ +import { + Quat, + Vec3 +} from 'playcanvas'; + +import { AxisArrow, AxisPlane } from './axis-shapes.js'; +import { LOCAL_COORD_SPACE } from './gizmo.js'; +import { GizmoTransform } from "./gizmo-transform.js"; + +// temporary variables +const tmpV1 = new Vec3(); +const tmpV2 = new Vec3(); +const tmpQ1 = new Quat(); + +/** + * Translation gizmo. + * + * @augments GizmoTransform + */ +class GizmoTranslate extends GizmoTransform { + _shapes = { + yz: new AxisPlane(this.app.graphicsDevice, { + axis: 'x', + flipAxis: 'y', + layers: [this.layer.id], + rotation: new Vec3(0, 0, -90), + defaultColor: this._materials.axis.x.cullNone, + hoverColor: this._materials.hover.x.cullNone + }), + xz: new AxisPlane(this.app.graphicsDevice, { + axis: 'y', + flipAxis: 'z', + layers: [this.layer.id], + rotation: new Vec3(0, 0, 0), + defaultColor: this._materials.axis.y.cullNone, + hoverColor: this._materials.hover.y.cullNone + }), + xy: new AxisPlane(this.app.graphicsDevice, { + axis: 'z', + flipAxis: 'x', + layers: [this.layer.id], + rotation: new Vec3(90, 0, 0), + defaultColor: this._materials.axis.z.cullNone, + hoverColor: this._materials.hover.z.cullNone + }), + x: new AxisArrow(this.app.graphicsDevice, { + axis: 'x', + layers: [this.layer.id], + rotation: new Vec3(0, 0, -90), + defaultColor: this._materials.axis.x.cullBack, + hoverColor: this._materials.hover.x.cullBack + }), + y: new AxisArrow(this.app.graphicsDevice, { + axis: 'y', + layers: [this.layer.id], + rotation: new Vec3(0, 0, 0), + defaultColor: this._materials.axis.y.cullBack, + hoverColor: this._materials.hover.y.cullBack + }), + z: new AxisArrow(this.app.graphicsDevice, { + axis: 'z', + layers: [this.layer.id], + rotation: new Vec3(90, 0, 0), + defaultColor: this._materials.axis.z.cullBack, + hoverColor: this._materials.hover.z.cullBack + }) + }; + + /** + * Internal mapping from each attached node to their starting position in local space. + * + * @type {Map} + * @private + */ + _nodeLocalPositions = new Map(); + + /** + * Internal mapping from each attached node to their starting position in world space. + * + * @type {Map} + * @private + */ + _nodePositions = new Map(); + + snapIncrement = 1; + + /** + * Creates a new GizmoTranslate object. + * + * @param {import('playcanvas').AppBase} app - The application instance. + * @param {import('playcanvas').CameraComponent} camera - The camera component. + * @param {import('playcanvas').Layer} layer - The render layer. + * @example + * const gizmo = new pcx.GizmoTranslate(app, camera, layer); + */ + constructor(app, camera, layer) { + super(app, camera, layer); + + this._createTransform(); + + this.on('transform:start', () => { + this._storeNodePositions(); + }); + + this.on('transform:move', (pointDelta) => { + if (this.snap) { + pointDelta.scale(1 / this.snapIncrement); + pointDelta.round(); + pointDelta.scale(this.snapIncrement); + } + this._setNodePositions(pointDelta); + }); + + this.on('nodes:detach', () => { + this._nodeLocalPositions.clear(); + this._nodePositions.clear(); + }); + } + + set axisGap(value) { + this._setArrowProp('gap', value); + } + + get axisGap() { + return this._shapes.x.gap; + } + + set axisLineThickness(value) { + this._setArrowProp('lineThickness', value); + } + + get axisLineThickness() { + return this._shapes.x.lineThickness; + } + + set axisLineLength(value) { + this._setArrowProp('lineLength', value); + } + + get axisLineLength() { + return this._shapes.x.lineLength; + } + + set axisLineTolerance(value) { + this._setArrowProp('tolerance', value); + } + + get axisLineTolerance() { + return this._shapes.x.tolerance; + } + + set axisArrowThickness(value) { + this._setArrowProp('arrowThickness', value); + } + + get axisArrowThickness() { + return this._shapes.x.arrowThickness; + } + + set axisArrowLength(value) { + this._setArrowProp('arrowLength', value); + } + + get axisArrowLength() { + return this._shapes.x.arrowLength; + } + + set axisPlaneSize(value) { + this._setPlaneProp('size', value); + } + + get axisPlaneSize() { + return this._shapes.yz.size; + } + + set axisPlaneGap(value) { + this._setPlaneProp('gap', value); + } + + get axisPlaneGap() { + return this._shapes.yz.gap; + } + + _setArrowProp(prop, value) { + this._shapes.x[prop] = value; + this._shapes.y[prop] = value; + this._shapes.z[prop] = value; + } + + _setPlaneProp(prop, value) { + this._shapes.yz[prop] = value; + this._shapes.xz[prop] = value; + this._shapes.xy[prop] = value; + } + + _storeNodePositions() { + for (let i = 0; i < this.nodes.length; i++) { + const node = this.nodes[i]; + this._nodeLocalPositions.set(node, node.getLocalPosition().clone()); + this._nodePositions.set(node, node.getPosition().clone()); + } + } + + _setNodePositions(pointDelta) { + for (let i = 0; i < this.nodes.length; i++) { + const node = this.nodes[i]; + if (this._coordSpace === LOCAL_COORD_SPACE) { + tmpV1.copy(pointDelta); + node.parent.getWorldTransform().getScale(tmpV2); + tmpV2.x = 1 / tmpV2.x; + tmpV2.y = 1 / tmpV2.y; + tmpV2.z = 1 / tmpV2.z; + tmpQ1.copy(node.getLocalRotation()).transformVector(tmpV1, tmpV1); + tmpV1.mul(tmpV2); + node.setLocalPosition(this._nodeLocalPositions.get(node).clone().add(tmpV1)); + } else { + node.setPosition(this._nodePositions.get(node).clone().add(pointDelta)); + } + } + + this._updatePosition(); + } +} + +export { GizmoTranslate }; diff --git a/extras/gizmo/gizmo.js b/extras/gizmo/gizmo.js new file mode 100644 index 00000000000..53d498e042c --- /dev/null +++ b/extras/gizmo/gizmo.js @@ -0,0 +1,342 @@ +import { + math, + PROJECTION_PERSPECTIVE, + EventHandler, + Entity, + Mat4, + Vec3 +} from 'playcanvas'; + +// temporary variables +const tmpV1 = new Vec3(); +const tmpM1 = new Mat4(); + +const xstart = new Vec3(); +const xdir = new Vec3(); + +// constants +const MIN_GIZMO_SCALE = 1e-4; +const PERS_SCALE_RATIO = 0.3; +const ORTHO_SCALE_RATIO = 0.32; + +/** + * Local coordinate space. + * + * @type {string} + */ +export const LOCAL_COORD_SPACE = 'local'; + +/** + * World coordinate space. + * + * @type {string} + */ +export const WORLD_COORD_SPACE = 'world'; + +/** + * The base class for all gizmos. + */ +class Gizmo extends EventHandler { + /** + * Internal version of the gizmo size. + * + * @type {number} + * @private + */ + _size = 1; + + /** + * Internal version of the gizmo scale. + * + * @type {number} + * @protected + */ + _scale = 1; + + /** + * Internal version of coordinate space. Defaults to {@link WORLD_COORD_SPACE}. + * + * @type {string} + * @protected + */ + _coordSpace = WORLD_COORD_SPACE; + + /** + * The application instance containing the gizmo. + * + * @type {import('playcanvas').AppBase} + */ + app; + + /** + * The camera entity that displays the gizmo. + * + * @type {Entity} + */ + camera; + + /** + * The graph nodes attached to the gizmo. + * + * @type {import('playcanvas').GraphNode} + */ + nodes = []; + + /** + * The root gizmo entity. + * + * @type {import('playcanvas').Entity} + */ + gizmo; + + /** + * @typedef IntersectData + * @property {import('./mesh-tri-data.js').MeshTriData[]} meshTriDataList - + * The array of {@link MeshTriData} + * @property {import('playcanvas').GraphNode} parent - The mesh parent node. + * @property {import('playcanvas').MeshInstance[]} meshInstances - + * array of mesh instances for rendering + */ + /** + * The intersection data object. + * + * @type {IntersectData[]} + */ + intersectData = []; + + /** + * Creates a new Gizmo object. + * + * @param {import('playcanvas').AppBase} app - The application instance. + * @param {import('playcanvas').CameraComponent} camera - The camera component. + * @param {import('playcanvas').Layer} layer - The render layer. + * @example + * const gizmo = new pcx.Gizmo(app, camera); + */ + constructor(app, camera, layer) { + super(); + + this.app = app; + this.camera = camera; + this.layer = layer; + + this._createGizmo(); + + this._updateScale(); + + this._onPointerDown = (e) => { + if (!this.gizmo.enabled || document.pointerLockElement) { + return; + } + const selection = this._getSelection(e.clientX, e.clientY); + if (selection[0]) { + e.preventDefault(); + } + this.fire('pointer:down', e.clientX, e.clientY, selection[0]); + }; + this._onPointerMove = (e) => { + if (!this.gizmo.enabled || document.pointerLockElement) { + return; + } + const selection = this._getSelection(e.clientX, e.clientY); + if (selection[0]) { + e.preventDefault(); + } + this.fire('pointer:move', e.clientX, e.clientY, selection[0]); + }; + this._onPointerUp = (e) => { + if (!this.gizmo.enabled || document.pointerLockElement) { + return; + } + this.fire('pointer:up'); + }; + this._onKeyDown = (e) => { + if (!this.gizmo.enabled) { + return; + } + this.fire('key:down', e.key, e.shiftKey, e.ctrlKey, e.metaKey); + }; + this._onKeyUp = (e) => { + if (!this.gizmo.enabled) { + return; + } + this.fire('key:up'); + }; + + if (window) { + window.addEventListener('pointerdown', this._onPointerDown); + window.addEventListener('pointermove', this._onPointerMove); + window.addEventListener('pointerup', this._onPointerUp); + window.addEventListener('keydown', this._onKeyDown); + window.addEventListener('keyup', this._onKeyUp); + } + + app.on('update', () => this._updateScale()); + + app.on('destroy', () => this.destroy()); + } + + set coordSpace(value) { + this._coordSpace = value ?? WORLD_COORD_SPACE; + this._updateRotation(); + } + + get coordSpace() { + return this._coordSpace; + } + + set size(value) { + this._size = value; + this._updateScale(); + } + + get size() { + return this._size; + } + + _getProjFrustumWidth() { + const gizmoPos = this.gizmo.getPosition(); + const cameraPos = this.camera.entity.getPosition(); + const dist = tmpV1.copy(gizmoPos).sub(cameraPos).dot(this.camera.entity.forward); + return dist * Math.tan(this.camera.fov * math.DEG_TO_RAD / 2); + } + + _createGizmo() { + this.gizmo = new Entity('gizmo'); + this.app.root.addChild(this.gizmo); + this.gizmo.enabled = false; + } + + _updatePosition() { + tmpV1.set(0, 0, 0); + for (let i = 0; i < this.nodes.length; i++) { + const node = this.nodes[i]; + tmpV1.add(node.getPosition()); + } + tmpV1.scale(1.0 / (this.nodes.length || 1)); + this.gizmo.setPosition(tmpV1); + + this.fire('position:set', tmpV1); + } + + _updateRotation() { + tmpV1.set(0, 0, 0); + if (this._coordSpace === LOCAL_COORD_SPACE) { + tmpV1.copy(this.nodes[this.nodes.length - 1].getEulerAngles()); + } + this.gizmo.setEulerAngles(tmpV1); + + this.fire('rotation:set', tmpV1); + } + + _updateScale() { + if (this.camera.projection === PROJECTION_PERSPECTIVE) { + this._scale = this._getProjFrustumWidth() * PERS_SCALE_RATIO; + } else { + this._scale = this.camera.orthoHeight * ORTHO_SCALE_RATIO; + } + this._scale = Math.max(this._scale * this._size, MIN_GIZMO_SCALE); + this.gizmo.setLocalScale(this._scale, this._scale, this._scale); + + this.fire('scale:set', this._scale); + } + + _getSelection(x, y) { + const start = this.camera.screenToWorld(x, y, 1); + const end = this.camera.screenToWorld(x, y, this.camera.farClip); + const dir = end.clone().sub(start).normalize(); + + const selection = []; + for (let i = 0; i < this.intersectData.length; i++) { + const { meshTriDataList, parent, meshInstances } = this.intersectData[i]; + const wtm = parent.getWorldTransform().clone(); + for (let j = 0; j < meshTriDataList.length; j++) { + const { tris, ptm } = meshTriDataList[j]; + tmpM1.copy(wtm).mul(ptm); + tmpM1.invert(); + tmpM1.transformPoint(start, xstart); + tmpM1.transformVector(dir, xdir); + xdir.normalize(); + + for (let k = 0; k < tris.length; k++) { + if (tris[k].intersectRay(xstart, xdir, tmpV1)) { + selection.push({ + dist: tmpV1.sub(xstart).length(), + meshInstances: meshInstances + }); + } + } + } + } + + if (selection.length) { + selection.sort((s0, s1) => s0.dist - s1.dist); + return selection[0].meshInstances; + } + + return []; + } + + /** + * Attach an array of graph nodes to the gizmo. + * + * @param {import('playcanvas').GraphNode} [nodes] - The graph nodes. Defaults to []. + * @example + * const gizmo = new pcx.Gizmo(); + * gizmo.attach([boxA, boxB]); + */ + attach(nodes = []) { + if (nodes.length === 0) { + return; + } + + this.nodes = nodes; + this._updatePosition(); + this._updateRotation(); + + this.fire('nodes:attach'); + + this.gizmo.enabled = true; + } + + /** + * Detaches all graph nodes from the gizmo. + * + * @example + * const gizmo = new pcx.Gizmo(); + * gizmo.attach([boxA, boxB]); + * gizmo.detach(); + */ + detach() { + this.gizmo.enabled = false; + + this.fire('nodes:detach'); + + this.nodes = []; + } + + /** + * Destroys the gizmo instance; detaches + * all graph nodes. + * + * @example + * const gizmo = new pcx.Gizmo(); + * gizmo.attach([boxA, boxB]); + * gizmo.destroy(); + */ + destroy() { + this.detach(); + + if (window) { + window.removeEventListener('pointerdown', this._onPointerDown); + window.removeEventListener('pointermove', this._onPointerMove); + window.removeEventListener('pointerup', this._onPointerUp); + window.removeEventListener('keydown', this._onKeyDown); + window.removeEventListener('keyup', this._onKeyUp); + } + + this.gizmo.destroy(); + } +} + +export { Gizmo }; diff --git a/extras/gizmo/mesh-tri-data.js b/extras/gizmo/mesh-tri-data.js new file mode 100644 index 00000000000..d851285166e --- /dev/null +++ b/extras/gizmo/mesh-tri-data.js @@ -0,0 +1,77 @@ +import { + Mesh, + Mat4, + Quat, + Vec3 +} from 'playcanvas'; + +import { Tri } from './tri.js'; + +// temporary variables +const tmpV1 = new Vec3(); +const tmpV2 = new Vec3(); +const tmpV3 = new Vec3(); + +/** + * The class for holding mesh triangle data. + */ +class MeshTriData { + /** + * The transform of the mesh. + * + * @type {Mat4} + */ + _ptm = new Mat4(); + + /** + * The array of triangles for the mesh. + * + * @type {import('./tri.js').Tri[]} + */ + tris; + + constructor(mesh) { + this.setTris(mesh); + } + + get ptm() { + return this._ptm; + } + + _trisFromMesh(mesh, destroy = true) { + const tris = []; + const pos = []; + const indices = []; + mesh.getPositions(pos); + mesh.getIndices(indices); + if (destroy) { + mesh.destroy(); + } + + for (let k = 0; k < indices.length; k += 3) { + const i1 = indices[k]; + const i2 = indices[k + 1]; + const i3 = indices[k + 2]; + + tmpV1.set(pos[i1 * 3], pos[i1 * 3 + 1], pos[i1 * 3 + 2]); + tmpV2.set(pos[i2 * 3], pos[i2 * 3 + 1], pos[i2 * 3 + 2]); + tmpV3.set(pos[i3 * 3], pos[i3 * 3 + 1], pos[i3 * 3 + 2]); + const tri = new Tri(tmpV1, tmpV2, tmpV3); + tris.push(tri); + } + return tris; + } + + setTransform(pos = new Vec3(), rot = new Quat(), scale = new Vec3()) { + this.ptm.setTRS(pos, rot, scale); + } + + setTris(mesh) { + if (!mesh || !(mesh instanceof Mesh)) { + throw new Error('No mesh provided.'); + } + this.tris = this._trisFromMesh(mesh); + } +} + +export { MeshTriData }; diff --git a/extras/gizmo/tri.js b/extras/gizmo/tri.js new file mode 100644 index 00000000000..52cc78fc4aa --- /dev/null +++ b/extras/gizmo/tri.js @@ -0,0 +1,67 @@ +import { + Vec3 +} from 'playcanvas'; + +const e1 = new Vec3(); +const e2 = new Vec3(); +const h = new Vec3(); +const s = new Vec3(); +const q = new Vec3(); + +// constants +const EPSILON = 1e-6; + +class Tri { + v0 = new Vec3(); + + v1 = new Vec3(); + + v2 = new Vec3(); + + constructor(v0, v1, v2) { + this.set(v0, v1, v2); + } + + set(v0, v1, v2) { + this.v0.copy(v0); + this.v1.copy(v1); + this.v2.copy(v2); + + return this; + } + + intersectRay(origin, dir, out, epsilon = EPSILON) { + e1.sub2(this.v1, this.v0); + e2.sub2(this.v2, this.v0); + h.cross(dir, e2); + const a = e1.dot(h); + if (a > -epsilon && a < epsilon) { + return false; + } + + const f = 1 / a; + s.sub2(origin, this.v0); + const u = f * s.dot(h); + if (u < 0 || u > 1) { + return false; + } + + q.cross(s, e1); + const v = f * dir.dot(q); + if (v < 0 || u + v > 1) { + return false; + } + + const t = f * e2.dot(q); + if (t > epsilon) { + if (out instanceof Vec3) { + out.copy(dir).scale(t).add(origin); + } + return true; + } + + return false; + } +} + +export { Tri }; diff --git a/extras/index.js b/extras/index.js index 308bd3967ac..f73456e0caa 100644 --- a/extras/index.js +++ b/extras/index.js @@ -17,3 +17,10 @@ export { RenderPassDownsample } from './render-passes/render-pass-downsample.js' export { RenderPassUpsample } from './render-passes/render-pass-upsample.js'; export { RenderPassBloom } from './render-passes/render-pass-bloom.js'; export { RenderPassTAA } from './render-passes/render-pass-taa.js'; + +// gizmo +export { Gizmo } from "./gizmo/gizmo.js"; +export { GizmoTransform } from "./gizmo/gizmo-transform.js"; +export { GizmoTranslate } from "./gizmo/gizmo-translate.js"; +export { GizmoRotate } from "./gizmo/gizmo-rotate.js"; +export { GizmoScale } from "./gizmo/gizmo-scale.js"; diff --git a/scripts/camera/orbit-camera.js b/scripts/camera/orbit-camera.js index d46c0604d4a..d53cf072ab6 100644 --- a/scripts/camera/orbit-camera.js +++ b/scripts/camera/orbit-camera.js @@ -41,6 +41,18 @@ Object.defineProperty(OrbitCamera.prototype, "distance", { } }); +// Property to get and set the camera orthoHeight +// Clamped above 0 +Object.defineProperty(OrbitCamera.prototype, "orthoHeight", { + get: function () { + return this.entity.camera.orthoHeight; + }, + + set: function (value) { + this.entity.camera.orthoHeight = Math.max(0, value); + } +}); + // Property to get and set the pitch of the camera around the pivot point (degrees) // Clamped between this.pitchAngleMin and this.pitchAngleMax @@ -472,7 +484,11 @@ OrbitCameraInputMouse.prototype.onMouseMove = function (event) { OrbitCameraInputMouse.prototype.onMouseWheel = function (event) { - this.orbitCamera.distance -= event.wheel * this.distanceSensitivity * (this.orbitCamera.distance * 0.1); + if (this.entity.camera.projection === pc.PROJECTION_PERSPECTIVE) { + this.orbitCamera.distance -= event.wheel * this.distanceSensitivity * (this.orbitCamera.distance * 0.1); + } else { + this.orbitCamera.orthoHeight -= event.wheel * this.distanceSensitivity; + } event.event.preventDefault(); }; diff --git a/src/scene/procedural.js b/src/scene/procedural.js index b70805628c4..28de5aea2ed 100644 --- a/src/scene/procedural.js +++ b/src/scene/procedural.js @@ -1,3 +1,4 @@ +import { math } from '../core/math/math.js'; import { Vec2 } from '../core/math/vec2.js'; import { Vec3 } from '../core/math/vec3.js'; @@ -298,6 +299,8 @@ function createMesh(device, positions, opts) { * (defaults to 0.2). * @param {number} [opts.ringRadius] - The radius from the centre of the torus to the centre of the * tube (defaults to 0.3). + * @param {number} [opts.sectorAngle] - The sector angle in degrees of the ring of the torus + * (defaults to 2 * Math.PI). * @param {number} [opts.segments] - The number of radial divisions forming cross-sections of the * torus ring (defaults to 20). * @param {number} [opts.sides] - The number of divisions around the tubular body of the torus ring @@ -309,6 +312,7 @@ function createTorus(device, opts = {}) { // Check the supplied options and provide defaults for unspecified ones const rc = opts.tubeRadius ?? 0.2; const rt = opts.ringRadius ?? 0.3; + const sectorAngle = (opts.sectorAngle ?? 360) * math.DEG_TO_RAD; const segments = opts.segments ?? 30; const sides = opts.sides ?? 20; const calcTangents = opts.calculateTangents ?? false; @@ -321,13 +325,13 @@ function createTorus(device, opts = {}) { for (let i = 0; i <= sides; i++) { for (let j = 0; j <= segments; j++) { - const x = Math.cos(2 * Math.PI * j / segments) * (rt + rc * Math.cos(2 * Math.PI * i / sides)); + const x = Math.cos(sectorAngle * j / segments) * (rt + rc * Math.cos(2 * Math.PI * i / sides)); const y = Math.sin(2 * Math.PI * i / sides) * rc; - const z = Math.sin(2 * Math.PI * j / segments) * (rt + rc * Math.cos(2 * Math.PI * i / sides)); + const z = Math.sin(sectorAngle * j / segments) * (rt + rc * Math.cos(2 * Math.PI * i / sides)); - const nx = Math.cos(2 * Math.PI * j / segments) * Math.cos(2 * Math.PI * i / sides); + const nx = Math.cos(sectorAngle * j / segments) * Math.cos(2 * Math.PI * i / sides); const ny = Math.sin(2 * Math.PI * i / sides); - const nz = Math.sin(2 * Math.PI * j / segments) * Math.cos(2 * Math.PI * i / sides); + const nz = Math.sin(sectorAngle * j / segments) * Math.cos(2 * Math.PI * i / sides); const u = i / sides; const v = 1 - j / segments;