From 5a676da02b11322e6205213caecfd2bed5b8e446 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Thu, 18 Jul 2024 15:17:04 +0100 Subject: [PATCH] Add view cube (#135) --- package-lock.json | 4 +- package.json | 2 +- src/editor.ts | 12 +++ src/scene-config.ts | 2 +- src/scene.ts | 2 +- src/style.scss | 8 ++ src/tools/transform-tool.ts | 6 ++ src/ui/editor.ts | 15 +++- src/ui/view-cube.ts | 161 ++++++++++++++++++++++++++++++++++++ 9 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 src/ui/view-cube.ts diff --git a/package-lock.json b/package-lock.json index 05b3d872..49a24a55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supersplat", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supersplat", - "version": "0.23.0", + "version": "0.24.0", "license": "MIT", "devDependencies": { "@playcanvas/eslint-config": "^1.7.1", diff --git a/package.json b/package.json index baee9e12..0b0b6e69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "supersplat", - "version": "0.23.0", + "version": "0.24.0", "author": "PlayCanvas", "homepage": "https://playcanvas.com/supersplat/editor", "description": "3D Gaussian Splat Editor", diff --git a/src/editor.ts b/src/editor.ts index e05602c7..c9fb3c26 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -160,6 +160,18 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S } }); + // handle camera align events + events.on('camera.align', (axis: string) => { + switch (axis) { + case 'px': scene.camera.setAzimElev(90, 0); break; + case 'py': scene.camera.setAzimElev(0, -90); break; + case 'pz': scene.camera.setAzimElev(0, 0); break; + case 'nx': scene.camera.setAzimElev(270, 0); break; + case 'ny': scene.camera.setAzimElev(0, 90); break; + case 'nz': scene.camera.setAzimElev(180, 0); break; + } + }); + events.on('select.all', () => { selectedSplats().forEach((splat) => { const splatData = splat.splatData; diff --git a/src/scene-config.ts b/src/scene-config.ts index ddf96501..dce366be 100644 --- a/src/scene-config.ts +++ b/src/scene-config.ts @@ -37,7 +37,7 @@ const sceneConfig = { enableZoom: true, dampingFactor: 0.2, minPolarAngle: 0, - maxPolarAngle: 2.8, + maxPolarAngle: Math.PI, minZoom: 0.001, maxZoom: 2.0, initialAzim: -45, diff --git a/src/scene.ts b/src/scene.ts index ef5922b3..bf01baaa 100644 --- a/src/scene.ts +++ b/src/scene.ts @@ -348,7 +348,7 @@ class Scene { this.forEachElement(e => e.onPreRender()); - this.events.fire('prerender'); + this.events.fire('prerender', this.camera.entity.getWorldTransform()); // debug - display scene bound if (this.config.debug.showBound) { diff --git a/src/style.scss b/src/style.scss index ad0de289..5a7e8666 100644 --- a/src/style.scss +++ b/src/style.scss @@ -381,6 +381,14 @@ body { height: 100%; } +#view-cube-container { + position: absolute; + width: 140px; + height: 140px; + right: 0px; + top: 0px; +} + #brush-select-canvas { display: none; position: absolute; diff --git a/src/tools/transform-tool.ts b/src/tools/transform-tool.ts index e05dc545..28d7b2c6 100644 --- a/src/tools/transform-tool.ts +++ b/src/tools/transform-tool.ts @@ -106,6 +106,12 @@ class TransformTool { const w = canvas.clientWidth; const h = canvas.clientHeight; this.gizmo.size = 1200 / Math.max(w, h); + + // FIXME: + // this is a temporary workaround to undo gizmo's own auto scaling. + // once gizmo's autoscaling code is removed, this line can go too. + // @ts-ignore + this.gizmo._deviceStartSize = Math.min(scene.app.graphicsDevice.width, scene.app.graphicsDevice.height); } }; diff --git a/src/ui/editor.ts b/src/ui/editor.ts index c6a34561..343a1a51 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -1,9 +1,11 @@ -import { Element, Container, Label } from 'pcui'; +import { Container, Label } from 'pcui'; import { ControlPanel } from './control-panel'; import { DataPanel } from './data-panel'; import { Toolbar } from './toolbar'; import { Events } from '../events'; import { Popup } from './popup'; +import { ViewCube } from './view-cube'; +import { Mat4 } from 'playcanvas'; import logo from './playcanvas-logo.png'; class EditorUI { @@ -78,6 +80,13 @@ class EditorUI { canvasContainer.append(filenameLabel); canvasContainer.append(toolsContainer); + // view axes container + const viewCube = new ViewCube(events); + canvasContainer.append(viewCube); + events.on('prerender', (cameraMatrix: Mat4) => { + viewCube.update(cameraMatrix); + }); + // control panel const controlPanel = new ControlPanel(events, remoteStorageMode); @@ -131,7 +140,9 @@ class EditorUI { canvas.height = Math.ceil(canvasContainer.dom.offsetHeight * pixelRatio); // disable context menu globally - document.addEventListener('contextmenu', event => event.preventDefault()); + document.addEventListener('contextmenu', (event: MouseEvent) => { + event.preventDefault(); + }, true); // whenever the canvas container is clicked, set keyboard focus on the body canvasContainer.dom.addEventListener('pointerdown', (event: PointerEvent) => { diff --git a/src/ui/view-cube.ts b/src/ui/view-cube.ts new file mode 100644 index 00000000..ed82d9bc --- /dev/null +++ b/src/ui/view-cube.ts @@ -0,0 +1,161 @@ +import { Container } from 'pcui'; +import { Mat4, Vec3 } from 'playcanvas'; +import { Events } from '../events'; + +const vecx = new Vec3(); +const vecy = new Vec3(); +const vecz = new Vec3(); +const mat4 = new Mat4(); + +class ViewCube extends Container { + update: (cameraMatrix: Mat4) => void; + + constructor(events: Events, args = {}) { + args = { + ...args, + id: 'view-cube-container' + }; + + super(args); + + // construct svg elements + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.id = 'view-cube-svg'; + + const group = document.createElementNS(svg.namespaceURI, 'g'); + svg.appendChild(group); + + const circle = (color: string, fill: boolean, text?: string) => { + const result = document.createElementNS(svg.namespaceURI, 'g') as SVGElement; + + const circle = document.createElementNS(svg.namespaceURI, 'circle') as SVGCircleElement; + circle.setAttribute('fill', color); + circle.setAttribute('fill-opacity', fill ? '1' : '0.14'); + circle.setAttribute('stroke', color); + circle.setAttribute('stroke-width', '2'); + circle.setAttribute('r', '10'); + circle.setAttribute('cx', '0'); + circle.setAttribute('cy', '0'); + + result.appendChild(circle); + + if (text) { + const t = document.createElementNS(svg.namespaceURI, 'text') as SVGTextElement; + t.setAttribute('font-size', '10'); + t.setAttribute('font-family', 'Arial'); + t.setAttribute('font-weight', 'bold'); + t.setAttribute('text-anchor', 'middle'); + t.setAttribute('alignment-baseline', 'central'); + t.setAttribute('pointer-events', 'none'); + t.textContent = text; + result.appendChild(t); + } + + result.setAttribute('cursor', 'pointer'); + + group.appendChild(result); + + return result; + }; + + const line = (color: string) => { + const result = document.createElementNS(svg.namespaceURI, 'line') as SVGLineElement; + result.setAttribute('stroke', color); + result.setAttribute('stroke-width', '2'); + group.appendChild(result); + return result; + }; + + const r = '#f44'; + const g = '#4f4'; + const b = '#77f'; + + const shapes = { + nx: circle(r, false), + ny: circle(g, false), + nz: circle(b, false), + xaxis: line(r), + yaxis: line(g), + zaxis: line(b), + px: circle(r, true, 'X'), + py: circle(g, true, 'Y'), + pz: circle(b, true, 'Z'), + }; + + shapes.px.children[0].addEventListener('pointerdown', () => { events.fire('camera.align', 'px'); }); + shapes.py.children[0].addEventListener('pointerdown', () => { events.fire('camera.align', 'py'); }); + shapes.pz.children[0].addEventListener('pointerdown', () => { events.fire('camera.align', 'pz'); }); + shapes.nx.children[0].addEventListener('pointerdown', () => { events.fire('camera.align', 'nx'); }); + shapes.ny.children[0].addEventListener('pointerdown', () => { events.fire('camera.align', 'ny'); }); + shapes.nz.children[0].addEventListener('pointerdown', () => { events.fire('camera.align', 'nz'); }); + + this.dom.appendChild(svg); + + let cw = 0; + let ch = 0; + + this.update = (cameraMatrix: Mat4) => { + const w = this.dom.clientWidth; + const h = this.dom.clientHeight; + + if (w && h) { + if (w !== cw || h !== ch) { + // resize elements + svg.setAttribute('width', w.toString()); + svg.setAttribute('height', h.toString()); + group.setAttribute('transform', `translate(${w * 0.5}, ${h * 0.5})`); + cw = w; + ch = h; + } + + mat4.invert(cameraMatrix); + mat4.getX(vecx); + mat4.getY(vecy); + mat4.getZ(vecz); + + const transform = (group: SVGElement, x: number, y: number) => { + group.setAttribute('transform', `translate(${x * 40}, ${y * 40})`); + }; + + const x2y2 = (line: SVGLineElement, x: number, y: number) => { + line.setAttribute('x2', (x * 40).toString()); + line.setAttribute('y2', (y * 40).toString()); + }; + + transform(shapes.px, vecx.x, -vecx.y); + transform(shapes.nx, -vecx.x, vecx.y); + transform(shapes.py, vecy.x, -vecy.y); + transform(shapes.ny, -vecy.x, vecy.y); + transform(shapes.pz, vecz.x, -vecz.y); + transform(shapes.nz, -vecz.x, vecz.y); + + x2y2(shapes.xaxis, vecx.x, -vecx.y); + x2y2(shapes.yaxis, vecy.x, -vecy.y); + x2y2(shapes.zaxis, vecz.x, -vecz.y); + + // reorder dom for the mighty svg painter's algorithm + const order = [ + { n: ['xaxis', 'px'], value: vecx.z }, + { n: ['yaxis', 'py'], value: vecy.z }, + { n: ['zaxis', 'pz'], value: vecz.z }, + { n: ['nx'], value: -vecx.z }, + { n: ['ny'], value: -vecy.z }, + { n: ['nz'], value: -vecz.z } + ].sort((a, b) => a.value - b.value); + + const fragment = document.createDocumentFragment(); + + order.forEach((o) => { + o.n.forEach((n) => { + // @ts-ignore + fragment.appendChild(shapes[n]); + }); + }); + + group.appendChild(fragment); + } + }; + } +} + +export { ViewCube };