HomeDashboard/.venv/lib/python3.12/site-packages/nicegui/elements/scene/scene.py
2026-01-03 14:54:18 +01:00

283 lines
12 KiB
Python

import asyncio
from dataclasses import dataclass
from typing import Any, Callable, Literal, Optional, Union
from typing_extensions import Self
from ... import binding
from ...dataclasses import KWONLY_SLOTS
from ...element import Element
from ...events import (
GenericEventArguments,
Handler,
SceneClickEventArguments,
SceneClickHit,
SceneDragEventArguments,
handle_event,
)
from .scene_object3d import Object3D
@dataclass(**KWONLY_SLOTS)
class SceneCamera:
type: Literal['perspective', 'orthographic']
params: dict[str, float]
x: float = 0
y: float = -3
z: float = 5
look_at_x: float = 0
look_at_y: float = 0
look_at_z: float = 0
up_x: float = 0
up_y: float = 0
up_z: float = 1
@dataclass(**KWONLY_SLOTS)
class SceneObject:
id: str = 'scene'
class Scene(Element, component='scene.js', esm={'nicegui-scene': 'dist'}, default_classes='nicegui-scene'):
# pylint: disable=import-outside-toplevel
from .scene_objects import AxesHelper as axes_helper
from .scene_objects import Box as box
from .scene_objects import Curve as curve
from .scene_objects import Cylinder as cylinder
from .scene_objects import Extrusion as extrusion
from .scene_objects import Gltf as gltf
from .scene_objects import Group as group
from .scene_objects import Line as line
from .scene_objects import PointCloud as point_cloud
from .scene_objects import QuadraticBezierTube as quadratic_bezier_tube
from .scene_objects import Ring as ring
from .scene_objects import Sphere as sphere
from .scene_objects import SpotLight as spot_light
from .scene_objects import Stl as stl
from .scene_objects import Text as text
from .scene_objects import Text3d as text3d
from .scene_objects import Texture as texture
def __init__(self,
width: int = 400,
height: int = 300,
# DEPRECATED: enforce keyword-only arguments in NiceGUI 4.0
grid: Union[bool, tuple[int, int]] = True,
camera: Optional[SceneCamera] = None,
on_click: Optional[Handler[SceneClickEventArguments]] = None,
click_events: list[str] = ['click', 'dblclick'], # noqa: B006
on_drag_start: Optional[Handler[SceneDragEventArguments]] = None,
on_drag_end: Optional[Handler[SceneDragEventArguments]] = None,
drag_constraints: str = '',
background_color: str = '#eee',
fps: int = 20,
show_stats: bool = False,
) -> None:
"""3D Scene
Display a 3D scene using `three.js <https://threejs.org/>`_.
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.
They can also be grouped to apply joint movements.
:param width: width of the canvas
:param height: height of the canvas
:param grid: whether to display a grid (boolean or tuple of ``size`` and ``divisions`` for `Three.js' GridHelper <https://threejs.org/docs/#api/en/helpers/GridHelper>`_, default: 100x100)
:param camera: camera definition, either instance of ``ui.scene.perspective_camera`` (default) or ``ui.scene.orthographic_camera``
:param on_click: callback to execute when a 3D object is clicked (use ``click_events`` to specify which events to subscribe to)
:param click_events: list of JavaScript click events to subscribe to (default: ``['click', 'dblclick']``)
: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'``)
:param background_color: background color of the scene (default: "#eee")
:param fps: target frame rate for the scene in frames per second (default: 20, *added in version 3.2.0*)
:param show_stats: whether to show performance stats (default: ``False``, *added in version 3.2.0*)
"""
super().__init__()
self._props['width'] = width
self._props['height'] = height
self._props['fps'] = fps
self._props['show_stats'] = show_stats
self._props['grid'] = grid
self._props['background_color'] = background_color
self.camera = camera or self.perspective_camera()
self._props['camera_type'] = self.camera.type
self._props['camera_params'] = self.camera.params
self.objects: dict[str, Object3D] = {}
self.stack: list[Union[Object3D, SceneObject]] = [SceneObject()]
self._click_handlers = [on_click] if on_click else []
self._props['click_events'] = click_events[:]
self._drag_start_handlers = [on_drag_start] if on_drag_start else []
self._drag_end_handlers = [on_drag_end] if on_drag_end else []
self.on('init', self._handle_init)
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 on_click(self, callback: Handler[SceneClickEventArguments]) -> Self:
"""Add a callback to be invoked when a 3D object is clicked."""
self._click_handlers.append(callback)
return self
def on_drag_start(self, callback: Handler[SceneDragEventArguments]) -> Self:
"""Add a callback to be invoked when a 3D object is dragged."""
self._drag_start_handlers.append(callback)
return self
def on_drag_end(self, callback: Handler[SceneDragEventArguments]) -> Self:
"""Add a callback to be invoked when a 3D object is dropped."""
self._drag_end_handlers.append(callback)
return self
@staticmethod
def perspective_camera(*, fov: float = 75, near: float = 0.1, far: float = 1000) -> SceneCamera:
"""Create a perspective camera.
:param fov: vertical field of view in degrees
:param near: near clipping plane
:param far: far clipping plane
"""
return SceneCamera(type='perspective', params={'fov': fov, 'near': near, 'far': far})
@staticmethod
def orthographic_camera(*, size: float = 10, near: float = 0.1, far: float = 1000) -> SceneCamera:
"""Create a orthographic camera.
The size defines the vertical size of the view volume, i.e. the distance between the top and bottom clipping planes.
The left and right clipping planes are set such that the aspect ratio matches the viewport.
:param size: vertical size of the view volume
:param near: near clipping plane
:param far: far clipping plane
"""
return SceneCamera(type='orthographic', params={'size': size, 'near': near, 'far': far})
def __enter__(self) -> Self:
Object3D.current_scene = self
super().__enter__()
return self
def __getattribute__(self, name: str) -> Any:
attribute = super().__getattribute__(name)
if isinstance(attribute, type) and issubclass(attribute, Object3D):
Object3D.current_scene = self
return attribute
def _handle_init(self) -> None:
self.move_camera(duration=0)
self.run_method('init_objects', [obj.data for obj in self.objects.values()])
async def initialized(self) -> None:
"""Wait until the scene is initialized."""
event = asyncio.Event()
self.on('init', event.set, [])
await self.client.connected()
await event.wait()
def _handle_click(self, e: GenericEventArguments) -> None:
arguments = SceneClickEventArguments(
sender=self,
client=self.client,
click_type=e.args['click_type'],
button=e.args['button'],
alt=e.args['alt_key'],
ctrl=e.args['ctrl_key'],
meta=e.args['meta_key'],
shift=e.args['shift_key'],
hits=[SceneClickHit(
object_id=hit['object_id'],
object_name=hit['object_name'],
x=hit['point']['x'],
y=hit['point']['y'],
z=hit['point']['z'],
) for hit in e.args['hits']],
)
for handler in self._click_handlers:
handle_event(handler, 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)
for handler in (self._drag_start_handlers if arguments.type == 'dragstart' else self._drag_end_handlers):
handle_event(handler, arguments)
def __len__(self) -> int:
return len(self.objects)
def move_camera(self,
x: Optional[float] = None,
y: Optional[float] = None,
z: Optional[float] = None,
look_at_x: Optional[float] = None,
look_at_y: Optional[float] = None,
look_at_z: Optional[float] = None,
up_x: Optional[float] = None,
up_y: Optional[float] = None,
up_z: Optional[float] = None,
duration: float = 0.5) -> None:
"""Move the camera to a new position.
:param x: camera x position
:param y: camera y position
:param z: camera z position
:param look_at_x: camera look-at x position
:param look_at_y: camera look-at y position
:param look_at_z: camera look-at z position
:param up_x: x component of the camera up vector
:param up_y: y component of the camera up vector
:param up_z: z component of the camera up vector
:param duration: duration of the movement in seconds (default: `0.5`)
"""
self.camera.x = self.camera.x if x is None else x
self.camera.y = self.camera.y if y is None else y
self.camera.z = self.camera.z if z is None else z
self.camera.look_at_x = self.camera.look_at_x if look_at_x is None else look_at_x
self.camera.look_at_y = self.camera.look_at_y if look_at_y is None else look_at_y
self.camera.look_at_z = self.camera.look_at_z if look_at_z is None else look_at_z
self.camera.up_x = self.camera.up_x if up_x is None else up_x
self.camera.up_y = self.camera.up_y if up_y is None else up_y
self.camera.up_z = self.camera.up_z if up_z is None else up_z
self.run_method('move_camera',
self.camera.x, self.camera.y, self.camera.z,
self.camera.look_at_x, self.camera.look_at_y, self.camera.look_at_z,
self.camera.up_x, self.camera.up_y, self.camera.up_z, duration)
async def get_camera(self) -> dict[str, Any]:
"""Get the current camera parameters.
In contrast to the `camera` property,
the result of this method includes the current camera pose caused by the user navigating the scene in the browser.
"""
return await self.run_method('get_camera')
def _handle_delete(self) -> None:
binding.remove(list(self.objects.values()))
super()._handle_delete()
def delete_objects(self, predicate: Callable[[Object3D], bool] = lambda _: True) -> None:
"""Remove objects from the scene.
:param predicate: function which returns `True` for objects which should be deleted
"""
for obj in list(self.objects.values()):
if predicate(obj) and obj.id in self.objects: # NOTE: object might have been deleted already by its parent
obj.delete()
def clear(self) -> Self:
"""Remove all objects from the scene."""
super().clear()
self.delete_objects()
return self