Repository: zauberzeug/nicegui
Pull request #4018 (2024-11-21 06:43:48)

Allow ungrouping 3D objects
This PR implements feature request #4015 introducing an `ungroup` method for 3D objects:

```py
import math
import time
from nicegui import ui

with ui.scene() as scene:
    with scene.group() as group:
        a = scene.sphere().move(-2)
        b = scene.sphere().move(0)
        c = scene.sphere().move(2)

ui.timer(0.1, lambda: group.move(y=math.sin(time.time())))
ui.button('Ungroup', on_click=a.ungroup)

ui.run()
```

Open tasks:

- [x] better name: "detach"
- [x] introduce "attach" for symmetry
- [ ] resolve pose so that the object doesn't move visually



Patch:
diff --git a/nicegui/classes.py b/nicegui/classes.py
index 74f90c1e..062684b1 100644
--- a/nicegui/classes.py
+++ b/nicegui/classes.py
@@ -15,8 +15,9 @@ class Classes(list, Generic[T]):
     def __call__(self,
                  add: Optional[str] = None, *,
                  remove: Optional[str] = None,
+                 toggle: Optional[str] = None,
                  replace: Optional[str] = None) -> T:
-        """Apply, remove, or replace HTML classes.
+        """Apply, remove, toggle, or replace HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -24,9 +25,10 @@ class Classes(list, Generic[T]):
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        new_classes = self.update_list(self, add, remove, replace)
+        new_classes = self.update_list(self, add, remove, toggle, replace)
         if self != new_classes:
             self[:] = new_classes
             self.element.update()
@@ -36,10 +38,18 @@ class Classes(list, Generic[T]):
     def update_list(classes: List[str],
                     add: Optional[str] = None,
                     remove: Optional[str] = None,
+                    toggle: Optional[str] = None,
                     replace: Optional[str] = None) -> List[str]:
         """Update a list of classes."""
         class_list = classes if replace is None else []
         class_list = [c for c in class_list if c not in (remove or '').split()]
         class_list += (add or '').split()
         class_list += (replace or '').split()
-        return list(dict.fromkeys(class_list))  # NOTE: remove duplicates while preserving order
+        class_list = list(dict.fromkeys(class_list))  # NOTE: remove duplicates while preserving order
+        if toggle is not None:
+            for class_ in toggle.split():
+                if class_ in class_list:
+                    class_list.remove(class_)
+                else:
+                    class_list.append(class_)
+        return class_list
diff --git a/nicegui/element.py b/nicegui/element.py
index 29af8709..ffa8e13b 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -222,8 +222,9 @@ class Element(Visibility):
     def default_classes(cls,
                         add: Optional[str] = None, *,
                         remove: Optional[str] = None,
+                        toggle: Optional[str] = None,
                         replace: Optional[str] = None) -> type[Self]:
-        """Apply, remove, or replace default HTML classes.
+        """Apply, remove, toggle, or replace default HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -233,9 +234,10 @@ class Element(Visibility):
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        cls._default_classes = Classes.update_list(cls._default_classes, add, remove, replace)
+        cls._default_classes = Classes.update_list(cls._default_classes, add, remove, toggle, replace)
         return cls
 
     @property
diff --git a/nicegui/elements/query.py b/nicegui/elements/query.py
index 67d4a78b..7c603afd 100644
--- a/nicegui/elements/query.py
+++ b/nicegui/elements/query.py
@@ -37,9 +37,13 @@ class Query:
         else:
             self.element = QueryElement(selector)
 
-    def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
-            -> Self:
-        """Apply, remove, or replace HTML classes.
+    def classes(self,
+                add: Optional[str] = None, *,
+                remove: Optional[str] = None,
+                toggle: Optional[str] = None,
+                replace: Optional[str] = None,
+                ) -> Self:
+        """Apply, remove, toggle, or replace HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -47,9 +51,10 @@ class Query:
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        classes = Classes.update_list(self.element.props['classes'], add, remove, replace)
+        classes = Classes.update_list(self.element.props['classes'], add, remove, toggle, replace)
         new_classes = [c for c in classes if c not in self.element.props['classes']]
         old_classes = [c for c in self.element.props['classes'] if c not in classes]
         if new_classes:
diff --git a/nicegui/elements/scene.js b/nicegui/elements/scene.js
index 659e9265..c72469a1 100644
--- a/nicegui/elements/scene.js
+++ b/nicegui/elements/scene.js
@@ -413,6 +413,22 @@ export default {
       const geometry = this.objects.get(object_id).geometry;
       set_point_cloud_data(position, color, geometry);
     },
+    attach(object_id, parent_id, x, y, z, R) {
+      if (!this.objects.has(object_id)) return;
+      const object = this.objects.get(object_id);
+      const parent = this.objects.get(parent_id);
+      parent.add(object);
+      this.move(object_id, x, y, z);
+      this.rotate(object_id, R);
+    },
+    detach(object_id, x, y, z, R) {
+      if (!this.objects.has(object_id)) return;
+      const object = this.objects.get(object_id);
+      object.removeFromParent();
+      this.scene.add(object);
+      this.move(object_id, x, y, z);
+      this.rotate(object_id, R);
+    },
     move_camera(x, y, z, look_at_x, look_at_y, look_at_z, up_x, up_y, up_z, duration) {
       if (this.camera_tween) this.camera_tween.stop();
       const camera_up_changed = up_x !== null || up_y !== null || up_z !== null;
diff --git a/nicegui/elements/scene_object3d.py b/nicegui/elements/scene_object3d.py
index 67a133ed..f30a608a 100644
--- a/nicegui/elements/scene_object3d.py
+++ b/nicegui/elements/scene_object3d.py
@@ -196,6 +196,138 @@ class Object3D:
             self._draggable()
         return self
 
+    def attach(self, parent: Object3D) -> None:
+        """Attach the object to a parent object.
+
+        The position and rotation of the object are preserved so that the object does not move in space.
+
+        But note that scaling is not preserved.
+        If either the parent or the object itself is scaled, the object shape and position can change.
+        """
+        self.detach()
+        self.parent = parent
+        self._move_into_parent(parent)
+        self.scene.run_method('attach', self.id, parent.id, self.x, self.y, self.z, self.R)
+
+    def _move_into_parent(self, parent: Union[Object3D, SceneObject]) -> None:
+        if not isinstance(parent, Object3D):
+            return
+        if isinstance(parent.parent, Object3D):
+            self._move_into_parent(parent.parent)
+        M1: List[List[float]] = [
+            [self.R[0][0], self.R[0][1], self.R[0][2], self.x],
+            [self.R[1][0], self.R[1][1], self.R[1][2], self.y],
+            [self.R[2][0], self.R[2][1], self.R[2][2], self.z],
+            [0, 0, 0, 1],
+        ]
+        M2_inv: List[List[float]] = [
+            [parent.R[0][0], parent.R[1][0], parent.R[2][0],
+             - parent.R[0][0] * parent.x
+             - parent.R[1][0] * parent.y
+             - parent.R[2][0] * parent.z],
+            [parent.R[0][1], parent.R[1][1], parent.R[2][1],
+             - parent.R[0][1] * parent.x
+             - parent.R[1][1] * parent.y
+             - parent.R[2][1] * parent.z],
+            [parent.R[0][2], parent.R[1][2], parent.R[2][2],
+             - parent.R[0][2] * parent.x
+             - parent.R[1][2] * parent.y
+             - parent.R[2][2] * parent.z],
+            [0, 0, 0, 1],
+        ]
+        M: List[List[float]] = [
+            [
+                M2_inv[0][0] * M1[0][0] + M2_inv[0][1] * M1[1][0] + M2_inv[0][2] * M1[2][0],
+                M2_inv[0][0] * M1[0][1] + M2_inv[0][1] * M1[1][1] + M2_inv[0][2] * M1[2][1],
+                M2_inv[0][0] * M1[0][2] + M2_inv[0][1] * M1[1][2] + M2_inv[0][2] * M1[2][2],
+                M2_inv[0][0] * M1[0][3] + M2_inv[0][1] * M1[1][3] + M2_inv[0][2] * M1[2][3] + M2_inv[0][3],
+            ],
+            [
+                M2_inv[1][0] * M1[0][0] + M2_inv[1][1] * M1[1][0] + M2_inv[1][2] * M1[2][0],
+                M2_inv[1][0] * M1[0][1] + M2_inv[1][1] * M1[1][1] + M2_inv[1][2] * M1[2][1],
+                M2_inv[1][0] * M1[0][2] + M2_inv[1][1] * M1[1][2] + M2_inv[1][2] * M1[2][2],
+                M2_inv[1][0] * M1[0][3] + M2_inv[1][1] * M1[1][3] + M2_inv[1][2] * M1[2][3] + M2_inv[1][3],
+            ],
+            [
+                M2_inv[2][0] * M1[0][0] + M2_inv[2][1] * M1[1][0] + M2_inv[2][2] * M1[2][0],
+                M2_inv[2][0] * M1[0][1] + M2_inv[2][1] * M1[1][1] + M2_inv[2][2] * M1[2][1],
+                M2_inv[2][0] * M1[0][2] + M2_inv[2][1] * M1[1][2] + M2_inv[2][2] * M1[2][2],
+                M2_inv[2][0] * M1[0][3] + M2_inv[2][1] * M1[1][3] + M2_inv[2][2] * M1[2][3] + M2_inv[2][3],
+            ],
+            [
+                0, 0, 0, 1,
+            ],
+        ]
+        self.x = M[0][3]
+        self.y = M[1][3]
+        self.z = M[2][3]
+        self.R = [
+            [M[0][0], M[0][1], M[0][2]],
+            [M[1][0], M[1][1], M[1][2]],
+            [M[2][0], M[2][1], M[2][2]],
+        ]
+
+    def detach(self) -> None:
+        """Remove the object from its parent group object.
+
+        The position and rotation of the object are preserved so that the object does not move in space.
+
+        But note that scaling is not preserved.
+        If either the parent or the object itself is scaled, the object shape and position can change.
+        """
+        self._move_out_of_parent(self.parent)
+        self.parent = self.scene.stack[0]
+        self.scene.run_method('detach', self.id, self.x, self.y, self.z, self.R)
+
+    def _move_out_of_parent(self, parent: Union[Object3D, SceneObject]) -> None:
+        if not isinstance(parent, Object3D):
+            return
+        M1: List[List[float]] = [
+            [self.R[0][0], self.R[0][1], self.R[0][2], self.x],
+            [self.R[1][0], self.R[1][1], self.R[1][2], self.y],
+            [self.R[2][0], self.R[2][1], self.R[2][2], self.z],
+            [0, 0, 0, 1],
+        ]
+        M2: List[List[float]] = [
+            [parent.R[0][0], parent.R[0][1], parent.R[0][2], parent.x],
+            [parent.R[1][0], parent.R[1][1], parent.R[1][2], parent.y],
+            [parent.R[2][0], parent.R[2][1], parent.R[2][2], parent.z],
+            [0, 0, 0, 1],
+        ]
+        M: List[List[float]] = [
+            [
+                M2[0][0] * M1[0][0] + M2[0][1] * M1[1][0] + M2[0][2] * M1[2][0],
+                M2[0][0] * M1[0][1] + M2[0][1] * M1[1][1] + M2[0][2] * M1[2][1],
+                M2[0][0] * M1[0][2] + M2[0][1] * M1[1][2] + M2[0][2] * M1[2][2],
+                M2[0][0] * M1[0][3] + M2[0][1] * M1[1][3] + M2[0][2] * M1[2][3] + M2[0][3],
+            ],
+            [
+                M2[1][0] * M1[0][0] + M2[1][1] * M1[1][0] + M2[1][2] * M1[2][0],
+                M2[1][0] * M1[0][1] + M2[1][1] * M1[1][1] + M2[1][2] * M1[2][1],
+                M2[1][0] * M1[0][2] + M2[1][1] * M1[1][2] + M2[1][2] * M1[2][2],
+                M2[1][0] * M1[0][3] + M2[1][1] * M1[1][3] + M2[1][2] * M1[2][3] + M2[1][3],
+            ],
+            [
+                M2[2][0] * M1[0][0] + M2[2][1] * M1[1][0] + M2[2][2] * M1[2][0],
+                M2[2][0] * M1[0][1] + M2[2][1] * M1[1][1] + M2[2][2] * M1[2][1],
+                M2[2][0] * M1[0][2] + M2[2][1] * M1[1][2] + M2[2][2] * M1[2][2],
+                M2[2][0] * M1[0][3] + M2[2][1] * M1[1][3] + M2[2][2] * M1[2][3] + M2[2][3],
+            ],
+            [
+                0, 0, 0, 1,
+            ],
+        ]
+        self.x = M[0][3]
+        self.y = M[1][3]
+        self.z = M[2][3]
+        self.R = [
+            [M[0][0], M[0][1], M[0][2]],
+            [M[1][0], M[1][1], M[1][2]],
+            [M[2][0], M[2][1], M[2][2]],
+        ]
+        if isinstance(parent.parent, Object3D):
+            self._move_out_of_parent(parent.parent)
+
     @property
     def children(self) -> List[Object3D]:
         """List of children of the object."""
diff --git a/website/documentation/content/scene_documentation.py b/website/documentation/content/scene_documentation.py
index 8634c10e..744be6ca 100644
--- a/website/documentation/content/scene_documentation.py
+++ b/website/documentation/content/scene_documentation.py
@@ -268,4 +268,25 @@ def custom_composed_objects() -> None:
         CoordinateSystem('custom frame').move(-2, -2, 1).rotate(0.1, 0.2, 0.3)
 
 
+@doc.demo('Attaching/detaching objects', '''
+    To add or remove objects from groups you can use the `attach` and `detach` methods.
+    The position and rotation of the object are preserved so that the object does not move in space.
+    But note that scaling is not preserved.
+    If either the parent or the object itself is scaled, the object shape and position can change.
+''')
+def attach_detach() -> None:
+    import math
+    import time
+
+    with ui.scene().classes('w-full h-64') as scene:
+        with scene.group() as group:
+            a = scene.box().move(-2)
+            b = scene.box().move(0)
+            c = scene.box().move(2)
+
+    ui.timer(0.1, lambda: group.move(y=math.sin(time.time())).rotate(0, 0, time.time()))
+    ui.button('Detach', on_click=a.detach)
+    ui.button('Attach', on_click=lambda: a.attach(group))
+
+
 doc.reference(ui.scene)


Test patch:
diff --git a/tests/test_element.py b/tests/test_element.py
index 66cce207..e7592e0a 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -35,6 +35,12 @@ def test_classes(screen: Screen):
     label.classes(replace='four')
     assert_classes('four')
 
+    label.classes(toggle='bg-red-500')
+    assert_classes('four bg-red-500')
+
+    label.classes(toggle='bg-red-500')
+    assert_classes('four')
+
 
 @pytest.mark.parametrize('value,expected', [
     (None, {}),