scene.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import asyncio
  2. from dataclasses import dataclass
  3. from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
  4. from typing_extensions import Self
  5. from .. import binding
  6. from ..awaitable_response import AwaitableResponse, NullResponse
  7. from ..dataclasses import KWONLY_SLOTS
  8. from ..element import Element
  9. from ..events import (
  10. GenericEventArguments,
  11. SceneClickEventArguments,
  12. SceneClickHit,
  13. SceneDragEventArguments,
  14. handle_event,
  15. )
  16. from .scene_object3d import Object3D
  17. @dataclass(**KWONLY_SLOTS)
  18. class SceneCamera:
  19. type: Literal['perspective', 'orthographic']
  20. params: Dict[str, float]
  21. x: float = 0
  22. y: float = -3
  23. z: float = 5
  24. look_at_x: float = 0
  25. look_at_y: float = 0
  26. look_at_z: float = 0
  27. up_x: float = 0
  28. up_y: float = 0
  29. up_z: float = 1
  30. @dataclass(**KWONLY_SLOTS)
  31. class SceneObject:
  32. id: str = 'scene'
  33. class Scene(Element,
  34. component='scene.js',
  35. libraries=['lib/tween/tween.umd.js'],
  36. exposed_libraries=[
  37. 'lib/three/three.module.js',
  38. 'lib/three/modules/CSS2DRenderer.js',
  39. 'lib/three/modules/CSS3DRenderer.js',
  40. 'lib/three/modules/DragControls.js',
  41. 'lib/three/modules/OrbitControls.js',
  42. 'lib/three/modules/STLLoader.js',
  43. 'lib/three/modules/GLTFLoader.js',
  44. 'lib/three/modules/BufferGeometryUtils.js',
  45. ]):
  46. # pylint: disable=import-outside-toplevel
  47. from .scene_objects import Box as box
  48. from .scene_objects import Curve as curve
  49. from .scene_objects import Cylinder as cylinder
  50. from .scene_objects import Extrusion as extrusion
  51. from .scene_objects import Gltf as gltf
  52. from .scene_objects import Group as group
  53. from .scene_objects import Line as line
  54. from .scene_objects import PointCloud as point_cloud
  55. from .scene_objects import QuadraticBezierTube as quadratic_bezier_tube
  56. from .scene_objects import Ring as ring
  57. from .scene_objects import Sphere as sphere
  58. from .scene_objects import SpotLight as spot_light
  59. from .scene_objects import Stl as stl
  60. from .scene_objects import Text as text
  61. from .scene_objects import Text3d as text3d
  62. from .scene_objects import Texture as texture
  63. def __init__(self,
  64. width: int = 400,
  65. height: int = 300,
  66. grid: Union[bool, Tuple[int, int]] = True,
  67. camera: Optional[SceneCamera] = None,
  68. on_click: Optional[Callable[..., Any]] = None,
  69. on_drag_start: Optional[Callable[..., Any]] = None,
  70. on_drag_end: Optional[Callable[..., Any]] = None,
  71. drag_constraints: str = '',
  72. background_color: str = '#eee',
  73. ) -> None:
  74. """3D Scene
  75. Display a 3D scene using `three.js <https://threejs.org/>`_.
  76. Currently NiceGUI supports boxes, spheres, cylinders/cones, extrusions, straight lines, curves and textured meshes.
  77. Objects can be translated, rotated and displayed with different color, opacity or as wireframes.
  78. They can also be grouped to apply joint movements.
  79. :param width: width of the canvas
  80. :param height: height of the canvas
  81. :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)
  82. :param camera: camera definition, either instance of ``ui.scene.perspective_camera`` (default) or ``ui.scene.orthographic_camera``
  83. :param on_click: callback to execute when a 3D object is clicked
  84. :param on_drag_start: callback to execute when a 3D object is dragged
  85. :param on_drag_end: callback to execute when a 3D object is dropped
  86. :param drag_constraints: comma-separated JavaScript expression for constraining positions of dragged objects (e.g. ``'x = 0, z = y / 2'``)
  87. :param background_color: background color of the scene (default: "#eee")
  88. """
  89. super().__init__()
  90. self._props['width'] = width
  91. self._props['height'] = height
  92. self._props['grid'] = grid
  93. self._props['background_color'] = background_color
  94. self.camera = camera or self.perspective_camera()
  95. self._props['camera_type'] = self.camera.type
  96. self._props['camera_params'] = self.camera.params
  97. self.objects: Dict[str, Object3D] = {}
  98. self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
  99. self._click_handlers = [on_click] if on_click else []
  100. self._drag_start_handlers = [on_drag_start] if on_drag_start else []
  101. self._drag_end_handlers = [on_drag_end] if on_drag_end else []
  102. self.is_initialized = False
  103. self.on('init', self._handle_init)
  104. self.on('click3d', self._handle_click)
  105. self.on('dragstart', self._handle_drag)
  106. self.on('dragend', self._handle_drag)
  107. self._props['drag_constraints'] = drag_constraints
  108. def on_click(self, callback: Callable[..., Any]) -> Self:
  109. """Add a callback to be invoked when a 3D object is clicked."""
  110. self._click_handlers.append(callback)
  111. return self
  112. def on_drag_start(self, callback: Callable[..., Any]) -> Self:
  113. """Add a callback to be invoked when a 3D object is dragged."""
  114. self._drag_start_handlers.append(callback)
  115. return self
  116. def on_drag_end(self, callback: Callable[..., Any]) -> Self:
  117. """Add a callback to be invoked when a 3D object is dropped."""
  118. self._drag_end_handlers.append(callback)
  119. return self
  120. @staticmethod
  121. def perspective_camera(*, fov: float = 75, near: float = 0.1, far: float = 1000) -> SceneCamera:
  122. """Create a perspective camera.
  123. :param fov: vertical field of view in degrees
  124. :param near: near clipping plane
  125. :param far: far clipping plane
  126. """
  127. return SceneCamera(type='perspective', params={'fov': fov, 'near': near, 'far': far})
  128. @staticmethod
  129. def orthographic_camera(*, size: float = 10, near: float = 0.1, far: float = 1000) -> SceneCamera:
  130. """Create a orthographic camera.
  131. The size defines the vertical size of the view volume, i.e. the distance between the top and bottom clipping planes.
  132. The left and right clipping planes are set such that the aspect ratio matches the viewport.
  133. :param size: vertical size of the view volume
  134. :param near: near clipping plane
  135. :param far: far clipping plane
  136. """
  137. return SceneCamera(type='orthographic', params={'size': size, 'near': near, 'far': far})
  138. def __enter__(self) -> Self:
  139. Object3D.current_scene = self
  140. super().__enter__()
  141. return self
  142. def __getattribute__(self, name: str) -> Any:
  143. attribute = super().__getattribute__(name)
  144. if isinstance(attribute, type) and issubclass(attribute, Object3D):
  145. Object3D.current_scene = self
  146. return attribute
  147. def _handle_init(self, e: GenericEventArguments) -> None:
  148. self.is_initialized = True
  149. with self.client.individual_target(e.args['socket_id']):
  150. self.move_camera(duration=0)
  151. self.run_method('init_objects', [obj.data for obj in self.objects.values()])
  152. async def initialized(self) -> None:
  153. """Wait until the scene is initialized."""
  154. event = asyncio.Event()
  155. self.on('init', event.set, [])
  156. await self.client.connected()
  157. await event.wait()
  158. def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
  159. if not self.is_initialized:
  160. return NullResponse()
  161. return super().run_method(name, *args, timeout=timeout, check_interval=check_interval)
  162. def _handle_click(self, e: GenericEventArguments) -> None:
  163. arguments = SceneClickEventArguments(
  164. sender=self,
  165. client=self.client,
  166. click_type=e.args['click_type'],
  167. button=e.args['button'],
  168. alt=e.args['alt_key'],
  169. ctrl=e.args['ctrl_key'],
  170. meta=e.args['meta_key'],
  171. shift=e.args['shift_key'],
  172. hits=[SceneClickHit(
  173. object_id=hit['object_id'],
  174. object_name=hit['object_name'],
  175. x=hit['point']['x'],
  176. y=hit['point']['y'],
  177. z=hit['point']['z'],
  178. ) for hit in e.args['hits']],
  179. )
  180. for handler in self._click_handlers:
  181. handle_event(handler, arguments)
  182. def _handle_drag(self, e: GenericEventArguments) -> None:
  183. arguments = SceneDragEventArguments(
  184. sender=self,
  185. client=self.client,
  186. type=e.args['type'],
  187. object_id=e.args['object_id'],
  188. object_name=e.args['object_name'],
  189. x=e.args['x'],
  190. y=e.args['y'],
  191. z=e.args['z'],
  192. )
  193. if arguments.type == 'dragend':
  194. self.objects[arguments.object_id].move(arguments.x, arguments.y, arguments.z)
  195. for handler in (self._drag_start_handlers if arguments.type == 'dragstart' else self._drag_end_handlers):
  196. handle_event(handler, arguments)
  197. def __len__(self) -> int:
  198. return len(self.objects)
  199. def move_camera(self,
  200. x: Optional[float] = None,
  201. y: Optional[float] = None,
  202. z: Optional[float] = None,
  203. look_at_x: Optional[float] = None,
  204. look_at_y: Optional[float] = None,
  205. look_at_z: Optional[float] = None,
  206. up_x: Optional[float] = None,
  207. up_y: Optional[float] = None,
  208. up_z: Optional[float] = None,
  209. duration: float = 0.5) -> None:
  210. """Move the camera to a new position.
  211. :param x: camera x position
  212. :param y: camera y position
  213. :param z: camera z position
  214. :param look_at_x: camera look-at x position
  215. :param look_at_y: camera look-at y position
  216. :param look_at_z: camera look-at z position
  217. :param up_x: x component of the camera up vector
  218. :param up_y: y component of the camera up vector
  219. :param up_z: z component of the camera up vector
  220. :param duration: duration of the movement in seconds (default: `0.5`)
  221. """
  222. self.camera.x = self.camera.x if x is None else x
  223. self.camera.y = self.camera.y if y is None else y
  224. self.camera.z = self.camera.z if z is None else z
  225. self.camera.look_at_x = self.camera.look_at_x if look_at_x is None else look_at_x
  226. self.camera.look_at_y = self.camera.look_at_y if look_at_y is None else look_at_y
  227. self.camera.look_at_z = self.camera.look_at_z if look_at_z is None else look_at_z
  228. self.camera.up_x = self.camera.up_x if up_x is None else up_x
  229. self.camera.up_y = self.camera.up_y if up_y is None else up_y
  230. self.camera.up_z = self.camera.up_z if up_z is None else up_z
  231. self.run_method('move_camera',
  232. self.camera.x, self.camera.y, self.camera.z,
  233. self.camera.look_at_x, self.camera.look_at_y, self.camera.look_at_z,
  234. self.camera.up_x, self.camera.up_y, self.camera.up_z, duration)
  235. def _handle_delete(self) -> None:
  236. binding.remove(list(self.objects.values()))
  237. super()._handle_delete()
  238. def delete_objects(self, predicate: Callable[[Object3D], bool] = lambda _: True) -> None:
  239. """Remove objects from the scene.
  240. :param predicate: function which returns `True` for objects which should be deleted
  241. """
  242. for obj in list(self.objects.values()):
  243. if predicate(obj):
  244. obj.delete()
  245. def clear(self) -> None:
  246. """Remove all objects from the scene."""
  247. super().clear()
  248. self.delete_objects()