def test_GIVEN_animation_parameters_WHEN_calling_set_neutron_animation_properties_THEN_properties_set(
):

    mock_neutron_animation_controller = Mock()
    animation_distance = 15
    time_span_offset = 5

    mock_neutron_animation = Mock()

    Gnomon.set_neutron_animation_properties(
        mock_neutron_animation,
        mock_neutron_animation_controller,
        animation_distance,
        time_span_offset,
    )

    mock_neutron_animation.setTargetObject.assert_called_once_with(
        mock_neutron_animation_controller)
    mock_neutron_animation.setPropertyName.assert_called_once_with(b"distance")
    mock_neutron_animation.setStartValue.assert_called_once_with(0)
    mock_neutron_animation.setEndValue.assert_called_once_with(
        animation_distance)
    mock_neutron_animation.setDuration.assert_called_once_with(
        500 + time_span_offset)
    mock_neutron_animation.setLoopCount.assert_called_once_with(-1)
    mock_neutron_animation.start.assert_called_once()
def test_GIVEN_radius_WHEN_calling_set_sphere_mesh_radius_THEN_radius_set():

    radius = 2
    mock_sphere_mesh = Mock()

    Gnomon.set_sphere_mesh_radius(mock_sphere_mesh, radius)

    mock_sphere_mesh.setRadius.assert_called_once_with(radius)
def test_GIVEN_cylinder_and_length_WHEN_calling_configure_gnomon_cylinder_THEN_properties_set(
):

    mock_cylinder_mesh = Mock()
    length = 20

    Gnomon.configure_gnomon_cylinder(mock_cylinder_mesh, length)

    mock_cylinder_mesh.setRadius.assert_called_once_with(length * 0.05)
    mock_cylinder_mesh.setLength.assert_called_once_with(length)
    mock_cylinder_mesh.setRings.assert_called_once_with(2)
def test_GIVEN_mesh_and_length_WHEN_calling_configure_gnomon_cone_THEN_properties_set(
):

    cone_mesh = Mock()
    gnomon_cylinder_length = 10

    Gnomon.configure_gnomon_cone(cone_mesh, gnomon_cylinder_length)

    cone_mesh.setLength.assert_called_once_with(gnomon_cylinder_length * 0.3)
    cone_mesh.setBottomRadius.assert_called_once_with(gnomon_cylinder_length *
                                                      0.1)
    cone_mesh.setTopRadius.assert_called_once_with(0)
def test_GIVEN_cylinder_dimensions_WHEN_calling_set_cylinder_mesh_dimensions_THEN_dimensions_set(
):

    radius = 2
    length = 10
    rings = 2

    mock_cylinder = Mock()

    Gnomon.set_cylinder_mesh_dimensions(mock_cylinder, radius, length, rings)

    mock_cylinder.setRadius.assert_called_once_with(radius)
    mock_cylinder.setLength.assert_called_once_with(length)
    mock_cylinder.setRings.assert_called_once_with(rings)
def test_GIVEN_entity_label_and_color_WHEN_calling_set_axis_label_text_THEN_properties_set(
):

    text_entity = Mock()
    text_label = "X"
    text_color = "green"

    Gnomon.set_axis_label_text(text_entity, text_label, text_color)

    text_entity.setText.assert_called_once_with(text_label)
    text_entity.setHeight.assert_called_once_with(1.2)
    text_entity.setWidth.assert_called_once_with(1)
    text_entity.setColor.assert_called_once_with(QColor(text_color))
    text_entity.setFont.assert_called_once_with(QFont("Courier New", 1))
def test_GIVEN_cylinder_transform_WHEN_calling_set_beam_transform_THEN_matrix_set(
):

    neutron_animation_length = 15

    expected_matrix = QMatrix4x4()
    expected_matrix.rotate(90, QVector3D(1, 0, 0))
    expected_matrix.translate(QVector3D(0, neutron_animation_length * 0.5, 0))

    mock_cylinder_transform = Mock()
    mock_cylinder_transform.setMatrix = Mock()

    Gnomon.set_beam_transform(mock_cylinder_transform,
                              neutron_animation_length)

    assert mock_cylinder_transform.setMatrix.call_args[0][0] == expected_matrix
def test_GIVEN_view_matrix_and_vector_WHEN_calling_create_billboard_transformation_THEN_corect_matrix_returned(
):

    view_matrix = QMatrix4x4(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
                             16)
    text_vector = QVector3D(10, 10, 10)

    expected_matrix = view_matrix.transposed()
    expected_matrix.setRow(3, QVector4D())
    expected_matrix.setColumn(3, QVector4D(text_vector, 1))

    actual_matrix = Gnomon.create_billboard_transformation(
        view_matrix, text_vector)

    assert expected_matrix == actual_matrix
def test_GIVEN_vectors_WHEN_calling_create_axis_label_matrices_THEN_correct_matrices_returned(
):

    vectors = [QVector3D(1, 0, 0), QVector3D(0, 1, 0), QVector3D(0, 0, 1)]

    expected_x = QMatrix4x4()
    expected_y = QMatrix4x4()
    expected_z = QMatrix4x4()

    expected_x.translate(vectors[0])
    expected_y.translate(vectors[1])
    expected_z.translate(vectors[2])

    actual_x, actual_y, actual_z = Gnomon.create_axis_label_matrices(vectors)

    assert expected_x == actual_x
    assert expected_y == actual_y
    assert expected_z == actual_z
def test_GIVEN_cone_length_WHEN_calling_create_cone_matrices_THEN_correct_matrices_returned(
):

    length = 8

    expected_x = QMatrix4x4()
    expected_y = QMatrix4x4()
    expected_z = QMatrix4x4()

    expected_x.rotate(270, QVector3D(0, 0, 1))
    expected_x.translate(QVector3D(0, length, 0))

    expected_y.translate(QVector3D(0, length, 0))

    expected_z.rotate(90, QVector3D(1, 0, 0))
    expected_z.translate(QVector3D(0, length, 0))

    actual_x, actual_y, actual_z = Gnomon.create_cone_matrices(length)

    assert expected_x == actual_x
    assert expected_y == actual_y
    assert expected_z == actual_z
Example #11
0
    def __init__(self, parent):
        super().__init__()

        self.root_entity = Qt3DCore.QEntity()

        # Make additional entities for the gnomon and instrument components
        self.combined_component_axes_entity = Qt3DCore.QEntity(self.root_entity)
        self.component_root_entity = Qt3DCore.QEntity(
            self.combined_component_axes_entity
        )
        self.axes_root_entity = Qt3DCore.QEntity(self.combined_component_axes_entity)
        self.gnomon_root_entity = Qt3DCore.QEntity(self.root_entity)

        # Create the 3DWindow and place it in a widget with a layout
        lay = QVBoxLayout(self)
        self.view = InstrumentZooming3DWindow(self.component_root_entity)
        self.view.defaultFrameGraph().setClearColor(QColor("lightgrey"))
        self.view.setRootEntity(self.root_entity)
        container = QWidget.createWindowContainer(self.view)
        lay.addWidget(container)

        # Set the properties of the instrument camera controller
        camera_entity = self.view.camera()
        cam_controller = Qt3DExtras.QFirstPersonCameraController(self.root_entity)
        cam_controller.setLinearSpeed(20)
        cam_controller.setCamera(camera_entity)

        # Enable the camera to see a large distance by giving it a small nearView and large farView
        self.view.camera().lens().setPerspectiveProjection(45, 16 / 9, 0.01, 1000)

        # Set the camera view centre as the origin and position the camera so that it looks down at the initial sample
        self.view.camera().setPosition(QVector3D(6, 8, 30))
        self.view.camera().setViewCenter(QVector3D(0, 0, 0))

        # Make sure that the size of the gnomon stays the same when the 3D view is resized
        self.view.heightChanged.connect(self.update_gnomon_size)
        self.view.widthChanged.connect(self.update_gnomon_size)

        # Keep a reference to the gnomon viewport so that it can be resized to preserve the original size of the gnomon
        self.gnomon_viewport = None

        # Choose a fixed height and width for the gnomon so that this can be preserved when the 3D view is resized
        self.gnomon_height = self.gnomon_width = 140

        # Create the gnomon resources
        self.gnomon = Gnomon(self.gnomon_root_entity, self.view.camera())

        # Create the axes lines objects
        InstrumentViewAxes(self.axes_root_entity, self.view.camera().farPlane())

        # Dictionary of components and transformations so that we can delete them later
        self.component_entities = {}
        self.transformations = {}

        # Create layers in order to allow one camera to only see the gnomon and one camera to only see the
        # components and axis lines
        self.create_layers()
        self.initialise_view()

        # Insert the beam cylinder last. This ensures that the semi-transparency works correctly.
        self.gnomon.setup_beam_cylinder()

        # Move the gnomon when the camera view changes
        self.view.camera().viewVectorChanged.connect(self.gnomon.update_gnomon)
Example #12
0
class InstrumentView(QWidget):
    """
    Class for managing the 3D view in the NeXus Constructor. Creates the initial sample, the initial beam, and the
    neutron animation.
    :param parent: The MainWindow in which this widget is created. This isn't used for anything but is accepted as an
                   argument in order to appease Qt Designer.
    """

    def delete(self):
        """
        Fixes Qt3D segfault - this needs to be called when the program closes otherwise Qt tries to draw objects as python is cleaning them up.
        """
        self.clear_all_components()
        del self.root_entity
        del self.view

    def __init__(self, parent):
        super().__init__()

        self.root_entity = Qt3DCore.QEntity()

        # Make additional entities for the gnomon and instrument components
        self.combined_component_axes_entity = Qt3DCore.QEntity(self.root_entity)
        self.component_root_entity = Qt3DCore.QEntity(
            self.combined_component_axes_entity
        )
        self.axes_root_entity = Qt3DCore.QEntity(self.combined_component_axes_entity)
        self.gnomon_root_entity = Qt3DCore.QEntity(self.root_entity)

        # Create the 3DWindow and place it in a widget with a layout
        lay = QVBoxLayout(self)
        self.view = InstrumentZooming3DWindow(self.component_root_entity)
        self.view.defaultFrameGraph().setClearColor(QColor("lightgrey"))
        self.view.setRootEntity(self.root_entity)
        container = QWidget.createWindowContainer(self.view)
        lay.addWidget(container)

        # Set the properties of the instrument camera controller
        camera_entity = self.view.camera()
        cam_controller = Qt3DExtras.QFirstPersonCameraController(self.root_entity)
        cam_controller.setLinearSpeed(20)
        cam_controller.setCamera(camera_entity)

        # Enable the camera to see a large distance by giving it a small nearView and large farView
        self.view.camera().lens().setPerspectiveProjection(45, 16 / 9, 0.01, 1000)

        # Set the camera view centre as the origin and position the camera so that it looks down at the initial sample
        self.view.camera().setPosition(QVector3D(6, 8, 30))
        self.view.camera().setViewCenter(QVector3D(0, 0, 0))

        # Make sure that the size of the gnomon stays the same when the 3D view is resized
        self.view.heightChanged.connect(self.update_gnomon_size)
        self.view.widthChanged.connect(self.update_gnomon_size)

        # Keep a reference to the gnomon viewport so that it can be resized to preserve the original size of the gnomon
        self.gnomon_viewport = None

        # Choose a fixed height and width for the gnomon so that this can be preserved when the 3D view is resized
        self.gnomon_height = self.gnomon_width = 140

        # Create the gnomon resources
        self.gnomon = Gnomon(self.gnomon_root_entity, self.view.camera())

        # Create the axes lines objects
        InstrumentViewAxes(self.axes_root_entity, self.view.camera().farPlane())

        # Dictionary of components and transformations so that we can delete them later
        self.component_entities = {}
        self.transformations = {}

        # Create layers in order to allow one camera to only see the gnomon and one camera to only see the
        # components and axis lines
        self.create_layers()
        self.initialise_view()

        # Insert the beam cylinder last. This ensures that the semi-transparency works correctly.
        self.gnomon.setup_beam_cylinder()

        # Move the gnomon when the camera view changes
        self.view.camera().viewVectorChanged.connect(self.gnomon.update_gnomon)

    def create_layers(self):
        """
        Assigns the gnomon view and component view to different cameras and viewports. Controls the buffer behaviour of
        the different viewports so that the depth buffer behaves in such a way that the gnomon is always in front.
        """
        # Set up view surface selector for filtering
        surface_selector = Qt3DRender.QRenderSurfaceSelector(self.root_entity)
        surface_selector.setSurface(self.view)

        main_camera = self.view.camera()
        viewport = Qt3DRender.QViewport(surface_selector)
        self.view.setActiveFrameGraph(surface_selector)

        # Filters out just the instrument for the main camera to see
        component_clear_buffers = self.create_camera_filter(
            viewport, self.combined_component_axes_entity, main_camera
        )

        # Have the component buffer take on the default behaviour
        component_clear_buffers.setBuffers(Qt3DRender.QClearBuffers.AllBuffers)

        # Set the background color of the main scene
        component_clear_buffers.setClearColor(QColor("lightgrey"))

        # Create a viewport for gnomon in small section of the screen
        self.gnomon_viewport = Qt3DRender.QViewport(surface_selector)
        self.update_gnomon_size()

        # Filter out the gnomon for just the gnomon camera to see
        gnomon_camera = self.gnomon.get_gnomon_camera()
        gnomon_clear_buffers = self.create_camera_filter(
            self.gnomon_viewport, self.gnomon_root_entity, gnomon_camera
        )
        # Make the gnomon appear in front of everything else
        gnomon_clear_buffers.setBuffers(Qt3DRender.QClearBuffers.DepthBuffer)

        self.gnomon.update_gnomon()

    def update_gnomon_size(self):
        """
        Ensures that the gnomon retains its size when the size of the 3D view has been changed. Calculates the desired
        gnomon size as a proportion of the current window size and passes these values to the gnomon viewport.
        """
        height_ratio, width_ratio = self.calculate_gnomon_rect(
            self.view.height(), self.view.width(), self.gnomon_height, self.gnomon_width
        )
        self.gnomon_viewport.setNormalizedRect(
            QRectF(1 - width_ratio, 1 - height_ratio, width_ratio, height_ratio)
        )

    @staticmethod
    def calculate_gnomon_rect(
        view_height: float, view_width: float, gnomon_height: float, gnomon_width: float
    ) -> Tuple[float, float]:
        """
        Finds the ratio of the desired gnomon height/width to the current 3D view height/width.
        :param view_height: The current 3D view height.
        :param view_width: The current 3D view width.
        :param gnomon_height: The desired gnomon height.
        :param gnomon_width: The desired gnomon width.
        :return: The gnomon height/width ratios that are required to determine the size of the gnomon viewport.
        """
        height_ratio = gnomon_height / view_height
        width_ratio = gnomon_width / view_width
        return height_ratio, width_ratio

    @staticmethod
    def create_camera_filter(
        viewport: Qt3DRender.QViewport,
        visible_entity: Qt3DCore.QEntity,
        camera_to_filter: Qt3DRender.QCamera,
    ) -> Qt3DRender.QClearBuffers:
        """
        Filter the objects that are visible to a camera.
        :param viewport: The viewport that the camera is using.
        :param visible_entity: Only children of this entity will be visible to the camera.
        :param camera_to_filter: The camera to apply the filter to.
        :return: The clear buffers
        """
        layer_filter = Qt3DRender.QLayerFilter(viewport)
        layer = Qt3DRender.QLayer(visible_entity)
        visible_entity.addComponent(layer)
        layer.setRecursive(True)
        layer_filter.addLayer(layer)
        camera_selector = Qt3DRender.QCameraSelector(layer_filter)
        camera_selector.setCamera(camera_to_filter)
        clear_buffers = Qt3DRender.QClearBuffers(camera_selector)
        return clear_buffers

    def add_component(
        self, name: str, geometry: OFFGeometry, positions: List[QVector3D] = None
    ):
        """
        Add a component to the instrument view given a name and its geometry.
        :param name: The name of the component.
        :param geometry: The geometry information of the component that is used to create a mesh.
        :param positions: Mesh is repeated at each of these positions
        """
        if geometry is None:
            return

        mesh = OffMesh(geometry.off_geometry, self.component_root_entity, positions)
        material = create_material(
            QColor("black"), QColor("grey"), self.component_root_entity
        )

        self.component_entities[name] = create_qentity(
            [mesh, material], self.component_root_entity
        )

    def get_entity(self, component_name: str) -> Qt3DCore.QEntity:
        """
        Obtain the entity from the InstrumentView based on its name.
        """
        try:
            return self.component_entities[component_name]
        except KeyError:
            logging.error(
                f"Unable to retrieve component {component_name} because it doesn't exist."
            )

    @staticmethod
    def zoom_to_component(entity: Qt3DCore.QEntity, camera: Qt3DRender.QCamera):
        """
        Instructs a camera to zoom in on an individual component.
        :param entity: The component entity that the camera should zoom in on.
        :param camera: The camera that will do the zooming.
        """
        camera.viewEntity(entity)

    def clear_all_components(self):
        """
        resets the entities in qt3d so all components are cleared from the 3d view.
        """
        for component in self.component_entities.keys():
            self.component_entities[component].setParent(None)
        self.component_entities = dict()

    def delete_component(self, name: str):
        """
        Delete a component from the InstrumentView by removing the components and entity from the dictionaries.
        :param name: The name of the component.
        """
        try:
            self.component_entities[name].setParent(None)
            self.component_entities.pop(name)
        except KeyError:
            logging.error(
                f"Unable to delete component {name} because it doesn't exist."
            )

    def add_transformation(
        self, component_name: str, transformation: Qt3DCore.QTransform
    ):
        """
        Add a transformation to a component, each component has a single transformation which contains
        the resultant transformation for its entire depends_on chain of translations and rotations
        """
        self.transformations[component_name] = transformation
        component = self.component_entities[component_name]
        component.addComponent(transformation)

    def clear_all_transformations(self):
        """
        Remove all transformations from all components
        """
        for component_name, transformation in self.transformations.items():
            self.component_entities[component_name].removeComponent(transformation)
        self.transformations = {}

    @staticmethod
    def set_cube_mesh_dimensions(
        cube_mesh: Qt3DExtras.QCuboidMesh, x: float, y: float, z: float
    ):
        """
        Sets the dimensions of a cube mesh.
        :param cube_mesh: The cube mesh to modify.
        :param x: The desired x extent.
        :param y: The desired y extent.
        :param z: The desired z extent.
        """
        cube_mesh.setXExtent(x)
        cube_mesh.setYExtent(y)
        cube_mesh.setZExtent(z)

    def setup_sample_cube(self):
        """
        Sets up the cube that represents a sample in the 3D view by giving the cube entity a mesh and a material.
        """
        cube_mesh = Qt3DExtras.QCuboidMesh(self.component_root_entity)
        self.set_cube_mesh_dimensions(cube_mesh, *[0.1, 0.1, 0.1])
        dark_red = QColor("#b00")
        sample_material = create_material(
            QColor("red"), dark_red, self.component_root_entity, alpha=0.5
        )
        self.component_entities["sample"] = create_qentity(
            [cube_mesh, sample_material], self.component_root_entity
        )

    def initialise_view(self):
        """
        Calls the methods for defining materials, setting up the sample cube, and setting up the neutrons. Beam-related
        functions are called outside of this method to ensure that those things are generated last.
        """
        self.setup_sample_cube()
        self.gnomon.create_gnomon()
        self.gnomon.setup_neutrons()