diff --git a/package-lock.json b/package-lock.json index 6a3a656d..3d36d20c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supersplat", - "version": "0.24.3", + "version": "0.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supersplat", - "version": "0.24.3", + "version": "0.25.0", "license": "MIT", "devDependencies": { "@playcanvas/eslint-config": "^1.7.1", diff --git a/package.json b/package.json index 69288032..2ed15850 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "supersplat", - "version": "0.24.3", + "version": "0.25.0", "author": "PlayCanvas", "homepage": "https://playcanvas.com/supersplat/editor", "description": "3D Gaussian Splat Editor", diff --git a/src/controllers.ts b/src/controllers.ts index f9b13799..132aaf1b 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -9,8 +9,8 @@ const worldDiff = new Vec3(); const dist = (x0: number, y0: number, x1: number, y1: number) => Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2); class PointerController { - destroy: () => void; update: (deltaTime: number) => void; + destroy: () => void; constructor(camera: Camera, target: HTMLElement) { @@ -154,7 +154,7 @@ class PointerController { }; const keydown = (event: KeyboardEvent) => { - if (keys.hasOwnProperty(event.key)) { + if (keys.hasOwnProperty(event.key) && event.target === document.body) { keys[event.key] = event.shiftKey ? 10 : (event.ctrlKey || event.metaKey || event.altKey ? 0.1 : 1); event.preventDefault(); event.stopPropagation(); diff --git a/src/splat.ts b/src/splat.ts index 18442a09..88fb1189 100644 --- a/src/splat.ts +++ b/src/splat.ts @@ -374,6 +374,8 @@ class Splat extends Element { this.worldBoundDirty = true; this.scene.boundDirty = true; + + this.scene.events.fire('splat.moved', this); } // get local space bound @@ -387,10 +389,11 @@ class Splat extends Element { localBound.halfExtents.set(0.5, 0.5, 0.5); } + this.localBoundDirty = false; + + // align the pivot point to the splat center this.entity.getWorldTransform().transformPoint(localBound.center, vec); this.setPivot(vec); - - this.localBoundDirty = false; } return this.localBoundStorage; @@ -416,6 +419,8 @@ class Splat extends Element { mat.transformPoint(position, veca); this.entity.setLocalPosition(-veca.x, -veca.y, -veca.z); this.pivot.setLocalPosition(position); + + this.scene.events.fire('splat.moved', this); } get visible() { diff --git a/src/style.scss b/src/style.scss index 9be0baf0..e087adcc 100644 --- a/src/style.scss +++ b/src/style.scss @@ -142,6 +142,7 @@ body { margin-right: 10px; color: $text-secondary; + #transform-panel & { content: '\E111'; }; #camera-panel & { content: '\E212'; }; #show-panel & { content: '\E117'; }; #selection-panel & { content: '\E398'; }; @@ -328,6 +329,15 @@ body { flex-direction: column; } +#transform-panel > .pcui-panel-content { + display: flex; + flex-direction: column; +} + +.transform-panel-axis-label { + text-align: center; +} + #camera-panel > .pcui-panel-content { display: flex; flex-direction: column; @@ -353,8 +363,8 @@ body { width: 100px; flex-shrink: 0; flex-grow: 0; - line-height: 24px; - margin: 2px 6px 2px 6px; + line-height: 32px; + margin: 0px 6px 0px 6px; } .control-element { diff --git a/src/tools/transform-tool.ts b/src/tools/transform-tool.ts index 2ab5a235..b30aaa6c 100644 --- a/src/tools/transform-tool.ts +++ b/src/tools/transform-tool.ts @@ -100,6 +100,10 @@ class TransformTool { this.update(); }); + events.on('splat.moved', (splat: Splat) => { + this.update(); + }); + const updateGizmoSize = () => { const canvas = document.getElementById('canvas'); if (canvas) { diff --git a/src/ui/control-panel.ts b/src/ui/control-panel.ts index 531fcf5d..5a6b392c 100644 --- a/src/ui/control-panel.ts +++ b/src/ui/control-panel.ts @@ -1,135 +1,11 @@ -import { BooleanInput, Button, Container, Element as PcuiElement, Label, NumericInput, Panel, RadioButton, SelectInput, SliderInput, TreeViewItem, VectorInput } from 'pcui'; +import { BooleanInput, Button, Container, Label, NumericInput, Panel, RadioButton, SelectInput, SliderInput, VectorInput } from 'pcui'; import { Events } from '../events'; +import { SplatItem, SplatList } from './splat-list'; +import { TransformPanel } from './transform-panel'; import { Element, ElementType } from '../element'; import { Splat } from '../splat'; import { version as appVersion } from '../../package.json'; -class SplatItem extends Container { - getSelected: () => boolean; - setSelected: (value: boolean) => void; - getVisible: () => boolean; - setVisible: (value: boolean) => void; - destroy: () => void; - - constructor(name: string, args = {}) { - args = { - ...args, - class: 'scene-panel-splat-item' - }; - - super(args); - - const text = new Label({ - class: 'scene-panel-splat-item-text', - text: name - }); - - const visible = new PcuiElement({ - class: ['scene-panel-splat-item-visible', 'checked'] - }); - - const remove = new PcuiElement({ - class: 'scene-panel-splat-item-delete' - }); - - this.append(text); - this.append(visible); - this.append(remove); - - this.getSelected = () => { - return this.class.contains('selected'); - }; - - this.setSelected = (value: boolean) => { - if (value !== this.selected) { - if (value) { - this.class.add('selected'); - this.emit('select', this); - } else { - this.class.remove('selected'); - this.emit('unselect', this); - } - } - }; - - this.getVisible = () => { - return visible.class.contains('checked'); - }; - - this.setVisible = (value: boolean) => { - if (value !== this.visible) { - if (value) { - visible.class.add('checked'); - this.emit('visible', this); - } else { - visible.class.remove('checked'); - this.emit('invisible', this); - } - } - }; - - const toggleVisible = (event: MouseEvent) => { - event.stopPropagation(); - this.visible = !this.visible; - }; - - const handleRemove = (event: MouseEvent) => { - event.stopPropagation(); - this.emit('removeClicked', this); - }; - - // handle clicks - visible.dom.addEventListener('click', toggleVisible, true); - remove.dom.addEventListener('click', handleRemove, true); - - this.destroy = () => { - visible.dom.removeEventListener('click', toggleVisible, true); - remove.dom.removeEventListener('click', handleRemove, true); - } - } - - get selected() { - return this.getSelected(); - } - - set selected(value) { - this.setSelected(value); - } - - get visible() { - return this.getVisible(); - } - - set visible(value) { - this.setVisible(value); - } -} - -class SplatList extends Container { - protected _onAppendChild(element: PcuiElement): void { - super._onAppendChild(element); - - if (element instanceof SplatItem) { - element.on('click', () => { - this.emit('click', element); - }); - - element.on('removeClicked', () => { - this.emit('removeClicked', element); - }); - } - } - - protected _onRemoveChild(element: PcuiElement): void { - if (element instanceof SplatItem) { - element.unbind('click'); - element.unbind('removeClicked'); - } - - super._onRemoveChild(element); - } -} - class ControlPanel extends Panel { constructor(events: Events, remoteStorageMode: boolean, args = { }) { args = { @@ -176,7 +52,14 @@ class ControlPanel extends Panel { splatList.append(item); items.set(splat, item); - item.on('visible', () => splat.visible = true); + item.on('visible', () => { + splat.visible = true; + + // also select it if there is no other selection + if (!events.invoke('selection')) { + events.fire('selection', splat); + } + }); item.on('invisible', () => splat.visible = false); } }); @@ -237,6 +120,8 @@ class ControlPanel extends Panel { } }); + const transformPanel = new TransformPanel(events); + // camera panel const cameraPanel = new Panel({ id: 'camera-panel', @@ -578,6 +463,7 @@ class ControlPanel extends Panel { id: 'control-panel-controls' }); + controlsContainer.append(transformPanel); controlsContainer.append(cameraPanel) controlsContainer.append(selectionPanel); controlsContainer.append(showPanel); diff --git a/src/ui/splat-list.ts b/src/ui/splat-list.ts new file mode 100644 index 00000000..1c9decfc --- /dev/null +++ b/src/ui/splat-list.ts @@ -0,0 +1,129 @@ +import { Container, Label, Element } from 'pcui'; + +class SplatItem extends Container { + getSelected: () => boolean; + setSelected: (value: boolean) => void; + getVisible: () => boolean; + setVisible: (value: boolean) => void; + destroy: () => void; + + constructor(name: string, args = {}) { + args = { + ...args, + class: 'scene-panel-splat-item' + }; + + super(args); + + const text = new Label({ + class: 'scene-panel-splat-item-text', + text: name + }); + + const visible = new Element({ + class: ['scene-panel-splat-item-visible', 'checked'] + }); + + const remove = new Element({ + class: 'scene-panel-splat-item-delete' + }); + + this.append(text); + this.append(visible); + this.append(remove); + + this.getSelected = () => { + return this.class.contains('selected'); + }; + + this.setSelected = (value: boolean) => { + if (value !== this.selected) { + if (value) { + this.class.add('selected'); + this.emit('select', this); + } else { + this.class.remove('selected'); + this.emit('unselect', this); + } + } + }; + + this.getVisible = () => { + return visible.class.contains('checked'); + }; + + this.setVisible = (value: boolean) => { + if (value !== this.visible) { + if (value) { + visible.class.add('checked'); + this.emit('visible', this); + } else { + visible.class.remove('checked'); + this.emit('invisible', this); + } + } + }; + + const toggleVisible = (event: MouseEvent) => { + event.stopPropagation(); + this.visible = !this.visible; + }; + + const handleRemove = (event: MouseEvent) => { + event.stopPropagation(); + this.emit('removeClicked', this); + }; + + // handle clicks + visible.dom.addEventListener('click', toggleVisible, true); + remove.dom.addEventListener('click', handleRemove, true); + + this.destroy = () => { + visible.dom.removeEventListener('click', toggleVisible, true); + remove.dom.removeEventListener('click', handleRemove, true); + } + } + + get selected() { + return this.getSelected(); + } + + set selected(value) { + this.setSelected(value); + } + + get visible() { + return this.getVisible(); + } + + set visible(value) { + this.setVisible(value); + } +} + +class SplatList extends Container { + protected _onAppendChild(element: Element): void { + super._onAppendChild(element); + + if (element instanceof SplatItem) { + element.on('click', () => { + this.emit('click', element); + }); + + element.on('removeClicked', () => { + this.emit('removeClicked', element); + }); + } + } + + protected _onRemoveChild(element: Element): void { + if (element instanceof SplatItem) { + element.unbind('click'); + element.unbind('removeClicked'); + } + + super._onRemoveChild(element); + } +} + +export { SplatList, SplatItem }; diff --git a/src/ui/transform-panel.ts b/src/ui/transform-panel.ts new file mode 100644 index 00000000..28261642 --- /dev/null +++ b/src/ui/transform-panel.ts @@ -0,0 +1,175 @@ +import { Container, Label, NumericInput, Panel, PanelArgs, VectorInput } from 'pcui'; +import { Quat, Vec3 } from 'playcanvas'; +import { Events } from '../events'; +import { Splat } from '../splat'; + +class TransformPanel extends Panel { + constructor(events: Events, args: PanelArgs = {}) { + args = { + id: 'transform-panel', + class: 'control-panel', + headerText: 'TRANSFORM', + ...args + }; + + super(args); + + const axis = new Container({ + class: 'control-parent' + }); + + const axisLabel = new Label({ + class: 'control-label', + text: '' + }); + + const xLabel = new Label({ + class: ['control-element-expand', 'transform-panel-axis-label'], + text: 'x' + }); + + const yLabel = new Label({ + class: ['control-element-expand', 'transform-panel-axis-label'], + text: 'y' + }); + + const zLabel = new Label({ + class: ['control-element-expand', 'transform-panel-axis-label'], + text: 'z' + }); + + axis.append(axisLabel); + axis.append(xLabel); + axis.append(yLabel); + axis.append(zLabel); + + // position + const position = new Container({ + class: 'control-parent' + }); + + const positionLabel = new Label({ + class: 'control-label', + text: 'Position' + }); + + const positionVector = new VectorInput({ + class: 'control-element-expand', + precision: 2, + dimensions: 3, + value: [0, 0, 0], + enabled: false + }); + + position.append(positionLabel); + position.append(positionVector); + + // rotation + const rotation = new Container({ + class: 'control-parent' + }); + + const rotationLabel = new Label({ + class: 'control-label', + text: 'Rotation' + }); + + const rotationVector = new VectorInput({ + class: 'control-element-expand', + precision: 2, + dimensions: 3, + value: [0, 0, 0], + enabled: false + }); + + rotation.append(rotationLabel); + rotation.append(rotationVector); + + // scale + const scale = new Container({ + class: 'control-parent' + }); + + const scaleLabel = new Label({ + class: 'control-label', + text: 'Scale' + }); + + const scaleInput = new NumericInput({ + class: 'control-element-expand', + precision: 2, + value: 1, + min: 0.01, + max: 10000, + enabled: false + }); + + scale.append(scaleLabel); + scale.append(scaleInput); + + this.append(axis); + this.append(position); + this.append(rotation); + this.append(scale); + + let selection: Splat | null = null; + + const toArray = (v: Vec3) => { + return [v.x, v.y, v.z]; + }; + + const toVec3 = (a: number[]) => { + return new Vec3(a[0], a[1], a[2]); + }; + + let uiUpdating = false; + + const updateUI = () => { + uiUpdating = true; + positionVector.value = toArray(selection.pivot.getLocalPosition()); + rotationVector.value = toArray(selection.pivot.getLocalEulerAngles()); + scaleInput.value = selection.pivot.getLocalScale().x; + uiUpdating = false; + }; + + events.on('selection.changed', (splat) => { + selection = splat; + + if (selection) { + // enable inputs + updateUI(); + positionVector.enabled = rotationVector.enabled = scaleInput.enabled = true; + } else { + // enable inputs + positionVector.enabled = rotationVector.enabled = scaleInput.enabled = false; + } + }); + + events.on('splat.moved', (splat: Splat) => { + if (splat === selection) { + updateUI(); + } + }); + + positionVector.on('change', () => { + if (!uiUpdating) { + selection.move(toVec3(positionVector.value), null, null); + } + }); + + rotationVector.on('change', () => { + if (!uiUpdating) { + const v = rotationVector.value; + selection.move(null, new Quat().setFromEulerAngles(v[0], v[1], v[2]), null); + } + }); + + scaleInput.on('change', () => { + if (!uiUpdating) { + selection.move(null, null, toVec3([scaleInput.value, scaleInput.value, scaleInput.value])); + } + }); + } +} + +export { TransformPanel };