diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fff10a0316..86335aaecc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,7 +190,7 @@ jobs: - name: Install utilities to build installer if: ${{ matrix.installer }} run: | - python -m pip install pyinstaller + python -m pip install pyinstaller==5.7.0 - name: Build sasview with pyinstaller if: ${{ matrix.installer }} @@ -233,17 +233,6 @@ jobs: installers/dist/sasview-pyinstaller-dist.tar.gz if-no-files-found: ignore - - name: Publish installer package - if: ${{ matrix.installer }} - uses: actions/upload-artifact@v3 - with: - name: SasView-Installer-${{ matrix.os }}-${{ matrix.python-version }} - path: | - installers/dist/setupSasView.exe - installers/dist/SasView5.dmg - installers/dist/sasview5.tar.gz - if-no-files-found: error - - name: Sign executable and create dmg (OSX) if: ${{ matrix.installer && startsWith(matrix.os, 'macos') }} env: @@ -258,10 +247,23 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k DloaAcYP build.keychain cd installers/dist + python ../../build_tools/fix_qt_folder_names_for_codesign.py SasView5.app python ../../build_tools/code_sign_osx.py codesign --verify --options=runtime --entitlements ../../build_tools/entitlements.plist --timestamp --deep --verbose=4 --force --sign "Developer ID Application: European Spallation Source Eric (W2AG9MPZ43)" SasView5.app hdiutil create SasView5.dmg -srcfolder SasView5.app -ov -format UDZO codesign -s "Developer ID Application: European Spallation Source Eric (W2AG9MPZ43)" SasView5.dmg + + - name: Publish installer package + if: ${{ matrix.installer }} + uses: actions/upload-artifact@v3 + with: + name: SasView-Installer-${{ matrix.os }}-${{ matrix.python-version }} + path: | + installers/dist/setupSasView.exe + installers/dist/SasView5.dmg + installers/dist/sasview5.tar.gz + if-no-files-found: error + # - name: Notarize Release Build (OSX) # if: ${{ env.RELEASE == 'true' && matrix.installer && startsWith(matrix.os, 'macos') }} diff --git a/build_tools/fix_qt_folder_names_for_codesign.py b/build_tools/fix_qt_folder_names_for_codesign.py new file mode 100644 index 0000000000..75cac1f449 --- /dev/null +++ b/build_tools/fix_qt_folder_names_for_codesign.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +import os +import shutil +import sys +from pathlib import Path +from typing import Generator, List, Optional + +from macholib.MachO import MachO + + +def create_symlink(folder: Path) -> None: + """Create the appropriate symlink in the MacOS folder + pointing to the Resources folder. + """ + sibbling = Path(str(folder).replace("MacOS", "")) + + # PyQt5/Qt/qml/QtQml/Models.2 + root = str(sibbling).partition("Contents")[2].lstrip("/") + # ../../../../ + backward = "../" * (root.count("/") + 1) + # ../../../../Resources/PyQt5/Qt/qml/QtQml/Models.2 + good_path = f"{backward}Resources/{root}" + + folder.symlink_to(good_path) + + +def fix_dll(dll: Path) -> None: + """Fix the DLL lookup paths to use relative ones for Qt dependencies. + Inspiration: PyInstaller/depend/dylib.py:mac_set_relative_dylib_deps() + Currently one header is pointing to (we are in the Resources folder): + @loader_path/../../../../QtCore (it is referencing to the old MacOS folder) + It will be converted to: + @loader_path/../../../../../../MacOS/QtCore + """ + + def match_func(pth: str) -> Optional[str]: + """Callback function for MachO.rewriteLoadCommands() that is + called on every lookup path setted in the DLL headers. + By returning None for system libraries, it changes nothing. + Else we return a relative path pointing to the good file + in the MacOS folder. + """ + basename = os.path.basename(pth) + if not basename.startswith("Qt"): + return None + return f"@loader_path{good_path}/{basename}" + + # Resources/PyQt5/Qt/qml/QtQuick/Controls.2/Fusion + root = str(dll.parent).partition("Contents")[2][1:] + # /../../../../../../.. + backward = "/.." * (root.count("/") + 1) + # /../../../../../../../MacOS + good_path = f"{backward}/MacOS" + + # Rewrite Mach headers with corrected @loader_path + dll = MachO(dll) + dll.rewriteLoadCommands(match_func) + with open(dll.filename, "rb+") as f: + for header in dll.headers: + f.seek(0) + dll.write(f) + f.seek(0, 2) + f.flush() + + +def find_problematic_folders(folder: Path) -> Generator[Path, None, None]: + """Recursively yields problematic folders (containing a dot in their name).""" + for path in folder.iterdir(): + if not path.is_dir() or path.is_symlink(): + # Skip simlinks as they are allowed (even with a dot) + continue + if "." in path.name: + yield path + else: + yield from find_problematic_folders(path) + + +def move_contents_to_resources(folder: Path) -> Generator[Path, None, None]: + """Recursively move any non symlink file from a problematic folder + to the sibbling one in Resources. + """ + for path in folder.iterdir(): + if path.is_symlink(): + continue + if path.name == "qml": + yield from move_contents_to_resources(path) + else: + sibbling = Path(str(path).replace("MacOS", "Resources")) + sibbling.parent.mkdir(parents=True, exist_ok=True) + shutil.move(path, sibbling) + yield sibbling + + +def main(args: List[str]) -> int: + """ + Fix the application to allow codesign (NXDRIVE-1301). + Take one or more .app as arguments: "Nuxeo Drive.app". + To overall process will: + - move problematic folders from MacOS to Resources + - fix the DLLs lookup paths + - create the appropriate symbolic link + """ + for app in args: + name = os.path.basename(app) + print(f">>> [{name}] Fixing Qt folder names") + path = Path(app) / "Contents" / "MacOS" + for folder in find_problematic_folders(path): + for file in move_contents_to_resources(folder): + try: + fix_dll(file) + except (ValueError, IsADirectoryError): + continue + shutil.rmtree(folder) + create_symlink(folder) + print(f" !! Fixed {folder}") + print(f">>> [{name}] Application fixed.") + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) \ No newline at end of file diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt index 428842bb01..416a3c54f7 100644 --- a/build_tools/requirements.txt +++ b/build_tools/requirements.txt @@ -36,4 +36,5 @@ bumps html2text jsonschema superqt - +pyopengl +pyopengl_accelerate diff --git a/docs/sphinx-docs/source/dev/gl/opengl.rst b/docs/sphinx-docs/source/dev/gl/opengl.rst new file mode 100644 index 0000000000..010c6a25c1 --- /dev/null +++ b/docs/sphinx-docs/source/dev/gl/opengl.rst @@ -0,0 +1,13 @@ +Open GL Subsystem +============== + +The SasView openGL subsystem is quite minimal, and works in the standard way though a scenegraph + +Within the `visual_checks` directory there are a couple of stand-alone python files that provide +a way of checking the rendering, and catalogue the available functions + + +Class Hierarchy +=============== + +TODO - data currently in PR. \ No newline at end of file diff --git a/installers/sasview.spec b/installers/sasview.spec index 8c9f770654..7f7c43a0d0 100644 --- a/installers/sasview.spec +++ b/installers/sasview.spec @@ -20,8 +20,8 @@ datas = [ ('../docs/sphinx-docs/build/html','doc') ] #TODO: Hopefully we can get away from version specific packages -datas.append((os.path.join(PYTHON_PACKAGES, 'jedi'), 'jedi')) datas.append((os.path.join(PYTHON_PACKAGES, 'debugpy'), 'debugpy')) +datas.append((os.path.join(PYTHON_PACKAGES, 'jedi'), 'jedi')) datas.append((os.path.join(PYTHON_PACKAGES, 'zmq'), 'zmq')) def add_data(data): diff --git a/src/sas/qtgui/GL/__init__.py b/src/sas/qtgui/GL/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/GL/color.py b/src/sas/qtgui/GL/color.py new file mode 100644 index 0000000000..026a0c0cc3 --- /dev/null +++ b/src/sas/qtgui/GL/color.py @@ -0,0 +1,97 @@ +from typing import Sequence, Union + +import logging +import numpy as np +import matplotlib as mpl +from enum import Enum +from dataclasses import dataclass + +from OpenGL.GL import glColor4f + +"Helper classes for dealing with colours" + +logger = logging.getLogger("GL.Color") + +class ColorSpecificationMethod(Enum): + """ Specifies how to colour an object""" + UNIFORM = 1 # Whole object a single colour + BY_COMPONENT = 2 # Each mesh or edge within the object a single colour + BY_VERTEX = 3 # Vertex colouring for the whole object + +@dataclass +class ColorSpecification: + """ Specification of how to colour an object, and the data needed to do so""" + method: ColorSpecificationMethod + data: np.ndarray + + +def uniform_coloring(r, g, b, alpha=1.0): + """ Create a ColorSpecification for colouring with a single colour""" + return ColorSpecification( + method=ColorSpecificationMethod.UNIFORM, + data=np.array([r, g, b, alpha])) + + +def edge_coloring(data: Sequence[Union[Sequence[float], np.ndarray]]) -> ColorSpecification: + """ Create a ColorSpecification for colouring each edge within an object a single colour""" + return _component_coloring(data) + + +def mesh_coloring(data: Sequence[Union[Sequence[float], np.ndarray]]) -> ColorSpecification: + """ Create a ColorSpecification for colouring each mesh within an object a single colour""" + return _component_coloring(data) + + +def _component_coloring(data: Sequence[Union[Sequence[float], np.ndarray]]) -> ColorSpecification: + """ Create a ColorSpecification for colouring each mesh/edge within an object a single colour""" + try: + data = np.array(data) + except: + raise ValueError("Colour data should be all n-by-3 or n-by-4") + + if data.shape[1] == 3: + data = np.concatenate((data, np.ones((data.shape[0], 1))), axis=1) + elif data.shape[1] == 4: + pass + else: + raise ValueError("Colour data should be all n-by-3 or n-by-4") + + return ColorSpecification(ColorSpecificationMethod.BY_COMPONENT, data) + + +def vertex_coloring(data: np.ndarray) -> ColorSpecification: + """ Create a ColorSpecification for using vertex colouring""" + try: + data = np.array(data) + except: + raise ValueError("Colour data should be all n-by-3 or n-by-4") + + if data.shape[1] == 3: + data = np.concatenate((data, np.ones((data.shape[0], 1))), axis=1) + elif data.shape[1] == 4: + pass + else: + raise ValueError("Colour data should be all n-by-3 or n-by-4") + + return ColorSpecification(ColorSpecificationMethod.BY_VERTEX, data) + + +class ColorMap(): + + _default_colormap = 'rainbow' + def __init__(self, colormap_name=_default_colormap, min_value=0.0, max_value=1.0): + """ Utility class for colormaps, principally used for mapping data in Surface""" + try: + self.colormap = mpl.colormaps[colormap_name] + except KeyError: + logger.warning(f"Bad colormap name '{colormap_name}'") + self.colormap = mpl.colormaps[ColorMap._default_colormap] + + self.min_value = min_value + self.max_value = max_value + + def vertex_coloring(self, values: np.ndarray): + """ Evaluate the color map and return a ColorSpecification object""" + scaled = (values - self.min_value) / (self.max_value - self.min_value) + scaled = np.clip(scaled, 0, 1) + return vertex_coloring(self.colormap(scaled)) diff --git a/src/sas/qtgui/GL/cone.py b/src/sas/qtgui/GL/cone.py new file mode 100644 index 0000000000..ba2793a8b7 --- /dev/null +++ b/src/sas/qtgui/GL/cone.py @@ -0,0 +1,64 @@ +from typing import Optional, Union, Sequence, List, Tuple + +import numpy as np + +from sas.qtgui.GL.models import FullModel +from sas.qtgui.GL.color import ColorSpecification + + +class Cone(FullModel): + """ Graphics primitive: Radius 1, Height 2 cone "centred" at (0,0,0)""" + + @staticmethod + def cone_vertices(n) -> List[Tuple[float, float, float]]: + """ Helper function: Vertices of the cone primitive""" + return [(0.0, 0.0, 1.0)] + [ + (np.sin(angle), np.cos(angle), -1.0) + for angle in 2*np.pi*np.arange(0, n)/n] + [(0.0, 0.0, -1.0)] + + @staticmethod + def cone_edges(n): + """ Helper function: Edges of the cone primitive""" + return [(0, i+1) for i in range(n)] + [(i+1, (i+1)%n+1) for i in range(n)] + + @staticmethod + def cone_tip_triangles(n) -> List[Tuple[int, int, int]]: + """ Helper function: Triangles in tip of the cone primitive""" + return [(0, i + 1, (i + 1) % n + 1) for i in range(n)] + + @staticmethod + def cone_base_triangles(n) -> List[Tuple[int, int, int]]: + """ Helper function: Triangles in base the cone primitive""" + return [((i + 1) % n + 1, i + 1, n+1) for i in range(n)] + + @staticmethod + def cone_triangles(n) -> List[List[Tuple[int, int, int]]]: + """ Helper function: The two separate meshes for triangles of the cone primitive""" + return [Cone.cone_base_triangles(n), + Cone.cone_tip_triangles(n)] + + def __init__(self, + n: int = 20, + colors: Optional[ColorSpecification]=None, + edge_colors: Optional[ColorSpecification]=None): + + super().__init__( + vertices=Cone.cone_vertices(n), + edges=Cone.cone_edges(n), + triangle_meshes=Cone.cone_triangles(n), + edge_colors=edge_colors, + colors=colors) + + if edge_colors is None: + self.wireframe_render_enabled = False + self.edge_colors = [] + else: + self.wireframe_render_enabled = True + self.edge_colors = edge_colors + + if colors is None: + self.solid_render_enabled = False + self.face_colors = [] + else: + self.solid_render_enabled = True + self.face_colors = colors diff --git a/src/sas/qtgui/GL/cube.py b/src/sas/qtgui/GL/cube.py new file mode 100644 index 0000000000..8ffbf7dab4 --- /dev/null +++ b/src/sas/qtgui/GL/cube.py @@ -0,0 +1,81 @@ +from typing import Optional, Union, Sequence + +from sas.qtgui.GL.models import FullModel +from sas.qtgui.GL.color import ColorSpecification + + +class Cube(FullModel): + """ Unit cube centred at 0,0,0""" + + cube_vertices = [ + (-0.5, -0.5, -0.5), + (-0.5, -0.5, 0.5), + (-0.5, 0.5, -0.5), + (-0.5, 0.5, 0.5), + ( 0.5, -0.5, -0.5), + ( 0.5, -0.5, 0.5), + ( 0.5, 0.5, -0.5), + ( 0.5, 0.5, 0.5) + ] + + cube_edges = [ + (0, 1), # Front face + (1, 5), + (5, 4), + (4, 0), + (2, 3), # Back face + (3, 7), + (7, 6), + (6, 2), + (1, 3), # between faces + (5, 7), + (4, 6), + (0, 2) + ] + + cube_triangles = [ + [(1,2,3), + (1,0,2)], + [(0,6,2), + (0,4,6)], + [(4,7,6), + (4,5,7)], + [(5,3,7), + (5,1,3)], + [(2,7,3), + (2,6,7)], + [(1,4,0), + (1,5,4)] + ] + + def __init__(self, + colors: Optional[ColorSpecification]=None, + edge_colors: Optional[ColorSpecification]=None): + + super().__init__( + vertices=Cube.cube_vertices, + edges=Cube.cube_edges, + triangle_meshes=Cube.cube_triangles, + edge_colors=edge_colors, + colors=colors) + + self.vertices = Cube.cube_vertices + self.edges = Cube.cube_edges + + if edge_colors is None: + self.wireframe_render_enabled = False + self.edge_colors = [] + else: + self.wireframe_render_enabled = True + self.edge_colors = edge_colors + + if colors is None: + self.solid_render_enabled = False + self.face_colors = [] + else: + self.solid_render_enabled = True + self.face_colors = colors + + + + diff --git a/src/sas/qtgui/GL/cylinder.py b/src/sas/qtgui/GL/cylinder.py new file mode 100644 index 0000000000..effd1258f5 --- /dev/null +++ b/src/sas/qtgui/GL/cylinder.py @@ -0,0 +1,79 @@ +from typing import Optional, List, Tuple + +import numpy as np + +from sas.qtgui.GL.models import FullModel +from sas.qtgui.GL.color import ColorSpecification + + +class Cylinder(FullModel): + """ Graphics primitive: Radius 1, Height 2 cone "centred" at (0,0,0)""" + + @staticmethod + def cylinder_vertices(n) -> List[Tuple[float, float, float]]: + """ Helper function: Vertices of the cylinder primitive""" + return [(0.0, 0.0, 1.0)] + \ + [ (np.sin(angle), np.cos(angle), 1.0) for angle in 2*np.pi*np.arange(0, n)/n] + \ + [(0.0, 0.0, -1.0)] + \ + [ (np.sin(angle), np.cos(angle), -1.0) for angle in 2*np.pi*np.arange(0, n)/n] + + + @staticmethod + def cylinder_edges(n): + """ Helper function: Edges of the cylinder primitive""" + return [(i+1, (i+1)%n+1) for i in range(n)] + \ + [(i + n + 2, (i+1)%n + n + 2) for i in range(n)] + \ + [(i+1, i + n + 2) for i in range(n)] + + @staticmethod + def cylinder_face_triangles(n, offset) -> List[Tuple[int, int, int]]: + """ Helper function: Faces of the ends of cylinder primitive""" + return [(i+offset + 1, (i + 1) % n + offset + 1, offset) for i in range(n)] + + @staticmethod + def cylinder_side_triangles(n) -> List[Tuple[int, int, int]]: + """ Helper function: Faces of the sides of the cylinder primitive""" + sides = [] + for i in range(n): + # Squares into triangles + + # Bottom left, top right, bottom right + sides.append((i + 1, (i + 1) % n + n + 2, (i + 1) % n + 1)) + # Top right, bottom left, top left + sides.append(((i + 1) % n + n + 2, i + 1, i + n + 2)) + return sides + + @staticmethod + def cylinder_triangles(n) -> List[List[Tuple[int, int, int]]]: + """ Helper function: All faces of the cylinder primitive""" + return [ + Cylinder.cylinder_face_triangles(n, 0), + Cylinder.cylinder_side_triangles(n), + [tuple(reversed(x)) for x in Cylinder.cylinder_face_triangles(n, n+1)] + ] + + def __init__(self, + n: int = 20, + colors: Optional[ColorSpecification]=None, + edge_colors: Optional[ColorSpecification]=None): + + super().__init__( + vertices=Cylinder.cylinder_vertices(n), + edges=Cylinder.cylinder_edges(n), + triangle_meshes=Cylinder.cylinder_triangles(n), + edge_colors=edge_colors, + colors=colors) + + if edge_colors is None: + self.wireframe_render_enabled = False + self.edge_colors = [] + else: + self.wireframe_render_enabled = True + self.edge_colors = edge_colors + + if colors is None: + self.solid_render_enabled = False + self.face_colors = [] + else: + self.solid_render_enabled = True + self.face_colors = colors diff --git a/src/sas/qtgui/GL/icosahedron.py b/src/sas/qtgui/GL/icosahedron.py new file mode 100644 index 0000000000..9eb40a5af3 --- /dev/null +++ b/src/sas/qtgui/GL/icosahedron.py @@ -0,0 +1,103 @@ +from typing import Optional, Union, Sequence + +import numpy as np + +from sas.qtgui.GL.color import ColorSpecification +from sas.qtgui.GL.models import FullModel + +ico_ring_h = np.sqrt(1/5) +ico_ring_r = np.sqrt(4/5) + + +class Icosahedron(FullModel): + """ Icosahedron centred at 0,0,0""" + + + + ico_vertices = \ + [(0.0, 0.0, 1.0)] + \ + [(ico_ring_r * np.cos(angle), ico_ring_r * np.sin(angle), ico_ring_h) for angle in 2*np.pi*np.arange(5)/5] + \ + [(ico_ring_r * np.cos(angle), ico_ring_r * np.sin(angle), -ico_ring_h) for angle in 2*np.pi*(np.arange(5)+0.5)/5] + \ + [(0.0, 0.0, -1.0)] + + ico_edges = [ + (0, 1), # Top converging + (0, 2), + (0, 3), + (0, 4), + (0, 5), + (1, 2), # Top radial + (2, 3), + (3, 4), + (4, 5), + (5, 1), # Middle diagonals, advanced + (1, 6), + (2, 7), + (3, 8), + (4, 9), + (5, 10), + (1, 10), # Middle diagonals, delayed + (2, 6), + (3, 7), + (4, 8), + (5, 9), + (6, 7), # Bottom radial + (7, 8), + (8, 9), + (9, 10), + (10, 6), + (6, 11), # Bottom converging + (7, 11), + (8, 11), + (9, 11), + (10, 11), + ] + + ico_triangles = [[ + (0, 1, 2), # Top cap + (0, 2, 3), + (0, 3, 4), + (0, 4, 5), + (0, 5, 1), + (2, 1, 6), # Top middle ring + (3, 2, 7), + (4, 3, 8), + (5, 4, 9), + (1, 5, 10), + (6, 10, 1), # Bottom middle ring + (7, 6, 2), + (8, 7, 3), + (9, 8, 4), + (10, 9, 5), + (6, 7, 11), # Bottom cap + (7, 8, 11), + (8, 9, 11), + (9, 10, 11), + (10, 6, 11) + ]] + + def __init__(self, + colors: Optional[ColorSpecification]=None, + edge_colors: Optional[ColorSpecification]=None): + + super().__init__( + vertices=Icosahedron.ico_vertices, + edges=Icosahedron.ico_edges, + triangle_meshes=Icosahedron.ico_triangles, + edge_colors=edge_colors, + colors=colors) + + + if edge_colors is None: + self.wireframe_render_enabled = False + self.edge_colors = [] + else: + self.wireframe_render_enabled = True + self.edge_colors = edge_colors + + if colors is None: + self.solid_render_enabled = False + self.face_colors = [] + else: + self.solid_render_enabled = True + self.face_colors = colors diff --git a/src/sas/qtgui/GL/models.py b/src/sas/qtgui/GL/models.py new file mode 100644 index 0000000000..a7c54a13bf --- /dev/null +++ b/src/sas/qtgui/GL/models.py @@ -0,0 +1,206 @@ + +""" +3D Model classes +""" + + +from typing import Sequence, Tuple, Union, Optional + +import numpy as np + +from OpenGL.GL import * + +from sas.qtgui.GL.renderable import Renderable +from sas.qtgui.GL.color import ColorSpecification, ColorSpecificationMethod + +VertexData = Union[Sequence[Tuple[float, float, float]], np.ndarray] +EdgeData = Union[Sequence[Tuple[int, int]], np.ndarray] +TriangleMeshData = Union[Sequence[Tuple[int, int, int]], np.ndarray] + + +class ModelBase(Renderable): + """ Base class for all models""" + + def __init__(self, vertices: VertexData): + self._vertices = vertices + self._vertex_array = np.array(vertices, dtype=float) + + # Vertices set and got as sequences of tuples, but a list of + @property + def vertices(self): + return self._vertices + + @vertices.setter + def vertices(self, new_vertices: VertexData): + self._vertices = new_vertices + self._vertex_array = np.array(new_vertices, dtype=float) + + + +class SolidModel(ModelBase): + """ Base class for the solid models""" + def __init__(self, + vertices: VertexData, + triangle_meshes: Sequence[TriangleMeshData]): + + ModelBase.__init__(self, vertices) + + self.solid_render_enabled = False + + self._triangle_meshes = triangle_meshes + self._triangle_mesh_arrays = [np.array(x) for x in triangle_meshes] + + @property + def triangle_meshes(self) -> Sequence[TriangleMeshData]: + return self._triangle_meshes + + @triangle_meshes.setter + def triangle_meshes(self, new_triangle_meshes: Sequence[TriangleMeshData]): + self._triangle_meshes = new_triangle_meshes + self._triangle_mesh_arrays = [np.array(x) for x in new_triangle_meshes] + + +class SolidVertexModel(SolidModel): + def __init__(self, + vertices: VertexData, + triangle_meshes: Sequence[TriangleMeshData], + colors: Optional[ColorSpecification]): + + """ + + + :vertices: Sequence[Tuple[float, float, float]], vertices of the model + :triangle_meshes: Sequence[Sequence[Tuple[int, int, int]]], sequence of triangle + meshes indices making up the shape + :colors: Optional[Union[Sequence[Color], Color]], single color for shape, or array with a colour for + each mesh or vertex (color_by_mesh selects which of these it is) + :color_by_mesh: bool = False, Colour in each mesh with a colour specified by colours + + """ + + super().__init__(vertices, triangle_meshes) + + self.colors = colors + + self.solid_render_enabled = self.colors is not None + + + def render_solid(self): + if self.solid_render_enabled: + + if self.colors.method == ColorSpecificationMethod.UNIFORM: + + glEnableClientState(GL_VERTEX_ARRAY) + glColor4f(*self.colors.data) + + glVertexPointerf(self._vertex_array) + + for triangle_mesh in self._triangle_mesh_arrays: + glDrawElementsui(GL_TRIANGLES, triangle_mesh) + + glDisableClientState(GL_VERTEX_ARRAY) + + elif self.colors.method == ColorSpecificationMethod.BY_COMPONENT: + + glEnableClientState(GL_VERTEX_ARRAY) + + glVertexPointerf(self._vertex_array) + + for triangle_mesh, color in zip(self._triangle_mesh_arrays, self.colors.data): + glColor4f(*color) + glDrawElementsui(GL_TRIANGLES, triangle_mesh) + + glDisableClientState(GL_VERTEX_ARRAY) + + elif self.colors.method == ColorSpecificationMethod.BY_VERTEX: + + glEnableClientState(GL_VERTEX_ARRAY) + glEnableClientState(GL_COLOR_ARRAY) + + glVertexPointerf(self._vertex_array) + glColorPointerf(self.colors.data) + + for triangle_mesh in self._triangle_mesh_arrays: + glDrawElementsui(GL_TRIANGLES, triangle_mesh) + + glDisableClientState(GL_COLOR_ARRAY) + glDisableClientState(GL_VERTEX_ARRAY) + + +class WireModel(ModelBase): + + def __init__(self, + vertices: VertexData, + edges: EdgeData, + edge_colors: Optional[ColorSpecification]): + + """ Wireframe Model + + :vertices: Sequence[Tuple[float, float, float]], vertices of the model + :edges: Sequence[Tuple[int, int]], indices of the points making up the edges + :edge_colors: Optional[Union[Sequence[Color], Color]], color of the individual edges or a single color for them all + """ + + + super().__init__(vertices) + + self.wireframe_render_enabled = edge_colors is not None + self.edges = edges + self.edge_colors = edge_colors + + def render_wireframe(self): + if self.wireframe_render_enabled: + vertices = self.vertices + colors = self.edge_colors + + if colors.method == ColorSpecificationMethod.UNIFORM: + + glBegin(GL_LINES) + glColor4f(*colors.data) + + for edge in self.edges: + glVertex3f(*vertices[edge[0]]) + glVertex3f(*vertices[edge[1]]) + + glEnd() + + elif colors.method == ColorSpecificationMethod.BY_COMPONENT: + + glBegin(GL_LINES) + for edge, color in zip(self.edges, colors.data): + + glColor4f(*color) + + glVertex3f(*vertices[edge[0]]) + glVertex3f(*vertices[edge[1]]) + + glEnd() + + elif colors.method == ColorSpecificationMethod.BY_VERTEX: + raise NotImplementedError("Vertex coloring of wireframe is currently not supported") + + else: + raise ValueError(f"Unknown coloring method: {ColorSpecification.method}") + + +class FullModel(SolidVertexModel, WireModel): + """ Model that has both wireframe and solid, vertex coloured rendering enabled, + + See SolidVertexModel and WireModel + """ + def __init__(self, + vertices: VertexData, + edges: EdgeData, + triangle_meshes: Sequence[TriangleMeshData], + edge_colors: Optional[ColorSpecification], + colors: Optional[ColorSpecification]): + + SolidVertexModel.__init__(self, + vertices=vertices, + triangle_meshes=triangle_meshes, + colors=colors) + WireModel.__init__(self, + vertices=vertices, + edges=edges, + edge_colors=edge_colors) + diff --git a/src/sas/qtgui/GL/renderable.py b/src/sas/qtgui/GL/renderable.py new file mode 100644 index 0000000000..b12a275578 --- /dev/null +++ b/src/sas/qtgui/GL/renderable.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod + +import logging + +logger = logging.getLogger("GL Subsystem") + +class Renderable(ABC): + """ Interface for everything that can be rendered with the OpenGL widget""" + + def render_wireframe(self): + logger.debug(f"{self.__class__} does not support wireframe rendering") + + + def render_solid(self): + logger.debug(f"{self.__class__} does not support solid rendering") diff --git a/src/sas/qtgui/GL/scene.py b/src/sas/qtgui/GL/scene.py new file mode 100644 index 0000000000..3c0c92c6d0 --- /dev/null +++ b/src/sas/qtgui/GL/scene.py @@ -0,0 +1,218 @@ +from typing import Optional, Tuple, List, Callable +import numpy as np + +from PyQt5 import QtWidgets, Qt, QtGui, QtOpenGL, QtCore + +from OpenGL.GL import * +from OpenGL.GLU import * + +from sas.qtgui.GL.renderable import Renderable +from sas.qtgui.GL.surface import Surface + +class Scene(QtOpenGL.QGLWidget): + + + def __init__(self, parent=None, on_key: Callable[[int], None] = lambda x: None): + + super().__init__(parent) + self.setMinimumSize(640, 480) + + self.view_azimuth = 0.0 + self.view_elevation = 0.0 + self.view_distance = 5.0 + self.view_centre = np.array([0.0, 0.0, 0.0]) + self.view_fov = 60 + + self.background_color = (0, 0, 0, 0) + + self.min_distance = 0.1 + self.max_distance = 250 + + # Mouse control settings + self.mouse_sensitivity_azimuth = 0.2 + self.mouse_sensitivity_elevation = 0.5 + self.mouse_sensitivity_distance = 1.0 + self.mouse_sensitivity_position = 0.01 + self.scroll_sensitivity = 0.0005 + + # Mouse control variables + self.mouse_position = None + self.view_centre_difference = np.array([0.0, 0.0, 0.0]) + self.view_azimuth_difference = 0.0 + self.view_elevation_difference = 0.0 + + self._items: List[Renderable] = [] + + self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) + + # Save the on_key callback + self.on_key = on_key + + + def initializeGL(self): + glClearDepth(1.0) + glDepthFunc(GL_LESS) + glEnable(GL_DEPTH_TEST) + glShadeModel(GL_SMOOTH) + + def default_viewport(self): + x = int(self.width() * self.devicePixelRatioF()) + y = int(self.height() * self.devicePixelRatioF()) + # return -x//2, -y//2, x//2, y//2 + # return -y//2, -x//2, x, y + return 0, 0, x, y + + + def paintGL(self): + """ + Paint the GL viewport + """ + + glViewport(*self.default_viewport()) + self.set_projection() + + glClearColor(*self.background_color) + glClear( GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT ) + + self.set_model_view() + + # self.test_paint() + glEnable(GL_POLYGON_OFFSET_FILL) + + for item in self._items: + glPolygonOffset(1.0, 10.0) + item.render_solid() + glPolygonOffset(0.0, 0.0) + item.render_wireframe() + + glPolygonOffset(0.0, 0.0) + + + def projection_matrix(self): + x0, y0, w, h = self.default_viewport() + dist = self.view_distance + nearClip = dist * 0.001 + farClip = dist * 1000. + + r = nearClip * np.tan(0.5 * np.radians(self.view_fov)) + t = r * h / w + + tr = QtGui.QMatrix4x4() + tr.frustum(-r, r, -t, t, nearClip, farClip) + return tr + + def set_projection(self): + + glMatrixMode(GL_PROJECTION) + glLoadIdentity() + # gluPerspective(45.0,1.33,0.1, 100.0) + glLoadMatrixf(np.array(self.projection_matrix().data(), dtype=np.float32)) + + def set_model_view(self): + + tr = QtGui.QMatrix4x4() + # tr.translate(0.0, 0.0, -self.view_distance) + tr.translate(0.0,0.0,-self.view_distance) + + + azimuth = np.radians(self.view_azimuth + self.view_azimuth_difference) + elevation = np.radians(np.clip(self.view_elevation + self.view_elevation_difference, -90, 90)) + centre = self.view_centre + self.view_centre_difference + + x = centre[0] + self.view_distance*np.cos(azimuth)*np.cos(elevation) + y = centre[1] - self.view_distance*np.sin(azimuth)*np.cos(elevation) + z = centre[2] + self.view_distance*np.sin(elevation) + + gluLookAt( + x, y, z, + centre[0], centre[1], centre[2], + 0.0, 0.0, 1.0) + + + glMatrixMode(GL_MODELVIEW) + + + def mousePressEvent(self, ev): + self.mouse_position = ev.localPos() + + def mouseMoveEvent(self, ev): + new_mouse_position = ev.localPos() + diff = new_mouse_position - self.mouse_position + + if ev.buttons() == QtCore.Qt.MouseButton.LeftButton: + self.view_azimuth_difference = self.mouse_sensitivity_azimuth * diff.x() + self.view_elevation_difference = self.mouse_sensitivity_elevation * diff.y() + + elif ev.buttons() == QtCore.Qt.MouseButton.RightButton: + self.view_centre_difference = np.array([self.mouse_sensitivity_position * diff.x(), 0, + self.mouse_sensitivity_position * diff.y()]) + + self.update() + def mouseReleaseEvent(self, ev): + # Mouse released, add dragging offset the view variables + + self.view_centre += np.array(self.view_centre_difference) + self.view_elevation += self.view_elevation_difference + self.view_azimuth += self.view_azimuth_difference + + self.view_centre_difference = np.array([0.0,0.0,0.0]) + self.view_azimuth_difference = 0.0 + self.view_elevation_difference = 0.0 + + self.update() + + def wheelEvent(self, event: QtGui.QWheelEvent): + scroll_amount = event.angleDelta().y() + + self.view_distance *= np.exp(scroll_amount * self.scroll_sensitivity) + + if self.view_distance < self.min_distance: + self.view_distance = self.min_distance + + if self.view_distance > self.max_distance: + self.view_distance = self.max_distance + + event.accept() + + self.update() + + def add(self, item: Renderable): + self._items.append(item) + + def keyPressEvent(self, event: QtGui.QKeyEvent): + self.on_key(event.key()) + + + + + + +def main(): + """ Show a demo of the opengl.rst window """ + import os + + os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1" + app = QtWidgets.QApplication([]) + + mainWindow = QtWidgets.QMainWindow() + viewer = Scene(mainWindow) + + x = np.linspace(-1, 1, 101) + y = np.linspace(-1, 1, 101) + x_grid, y_grid = np.meshgrid(x, y) + + r_sq = x_grid**2 + y_grid**2 + z = np.cos(np.sqrt(r_sq))/(r_sq+1) + + viewer.add(Surface(x, y, z, edge_skip=4)) + + mainWindow.setCentralWidget(viewer) + + mainWindow.show() + + mainWindow.resize(600, 600) + app.exec_() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/sas/qtgui/GL/sphere.py b/src/sas/qtgui/GL/sphere.py new file mode 100644 index 0000000000..f01ae0c37a --- /dev/null +++ b/src/sas/qtgui/GL/sphere.py @@ -0,0 +1,114 @@ +from typing import Optional, Union, Sequence + +import numpy as np + +from sas.qtgui.GL.models import FullModel +from sas.qtgui.GL.color import ColorSpecification + + +class Sphere(FullModel): + @staticmethod + def sphere_vertices(n_horizontal, n_segments): + """ Helper function: Vertices of the UV sphere primitive""" + vertices = [(0.0, 0.0, 1.0)] + + for theta in (np.pi/n_horizontal)*np.arange(0.5, n_horizontal): + for phi in (2*np.pi/n_segments)*np.arange(n_segments): + sin_theta = np.sin(theta) + x = sin_theta * np.cos(phi) + y = sin_theta * np.sin(phi) + z = np.cos(theta) + vertices.append((x,y,z)) + + vertices.append((0.0, 0.0, -1.0)) + + return vertices + + @staticmethod + def sphere_edges(n_horizontal, n_segments, grid_gap): + + """ Helper function: Edges of the UV sphere primitive""" + edges = [] + + # Bands + for i in range(0, n_horizontal, grid_gap): + for j in range(n_segments): + edges.append((i*n_segments + j + 1, i*n_segments + (j+1)%n_segments + 1)) + + # Vertical lines + for i in range(n_horizontal-1): + for j in range(0, n_segments, grid_gap): + edges.append((i*n_segments + j + 1, (i+1)*n_segments + j + 1)) + + return edges + + @staticmethod + def sphere_triangles(n_horizontal, n_segments): + """ Helper function: Triangles of the UV sphere primitive""" + triangles = [] + last_index = n_horizontal*n_segments + 1 + + # Top cap + for j in range(n_segments): + triangles.append((j+1, (j+1)%n_segments + 1, 0)) + + # Mid bands + for i in range(n_horizontal-1): + for j in range(n_segments): + triangles.append((i*n_segments + j + 1, (i+1)*n_segments + (j+1)%n_segments + 1, (i+1)*n_segments + j+1)) + triangles.append(((i+1)*n_segments + (j+1)%n_segments + 1, i*n_segments + j + 1, i*n_segments + (j+1)%n_segments + 1)) + + # Bottom cap + for j in range(n_segments): + triangles.append(((n_horizontal-1)*n_segments + j + 1, (n_horizontal-1)*n_segments + (j + 1) % n_segments + 1, last_index)) + + return [triangles] + + def __init__(self, + n_horizontal: int = 21, + n_segments: int = 28, + grid_gap: int = 1, + colors: Optional[ColorSpecification]=None, + edge_colors: Optional[ColorSpecification]=None): + + """ + + UV Sphere Primitive + + :param n_horizontal: Number of horizontal bands + :param n_segments: Number of segments (angular) + :param grid_gap: Coarse grain the wireframe by skipping every 'grid_gap' coordinates + :param colors: List of colours for each vertex, or a single color for all + :param edge_colors: List of colours for each edge, or a single color for all + + Note: For aesthetically pleasing results with `grid_gap`, `n_segments` should be a multiple + of `grid_gap`, and `n_horizontal - 1` should be too. + + Default parameters should work with a grid gap of 2 or 4. + """ + if n_segments < 3: + raise ValueError(f"Sphere must have at least 3 segments, got {n_segments}") + + if n_horizontal < 2: + raise ValueError(f"Sphere must have at least 2 horizontal strips, got {n_horizontal}") + + super().__init__( + vertices=Sphere.sphere_vertices(n_horizontal, n_segments), + edges=Sphere.sphere_edges(n_horizontal, n_segments, grid_gap), + triangle_meshes=Sphere.sphere_triangles(n_horizontal, n_segments), + edge_colors=edge_colors, + colors=colors) + + if edge_colors is None: + self.wireframe_render_enabled = False + self.edge_colors = [] + else: + self.wireframe_render_enabled = True + self.edge_colors = edge_colors + + if colors is None: + self.solid_render_enabled = False + self.face_colors = [] + else: + self.solid_render_enabled = True + self.face_colors = colors diff --git a/src/sas/qtgui/GL/surface.py b/src/sas/qtgui/GL/surface.py new file mode 100644 index 0000000000..d88b5678c5 --- /dev/null +++ b/src/sas/qtgui/GL/surface.py @@ -0,0 +1,106 @@ +from typing import Tuple + +import logging +import numpy as np + +from sas.qtgui.GL.models import FullModel +from sas.qtgui.GL.color import ColorMap, uniform_coloring + +logger = logging.getLogger("GL.Surface") + +class Surface(FullModel): + + + @staticmethod + def calculate_edge_indices(nx, ny, gap=1): + """ Helper function to calculate the indices of the edges""" + all_edges = [] + for i in range(nx-1): + for j in range(0, ny, gap): + all_edges.append((j*nx + i, j*nx + i + 1)) + + for i in range(0, nx, gap): + for j in range(ny-1): + all_edges.append((j*nx + i, (j+1)*nx + i)) + + return all_edges + + @staticmethod + def calculate_triangles(nx, ny): + """ Helper function to calculate the indices of the triangles in the mesh""" + triangles = [] + for i in range(nx-1): + for j in range(ny-1): + triangles.append((j*nx + i, (j+1)*nx+(i+1), j*nx + (i + 1))) + triangles.append((j*nx + i, (j+1)*nx + i, (j+1)*nx + (i+1))) + return triangles + + def __init__(self, + x_values: np.ndarray, + y_values: np.ndarray, + z_data: np.ndarray, + colormap: str= ColorMap._default_colormap, + c_range: Tuple[float, float] = (0, 1), + edge_skip: int=1): + + """ Surface plot + + + :param x_values: 1D array of x values + :param y_values: 1D array of y values + :param z_data: 2D array of z values + :param colormap: name of a matplotlib colour map + :param c_range: min and max values for the color map to span + :param edge_skip: skip every `edge_skip` index when drawing wireframe + """ + + + self.x_data, self.y_data = np.meshgrid(x_values, y_values) + self.z_data = z_data + + self.n_x = len(x_values) + self.n_y = len(y_values) + + self.c_range = c_range + self._colormap_name = colormap + self._colormap = ColorMap(colormap, min_value=c_range[0], max_value=c_range[1]) + + self.x_flat = self.x_data.flatten() + self.y_flat = self.y_data.flatten() + self.z_flat = self.z_data.flatten() + + verts = np.vstack((self.x_flat, self.y_flat, self.z_flat)).T + + super().__init__( + vertices=verts, + edges=Surface.calculate_edge_indices(self.n_x, self.n_y, edge_skip), + triangle_meshes=[Surface.calculate_triangles(self.n_x, self.n_y)], + edge_colors=uniform_coloring(1.0,1.0,1.0), + colors=self._colormap.vertex_coloring(self.z_flat) + ) + + self.wireframe_render_enabled = True + self.solid_render_enabled = True + + + def set_z_data(self, z_data): + + "Set the z data on this surface plot" + + self.z_data = z_data + self.z_flat = z_data.flatten() + + self.vertices = np.vstack((self.x_flat, self.y_flat, self.z_flat)).T + self.colors = self._colormap.vertex_coloring(self.z_flat) + + @property + def colormap(self) -> str: + """ Name of the colormap""" + return self._colormap_name + + @colormap.setter + def colormap(self, colormap: str): + if self._colormap_name != colormap: + self._colormap = ColorMap(colormap, min_value=self.c_range[0], max_value=self.c_range[1]) + self.colors = self._colormap.vertex_coloring(self.z_flat) + self._colormap_name = colormap diff --git a/src/sas/qtgui/GL/transforms.py b/src/sas/qtgui/GL/transforms.py new file mode 100644 index 0000000000..1904bb7d6d --- /dev/null +++ b/src/sas/qtgui/GL/transforms.py @@ -0,0 +1,150 @@ +import logging +from typing import List + +import numpy as np + +from OpenGL.GL import * +from OpenGL.GLU import * + +from sas.qtgui.GL.renderable import Renderable + + +logger = logging.getLogger("GL.transforms") + +class SceneGraphNode(Renderable): + """ + General transform - also doubles as a scene graph node + + For the sake of speed, the transformation matrix shape is not checked. + It should be a 4x4 transformation matrix + """ + + def __init__(self, *children: Renderable): + super().__init__() + self.children: List[Renderable] = list(children) + self.solid_render_enabled = True + self.wireframe_render_enabled = True + + + def add_child(self, child: Renderable): + """ Add a renderable object to this scene graph node""" + self.children.append(child) + + def apply(self): + """ GL operations needed to apply any transformations associated with this node """ + + def render_solid(self): + if self.solid_render_enabled: + + # Apply transform + glPushMatrix() + self.apply() + + # Check stack + if glGetIntegerv(GL_MODELVIEW_STACK_DEPTH) >= 16: + logger.info("GL Stack size utilisation {glGetIntegerv(GL_MODELVIEW_STACK_DEPTH))}, the limit could be as low as is 16") + + # Render children + for child in self.children: + child.render_solid() + + # Unapply + glPopMatrix() + + + def render_wireframe(self): + + if self.wireframe_render_enabled: + # Apply transform + glPushMatrix() + self.apply() + + # Check stack + if glGetIntegerv(GL_MODELVIEW_STACK_DEPTH) >= 16: + logger.info("GL Stack size utilisation {glGetIntegerv(GL_MODELVIEW_STACK_DEPTH))}, the limit could be as low as is 16") + + + # Render children + for child in self.children: + child.render_wireframe() + + # unapply transform + glPopMatrix() + + +class Rotation(SceneGraphNode): + + + def __init__(self, angle, x, y, z, *children: Renderable): + """ + Rotate the children of this node + + :param angle: angle of rotation in degrees + :param axis: axis for rotation + """ + super().__init__(*children) + self.angle = angle + self.x = x + self.y = y + self.z = z + + def apply(self): + glRotate(self.angle, self.x, self.y, self.z) + + +class Translation(SceneGraphNode): + + + def __init__(self, x: float, y: float, z: float, *children: Renderable): + """ + Translate the children of this node + + :param x: x translation + :param y: y translation + :param z: z translation + """ + super().__init__(*children) + self.x = x + self.y = y + self.z = z + + def apply(self): + glTranslate(self.x, self.y, self.z) + + +class Scaling(SceneGraphNode): + + def __init__(self, x: float, y: float, z: float, *children: Renderable): + """ + Scale the children of this node + + :param x: x scale + :param y: y scale + :param z: z scale + """ + + super().__init__(*children) + self.x = x + self.y = y + self.z = z + + def apply(self): + glScale(self.x, self.y, self.z) + +class MatrixTransform(SceneGraphNode): + + def __init__(self, matrix: np.ndarray, *children: Renderable): + """ + + Apply a 4x4 transformation matrix to the children of this node + + :param matrix: a 4x4 transformation matrix + + """ + + super().__init__(*children) + + self.matrix = matrix + + def apply(self): + glMultMatrixd(self.matrix) \ No newline at end of file diff --git a/src/sas/qtgui/GL/visual_checks/primitive_library.py b/src/sas/qtgui/GL/visual_checks/primitive_library.py new file mode 100644 index 0000000000..9230fdbe30 --- /dev/null +++ b/src/sas/qtgui/GL/visual_checks/primitive_library.py @@ -0,0 +1,126 @@ +""" As close a thing as there are to tests for GL""" + +import numpy as np + +from PyQt5 import QtWidgets + +from sas.qtgui.GL.scene import Scene +from sas.qtgui.GL.models import ModelBase +from sas.qtgui.GL.color import uniform_coloring, mesh_coloring, vertex_coloring +from sas.qtgui.GL.surface import Surface +from sas.qtgui.GL.cone import Cone +from sas.qtgui.GL.cube import Cube +from sas.qtgui.GL.cylinder import Cylinder +from sas.qtgui.GL.icosahedron import Icosahedron +from sas.qtgui.GL.sphere import Sphere + + +def mesh_example(): + x = np.linspace(-1, 1, 101) + y = np.linspace(-1, 1, 101) + x_grid, y_grid = np.meshgrid(x, y) + + r_sq = x_grid**2 + y_grid**2 + z = np.cos(np.sqrt(r_sq))/(r_sq+1) + + return Surface(x, y, z, edge_skip=4) + + +def primative_library(): + """ Shows all the existing primitives that can be rendered, press a key to go through them""" + + import sys, os, traceback + def excepthook(exc_type, exc_value, exc_tb): + tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) + print("error catched!:") + print("error message:\n", tb) + QtWidgets.QApplication.quit() + + sys.excepthook = excepthook + + os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1" + app = QtWidgets.QApplication([]) + + + item_list = [ + mesh_example(), + Cube(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0.7, 0.2, 0)), + Cube(edge_colors=uniform_coloring(1, 1, 1), colors=mesh_coloring([ + (1,0,0), + (0,1,0), + (0,0,1), + (1,1,0), + (0,1,1), + (1,0,1) + ])), + Cone(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0, 0.7, 0.2)), + Cylinder(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0, 0.2, 0.7)), + Icosahedron(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0.7, 0, 0.7)), + Sphere(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0.7, 0.7, 0.0)), + Sphere(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0.7, 0.4, 0.0), grid_gap=4) + ] + + # Turn off all of them + for item in item_list: + item.solid_render_enabled = False + item.wireframe_render_enabled = False + + + # Thing for going through each of the draw types of the primatives + + def item_states(item: ModelBase): + + item.solid_render_enabled = True + item.wireframe_render_enabled = True + + yield None + + item.solid_render_enabled = False + item.wireframe_render_enabled = True + + yield None + + item.solid_render_enabled = True + item.wireframe_render_enabled = False + + yield None + + item.solid_render_enabled = False + item.wireframe_render_enabled = False + + def scan_states(): + while True: + for item in item_list: + for _ in item_states(item): + yield None + + state = scan_states() + next(state) + + + mainWindow = QtWidgets.QMainWindow() + viewer = Scene(parent=mainWindow) + + # Keyboard callback + def enable_disable(key): + next(state) + viewer.update() + + viewer.on_key = enable_disable + + for item in item_list: + viewer.add(item) + + mainWindow.setCentralWidget(viewer) + + mainWindow.show() + + mainWindow.resize(600, 600) + + + + app.exec_() + + +if __name__ == "__main__": + primative_library() \ No newline at end of file diff --git a/src/sas/qtgui/GL/visual_checks/transform_library.py b/src/sas/qtgui/GL/visual_checks/transform_library.py new file mode 100644 index 0000000000..08eec04574 --- /dev/null +++ b/src/sas/qtgui/GL/visual_checks/transform_library.py @@ -0,0 +1,140 @@ +""" As close a thing as there are to tests for GL""" + +from PyQt5 import QtWidgets + +from sas.qtgui.GL.scene import Scene +from sas.qtgui.GL.color import uniform_coloring +from sas.qtgui.GL.cone import Cone +from sas.qtgui.GL.cube import Cube +from sas.qtgui.GL.cylinder import Cylinder +from sas.qtgui.GL.icosahedron import Icosahedron +from sas.qtgui.GL.transforms import SceneGraphNode, Translation, Rotation, Scaling + +def transform_tests(): + """ Shows all the existing primitives that can be rendered, press a key to go through them + + 1) 4 shapes in a vertical 2 unit x 2 unit grid - as cylinder and cone have r=1, they should touch + 2) 27 cubes in 3x3x3 grid, with scaling changing (1/2, 1, 2) in the same dimension as the translation + 3) As above but rotations by 45 degrees in around axis in the same dimension as the translation + + """ + + import os + + os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1" + app = QtWidgets.QApplication([]) + + cube = Cube(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0.7, 0.2, 0)) + cone = Cone(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0, 0.7, 0.2)) + cylinder = Cylinder(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0, 0.2, 0.7)) + icos = Icosahedron(edge_colors=uniform_coloring(1, 1, 1), colors=uniform_coloring(0.7, 0, 0.7)) + + + # Translations + translate_test = \ + SceneGraphNode( + Translation(0,0,1, + Translation(0,-1,0,cube), + Translation(0,1,0,cone)), + Translation(0,0,-1, + Translation(0,-1,0,cylinder), + Translation(0,1,0,icos))) + + # Scaling + scaling_components = [] + for i in range(3): + for j in range(3): + for k in range(3): + component = Translation(-2 * (i-1), -2 * (j-1), -2 * (k-1), Scaling(2 ** (i - 1), 2 ** (j - 1), 2 ** (k - 1), cube)) + scaling_components.append(component) + + scaling_test = Scaling(0.5, 0.5, 0.5, *scaling_components) + + + + # Rotations + cone_sphere = Scaling(0.5, 0.5, 0.5, + cone, + Translation(0, 1, 1, + Scaling(0.5, 0.5, 0.5, + icos))) + + scaling_components = [] + for i in range(3): + for j in range(3): + for k in range(3): + component = Translation(-2 * (i-1), -2 * (j-1), -2 * (k-1), + Rotation(45*(i - 1), 1, 0, 0, + Rotation(45*(j-1), 0, 1, 0, + Rotation(45*(k-1), 0, 0, 1, + cone_sphere)))) + scaling_components.append(component) + + rotation_test = Scaling(0.5, 0.5, 0.5, *scaling_components) + + + # + # Thing to iterate through the different tests + # + + item_list = [ + translate_test, + scaling_test, + rotation_test + ] + + # Turn off all of them + for item in item_list: + item.solid_render_enabled = False + item.wireframe_render_enabled = False + + + # Thing for going through each of the draw types of the primatives + + def item_states(item: SceneGraphNode): + + item.solid_render_enabled = True + item.wireframe_render_enabled = True + + yield None + + item.solid_render_enabled = False + item.wireframe_render_enabled = False + + def scan_states(): + while True: + for item in item_list: + for _ in item_states(item): + yield None + + state = scan_states() + next(state) + + # + # Set up and show window + # + + + mainWindow = QtWidgets.QMainWindow() + viewer = Scene(parent=mainWindow) + + # Keyboard callback + def enable_disable(key): + next(state) + viewer.update() + + viewer.on_key = enable_disable + + for item in item_list: + viewer.add(item) + + mainWindow.setCentralWidget(viewer) + + mainWindow.show() + + mainWindow.resize(600, 600) + app.exec_() + + +if __name__ == "__main__": + transform_tests() \ No newline at end of file diff --git a/src/sas/qtgui/MainWindow/GuiManager.py b/src/sas/qtgui/MainWindow/GuiManager.py index 03e8098a34..da29d86dd8 100644 --- a/src/sas/qtgui/MainWindow/GuiManager.py +++ b/src/sas/qtgui/MainWindow/GuiManager.py @@ -31,6 +31,7 @@ from sas.qtgui.Utilities.PluginManager import PluginManager from sas.qtgui.Utilities.GridPanel import BatchOutputPanel from sas.qtgui.Utilities.ResultPanel import ResultPanel +from sas.qtgui.Utilities.OrientationViewer.OrientationViewer import show_orientation_viewer from sas.qtgui.Utilities.HidableDialog import hidable_dialog from sas.qtgui.Utilities.Reports.ReportDialog import ReportDialog @@ -651,7 +652,7 @@ def addTriggers(self): #self._workspace.actionImage_Viewer.setVisible(False) self._workspace.actionCombine_Batch_Fit.setVisible(False) # orientation viewer set to invisible SASVIEW-1132 - self._workspace.actionOrientation_Viewer.setVisible(False) + self._workspace.actionOrientation_Viewer.setVisible(True) # File self._workspace.actionLoadData.triggered.connect(self.actionLoadData) @@ -732,6 +733,7 @@ def addTriggers(self): self.communicate.sendDataToGridSignal.connect(self.showBatchOutput) self.communicate.resultPlotUpdateSignal.connect(self.showFitResults) + #============ FILE ================= def actionLoadData(self): """ @@ -1022,11 +1024,7 @@ def actionOrientation_Viewer(self): """ Make sasmodels orientation & jitter viewer available """ - from sasmodels.jitter import run as orientation_run - try: - orientation_run() - except Exception as ex: - logging.error(str(ex)) + show_orientation_viewer() def actionImage_Viewer(self): """ diff --git a/src/sas/qtgui/MainWindow/MainWindow.py b/src/sas/qtgui/MainWindow/MainWindow.py index 264a7859c5..9d9a26cbe5 100644 --- a/src/sas/qtgui/MainWindow/MainWindow.py +++ b/src/sas/qtgui/MainWindow/MainWindow.py @@ -81,6 +81,9 @@ def run_sasview(): app = QApplication([]) + app.setAttribute(Qt.AA_ShareOpenGLContexts) + + #Initialize logger from sas.system.log import SetupLogger SetupLogger(__name__).config_development() @@ -105,6 +108,10 @@ def run_sasview(): signal.signal(signal.SIGINT, signal.SIG_DFL) # Main must have reference to the splash screen, so making it explicit + + app.setAttribute(Qt.AA_EnableHighDpiScaling) + app.setStyleSheet("* {font-size: 11pt;}") + splash = SplashScreen() splash.show() diff --git a/src/sas/qtgui/Plotting/UnitTesting/AddTextTest.py b/src/sas/qtgui/Plotting/UnitTesting/AddTextTest.py index f39dc048e9..398e5be86c 100644 --- a/src/sas/qtgui/Plotting/UnitTesting/AddTextTest.py +++ b/src/sas/qtgui/Plotting/UnitTesting/AddTextTest.py @@ -49,6 +49,6 @@ def testOnColorChange(self, widget, mocker): # Call the method widget.onColorChange(None) # Check that the text field got the new color info for text - assert widget.textEdit.palette().color(QtGui.QPalette.Text) == new_color + assert widget.textEdit.palette().vertex_coloring(QtGui.QPalette.Text) == new_color # ... and the hex value of this color is correct assert widget.color() == "#ff0000" diff --git a/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py new file mode 100644 index 0000000000..00290f5965 --- /dev/null +++ b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py @@ -0,0 +1,324 @@ +from typing import Optional, List + +import numpy as np +from scipy.special import erfinv + +from PyQt5 import QtWidgets +from PyQt5.QtWidgets import QSizePolicy +from PyQt5.QtCore import Qt + +from sasmodels.core import load_model_info, build_model +from sasmodels.data import empty_data2D +from sasmodels.direct_model import DirectModel + +from sas.qtgui.GL.scene import Scene +from sas.qtgui.GL.transforms import Rotation, Scaling, Translation +from sas.qtgui.GL.surface import Surface +from sas.qtgui.GL.cylinder import Cylinder +from sas.qtgui.GL.cone import Cone +from sas.qtgui.GL.cube import Cube +from sas.qtgui.GL.color import uniform_coloring + +from sas.qtgui.Utilities.OrientationViewer.OrientationViewerController import OrientationViewierController, Orientation + + +class OrientationViewer(QtWidgets.QWidget): + """ Orientation viewer widget """ + + + + # Dimensions of scattering cuboid + a = 0.1 + b = 0.4 + c = 1.0 + + arrow_size = 0.2 + arrow_color = uniform_coloring(0.9, 0.9, 0.9) + ghost_color = uniform_coloring(0.0, 0.6, 0.2) + cube_color = uniform_coloring(0.0, 0.8, 0.0) + + cuboid_scaling = [a, b, c] + + n_ghosts_per_perameter = 8 + n_q_samples = 128 + log_I_max = 10 + log_I_min = -3 + q_max = 0.5 + polydispersity_distribution = "gaussian" + + log_I_range = log_I_max - log_I_min + + + + @staticmethod + def create_ghost(): + """ Helper function: Create a ghost cube""" + return Scaling(OrientationViewer.a, + OrientationViewer.b, + OrientationViewer.c, + Cube(edge_colors=OrientationViewer.ghost_color)) + + def __init__(self, parent=None): + super().__init__() + + self.parent = parent + + self.setWindowTitle("Orientation Viewer") + + self._colormap_name = 'viridis' + + self.scene = Scene() + self.scene.view_elevation = 20 + + self.scene.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.controller = OrientationViewierController() + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.scene) + layout.addWidget(self.controller) + self.setLayout(layout) + + self.arrow = Translation(0,0,1.5, + Rotation(180,0,1,0, + Scaling( + OrientationViewer.arrow_size, + OrientationViewer.arrow_size, + OrientationViewer.arrow_size, + Scaling(0.1, 0.1, 1, + Cylinder(colors=OrientationViewer.arrow_color)), + Translation(0,0,1.3, + Scaling(0.3, 0.3, 0.3, + Cone(colors=OrientationViewer.arrow_color)))))) + + self.scene.add(self.arrow) + + self.image_plane_coordinate_points = np.linspace(-3, 3, OrientationViewer.n_q_samples) + + # temporary plot data + x, y = np.meshgrid(self.image_plane_coordinate_points, self.image_plane_coordinate_points) + self.image_plane_data = np.zeros_like(x) + + self.surface = Surface( + self.image_plane_coordinate_points, + self.image_plane_coordinate_points, + self.image_plane_data, + edge_skip=8) + + self.surface.wireframe_render_enabled = False + # self.surface.colormap = 'Greys' + + + self.image_plane = Translation(0,0,-1, Scaling(0.5, 0.5, 0.5, self.surface)) + + self.scene.add(self.image_plane) + + self.ghost_spacings = erfinv(np.linspace(-1, 1, OrientationViewer.n_ghosts_per_perameter+2)[1:-1])/np.sqrt(2) + + self.all_ghosts = [] + for a in self.ghost_spacings: + b_ghosts = [] + for b in self.ghost_spacings: + c_ghosts = [] + for c in self.ghost_spacings: + ghost = Rotation(0, 0, 0, 1, OrientationViewer.create_ghost()) + c_ghosts.append(ghost) + ghosts = Rotation(0,0,1,0, *c_ghosts) + b_ghosts.append(ghosts) + ghosts = Rotation(0,1,0,0,*b_ghosts) + self.all_ghosts.append(ghosts) + + + self.first_rotation = Rotation(0,0,0,1, + Scaling(OrientationViewer.a, + OrientationViewer.b, + OrientationViewer.c, + Cube( + edge_colors=OrientationViewer.ghost_color, + colors=OrientationViewer.cube_color)), + *self.all_ghosts) + + self.second_rotation = Rotation(0,0,1,0,self.first_rotation) + self.third_rotation = Rotation(0,0,0,1,self.second_rotation) + + self.cubes = Translation(0,0,0.5,self.third_rotation) + + self.scene.add(self.cubes) + + # + + # + # for _, _, _, ghost in self.ghosts: + # ghost.setTransform(OrientationViewerGraphics.createCubeTransform(0, 0, 0, OrientationViewer.cuboid_scaling)) + # + # + self.controller.sliderSet.connect(self.on_angle_changed) + self.controller.sliderMoved.connect(self.on_angle_changing) + + self.calculator = OrientationViewer.create_calculator() + self.on_angle_changed(Orientation()) + + @property + def colormap(self) -> str: + return self._colormap_name + + @colormap.setter + def colormap(self, colormap_name: str): + self._colormap_name = colormap_name + self.surface.colormap = self._colormap_name + + def _set_image_data(self, orientation: Orientation): + """ Set the data on the plot""" + + data = self.scatering_data(orientation) + + scaled_data = (np.log(data) - OrientationViewer.log_I_min) / OrientationViewer.log_I_range + self.image_plane_data = np.clip(scaled_data, 0, 1) + + self.surface.set_z_data(self.image_plane_data) + + # self.surface.colormap = self.colormap + + self.scene.update() + + + def orient_ghosts(self, orientation: Orientation): + + for a, a_ghosts in zip(self.ghost_spacings, self.all_ghosts): + a_ghosts.angle = a*orientation.dphi + for b, b_ghosts in zip(self.ghost_spacings, a_ghosts.children): + b_ghosts.angle = b*orientation.dtheta + for c, c_ghosts in zip(self.ghost_spacings, b_ghosts.children): + c_ghosts.angle = c*orientation.dpsi + + def on_angle_changed(self, orientation: Optional[Orientation]): + + """ Response to angle change""" + + if orientation is None: + return + + + #r_mat = Rz(phi_deg) @ Ry(theta_deg) @ Rz(psi_deg) @ np.diag(scaling) + self.first_rotation.angle = orientation.psi + self.second_rotation.angle = orientation.theta + self.third_rotation.angle = orientation.phi + + self.orient_ghosts(orientation) + + + self._set_image_data(orientation) + + + def on_angle_changing(self, orientation: Optional[Orientation]): + + """ Response to angle change""" + + if orientation is None: + return + + # self.surface.colormap = "Greys" + + #r_mat = Rz(phi_deg) @ Ry(theta_deg) @ Rz(psi_deg) @ np.diag(scaling) + self.first_rotation.angle = orientation.psi + self.second_rotation.angle = orientation.theta + self.third_rotation.angle = orientation.phi + + self.orient_ghosts(orientation) + + self.scene.update() + + @staticmethod + def create_calculator(): + """ + Make a parallelepiped model calculator for q range -qmax to qmax with n samples + """ + model_info = load_model_info("parallelepiped") + model = build_model(model_info) + q = np.linspace(-OrientationViewer.q_max, OrientationViewer.q_max, OrientationViewer.n_q_samples) + data = empty_data2D(q, q) + calculator = DirectModel(data, model) + + return calculator + + + def polydispersity_sample_count(self, orientation): + """ Work out how many samples to do for the polydispersity""" + polydispersity = [orientation.dtheta, orientation.dphi, orientation.dpsi] + is_polydisperse = [1 if x > 0 else 0 for x in polydispersity] + n_polydisperse = np.sum(is_polydisperse) + + samples = int(200 / (n_polydisperse**2 + 1)) # + + return (samples * x for x in is_polydisperse) + + def scatering_data(self, orientation: Orientation) -> np.ndarray: + + # add the orientation parameters to the model parameters + + theta_pd_n, phi_pd_n, psi_pd_n = self.polydispersity_sample_count(orientation) + + data = self.calculator( + theta=orientation.theta, + theta_pd=orientation.dtheta, + theta_pd_type=OrientationViewer.polydispersity_distribution, + theta_pd_n=theta_pd_n, + phi=orientation.phi, + phi_pd=orientation.dphi, + phi_pd_type=OrientationViewer.polydispersity_distribution, + phi_pd_n=phi_pd_n, + psi=orientation.psi, + psi_pd=orientation.dpsi, + psi_pd_type=OrientationViewer.polydispersity_distribution, + psi_pd_n=psi_pd_n, + a=OrientationViewer.a, + b=OrientationViewer.b, + c=OrientationViewer.c, + background=np.exp(OrientationViewer.log_I_min)) + + return np.reshape(data, (OrientationViewer.n_q_samples, OrientationViewer.n_q_samples)) + + def closeEvent(self, event): + try: + _orientation_viewers.remove(self) + except ValueError: # Not in list + pass + + event.accept() + + +# Code for handling multiple orientation viewers +_orientation_viewers = [] +def show_orientation_viewer(): + ov = OrientationViewer() + ov.show() + ov.resize(600, 600) + + _orientation_viewers.append(ov) + + + +def main(): + + import os + + os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1" + app = QtWidgets.QApplication([]) + + app.setAttribute(Qt.AA_EnableHighDpiScaling) + app.setAttribute(Qt.AA_ShareOpenGLContexts) + + + mainWindow = QtWidgets.QMainWindow() + viewer = OrientationViewer(mainWindow) + + mainWindow.setCentralWidget(viewer) + + mainWindow.show() + + mainWindow.resize(600, 600) + app.exec_() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewerController.py b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewerController.py new file mode 100644 index 0000000000..3c464b18a1 --- /dev/null +++ b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewerController.py @@ -0,0 +1,107 @@ +from typing import NamedTuple + +from PyQt5 import QtWidgets +from PyQt5.QtCore import pyqtSignal + + + +from sas.qtgui.Utilities.OrientationViewer.UI.OrientationViewerControllerUI import Ui_OrientationViewierControllerUI + +class Orientation(NamedTuple): + """ Data sent when updating the plot""" + theta: int = 0 + phi: int = 0 + psi: int = 0 + dtheta: int = 0 + dphi: int = 0 + dpsi: int = 0 + + + +class OrientationViewierController(QtWidgets.QDialog, Ui_OrientationViewierControllerUI): + + """ Widget that controls the orientation viewer""" + + sliderSet = pyqtSignal(Orientation, name='sliderSet') + sliderMoved = pyqtSignal(Orientation, name='sliderMoved') + + def __init__(self, parent=None): + super().__init__() + + self.setupUi(self) + + self.setLabels(Orientation()) + + # All sliders emit the same signal - the angular coordinates in degrees + self.thetaSlider.sliderReleased.connect(self.onSliderSet) + self.phiSlider.sliderReleased.connect(self.onSliderSet) + self.psiSlider.sliderReleased.connect(self.onSliderSet) + self.deltaTheta.sliderReleased.connect(self.onSliderSet) + self.deltaPhi.sliderReleased.connect(self.onSliderSet) + self.deltaPsi.sliderReleased.connect(self.onSliderSet) + + self.thetaSlider.valueChanged.connect(self.onSliderMoved) + self.phiSlider.valueChanged.connect(self.onSliderMoved) + self.psiSlider.valueChanged.connect(self.onSliderMoved) + self.deltaTheta.valueChanged.connect(self.onSliderMoved) + self.deltaPhi.valueChanged.connect(self.onSliderMoved) + self.deltaPsi.valueChanged.connect(self.onSliderMoved) + + def setLabels(self, orientation: Orientation): + + self.thetaNumber.setText(f"{orientation.theta}°") + self.phiNumber.setText(f"{orientation.phi}°") + self.psiNumber.setText(f"{orientation.psi}°") + + self.deltaThetaNumber.setText(f"{orientation.dtheta}°") + self.deltaPhiNumber.setText(f"{orientation.dphi}°") + self.deltaPsiNumber.setText(f"{orientation.dpsi}°") + + + def onSliderSet(self): + theta = self.thetaSlider.value() + phi = self.phiSlider.value() + psi = self.psiSlider.value() + + dtheta = self.deltaTheta.value() + dphi = self.deltaPhi.value() + dpsi = self.deltaPsi.value() + + orientation = Orientation(theta, phi, psi, dtheta, dphi, dpsi) + self.sliderSet.emit(orientation) + self.setLabels(orientation) + + def onSliderMoved(self): + + theta = self.thetaSlider.value() + phi = self.phiSlider.value() + psi = self.psiSlider.value() + + dtheta = self.deltaTheta.value() + dphi = self.deltaPhi.value() + dpsi = self.deltaPsi.value() + + orientation = Orientation(theta, phi, psi, dtheta, dphi, dpsi) + self.sliderMoved.emit(orientation) + self.setLabels(orientation) + + + + +def main(): + """ Show a demo of the slider """ + app = QtWidgets.QApplication([]) + + mainWindow = QtWidgets.QMainWindow() + viewer = OrientationViewierController(mainWindow) + + mainWindow.setCentralWidget(viewer) + + mainWindow.show() + + mainWindow.resize(700, 100) + app.exec_() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/sas/qtgui/Utilities/OrientationViewer/UI/OrientationViewerControllerUI.ui b/src/sas/qtgui/Utilities/OrientationViewer/UI/OrientationViewerControllerUI.ui new file mode 100644 index 0000000000..627b08750b --- /dev/null +++ b/src/sas/qtgui/Utilities/OrientationViewer/UI/OrientationViewerControllerUI.ui @@ -0,0 +1,539 @@ + + + OrientationViewierControllerUI + + + + 0 + 0 + 664 + 250 + + + + + 0 + 0 + + + + Form + + + + + + + 0 + 0 + + + + + 50 + 0 + + + + + 100 + 16777215 + + + + 60 + + + Qt::Horizontal + + + QSlider::TicksBothSides + + + 15 + + + + + + + + 0 + 0 + + + + + 50 + 0 + + + + + 100 + 16777215 + + + + 60 + + + Qt::Horizontal + + + QSlider::TicksBothSides + + + 15 + + + + + + + + 10 + + + + TextLabel + + + Qt::AlignCenter + + + + + + + + 10 + + + + TextLabel + + + Qt::AlignCenter + + + + + + + + 10 + + + + TextLabel + + + Qt::AlignCenter + + + + + + + + 10 + + + + TextLabel + + + Qt::AlignCenter + + + + + + + + 10 + + + + TextLabel + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + + 50 + 0 + + + + + 100 + 16777215 + + + + 60 + + + Qt::Horizontal + + + QSlider::TicksBothSides + + + 15 + + + + + + + + 10 + + + + TextLabel + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + + 30 + 0 + + + + + 30 + 16777215 + + + + + 11 + + + + Δθ + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 30 + 0 + + + + + 30 + 16777215 + + + + + 11 + + + + Δφ + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 400 + 0 + + + + + 400 + 16777215 + + + + -180 + + + 180 + + + 1 + + + 15 + + + Qt::Horizontal + + + QSlider::TicksBothSides + + + 15 + + + + + + + + 0 + 0 + + + + + 20 + 0 + + + + + 20 + 16777215 + + + + + 11 + + + + ψ + + + + + + + + 0 + 0 + + + + + 20 + 0 + + + + + 20 + 16777215 + + + + + 11 + + + + φ + + + + + + + + 400 + 0 + + + + + 400 + 16777215 + + + + -180 + + + 180 + + + 15 + + + Qt::Horizontal + + + QSlider::TicksBothSides + + + 15 + + + + + + + + 400 + 0 + + + + + 400 + 16777215 + + + + -90 + + + 90 + + + 15 + + + Qt::Horizontal + + + QSlider::TicksBothSides + + + 15 + + + + + + + + 0 + 0 + + + + + 30 + 0 + + + + + 30 + 16777215 + + + + + 11 + + + + Δψ + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 20 + 0 + + + + + 20 + 16777215 + + + + + 11 + + + + θ + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + diff --git a/src/sas/qtgui/Utilities/OrientationViewer/UI/__init__.py b/src/sas/qtgui/Utilities/OrientationViewer/UI/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/Utilities/OrientationViewer/__init__.py b/src/sas/qtgui/Utilities/OrientationViewer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/system/log.ini b/src/sas/system/log.ini index 60b82fb5bc..295eacd525 100644 --- a/src/sas/system/log.ini +++ b/src/sas/system/log.ini @@ -9,10 +9,10 @@ keys=simple,detailed [formatter_simple] -#format=%(asctime)s - %(name)s - %(levelname)s - %(message)s +format=%(asctime)s - %(name)s - %(levelname)s - %(message)s #format=%(asctime)s - %(levelname)s : %(name)s:%(pathname)s:%(lineno)4d: %(message)s #format=%(asctime)s - %(levelname)s : %(name)s:%(lineno)4d: %(message)s -format=%(asctime)s - %(levelname)s: %(message)s +#format=%(asctime)s - %(levelname)s: %(message)s datefmt=%H:%M:%S [formatter_detailed] @@ -41,7 +41,7 @@ args=(os.path.join(os.path.expanduser("~"),'sasview.log'),"a") # Loggers [loggers] -keys=root,saspr,sasgui,sascalc,sasmodels,h5py +keys=root,saspr,sasgui,sascalc,sasmodels,h5py,glshaders [logger_root] level=DEBUG @@ -76,4 +76,10 @@ propagate=0 level=DEBUG qualname=h5py handlers= +propagate=0 + +[logger_glshaders] +level=DEBUG +qualname=OpenGL.GL.shaders +handlers= propagate=0 \ No newline at end of file