1import weakref
2from numbers import Number as numeric_type
3
4import numpy as np
5
6from yt.funcs import ensure_numpy_array, is_sequence
7from yt.units.yt_array import YTArray, YTQuantity
8from yt.utilities.math_utils import get_rotation_matrix
9from yt.utilities.orientation import Orientation
10
11from .lens import Lens, lenses
12from .utils import data_source_or_all
13
14
15def _sanitize_camera_property_units(value, scene):
16    if is_sequence(value):
17        if len(value) == 1:
18            return _sanitize_camera_property_units(value[0], scene)
19        elif isinstance(value, YTArray) and len(value) == 3:
20            return scene.arr(value).in_units("unitary")
21        elif (
22            len(value) == 2
23            and isinstance(value[0], numeric_type)
24            and isinstance(value[1], str)
25        ):
26            return scene.arr([scene.arr(value[0], value[1]).in_units("unitary")] * 3)
27        if len(value) == 3:
28            if all([is_sequence(v) for v in value]):
29                if all(
30                    [
31                        isinstance(v[0], numeric_type) and isinstance(v[1], str)
32                        for v in value
33                    ]
34                ):
35                    return scene.arr([scene.arr(v[0], v[1]) for v in value])
36                else:
37                    raise RuntimeError(
38                        f"Cannot set camera width to invalid value '{value}'"
39                    )
40            return scene.arr(value, "unitary")
41    else:
42        if isinstance(value, (YTQuantity, YTArray)):
43            return scene.arr([value.d] * 3, value.units).in_units("unitary")
44        elif isinstance(value, numeric_type):
45            return scene.arr([value] * 3, "unitary")
46    raise RuntimeError(f"Cannot set camera width to invalid value '{value}'")
47
48
49class Camera(Orientation):
50    r"""A representation of a point of view into a Scene.
51
52    It is defined by a position (the location of the camera
53    in the simulation domain,), a focus (the point at which the
54    camera is pointed), a width (the width of the snapshot that will
55    be taken, a resolution (the number of pixels in the image), and
56    a north_vector (the "up" direction in the resulting image). A
57    camera can use a variety of different Lens objects.
58
59    Parameters
60    ----------
61    scene: A :class:`yt.visualization.volume_rendering.scene.Scene` object
62        A scene object that the camera will be attached to.
63    data_source: :class:`AMR3DData` or :class:`Dataset`, optional
64        This is the source to be rendered, which can be any arbitrary yt
65        data object or dataset.
66    lens_type: string, optional
67        This specifies the type of lens to use for rendering. Current
68        options are 'plane-parallel', 'perspective', and 'fisheye'. See
69        :class:`yt.visualization.volume_rendering.lens.Lens` for details.
70        Default: 'plane-parallel'
71    auto: boolean
72        If True, build smart defaults using the data source extent. This
73        can be time-consuming to iterate over the entire dataset to find
74        the positional bounds. Default: False
75
76    Examples
77    --------
78
79    In this example, the camera is set using defaults that are chosen
80    to be reasonable for the argument Dataset.
81
82    >>> import yt
83    >>> from yt.visualization.volume_rendering.api import Scene
84    >>> ds = yt.load("IsolatedGalaxy/galaxy0030/galaxy0030")
85    >>> sc = Scene()
86    >>> cam = sc.add_camera(ds)
87
88    Here, we set the camera properties manually:
89
90    >>> import yt
91    >>> from yt.visualization.volume_rendering.api import Scene
92    >>> sc = Scene()
93    >>> cam = sc.add_camera()
94    >>> cam.position = np.array([0.5, 0.5, -1.0])
95    >>> cam.focus = np.array([0.5, 0.5, 0.0])
96    >>> cam.north_vector = np.array([1.0, 0.0, 0.0])
97
98    Finally, we create a camera with a non-default lens:
99
100    >>> import yt
101    >>> from yt.visualization.volume_rendering.api import Scene
102    >>> ds = yt.load("IsolatedGalaxy/galaxy0030/galaxy0030")
103    >>> sc = Scene()
104    >>> cam = sc.add_camera(ds, lens_type="perspective")
105
106    """
107
108    _moved = True
109    _width = None
110    _focus = None
111    _position = None
112    _resolution = None
113
114    def __init__(self, scene, data_source=None, lens_type="plane-parallel", auto=False):
115        # import this here to avoid an import cycle
116        from .scene import Scene
117
118        if not isinstance(scene, Scene):
119            raise RuntimeError(
120                "The first argument passed to the Camera initializer is a "
121                "%s object, expected a %s object" % (type(scene), Scene)
122            )
123        self.scene = weakref.proxy(scene)
124        self.lens = None
125        self.north_vector = None
126        self.normal_vector = None
127        self.light = None
128        self.data_source = data_source_or_all(data_source)
129        self._resolution = (512, 512)
130
131        if self.data_source is not None:
132            self.scene._set_new_unit_registry(self.data_source.ds.unit_registry)
133            self._focus = self.data_source.ds.domain_center
134            self._position = self.data_source.ds.domain_right_edge
135            self._width = self.data_source.ds.arr(
136                [1.5 * self.data_source.ds.domain_width.max()] * 3
137            )
138            self._domain_center = self.data_source.ds.domain_center
139            self._domain_width = self.data_source.ds.domain_width
140        else:
141            self._focus = scene.arr([0.0, 0.0, 0.0], "unitary")
142            self._width = scene.arr([1.0, 1.0, 1.0], "unitary")
143            self._position = scene.arr([1.0, 1.0, 1.0], "unitary")
144
145        if auto:
146            self.set_defaults_from_data_source(data_source)
147
148        super().__init__(
149            self.focus - self.position, self.north_vector, steady_north=False
150        )
151
152        self.set_lens(lens_type)
153
154    def position():
155        doc = """
156        The location of the camera.
157
158        Parameters
159        ----------
160
161        position : number, YTQuantity, :obj:`!iterable`, or 3 element YTArray
162            If a scalar, assumes that the position is the same in all three
163            coordinates. If an iterable, must contain only scalars or
164            (length, unit) tuples.
165        """
166
167        def fget(self):
168            return self._position
169
170        def fset(self, value):
171            position = _sanitize_camera_property_units(value, self.scene)
172            if np.array_equal(position, self.focus):
173                raise RuntimeError(
174                    "Cannot set the camera focus and position to the same value"
175                )
176            self._position = position
177            self.switch_orientation(
178                normal_vector=self.focus - self._position,
179                north_vector=self.north_vector,
180            )
181
182        def fdel(self):
183            del self._position
184
185        return locals()
186
187    position = property(**position())
188
189    def width():
190        doc = """The width of the region that will be seen in the image.
191
192        Parameters
193        ----------
194
195        width : number, YTQuantity, :obj:`!iterable`, or 3 element YTArray
196            The width of the volume rendering in the horizontal, vertical, and
197            depth directions. If a scalar, assumes that the width is the same in
198            all three directions. If an iterable, must contain only scalars or
199            (length, unit) tuples.
200        """
201
202        def fget(self):
203            return self._width
204
205        def fset(self, value):
206            width = _sanitize_camera_property_units(value, self.scene)
207            self._width = width
208            self.switch_orientation()
209
210        def fdel(self):
211            del self._width
212            self._width = None
213
214        return locals()
215
216    width = property(**width())
217
218    def focus():
219        doc = """
220        The focus defines the point the Camera is pointed at.
221
222        Parameters
223        ----------
224
225        focus : number, YTQuantity, :obj:`!iterable`, or 3 element YTArray
226            The width of the volume rendering in the horizontal, vertical, and
227            depth directions. If a scalar, assumes that the width is the same in
228            all three directions. If an iterable, must contain only scalars or
229            (length, unit) tuples.
230        """
231
232        def fget(self):
233            return self._focus
234
235        def fset(self, value):
236            focus = _sanitize_camera_property_units(value, self.scene)
237            if np.array_equal(focus, self.position):
238                raise RuntimeError(
239                    "Cannot set the camera focus and position to the same value"
240                )
241            self._focus = focus
242            self.switch_orientation(
243                normal_vector=self.focus - self._position, north_vector=None
244            )
245
246        def fdel(self):
247            del self._focus
248
249        return locals()
250
251    focus = property(**focus())
252
253    def resolution():
254        doc = """The resolution is the number of pixels in the image that
255               will be produced. Must be a 2-tuple of integers or an integer."""
256
257        def fget(self):
258            return self._resolution
259
260        def fset(self, value):
261            if is_sequence(value):
262                if len(value) != 2:
263                    raise RuntimeError
264            else:
265                value = (value, value)
266            self._resolution = value
267
268        def fdel(self):
269            del self._resolution
270            self._resolution = None
271
272        return locals()
273
274    resolution = property(**resolution())
275
276    def set_resolution(self, resolution):
277        """
278        The resolution is the number of pixels in the image that
279        will be produced. Must be a 2-tuple of integers or an integer.
280        """
281        self.resolution = resolution
282
283    def get_resolution(self):
284        """
285        Returns the resolution of the volume rendering
286        """
287        return self.resolution
288
289    def _get_sampler_params(self, render_source):
290        lens_params = self.lens._get_sampler_params(self, render_source)
291        lens_params.update(width=self.width)
292        pos = self.position.in_units("code_length").d
293        width = self.width.in_units("code_length").d
294        lens_params.update(camera_data=np.vstack((pos, width, self.unit_vectors.d)))
295        return lens_params
296
297    def set_lens(self, lens_type):
298        r"""Set the lens to be used with this camera.
299
300        Parameters
301        ----------
302
303        lens_type : string
304            Must be one of the following:
305            'plane-parallel'
306            'perspective'
307            'stereo-perspective'
308            'fisheye'
309            'spherical'
310            'stereo-spherical'
311
312        """
313        if isinstance(lens_type, Lens):
314            self.lens = lens_type
315        elif lens_type not in lenses:
316            raise RuntimeError(
317                "Lens type %s not in available list of available lens "
318                "types (%s)" % (lens_type, list(lenses.keys()))
319            )
320        else:
321            self.lens = lenses[lens_type]()
322        self.lens.set_camera(self)
323
324    def set_defaults_from_data_source(self, data_source):
325        """Resets the camera attributes to their default values"""
326
327        position = data_source.ds.domain_right_edge
328
329        width = 1.5 * data_source.ds.domain_width.max()
330        (xmi, xma), (ymi, yma), (zmi, zma) = data_source.quantities["Extrema"](
331            ["x", "y", "z"]
332        )
333        width = np.sqrt((xma - xmi) ** 2 + (yma - ymi) ** 2 + (zma - zmi) ** 2)
334        focus = data_source.get_field_parameter("center")
335
336        if is_sequence(width) and len(width) > 1 and isinstance(width[1], str):
337            width = data_source.ds.quan(width[0], units=width[1])
338            # Now convert back to code length for subsequent manipulation
339            width = width.in_units("code_length")  # .value
340        if not is_sequence(width):
341            width = data_source.ds.arr([width, width, width], units="code_length")
342            # left/right, top/bottom, front/back
343        if not isinstance(width, YTArray):
344            width = data_source.ds.arr(width, units="code_length")
345        if not isinstance(focus, YTArray):
346            focus = data_source.ds.arr(focus, units="code_length")
347
348        # We can't use the property setters yet, since they rely on attributes
349        # that will not be set up until the base class initializer is called.
350        # See Issue #1131.
351        self._width = width
352        self._focus = focus
353        self._position = position
354        self._domain_center = data_source.ds.domain_center
355        self._domain_width = data_source.ds.domain_width
356
357        super().__init__(
358            self.focus - self.position, self.north_vector, steady_north=False
359        )
360        self._moved = True
361
362    def set_width(self, width):
363        r"""Set the width of the image that will be produced by this camera.
364
365        Parameters
366        ----------
367
368        width : number, YTQuantity, :obj:`!iterable`, or 3 element YTArray
369            The width of the volume rendering in the horizontal, vertical, and
370            depth directions. If a scalar, assumes that the width is the same in
371            all three directions. If an iterable, must contain only scalars or
372            (length, unit) tuples.
373        """
374        self.width = width
375        self.switch_orientation()
376
377    def get_width(self):
378        """Return the current camera width"""
379        return self.width
380
381    def set_position(self, position, north_vector=None):
382        r"""Set the position of the camera.
383
384        Parameters
385        ----------
386
387        position : number, YTQuantity, :obj:`!iterable`, or 3 element YTArray
388            If a scalar, assumes that the position is the same in all three
389            coordinates. If an iterable, must contain only scalars or
390            (length, unit) tuples.
391
392        north_vector : array_like, optional
393            The 'up' direction for the plane of rays. If not specific,
394            calculated automatically.
395
396        """
397        if north_vector is not None:
398            self.north_vector = north_vector
399        self.position = position
400
401    def get_position(self):
402        """Return the current camera position"""
403        return self.position
404
405    def set_focus(self, new_focus):
406        """Sets the point the Camera is pointed at.
407
408        Parameters
409        ----------
410
411        new_focus : number, YTQuantity, :obj:`!iterable`, or 3 element YTArray
412            If a scalar, assumes that the focus is the same is all three
413            coordinates. If an iterable, must contain only scalars or
414            (length, unit) tuples.
415
416        """
417        self.focus = new_focus
418
419    def get_focus(self):
420        """Returns the current camera focus"""
421        return self.focus
422
423    def switch_orientation(self, normal_vector=None, north_vector=None):
424        r"""Change the view direction based on any of the orientation parameters.
425
426        This will recalculate all the necessary vectors and vector planes
427        related to an orientable object.
428
429        Parameters
430        ----------
431        normal_vector: array_like, optional
432            The new looking vector from the camera to the focus.
433        north_vector : array_like, optional
434            The 'up' direction for the plane of rays.  If not specific,
435            calculated automatically.
436        """
437        if north_vector is None:
438            north_vector = self.north_vector
439        if normal_vector is None:
440            normal_vector = self.normal_vector
441        self._setup_normalized_vectors(normal_vector, north_vector)
442        self.lens.setup_box_properties(self)
443
444    def switch_view(self, normal_vector=None, north_vector=None):
445        r"""Change the view based on any of the view parameters.
446
447        This will recalculate the orientation and width based on any of
448        normal_vector, width, center, and north_vector.
449
450        Parameters
451        ----------
452        normal_vector: array_like, optional
453            The new looking vector from the camera to the focus.
454        north_vector : array_like, optional
455            The 'up' direction for the plane of rays.  If not specific,
456            calculated automatically.
457        """
458        if north_vector is None:
459            north_vector = self.north_vector
460        if normal_vector is None:
461            normal_vector = self.normal_vector
462        self.switch_orientation(normal_vector=normal_vector, north_vector=north_vector)
463        self._moved = True
464
465    def rotate(self, theta, rot_vector=None, rot_center=None):
466        r"""Rotate by a given angle
467
468        Rotate the view.  If `rot_vector` is None, rotation will occur
469        around the `north_vector`.
470
471        Parameters
472        ----------
473        theta : float, in radians
474             Angle (in radians) by which to rotate the view.
475        rot_vector  : array_like, optional
476            Specify the rotation vector around which rotation will
477            occur.  Defaults to None, which sets rotation around
478            `north_vector`
479        rot_center  : array_like, optional
480            Specify the center around which rotation will occur. Defaults
481            to None, which sets rotation around the original camera position
482            (i.e. the camera position does not change)
483
484        Examples
485        --------
486
487        >>> import yt
488        >>> import numpy as np
489        >>> from yt.visualization.volume_rendering.api import Scene
490        >>> sc = Scene()
491        >>> cam = sc.add_camera()
492        >>> # rotate the camera by pi / 4 radians:
493        >>> cam.rotate(np.pi / 4.0)
494        >>> # rotate the camera about the y-axis instead of cam.north_vector:
495        >>> cam.rotate(np.pi / 4.0, np.array([0.0, 1.0, 0.0]))
496        >>> # rotate the camera about the origin instead of its own position:
497        >>> cam.rotate(np.pi / 4.0, rot_center=np.array([0.0, 0.0, 0.0]))
498
499        """
500        rotate_all = rot_vector is not None
501        if rot_vector is None:
502            rot_vector = self.north_vector
503        if rot_center is None:
504            rot_center = self._position
505        rot_vector = ensure_numpy_array(rot_vector)
506        rot_vector = rot_vector / np.linalg.norm(rot_vector)
507
508        new_position = self._position - rot_center
509        R = get_rotation_matrix(theta, rot_vector)
510        new_position = np.dot(R, new_position) + rot_center
511
512        if (new_position == self._position).all():
513            normal_vector = self.unit_vectors[2]
514        else:
515            normal_vector = rot_center - new_position
516        normal_vector = normal_vector / np.sqrt((normal_vector ** 2).sum())
517
518        if rotate_all:
519            self.switch_view(
520                normal_vector=np.dot(R, normal_vector),
521                north_vector=np.dot(R, self.unit_vectors[1]),
522            )
523        else:
524            self.switch_view(normal_vector=np.dot(R, normal_vector))
525        if (new_position != self._position).any():
526            self.set_position(new_position)
527
528    def pitch(self, theta, rot_center=None):
529        r"""Rotate by a given angle about the horizontal axis
530
531        Pitch the view.
532
533        Parameters
534        ----------
535        theta : float, in radians
536             Angle (in radians) by which to pitch the view.
537        rot_center  : array_like, optional
538            Specify the center around which rotation will occur.
539
540        Examples
541        --------
542
543        >>> import yt
544        >>> import numpy as np
545        >>> from yt.visualization.volume_rendering.api import Scene
546        >>> sc = Scene()
547        >>> sc.add_camera()
548        >>> # pitch the camera by pi / 4 radians:
549        >>> cam.pitch(np.pi / 4.0)
550        >>> # pitch the camera about the origin instead of its own position:
551        >>> cam.pitch(np.pi / 4.0, rot_center=np.array([0.0, 0.0, 0.0]))
552
553        """
554        self.rotate(theta, rot_vector=self.unit_vectors[0], rot_center=rot_center)
555
556    def yaw(self, theta, rot_center=None):
557        r"""Rotate by a given angle about the vertical axis
558
559        Yaw the view.
560
561        Parameters
562        ----------
563        theta : float, in radians
564             Angle (in radians) by which to yaw the view.
565        rot_center  : array_like, optional
566            Specify the center around which rotation will occur.
567
568        Examples
569        --------
570
571        >>> import yt
572        >>> import numpy as np
573        >>> from yt.visualization.volume_rendering.api import Scene
574        >>> sc = Scene()
575        >>> cam = sc.add_camera()
576        >>> # yaw the camera by pi / 4 radians:
577        >>> cam.yaw(np.pi / 4.0)
578        >>> # yaw the camera about the origin instead of its own position:
579        >>> cam.yaw(np.pi / 4.0, rot_center=np.array([0.0, 0.0, 0.0]))
580
581        """
582        self.rotate(theta, rot_vector=self.unit_vectors[1], rot_center=rot_center)
583
584    def roll(self, theta, rot_center=None):
585        r"""Rotate by a given angle about the view normal axis
586
587        Roll the view.
588
589        Parameters
590        ----------
591        theta : float, in radians
592             Angle (in radians) by which to roll the view.
593        rot_center  : array_like, optional
594            Specify the center around which rotation will occur.
595
596        Examples
597        --------
598
599        >>> import yt
600        >>> import numpy as np
601        >>> from yt.visualization.volume_rendering.api import Scene
602        >>> sc = Scene()
603        >>> cam = sc.add_camera(ds)
604        >>> # roll the camera by pi / 4 radians:
605        >>> cam.roll(np.pi / 4.0)
606        >>> # roll the camera about the origin instead of its own position:
607        >>> cam.roll(np.pi / 4.0, rot_center=np.array([0.0, 0.0, 0.0]))
608
609        """
610        self.rotate(theta, rot_vector=self.unit_vectors[2], rot_center=rot_center)
611
612    def iter_rotate(self, theta, n_steps, rot_vector=None, rot_center=None):
613        r"""Loop over rotate, creating a rotation
614
615        This will rotate `n_steps` until the current view has been
616        rotated by an angle `theta`.
617
618        Parameters
619        ----------
620        theta : float, in radians
621            Angle (in radians) by which to rotate the view.
622        n_steps : int
623            The number of snapshots to make.
624        rot_vector  : array_like, optional
625            Specify the rotation vector around which rotation will
626            occur.  Defaults to None, which sets rotation around the
627            original `north_vector`
628        rot_center  : array_like, optional
629            Specify the center around which rotation will occur. Defaults
630            to None, which sets rotation around the original camera position
631            (i.e. the camera position does not change)
632
633        Examples
634        --------
635
636        >>> import yt
637        >>> import numpy as np
638        >>> ds = yt.load("IsolatedGalaxy/galaxy0030/galaxy0030")
639
640        >>> im, sc = yt.volume_render(ds)
641        >>> cam = sc.camera
642        >>> for i in cam.iter_rotate(np.pi, 10):
643        ...     im = sc.render()
644        ...     sc.save("rotation_%04i.png" % i)
645
646        """
647
648        dtheta = (1.0 * theta) / n_steps
649        for i in range(n_steps):
650            self.rotate(dtheta, rot_vector=rot_vector, rot_center=rot_center)
651            yield i
652
653    def iter_move(self, final, n_steps, exponential=False):
654        r"""Loop over an iter_move and return snapshots along the way.
655
656        This will yield `n_steps` until the current view has been
657        moved to a final center of `final`.
658
659        Parameters
660        ----------
661        final : YTArray
662            The final center to move to after `n_steps`
663        n_steps : int
664            The number of snapshots to make.
665        exponential : boolean
666            Specifies whether the move/zoom transition follows an
667            exponential path toward the destination or linear.
668            Default is False.
669
670        Examples
671        --------
672
673        >>> import yt
674        >>> import numpy as np
675        >>> ds = yt.load("IsolatedGalaxy/galaxy0030/galaxy0030")
676        >>> final_position = ds.arr([0.2, 0.3, 0.6], "unitary")
677        >>> im, sc = yt.volume_render(ds)
678        >>> cam = sc.camera
679        >>> for i in cam.iter_move(final_position, 10):
680        ...     sc.render()
681        ...     sc.save("move_%04i.png" % i)
682
683        """
684        assert isinstance(final, YTArray)
685        if exponential:
686            position_diff = (final / self.position) * 1.0
687            dx = position_diff ** (1.0 / n_steps)
688        else:
689            dx = (final - self.position) * 1.0 / n_steps
690        for i in range(n_steps):
691            if exponential:
692                self.set_position(self.position * dx)
693            else:
694                self.set_position(self.position + dx)
695            yield i
696
697    def zoom(self, factor):
698        r"""Change the width of the FOV of the camera.
699
700        This will appear to zoom the camera in by some `factor` toward the
701        focal point along the current view direction, but really it's just
702        changing the width of the field of view.
703
704        Parameters
705        ----------
706        factor : float
707            The factor by which to divide the width
708
709        Examples
710        --------
711
712        >>> import yt
713        >>> from yt.visualization.volume_rendering.api import Scene
714        >>> ds = yt.load("IsolatedGalaxy/galaxy0030/galaxy0030")
715        >>> sc = Scene()
716        >>> cam = sc.add_camera(ds)
717        >>> cam.zoom(1.1)
718
719        """
720
721        self.width[:2] = self.width[:2] / factor
722
723    def iter_zoom(self, final, n_steps):
724        r"""Loop over a iter_zoom and return snapshots along the way.
725
726        This will yield `n_steps` snapshots until the current view has been
727        zooming in to a final factor of `final`.
728
729        Parameters
730        ----------
731        final : float
732            The zoom factor, with respect to current, desired at the end of the
733            sequence.
734        n_steps : int
735            The number of zoom snapshots to make.
736
737        Examples
738        --------
739
740        >>> import yt
741        >>> ds = yt.load("IsolatedGalaxy/galaxy0030/galaxy0030")
742        >>> im, sc = yt.volume_render(ds)
743        >>> cam = sc.camera
744        >>> for i in cam.iter_zoom(100.0, 10):
745        ...     sc.render()
746        ...     sc.save("zoom_%04i.png" % i)
747
748        """
749        f = final ** (1.0 / n_steps)
750        for i in range(n_steps):
751            self.zoom(f)
752            yield i
753
754    def __repr__(self):
755        disp = (
756            "<Camera Object>:\n\tposition:%s\n\tfocus:%s\n\t"
757            + "north_vector:%s\n\twidth:%s\n\tlight:%s\n\tresolution:%s\n"
758        ) % (
759            self.position,
760            self.focus,
761            self.north_vector,
762            self.width,
763            self.light,
764            self.resolution,
765        )
766        disp += f"Lens: {self.lens}"
767        return disp
768