浏览代码

Merge pull request #1201 from zauberzeug/draggable-scene-objects

Support drag and drop for 3D objects in `ui.scene`
Falko Schindler 1 年之前
父节点
当前提交
8adfa1865f

+ 220 - 0
nicegui/elements/lib/three/modules/DragControls.js

@@ -0,0 +1,220 @@
+import {
+	EventDispatcher,
+	Matrix4,
+	Plane,
+	Raycaster,
+	Vector2,
+	Vector3
+} from 'three';
+
+const _plane = new Plane();
+const _raycaster = new Raycaster();
+
+const _pointer = new Vector2();
+const _offset = new Vector3();
+const _intersection = new Vector3();
+const _worldPosition = new Vector3();
+const _inverseMatrix = new Matrix4();
+
+class DragControls extends EventDispatcher {
+
+	constructor( _objects, _camera, _domElement ) {
+
+		super();
+
+		_domElement.style.touchAction = 'none'; // disable touch scroll
+
+		let _selected = null, _hovered = null;
+
+		const _intersections = [];
+
+		//
+
+		const scope = this;
+
+		function activate() {
+
+			_domElement.addEventListener( 'pointermove', onPointerMove );
+			_domElement.addEventListener( 'pointerdown', onPointerDown );
+			_domElement.addEventListener( 'pointerup', onPointerCancel );
+			_domElement.addEventListener( 'pointerleave', onPointerCancel );
+
+		}
+
+		function deactivate() {
+
+			_domElement.removeEventListener( 'pointermove', onPointerMove );
+			_domElement.removeEventListener( 'pointerdown', onPointerDown );
+			_domElement.removeEventListener( 'pointerup', onPointerCancel );
+			_domElement.removeEventListener( 'pointerleave', onPointerCancel );
+
+			_domElement.style.cursor = '';
+
+		}
+
+		function dispose() {
+
+			deactivate();
+
+		}
+
+		function getObjects() {
+
+			return _objects;
+
+		}
+
+		function getRaycaster() {
+
+			return _raycaster;
+
+		}
+
+		function onPointerMove( event ) {
+
+			if ( scope.enabled === false ) return;
+
+			updatePointer( event );
+
+			_raycaster.setFromCamera( _pointer, _camera );
+
+			if ( _selected ) {
+
+				if ( _raycaster.ray.intersectPlane( _plane, _intersection ) ) {
+
+					_selected.position.copy( _intersection.sub( _offset ).applyMatrix4( _inverseMatrix ) );
+
+				}
+
+				scope.dispatchEvent( { type: 'drag', object: _selected } );
+
+				return;
+
+			}
+
+			// hover support
+
+			if ( event.pointerType === 'mouse' || event.pointerType === 'pen' ) {
+
+				_intersections.length = 0;
+
+				_raycaster.setFromCamera( _pointer, _camera );
+				_raycaster.intersectObjects( _objects, true, _intersections );
+
+				if ( _intersections.length > 0 ) {
+
+					const object = _intersections[ 0 ].object;
+
+					_plane.setFromNormalAndCoplanarPoint( _camera.getWorldDirection( _plane.normal ), _worldPosition.setFromMatrixPosition( object.matrixWorld ) );
+
+					if ( _hovered !== object && _hovered !== null ) {
+
+						scope.dispatchEvent( { type: 'hoveroff', object: _hovered } );
+
+						_domElement.style.cursor = 'auto';
+						_hovered = null;
+
+					}
+
+					if ( _hovered !== object ) {
+
+						scope.dispatchEvent( { type: 'hoveron', object: object } );
+
+						_domElement.style.cursor = 'pointer';
+						_hovered = object;
+
+					}
+
+				} else {
+
+					if ( _hovered !== null ) {
+
+						scope.dispatchEvent( { type: 'hoveroff', object: _hovered } );
+
+						_domElement.style.cursor = 'auto';
+						_hovered = null;
+
+					}
+
+				}
+
+			}
+
+		}
+
+		function onPointerDown( event ) {
+
+			if ( scope.enabled === false ) return;
+
+			updatePointer( event );
+
+			_intersections.length = 0;
+
+			_raycaster.setFromCamera( _pointer, _camera );
+			_raycaster.intersectObjects( _objects, true, _intersections );
+
+			if ( _intersections.length > 0 ) {
+
+				_selected = ( scope.transformGroup === true ) ? _objects[ 0 ] : _intersections[ 0 ].object;
+
+				_plane.setFromNormalAndCoplanarPoint( _camera.getWorldDirection( _plane.normal ), _worldPosition.setFromMatrixPosition( _selected.matrixWorld ) );
+
+				if ( _raycaster.ray.intersectPlane( _plane, _intersection ) ) {
+
+					_inverseMatrix.copy( _selected.parent.matrixWorld ).invert();
+					_offset.copy( _intersection ).sub( _worldPosition.setFromMatrixPosition( _selected.matrixWorld ) );
+
+				}
+
+				_domElement.style.cursor = 'move';
+
+				scope.dispatchEvent( { type: 'dragstart', object: _selected } );
+
+			}
+
+
+		}
+
+		function onPointerCancel() {
+
+			if ( scope.enabled === false ) return;
+
+			if ( _selected ) {
+
+				scope.dispatchEvent( { type: 'dragend', object: _selected } );
+
+				_selected = null;
+
+			}
+
+			_domElement.style.cursor = _hovered ? 'pointer' : 'auto';
+
+		}
+
+		function updatePointer( event ) {
+
+			const rect = _domElement.getBoundingClientRect();
+
+			_pointer.x = ( event.clientX - rect.left ) / rect.width * 2 - 1;
+			_pointer.y = - ( event.clientY - rect.top ) / rect.height * 2 + 1;
+
+		}
+
+		activate();
+
+		// API
+
+		this.enabled = true;
+		this.transformGroup = false;
+
+		this.activate = activate;
+		this.deactivate = deactivate;
+		this.dispose = dispose;
+		this.getObjects = getObjects;
+		this.getRaycaster = getRaycaster;
+
+	}
+
+}
+
+export { DragControls };

+ 31 - 0
nicegui/elements/scene.js

@@ -1,6 +1,7 @@
 import * as THREE from "three";
 import * as THREE from "three";
 import { CSS2DRenderer, CSS2DObject } from "CSS2DRenderer";
 import { CSS2DRenderer, CSS2DObject } from "CSS2DRenderer";
 import { CSS3DRenderer, CSS3DObject } from "CSS3DRenderer";
 import { CSS3DRenderer, CSS3DObject } from "CSS3DRenderer";
+import { DragControls } from "DragControls";
 import { OrbitControls } from "OrbitControls";
 import { OrbitControls } from "OrbitControls";
 import { STLLoader } from "STLLoader";
 import { STLLoader } from "STLLoader";
 
 
@@ -59,6 +60,7 @@ export default {
     this.scene = new THREE.Scene();
     this.scene = new THREE.Scene();
     this.objects = new Map();
     this.objects = new Map();
     this.objects.set("scene", this.scene);
     this.objects.set("scene", this.scene);
+    this.draggable_objects = [];
 
 
     window["scene_" + this.$el.id] = this.scene; // NOTE: for selenium tests only
     window["scene_" + this.$el.id] = this.scene; // NOTE: for selenium tests only
 
 
@@ -117,6 +119,28 @@ export default {
       this.scene.add(grid);
       this.scene.add(grid);
     }
     }
     this.controls = new OrbitControls(this.camera, this.renderer.domElement);
     this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+    this.drag_controls = new DragControls(this.draggable_objects, this.camera, this.renderer.domElement);
+    const applyConstraint = (constraint, position) => {
+      if (!constraint) return;
+      const [variable, expression] = constraint.split("=").map((s) => s.trim());
+      position[variable] = eval(expression.replace(/x|y|z/g, (match) => `(${position[match]})`));
+    };
+    const handleDrag = (event) => {
+      this.drag_constraints.split(",").forEach((constraint) => applyConstraint(constraint, event.object.position));
+      if (event.type === "drag") return;
+      this.$emit(event.type, {
+        type: event.type,
+        object_id: event.object.object_id,
+        object_name: event.object.name,
+        x: event.object.position.x,
+        y: event.object.position.y,
+        z: event.object.position.z,
+      });
+      this.controls.enabled = event.type == "dragend";
+    };
+    this.drag_controls.addEventListener("dragstart", handleDrag);
+    this.drag_controls.addEventListener("drag", handleDrag);
+    this.drag_controls.addEventListener("dragend", handleDrag);
 
 
     const render = () => {
     const render = () => {
       requestAnimationFrame(() => setTimeout(() => render(), 1000 / 20));
       requestAnimationFrame(() => setTimeout(() => render(), 1000 / 20));
@@ -300,10 +324,16 @@ export default {
       if (!this.objects.has(object_id)) return;
       if (!this.objects.has(object_id)) return;
       this.objects.get(object_id).visible = value;
       this.objects.get(object_id).visible = value;
     },
     },
+    draggable(object_id, value) {
+      if (!this.objects.has(object_id)) return;
+      if (value) this.draggable_objects.push(this.objects.get(object_id));
+      else this.draggable_objects.pop(this.objects.get(object_id));
+    },
     delete(object_id) {
     delete(object_id) {
       if (!this.objects.has(object_id)) return;
       if (!this.objects.has(object_id)) return;
       this.objects.get(object_id).removeFromParent();
       this.objects.get(object_id).removeFromParent();
       this.objects.delete(object_id);
       this.objects.delete(object_id);
+      this.draggable_objects.pop(this.objects.get(object_id));
     },
     },
     set_texture_url(object_id, url) {
     set_texture_url(object_id, url) {
       if (!this.objects.has(object_id)) return;
       if (!this.objects.has(object_id)) return;
@@ -371,5 +401,6 @@ export default {
     width: Number,
     width: Number,
     height: Number,
     height: Number,
     grid: Boolean,
     grid: Boolean,
+    drag_constraints: String,
   },
   },
 };
 };

+ 31 - 3
nicegui/elements/scene.py

@@ -3,7 +3,8 @@ from typing import Any, Callable, Dict, List, Optional, Union
 
 
 from .. import binding, globals
 from .. import binding, globals
 from ..element import Element
 from ..element import Element
-from ..events import GenericEventArguments, SceneClickEventArguments, SceneClickHit, handle_event
+from ..events import (GenericEventArguments, SceneClickEventArguments, SceneClickHit, SceneDragEventArguments,
+                      handle_event)
 from ..helpers import KWONLY_SLOTS
 from ..helpers import KWONLY_SLOTS
 from .scene_object3d import Object3D
 from .scene_object3d import Object3D
 
 
@@ -33,6 +34,7 @@ class Scene(Element,
                 'lib/three/three.module.js',
                 'lib/three/three.module.js',
                 'lib/three/modules/CSS2DRenderer.js',
                 'lib/three/modules/CSS2DRenderer.js',
                 'lib/three/modules/CSS3DRenderer.js',
                 'lib/three/modules/CSS3DRenderer.js',
+                'lib/three/modules/DragControls.js',
                 'lib/three/modules/OrbitControls.js',
                 'lib/three/modules/OrbitControls.js',
                 'lib/three/modules/STLLoader.js',
                 'lib/three/modules/STLLoader.js',
             ]):
             ]):
@@ -57,10 +59,13 @@ class Scene(Element,
                  height: int = 300,
                  height: int = 300,
                  grid: bool = True,
                  grid: bool = True,
                  on_click: Optional[Callable[..., Any]] = None,
                  on_click: Optional[Callable[..., Any]] = None,
+                 on_drag_start: Optional[Callable[..., Any]] = None,
+                 on_drag_end: Optional[Callable[..., Any]] = None,
+                 drag_constraints: str = '',
                  ) -> None:
                  ) -> None:
         """3D Scene
         """3D Scene
 
 
-        Display a 3d scene using `three.js <https://threejs.org/>`_.
+        Display a 3D scene using `three.js <https://threejs.org/>`_.
         Currently NiceGUI supports boxes, spheres, cylinders/cones, extrusions, straight lines, curves and textured meshes.
         Currently NiceGUI supports boxes, spheres, cylinders/cones, extrusions, straight lines, curves and textured meshes.
         Objects can be translated, rotated and displayed with different color, opacity or as wireframes.
         Objects can be translated, rotated and displayed with different color, opacity or as wireframes.
         They can also be grouped to apply joint movements.
         They can also be grouped to apply joint movements.
@@ -68,7 +73,10 @@ class Scene(Element,
         :param width: width of the canvas
         :param width: width of the canvas
         :param height: height of the canvas
         :param height: height of the canvas
         :param grid: whether to display a grid
         :param grid: whether to display a grid
-        :param on_click: callback to execute when a 3d object is clicked
+        :param on_click: callback to execute when a 3D object is clicked
+        :param on_drag_start: callback to execute when a 3D object is dragged
+        :param on_drag_end: callback to execute when a 3D object is dropped
+        :param drag_constraints: comma-separated JavaScript expression for constraining positions of dragged objects (e.g. ``'x = 0, z = y / 2'``)
         """
         """
         super().__init__()
         super().__init__()
         self._props['width'] = width
         self._props['width'] = width
@@ -78,9 +86,14 @@ class Scene(Element,
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self.camera: SceneCamera = SceneCamera()
         self.camera: SceneCamera = SceneCamera()
         self.on_click = on_click
         self.on_click = on_click
+        self.on_drag_start = on_drag_start
+        self.on_drag_end = on_drag_end
         self.is_initialized = False
         self.is_initialized = False
         self.on('init', self.handle_init)
         self.on('init', self.handle_init)
         self.on('click3d', self.handle_click)
         self.on('click3d', self.handle_click)
+        self.on('dragstart', self.handle_drag)
+        self.on('dragend', self.handle_drag)
+        self._props['drag_constraints'] = drag_constraints
 
 
     def __enter__(self) -> 'Scene':
     def __enter__(self) -> 'Scene':
         Object3D.current_scene = self
         Object3D.current_scene = self
@@ -124,6 +137,21 @@ class Scene(Element,
         )
         )
         handle_event(self.on_click, arguments)
         handle_event(self.on_click, arguments)
 
 
+    def handle_drag(self, e: GenericEventArguments) -> None:
+        arguments = SceneDragEventArguments(
+            sender=self,
+            client=self.client,
+            type=e.args['type'],
+            object_id=e.args['object_id'],
+            object_name=e.args['object_name'],
+            x=e.args['x'],
+            y=e.args['y'],
+            z=e.args['z'],
+        )
+        if arguments.type == 'dragend':
+            self.objects[arguments.object_id].move(arguments.x, arguments.y, arguments.z)
+        handle_event(self.on_drag_start if arguments.type == 'dragstart' else self.on_drag_end, arguments)
+
     def __len__(self) -> int:
     def __len__(self) -> int:
         return len(self.objects)
         return len(self.objects)
 
 

+ 11 - 0
nicegui/elements/scene_object3d.py

@@ -24,6 +24,7 @@ class Object3D:
         self.opacity: float = 1.0
         self.opacity: float = 1.0
         self.side_: str = 'front'
         self.side_: str = 'front'
         self.visible_: bool = True
         self.visible_: bool = True
+        self.draggable_: bool = False
         self.x: float = 0
         self.x: float = 0
         self.y: float = 0
         self.y: float = 0
         self.z: float = 0
         self.z: float = 0
@@ -46,6 +47,7 @@ class Object3D:
         self._rotate()
         self._rotate()
         self._scale()
         self._scale()
         self._visible()
         self._visible()
+        self._draggable()
 
 
     def __enter__(self):
     def __enter__(self):
         self.scene.stack.append(self)
         self.scene.stack.append(self)
@@ -75,6 +77,9 @@ class Object3D:
     def _visible(self) -> None:
     def _visible(self) -> None:
         self.scene.run_method('visible', self.id, self.visible_)
         self.scene.run_method('visible', self.id, self.visible_)
 
 
+    def _draggable(self) -> None:
+        self.scene.run_method('draggable', self.id, self.draggable_)
+
     def _delete(self) -> None:
     def _delete(self) -> None:
         self.scene.run_method('delete', self.id)
         self.scene.run_method('delete', self.id)
 
 
@@ -132,6 +137,12 @@ class Object3D:
             self._visible()
             self._visible()
         return self
         return self
 
 
+    def draggable(self, value: bool = True):
+        if self.draggable_ != value:
+            self.draggable_ = value
+            self._draggable()
+        return self
+
     def delete(self) -> None:
     def delete(self) -> None:
         children = [object for object in self.scene.objects.values() if object.parent == self]
         children = [object for object in self.scene.objects.values() if object.parent == self]
         for child in children:
         for child in children:

+ 11 - 1
nicegui/events.py

@@ -1,6 +1,6 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
 from inspect import Parameter, signature
 from inspect import Parameter, signature
-from typing import TYPE_CHECKING, Any, Awaitable, BinaryIO, Callable, Dict, List, Optional
+from typing import TYPE_CHECKING, Any, Awaitable, BinaryIO, Callable, Dict, List, Literal, Optional
 
 
 from . import background_tasks, globals
 from . import background_tasks, globals
 from .helpers import KWONLY_SLOTS
 from .helpers import KWONLY_SLOTS
@@ -53,6 +53,16 @@ class SceneClickEventArguments(ClickEventArguments):
     hits: List[SceneClickHit]
     hits: List[SceneClickHit]
 
 
 
 
+@dataclass(**KWONLY_SLOTS)
+class SceneDragEventArguments(ClickEventArguments):
+    type: Literal['dragstart', 'dragend']
+    object_id: str
+    object_name: str
+    x: float
+    y: float
+    z: float
+
+
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
 class ColorPickEventArguments(EventArguments):
 class ColorPickEventArguments(EventArguments):
     color: str
     color: str

+ 1 - 0
npm.json

@@ -86,6 +86,7 @@
       "package/examples/jsm/renderers/CSS2DRenderer(\\.min)?\\.js",
       "package/examples/jsm/renderers/CSS2DRenderer(\\.min)?\\.js",
       "package/examples/jsm/renderers/CSS3DRenderer(\\.min)?\\.js",
       "package/examples/jsm/renderers/CSS3DRenderer(\\.min)?\\.js",
       "package/examples/jsm/controls/OrbitControls(\\.min)?\\.js",
       "package/examples/jsm/controls/OrbitControls(\\.min)?\\.js",
+      "package/examples/jsm/controls/DragControls(\\.min)?\\.js",
       "package/examples/jsm/loaders/STLLoader(\\.min)?\\.js",
       "package/examples/jsm/loaders/STLLoader(\\.min)?\\.js",
       "package/examples/jsm/libs/tween\\.module\\.min\\.js"
       "package/examples/jsm/libs/tween\\.module\\.min\\.js"
     ],
     ],

+ 27 - 0
website/more_documentation/scene_documentation.py

@@ -56,6 +56,33 @@ def more() -> None:
             scene.sphere().move(x=-1, z=1).with_name('sphere')
             scene.sphere().move(x=-1, z=1).with_name('sphere')
             scene.box().move(x=1, z=1).with_name('box')
             scene.box().move(x=1, z=1).with_name('box')
 
 
+    @text_demo('Draggable objects', '''
+        You can make objects draggable using the `.draggable` method.
+        There is an optional `on_drag_start` and `on_drag_end` argument to `ui.scene` to handle drag events.
+        The callbacks receive a `SceneDragEventArguments` object with the following attributes:
+        
+        - `type`: the type of drag event ("dragstart" or "dragend").
+        - `object_id`: the id of the object that was dragged.
+        - `object_name`: the name of the object that was dragged.
+        - `x`, `y`, `z`: the x, y and z coordinates of the dragged object.
+               
+        You can also use the `drag_constraints` argument to set comma-separated JavaScript expressions
+        for constraining positions of dragged objects.
+    ''')
+    def draggable_objects() -> None:
+        from nicegui import events
+
+        def handle_drag(e: events.SceneDragEventArguments):
+            ui.notify(f'You dropped the sphere at ({e.x:.2f}, {e.y:.2f}, {e.z:.2f})')
+
+        with ui.scene(width=285, height=220,
+                      drag_constraints='z = 1', on_drag_end=handle_drag) as scene:
+            sphere = scene.sphere().move(z=1).draggable()
+
+        ui.switch('draggable sphere',
+                  value=sphere.draggable_,
+                  on_change=lambda e: sphere.draggable(e.value))
+
     @text_demo('Rendering point clouds', '''
     @text_demo('Rendering point clouds', '''
         You can render point clouds using the `point_cloud` method.
         You can render point clouds using the `point_cloud` method.
         The `points` argument is a list of point coordinates, and the `colors` argument is a list of RGB colors (0..1).
         The `points` argument is a list of point coordinates, and the `colors` argument is a list of RGB colors (0..1).