diff --git a/CHANGELOG.md b/CHANGELOG.md index 9610a915..81ed83f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Arrow key nudging** (#89): Move selected items with arrow keys (0.1 mm step) or Shift+Arrow (1.0 mm step); both step sizes are configurable in Preferences → Canvas & Editing - **Configurable ruler label positioning** (#90): Ruler labels can be placed Above, Below, Left, or Right relative to the segment via the Edit dialog; setting is serialized and fully undoable - **Cross-instance copy/paste** (#34): Items copied in one Optiverse window can be pasted into another via the system clipboard (uses custom MIME type for serialization) +- **PyOpticL export**: New File menu entry **Export PyOpticL Layout…** produces a self-contained folder (`.py` plus `models//.step` and `.json` per component) shaped exactly the way `PyOpticL.utils.import_model` expects, ready to run in FreeCAD with the PyOpticL workbench. Components must have an attached STEP file (set when importing a STEP into a component); a warning dialog lists any without one before export ### Changed diff --git a/pyproject.toml b/pyproject.toml index 6bcf4482..9a029a79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,8 @@ module = [ "numba.*", "Foundation.*", "AppKit.*", + "OCP.*", + "pyqtgraph.*", "optiverse.ui.objects", "optiverse.ui.raytracing", ] diff --git a/src/optiverse/cad/__init__.py b/src/optiverse/cad/__init__.py new file mode 100644 index 00000000..4173a003 --- /dev/null +++ b/src/optiverse/cad/__init__.py @@ -0,0 +1,6 @@ +"""CAD integration module for STEP file handling and 3D preview.""" + +from .step_preview_dialog import StepPreviewDialog +from .step_renderer import is_cad_available, load_step_mesh + +__all__ = ["StepPreviewDialog", "is_cad_available", "load_step_mesh"] diff --git a/src/optiverse/cad/step_preview_dialog.py b/src/optiverse/cad/step_preview_dialog.py new file mode 100644 index 00000000..f332bd9e --- /dev/null +++ b/src/optiverse/cad/step_preview_dialog.py @@ -0,0 +1,331 @@ +""" +Interactive STEP preview dialog. + +Shows a 3-D mesh viewer (pyqtgraph GLViewWidget) alongside a live 2-D +orthographic projection. The user rotates the model to choose the +projection plane, then clicks "Use This View" to capture the result. +""" + +from __future__ import annotations + +import logging +import math +from typing import cast + +import numpy as np +from PyQt6 import QtCore, QtGui, QtWidgets + +from .step_renderer import ( + PRESET_VIEWS, + is_viewer_available, + mesh_bounding_box, + project_mesh_to_2d, +) + +_logger = logging.getLogger(__name__) + + +def _rotation_from_gl_view(gl_widget) -> np.ndarray: + """Extract the 3x3 rotation matrix directly from pyqtgraph's view matrix.""" + vm = gl_widget.viewMatrix() + data = np.array(vm.data(), dtype=np.float64).reshape(4, 4).T + return cast(np.ndarray, data[:3, :3].copy()) + + +class StepPreviewDialog(QtWidgets.QDialog): + """Dialog for interactively choosing a 2-D projection from a STEP mesh. + + Attributes: + result_pixmap: The captured 2-D QPixmap (set after accept). + result_rotation: The 3x3 rotation matrix used for the projection. + result_height_mm: Physical height the user entered. + """ + + result_pixmap: QtGui.QPixmap | None = None + result_rotation: np.ndarray | None = None + result_height_mm: float = 25.4 + + def __init__( + self, + vertices: np.ndarray, + faces: np.ndarray, + face_colors: np.ndarray | None = None, + initial_rotation: np.ndarray | None = None, + initial_height_mm: float = 25.4, + parent: QtWidgets.QWidget | None = None, + ): + super().__init__(parent) + self.setWindowTitle("Import STEP – Choose Projection View") + self.resize(900, 520) + + self._vertices = vertices + self._faces = faces + self._face_colors = face_colors + self._current_rotation = ( + initial_rotation.copy() + if initial_rotation is not None + else PRESET_VIEWS["Front"].copy() + ) + + # Compute auto height from bounding box (longest XY extent) + bbox_min, bbox_max = mesh_bounding_box(vertices) + bbox_size = bbox_max - bbox_min + self._auto_height_mm = float(max(bbox_size[0], bbox_size[1], bbox_size[2])) + + self._build_ui(initial_height_mm) + self._update_preview() + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _build_ui(self, initial_height_mm: float): + layout = QtWidgets.QVBoxLayout(self) + + # Top: split between 3-D viewer and 2-D preview + splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + + # Left: 3-D GL viewer + if is_viewer_available(): + import sys + + import pyqtgraph.opengl as gl + + if sys.platform == "darwin": + fmt = QtGui.QSurfaceFormat() + fmt.setRenderableType(QtGui.QSurfaceFormat.RenderableType.OpenGL) + fmt.setProfile(QtGui.QSurfaceFormat.OpenGLContextProfile.CoreProfile) + fmt.setVersion(4, 1) + QtGui.QSurfaceFormat.setDefaultFormat(fmt) + + self._gl_widget = gl.GLViewWidget() + self._gl_widget.setMinimumSize(400, 380) + self._gl_widget.setBackgroundColor((80, 80, 80)) + elev, azim = self._rotation_to_elev_azim(self._current_rotation) + self._gl_widget.setCameraPosition( + distance=self._auto_height_mm * 2.5, elevation=elev, azimuth=azim, + ) + + from pyqtgraph.opengl import shaders as gl_shaders + + _brighter_shader = gl_shaders.ShaderProgram("_stepBright", [ + gl_shaders.VertexShader(""" + uniform mat4 u_mvp; + uniform mat3 u_normal; + attribute vec4 a_position; + attribute vec3 a_normal; + attribute vec4 a_color; + varying vec4 v_color; + varying vec3 v_normal; + void main() { + v_normal = normalize(u_normal * a_normal); + v_color = a_color; + gl_Position = u_mvp * a_position; + } + """), + gl_shaders.FragmentShader(""" + #ifdef GL_ES + precision mediump float; + #endif + varying vec4 v_color; + varying vec3 v_normal; + void main() { + vec3 norm = normalize(v_normal); + vec3 lightDir = normalize(vec3(0.0, 1.0, 1.0)); + float diff = max(dot(norm, lightDir), 0.0) * 0.65; + vec3 viewDir = vec3(0.0, 0.0, 1.0); + vec3 halfDir = normalize(lightDir + viewDir); + float spec = pow(max(dot(norm, halfDir), 0.0), 32.0) * 0.3; + vec3 rgb = v_color.rgb * (0.55 + diff) + vec3(spec); + gl_FragColor = vec4(min(rgb, vec3(1.0)), v_color.a); + } + """), + ]) + + mesh_kwargs = dict( + vertexes=self._vertices, + faces=self._faces, + smooth=False, + drawEdges=False, + shader=_brighter_shader, + ) + if self._face_colors is not None: + mesh_kwargs["faceColors"] = self._face_colors + mesh_item = gl.GLMeshItem(**mesh_kwargs) + self._gl_widget.addItem(mesh_item) + splitter.addWidget(self._gl_widget) + + # Timer to sync rotation from GL camera on mouse release + self._sync_timer = QtCore.QTimer(self) + self._sync_timer.setInterval(200) + self._sync_timer.setSingleShot(True) + self._sync_timer.timeout.connect(self._on_camera_changed) + self._gl_widget.installEventFilter(self) + else: + placeholder = QtWidgets.QLabel( + "pyqtgraph not installed.\n\n" + "Install with: pip install pyqtgraph\n\n" + "Use the preset view buttons below." + ) + placeholder.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + placeholder.setMinimumSize(400, 380) + splitter.addWidget(placeholder) + self._gl_widget = None + + # Right: 2-D projection preview + self._preview_label = QtWidgets.QLabel() + self._preview_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._preview_label.setMinimumSize(300, 380) + self._preview_label.setStyleSheet( + "QLabel { background-color: white; border: 1px solid #ccc; }" + ) + splitter.addWidget(self._preview_label) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 2) + + layout.addWidget(splitter) + + # Controls row + controls = QtWidgets.QHBoxLayout() + + controls.addWidget(QtWidgets.QLabel("Physical height:")) + self._height_spin = QtWidgets.QDoubleSpinBox() + self._height_spin.setRange(0.1, 1e6) + self._height_spin.setDecimals(2) + self._height_spin.setSuffix(" mm") + self._height_spin.setValue(self._auto_height_mm) + controls.addWidget(self._height_spin) + + controls.addSpacing(16) + + self._flip_h = QtWidgets.QCheckBox("Flip horizontal") + self._flip_h.toggled.connect(self._update_preview) + controls.addWidget(self._flip_h) + + self._flip_v = QtWidgets.QCheckBox("Flip vertical") + self._flip_v.toggled.connect(self._update_preview) + controls.addWidget(self._flip_v) + + controls.addStretch() + layout.addLayout(controls) + + # Snap-to-view buttons + snap_row = QtWidgets.QHBoxLayout() + snap_row.addWidget(QtWidgets.QLabel("Snap to:")) + for name in PRESET_VIEWS: + btn = QtWidgets.QPushButton(name) + btn.setMaximumWidth(80) + btn.clicked.connect(lambda checked, n=name: self._snap_to_view(n)) + snap_row.addWidget(btn) + snap_row.addStretch() + layout.addLayout(snap_row) + + # Dialog buttons + btn_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + ok_btn = btn_box.button(QtWidgets.QDialogButtonBox.StandardButton.Ok) + if ok_btn: + ok_btn.setText("Use This View") + btn_box.accepted.connect(self._on_accept) + btn_box.rejected.connect(self.reject) + layout.addWidget(btn_box) + + # ------------------------------------------------------------------ + # Event filter for camera changes + # ------------------------------------------------------------------ + + def eventFilter(self, obj, event): + if obj is self._gl_widget and event.type() in ( + QtCore.QEvent.Type.MouseButtonRelease, + QtCore.QEvent.Type.Wheel, + ): + self._sync_timer.start() + return super().eventFilter(obj, event) + + # ------------------------------------------------------------------ + # Handlers + # ------------------------------------------------------------------ + + def _on_camera_changed(self): + """Sync rotation matrix from GL camera and update 2-D preview.""" + if self._gl_widget is not None: + self._current_rotation = _rotation_from_gl_view(self._gl_widget) + self._update_preview() + + def _snap_to_view(self, name: str): + if self._gl_widget is not None: + elev, azim = self._rotation_to_elev_azim(PRESET_VIEWS[name]) + self._gl_widget.setCameraPosition(elevation=elev, azimuth=azim) + self._current_rotation = _rotation_from_gl_view(self._gl_widget) + else: + self._current_rotation = PRESET_VIEWS[name].copy() + self._update_preview() + + @staticmethod + def _rotation_to_elev_azim(rot: np.ndarray) -> tuple[float, float]: + """Convert a 3x3 rotation to (elevation, azimuth) in degrees for pyqtgraph. + + Inverse of _rotation_from_gl_view. Camera position direction in world + coords is rot^T @ [0, 0, 1] (positive Z in view = towards viewer). + pyqtgraph: pos = (cos(e)*cos(a), cos(e)*sin(a), sin(e)) * dist. + """ + pos_dir = rot.T @ np.array([0, 0, 1], dtype=np.float64) + elev = math.degrees(math.asin(np.clip(pos_dir[2], -1, 1))) + azim = math.degrees(math.atan2(pos_dir[1], pos_dir[0])) + return elev, azim + + def _update_preview(self): + """Re-render the 2-D projection and display it.""" + rot = self._current_rotation.copy() + + if self._flip_h.isChecked(): + rot[0, :] *= -1 # flip X + if self._flip_v.isChecked(): + rot[1, :] *= -1 # flip Y + + # Update height from projected Y-extent + rotated = (rot @ self._vertices.T).T + proj_height_mm = float(rotated[:, 1].max() - rotated[:, 1].min()) + if proj_height_mm > 0: + self._height_spin.blockSignals(True) + self._height_spin.setValue(proj_height_mm) + self._height_spin.blockSignals(False) + + pix = project_mesh_to_2d( + self._vertices, + self._faces, + rot, + face_colors=self._face_colors, + height_px=800, + ) + if pix and not pix.isNull(): + scaled = pix.scaled( + self._preview_label.size(), + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation, + ) + self._preview_label.setPixmap(scaled) + else: + self._preview_label.setText("(projection failed)") + + def _on_accept(self): + """Capture the final pixmap and rotation, then accept.""" + rot = self._current_rotation.copy() + if self._flip_h.isChecked(): + rot[0, :] *= -1 + if self._flip_v.isChecked(): + rot[1, :] *= -1 + + self.result_pixmap = project_mesh_to_2d( + self._vertices, + self._faces, + rot, + face_colors=self._face_colors, + height_px=1000, + margin_fraction=0.0, + ) + self.result_rotation = rot + self.result_height_mm = self._height_spin.value() + self.accept() diff --git a/src/optiverse/cad/step_renderer.py b/src/optiverse/cad/step_renderer.py new file mode 100644 index 00000000..f14c5e53 --- /dev/null +++ b/src/optiverse/cad/step_renderer.py @@ -0,0 +1,460 @@ +""" +STEP file loading, tessellation, and 2D orthographic projection. + +All functions in this module require optional dependencies (cadquery/OCP). +Use ``is_cad_available()`` to check before calling rendering functions. + +Install with: pip install cadquery pyqtgraph +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from PyQt6.QtGui import QPixmap + +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Dependency probing +# --------------------------------------------------------------------------- + +_CAD_AVAILABLE: bool | None = None +_PYQTGRAPH_AVAILABLE: bool | None = None + + +def is_cad_available() -> bool: + """Return True if cadquery/OCP is importable.""" + global _CAD_AVAILABLE + if _CAD_AVAILABLE is None: + try: + import OCP.STEPControl # noqa: F401 + + _CAD_AVAILABLE = True + except ImportError: + _CAD_AVAILABLE = False + return _CAD_AVAILABLE + + +def is_viewer_available() -> bool: + """Return True if pyqtgraph (OpenGL 3-D viewer) is importable.""" + global _PYQTGRAPH_AVAILABLE + if _PYQTGRAPH_AVAILABLE is None: + try: + import pyqtgraph.opengl # noqa: F401 + + _PYQTGRAPH_AVAILABLE = True + except ImportError: + _PYQTGRAPH_AVAILABLE = False + return _PYQTGRAPH_AVAILABLE + + +def missing_dependency_message() -> str: + """Human-readable message listing which optional packages are missing.""" + missing: list[str] = [] + if not is_cad_available(): + missing.append("cadquery") + if not is_viewer_available(): + missing.append("pyqtgraph") + if not missing: + return "" + pkgs = " ".join(missing) + return ( + f"Optional dependencies not installed: {', '.join(missing)}.\n" + f"Install with: pip install {pkgs}" + ) + + +# --------------------------------------------------------------------------- +# STEP loading & tessellation (with per-face colors) +# --------------------------------------------------------------------------- + + +def load_step_mesh( + step_path: str, + linear_deflection: float = 0.1, + angular_deflection: float = 0.5, +) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None: + """Load a STEP file and tessellate its shape into a colored triangle mesh. + + Extracts per-solid colors from the STEP file's XDE document when available. + + Args: + step_path: Path to a ``.step`` or ``.stp`` file. + linear_deflection: Maximum linear deviation of tessellation (mm). + angular_deflection: Maximum angular deviation of tessellation (radians). + + Returns: + ``(vertices, faces, face_colors)`` where *vertices* is ``(N, 3)`` float64, + *faces* is ``(M, 3)`` int32, and *face_colors* is ``(M, 4)`` float32 RGBA + (values 0-1). Returns ``None`` on failure. + """ + if not is_cad_available(): + _logger.warning("cadquery/OCP not available – cannot load STEP file") + return None + + try: + from OCP.BRep import BRep_Tool + from OCP.BRepMesh import BRepMesh_IncrementalMesh + from OCP.TopAbs import TopAbs_FACE, TopAbs_SOLID + from OCP.TopExp import TopExp_Explorer + from OCP.TopLoc import TopLoc_Location + from OCP.TopoDS import TopoDS + + # Try XDE reader for color support + color_tool = None + shape = None + try: + from OCP.STEPCAFControl import STEPCAFControl_Reader + from OCP.TCollection import TCollection_ExtendedString + from OCP.TDocStd import TDocStd_Document + from OCP.XCAFApp import XCAFApp_Application + from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool + + app = XCAFApp_Application.GetApplication_s() + doc = TDocStd_Document(TCollection_ExtendedString("MDTV-XCAF")) + app.InitDocument(doc) + + reader = STEPCAFControl_Reader() + reader.SetColorMode(True) + status = reader.ReadFile(step_path) + if status == 1: + reader.Transfer(doc) + shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) + color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) + from OCP.TDF import TDF_LabelSequence + labels = TDF_LabelSequence() + shape_tool.GetFreeShapes(labels) + if labels.Length() > 0: + shape = XCAFDoc_ShapeTool.GetShape_s(labels.Value(1)) + except Exception: + _logger.debug("XDE color reading failed, falling back to plain reader") + + # Fallback to basic reader if XDE failed + if shape is None: + from OCP.STEPControl import STEPControl_Reader as BasicReader + basic_reader = BasicReader() + status = basic_reader.ReadFile(step_path) + if status != 1: + _logger.error("Failed to read STEP file: %s (status %s)", step_path, status) + return None + basic_reader.TransferRoots() + shape = basic_reader.OneShape() + + BRepMesh_IncrementalMesh(shape, linear_deflection, False, angular_deflection, True) + + # Build a map from solid → color by querying each solid in the shape + solid_colors: dict[int, tuple[float, float, float]] = {} + if color_tool is not None: + from OCP.Quantity import Quantity_Color + from OCP.XCAFDoc import XCAFDoc_ColorGen, XCAFDoc_ColorSurf + + solid_explorer = TopExp_Explorer(shape, TopAbs_SOLID) + while solid_explorer.More(): + solid = TopoDS.Solid_s(solid_explorer.Current()) + c = Quantity_Color() + if color_tool.GetColor(solid, XCAFDoc_ColorSurf, c): + solid_colors[id(solid_explorer.Current())] = (c.Red(), c.Green(), c.Blue()) + elif color_tool.GetColor(solid, XCAFDoc_ColorGen, c): + solid_colors[id(solid_explorer.Current())] = (c.Red(), c.Green(), c.Blue()) + solid_explorer.Next() + + # Tessellate per-solid to preserve color grouping + all_verts: list[list[float]] = [] + all_faces: list[list[int]] = [] + all_colors: list[list[float]] = [] + vert_offset = 0 + + # Default palette for solids without explicit color + _default_palette = [ + (0.72, 0.72, 0.76), + (0.62, 0.67, 0.72), + (0.76, 0.73, 0.69), + (0.67, 0.72, 0.67), + ] + + def _get_solid_color(solid, solid_idx: int) -> tuple[float, float, float]: + """Get color for a solid: try XDE lookup, then fallback to palette.""" + if color_tool is not None: + from OCP.Quantity import Quantity_Color + from OCP.XCAFDoc import XCAFDoc_ColorGen, XCAFDoc_ColorSurf + c = Quantity_Color() + if color_tool.GetColor(solid, XCAFDoc_ColorSurf, c): + return (c.Red(), c.Green(), c.Blue()) + if color_tool.GetColor(solid, XCAFDoc_ColorGen, c): + return (c.Red(), c.Green(), c.Blue()) + return _default_palette[solid_idx % len(_default_palette)] + + def _tessellate_shape(sub_shape, fallback_rgba: list[float]): + """Tessellate all faces of a shape and append to result arrays.""" + nonlocal vert_offset + face_explorer = TopExp_Explorer(sub_shape, TopAbs_FACE) + while face_explorer.More(): + face = TopoDS.Face_s(face_explorer.Current()) + + # Per-face color lookup (prefer face color over solid/default) + face_rgba = fallback_rgba + if color_tool is not None: + from OCP.Quantity import Quantity_Color + from OCP.XCAFDoc import XCAFDoc_ColorGen, XCAFDoc_ColorSurf + c = Quantity_Color() + if color_tool.GetColor(face, XCAFDoc_ColorSurf, c) or \ + color_tool.GetColor(face, XCAFDoc_ColorGen, c): + face_rgba = [c.Red(), c.Green(), c.Blue(), 1.0] + + loc = TopLoc_Location() + triangulation = BRep_Tool.Triangulation_s(face, loc) + if triangulation is None: + face_explorer.Next() + continue + + trsf = loc.Transformation() + n_nodes = triangulation.NbNodes() + n_tris = triangulation.NbTriangles() + + for i in range(1, n_nodes + 1): + pnt = triangulation.Node(i) + pnt_transformed = pnt.Transformed(trsf) + all_verts.append( + [pnt_transformed.X(), pnt_transformed.Y(), pnt_transformed.Z()] + ) + + for i in range(1, n_tris + 1): + tri = triangulation.Triangle(i) + n1, n2, n3 = tri.Get() + all_faces.append([ + n1 - 1 + vert_offset, + n2 - 1 + vert_offset, + n3 - 1 + vert_offset, + ]) + all_colors.append(face_rgba) + + vert_offset += n_nodes + face_explorer.Next() + + # Iterate solids for proper color grouping + solid_explorer = TopExp_Explorer(shape, TopAbs_SOLID) + solid_idx = 0 + has_solids = False + while solid_explorer.More(): + has_solids = True + solid = TopoDS.Solid_s(solid_explorer.Current()) + rgb = _get_solid_color(solid, solid_idx) + rgba = [rgb[0], rgb[1], rgb[2], 1.0] + _tessellate_shape(solid, rgba) + solid_idx += 1 + solid_explorer.Next() + + # Fallback: if no solids found, tessellate all faces directly + if not has_solids: + rgba = [0.7, 0.7, 0.75, 1.0] + _tessellate_shape(shape, rgba) + + if not all_verts or not all_faces: + _logger.error("STEP file produced empty mesh: %s", step_path) + return None + + vertices = np.array(all_verts, dtype=np.float64) + faces = np.array(all_faces, dtype=np.int32) + face_colors = np.array(all_colors, dtype=np.float32) + + # Centre the mesh on its bounding-box midpoint + bbox_min = vertices.min(axis=0) + bbox_max = vertices.max(axis=0) + centre = (bbox_min + bbox_max) / 2.0 + vertices -= centre + + return vertices, faces, face_colors + + except Exception: + _logger.exception("Error loading STEP file: %s", step_path) + return None + + +def mesh_bounding_box(vertices: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Return (min_corner, max_corner) of an (N, 3) vertex array.""" + return vertices.min(axis=0), vertices.max(axis=0) + + +# --------------------------------------------------------------------------- +# 2-D orthographic projection (filled, colored) +# --------------------------------------------------------------------------- + + +def project_mesh_to_2d( + vertices: np.ndarray, + faces: np.ndarray, + rotation: np.ndarray, + face_colors: np.ndarray | None = None, + height_px: int = 1000, + margin_fraction: float = 0.05, + bg_color: tuple[int, int, int, int] = (255, 255, 255, 0), +) -> QPixmap | None: + """Render an orthographic 2-D projection of a mesh with per-face colors. + + Draws filled triangles using a painter's algorithm (back-to-front Z-sort). + + Args: + vertices: ``(N, 3)`` mesh vertices (centred at origin). + faces: ``(M, 3)`` triangle indices. + rotation: ``(3, 3)`` rotation matrix applied to vertices before projection. + face_colors: ``(M, 4)`` RGBA float32 (0-1) per face. If None, uses grey. + height_px: Pixel height of the output image. + margin_fraction: Fractional margin around the projected bounds. + bg_color: RGBA background colour. + + Returns: + ``QPixmap`` with the rendered image, or ``None`` on failure. + """ + try: + from PyQt6.QtCore import QPointF, Qt + from PyQt6.QtGui import QBrush, QColor, QImage, QPainter, QPixmap, QPolygonF + + rotated = (rotation @ vertices.T).T # (N, 3) + + # Project: drop Z, keep X (right) and Y (up) + proj_x = rotated[:, 0] + proj_y = rotated[:, 1] + proj_z = rotated[:, 2] + + x_min, x_max = proj_x.min(), proj_x.max() + y_min, y_max = proj_y.min(), proj_y.max() + x_range = x_max - x_min or 1.0 + y_range = y_max - y_min or 1.0 + + margin = max(x_range, y_range) * margin_fraction + x_min -= margin + x_max += margin + y_min -= margin + y_max += margin + x_range = x_max - x_min + y_range = y_max - y_min + + aspect = x_range / y_range + height = height_px + width = max(1, int(height * aspect)) + + def to_px(x: float, y: float) -> tuple[float, float]: + px = (x - x_min) / x_range * (width - 1) + py = (1.0 - (y - y_min) / y_range) * (height - 1) + return px, py + + img = QImage(width, height, QImage.Format.Format_ARGB32) + img.fill(QColor(*bg_color)) + + painter = QPainter(img) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + # Compute per-face average Z for depth sorting (painter's algorithm) + face_z = np.mean(proj_z[faces], axis=1) + sort_order = np.argsort(face_z) # back to front + + # Default face color if none provided + default_rgba = np.array([0.7, 0.7, 0.75, 1.0], dtype=np.float32) + + for fi in sort_order: + f = faces[fi] + + if face_colors is not None and fi < len(face_colors): + rgba = face_colors[fi] + else: + rgba = default_rgba + + # Simple shading: darken faces facing away from light + # Compute face normal in rotated space + v0, v1, v2 = rotated[f[0]], rotated[f[1]], rotated[f[2]] + edge1 = v1 - v0 + edge2 = v2 - v0 + normal = np.cross(edge1, edge2) + norm_len = np.linalg.norm(normal) + if norm_len > 1e-12: + normal /= norm_len + # Light from upper-front-right + light_dir = np.array([0.3, 0.4, 0.9]) + light_dir /= np.linalg.norm(light_dir) + shade = max(0.0, float(np.dot(normal, light_dir))) + # Ambient + diffuse + brightness = 0.45 + 0.55 * shade + else: + brightness = 0.6 + + r = int(min(255, rgba[0] * brightness * 255)) + g = int(min(255, rgba[1] * brightness * 255)) + b = int(min(255, rgba[2] * brightness * 255)) + a = int(rgba[3] * 255) + + color = QColor(r, g, b, a) + painter.setBrush(QBrush(color)) + painter.setPen(Qt.PenStyle.NoPen) + + # Draw filled triangle + pts = [QPointF(*to_px(proj_x[f[i]], proj_y[f[i]])) for i in range(3)] + polygon = QPolygonF(pts) + painter.drawPolygon(polygon) + + painter.end() + return QPixmap.fromImage(img) + + except Exception: + _logger.exception("Error projecting mesh to 2D") + return None + + +# --------------------------------------------------------------------------- +# Preset view rotations (matching pyqtgraph's viewMatrix for Z-up models) +# --------------------------------------------------------------------------- + +# Front: looking along -Y, Z-up (elev=0, azim=90) +VIEW_FRONT = np.array([ + [-1, 0, 0], + [0, 0, 1], + [0, 1, 0], +], dtype=np.float64) + +# Back: looking along +Y, Z-up (elev=0, azim=-90) +VIEW_BACK = np.array([ + [1, 0, 0], + [0, 0, 1], + [0, -1, 0], +], dtype=np.float64) + +# Top: looking along -Z (elev=90, azim=0) +VIEW_TOP = np.array([ + [0, 1, 0], + [-1, 0, 0], + [0, 0, 1], +], dtype=np.float64) + +# Bottom: looking along +Z (elev=-90, azim=0) +VIEW_BOTTOM = np.array([ + [0, 1, 0], + [1, 0, 0], + [0, 0, -1], +], dtype=np.float64) + +# Left: looking along +X, Z-up (elev=0, azim=180) +VIEW_LEFT = np.array([ + [0, -1, 0], + [0, 0, 1], + [-1, 0, 0], +], dtype=np.float64) + +# Right: looking along -X, Z-up (elev=0, azim=0) +VIEW_RIGHT = np.array([ + [0, 1, 0], + [0, 0, 1], + [1, 0, 0], +], dtype=np.float64) + +PRESET_VIEWS: dict[str, np.ndarray] = { + "Front": VIEW_FRONT, + "Back": VIEW_BACK, + "Top": VIEW_TOP, + "Bottom": VIEW_BOTTOM, + "Left": VIEW_LEFT, + "Right": VIEW_RIGHT, +} diff --git a/src/optiverse/core/models.py b/src/optiverse/core/models.py index 7496b740..1831f32d 100644 --- a/src/optiverse/core/models.py +++ b/src/optiverse/core/models.py @@ -134,6 +134,7 @@ class ComponentRecord: angle_deg: float = 0.0 # optical axis angle (degrees) category: str = "" # Component category (e.g., "background", "lenses", "mirrors") notes: str = "" + step_file_path: str = "" # Path to original STEP file (for PyOpticL 3-D export) def serialize_component(rec: ComponentRecord, settings_service=None) -> dict[str, Any]: @@ -189,6 +190,16 @@ def serialize_component(rec: ComponentRecord, settings_service=None) -> dict[str if rec.interfaces: base["interfaces"] = [iface.to_dict() for iface in rec.interfaces] + # Serialize STEP file path (same portable-path strategy as image_path) + if rec.step_file_path: + library_roots_for_step = get_all_library_roots(settings_service) + step_relative = make_library_relative(rec.step_file_path, library_roots_for_step) + if step_relative: + base["step_file_path"] = step_relative + else: + rel = to_relative_path(rec.step_file_path) + base["step_file_path"] = rel if isinstance(rel, str) else str(rel) + return base @@ -237,6 +248,14 @@ def deserialize_component(data: dict[str, Any], settings_service=None) -> Compon category = str(data.get("category", "")) notes = str(data.get("notes", "")) + # Resolve STEP file path (same strategy as image_path) + step_file_path_raw = str(data.get("step_file_path", "")) + step_file_path: str = "" + if step_file_path_raw: + library_roots_for_step = get_all_library_roots(settings_service) + abs_step = to_absolute_path(step_file_path_raw, library_roots_for_step) + step_file_path = abs_step if abs_step is not None else "" + # Deserialize interfaces (single current schema, Y-up; no legacy fallback) interfaces: list[InterfaceDefinition] = [] interfaces_data = data.get("interfaces") @@ -255,6 +274,7 @@ def deserialize_component(data: dict[str, Any], settings_service=None) -> Compon angle_deg=angle_deg, category=category, notes=notes, + step_file_path=step_file_path, ) @@ -508,6 +528,7 @@ class ComponentParams: # Metadata category: str | None = None notes: str | None = None + step_file_path: str | None = None # Original STEP file (for PyOpticL 3-D export) def __post_init__(self): """Ensure interfaces list exists.""" diff --git a/src/optiverse/export/__init__.py b/src/optiverse/export/__init__.py new file mode 100644 index 00000000..43830326 --- /dev/null +++ b/src/optiverse/export/__init__.py @@ -0,0 +1 @@ +"""Export modules for converting Optiverse scenes to external formats.""" diff --git a/src/optiverse/export/pyopticl_dialogs.py b/src/optiverse/export/pyopticl_dialogs.py new file mode 100644 index 00000000..7d3d22db --- /dev/null +++ b/src/optiverse/export/pyopticl_dialogs.py @@ -0,0 +1,124 @@ +""" +Dialogs for PyOpticL export: pre-flight warnings and baseplate configuration. +""" + +from __future__ import annotations + +from PyQt6 import QtWidgets + +from .pyopticl_exporter import BaseplateOptions + + +class MissingStepWarningDialog(QtWidgets.QDialog): + """Warn the user about components that lack STEP files.""" + + def __init__(self, missing_names: list[str], parent: QtWidgets.QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Missing STEP Files") + self.resize(420, 300) + + layout = QtWidgets.QVBoxLayout(self) + + label = QtWidgets.QLabel( + f"{len(missing_names)} component(s) do not have STEP files " + "attached and will be skipped in the 3-D model." + ) + label.setWordWrap(True) + layout.addWidget(label) + + list_widget = QtWidgets.QListWidget() + for name in missing_names: + list_widget.addItem(name) + layout.addWidget(list_widget) + + hint = QtWidgets.QLabel( + "To fix: open each component in the Component Editor and use " + "File > Import STEP\u2026 to attach a 3-D model." + ) + hint.setWordWrap(True) + layout.addWidget(hint) + + btn_box = QtWidgets.QDialogButtonBox() + btn_box.addButton("Export Anyway", QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole) + btn_box.addButton(QtWidgets.QDialogButtonBox.StandardButton.Cancel) + btn_box.accepted.connect(self.accept) + btn_box.rejected.connect(self.reject) + layout.addWidget(btn_box) + + +class BaseplateOptionsDialog(QtWidgets.QDialog): + """Configure baseplate dimensions and export settings.""" + + def __init__( + self, + defaults: BaseplateOptions, + parent: QtWidgets.QWidget | None = None, + ): + super().__init__(parent) + self.setWindowTitle("PyOpticL Export – Baseplate Options") + self.resize(380, 280) + + form = QtWidgets.QFormLayout(self) + + self._label_edit = QtWidgets.QLineEdit(defaults.label) + form.addRow("Layout name:", self._label_edit) + + form.addRow(self._separator()) + + self._width = self._mm_spin(defaults.width_mm) + form.addRow("Width:", self._width) + + self._height = self._mm_spin(defaults.height_mm) + form.addRow("Height:", self._height) + + self._thickness = self._mm_spin(defaults.thickness_mm) + form.addRow("Thickness:", self._thickness) + + self._optical_height = self._mm_spin(defaults.optical_height_mm) + form.addRow("Optical height:", self._optical_height) + + self._gap = self._mm_spin(defaults.gap_mm) + form.addRow("Gap:", self._gap) + + form.addRow(self._separator()) + + self._metric = QtWidgets.QCheckBox("Metric (25 mm grid, M6 bolts)") + self._metric.setChecked(defaults.metric) + form.addRow("Grid:", self._metric) + + btn_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + ok_btn = btn_box.button(QtWidgets.QDialogButtonBox.StandardButton.Ok) + if ok_btn: + ok_btn.setText("Export") + btn_box.accepted.connect(self.accept) + btn_box.rejected.connect(self.reject) + form.addRow(btn_box) + + @staticmethod + def _mm_spin(value: float) -> QtWidgets.QDoubleSpinBox: + spin = QtWidgets.QDoubleSpinBox() + spin.setRange(0.1, 1e6) + spin.setDecimals(2) + spin.setSuffix(" mm") + spin.setValue(value) + return spin + + @staticmethod + def _separator() -> QtWidgets.QFrame: + line = QtWidgets.QFrame() + line.setFrameShape(QtWidgets.QFrame.Shape.HLine) + return line + + def get_options(self) -> BaseplateOptions: + return BaseplateOptions( + width_mm=self._width.value(), + height_mm=self._height.value(), + thickness_mm=self._thickness.value(), + optical_height_mm=self._optical_height.value(), + gap_mm=self._gap.value(), + metric=self._metric.isChecked(), + label=self._label_edit.text().strip() or "Optiverse Export", + ) diff --git a/src/optiverse/export/pyopticl_exporter.py b/src/optiverse/export/pyopticl_exporter.py new file mode 100644 index 00000000..45ffafa0 --- /dev/null +++ b/src/optiverse/export/pyopticl_exporter.py @@ -0,0 +1,525 @@ +""" +Export an Optiverse scene to a PyOpticL v2 layout folder. + +The generated folder contains a Python script and a ``models/`` directory +laid out exactly the way PyOpticL's ``import_model`` expects: + + / + .py + models/ + /.step + /.json + /... + +The script can be executed in FreeCAD with the PyOpticL workbench to +produce a 3-D CAD model with a precision-drilled baseplate. +""" + +from __future__ import annotations + +import datetime +import json +import logging +import math +import os +import re +import shutil +from dataclasses import dataclass +from typing import Any + +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Configuration dataclass +# --------------------------------------------------------------------------- + + +@dataclass +class BaseplateOptions: + """User-configurable options for the exported baseplate.""" + + width_mm: float = 200.0 + height_mm: float = 150.0 + thickness_mm: float = 25.4 # 1 inch + optical_height_mm: float = 12.7 # 0.5 inch + gap_mm: float = 3.175 # 1/8 inch + metric: bool = False + label: str = "Optiverse Export" + + +_STEM_SANITIZER = re.compile(r"[^A-Za-z0-9._-]") + + +def _sanitize_stem(s: str) -> str: + """Return a string safe to use as both a model name and folder name.""" + cleaned = _STEM_SANITIZER.sub("_", s) if s else "" + return cleaned or "part" + + +# --------------------------------------------------------------------------- +# Interface mapping helpers +# --------------------------------------------------------------------------- + + +def _interface_to_pyopticl(iface_dict: dict[str, Any]) -> str | None: + """Generate a PyOpticL Interface constructor call from an InterfaceDefinition dict. + + Returns a Python source fragment, or None if the interface type is not exportable. + """ + etype = iface_dict.get("element_type", "") + + # Compute interface length as approximate diameter/size + x1 = iface_dict.get("x1_mm", 0.0) + y1 = iface_dict.get("y1_mm", 0.0) + x2 = iface_dict.get("x2_mm", 0.0) + y2 = iface_dict.get("y2_mm", 0.0) + length = math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + diameter_expr = f'dim({length:.1f}, "mm")' + + if etype == "mirror": + return f"Reflection(position=(0, 0, 0), rotation=(0, 0, 0), diameter={diameter_expr})" + + if etype == "lens": + efl = iface_dict.get("efl_mm", 100.0) + return ( + f"Lens(position=(0, 0, 0), rotation=(0, 0, 0), " + f'diameter={diameter_expr}, focal_length=dim({efl:.1f}, "mm"))' + ) + + if etype == "beam_splitter": + ratio = iface_dict.get("split_R", 50.0) / 100.0 + pol = iface_dict.get("pbs_transmission_axis_deg") + is_pol = iface_dict.get("is_polarizing", False) + diag = length * math.sqrt(2) + parts = [ + "position=(0, 0, 0)", + "rotation=(0, 0, -45)", + f'width=dim({diag:.1f}, "mm")', + f'height=dim({diag:.1f}, "mm")', + ] + if is_pol and pol is not None: + parts.append(f"ref_polarization={pol:.1f}") + else: + parts.append(f"ref_ratio={ratio:.3f}") + return f"Reflection({', '.join(parts)})" + + if etype == "dichroic": + cutoff = iface_dict.get("cutoff_wavelength_nm", 550.0) + pass_type = iface_dict.get("pass_type", "longpass") + if pass_type == "longpass": + wavelengths = f"[(None, {cutoff:.0f})]" + else: + wavelengths = f"[({cutoff:.0f}, None)]" + return ( + f"Reflection(position=(0, 0, 0), rotation=(0, 0, 0), " + f"diameter={diameter_expr}, ref_wavelengths={wavelengths})" + ) + + if etype == "polarizing_interface": + subtype = iface_dict.get("polarizer_subtype", "waveplate") + if subtype == "waveplate": + phase = iface_dict.get("phase_shift_deg", 90.0) + retardance = phase / 360.0 + fast_axis = iface_dict.get("fast_axis_deg", 0.0) + return ( + f"Waveplate(position=(0, 0, 0), rotation=(0, 0, 0), " + f"diameter={diameter_expr}, retardance={retardance:.4f}, " + f"fast_axis_angle={fast_axis:.1f})" + ) + return None + + if etype == "beam_block": + return f"Stop(position=(0, 0, 0), rotation=(0, 0, 0), diameter={diameter_expr})" + + return None + + +# --------------------------------------------------------------------------- +# Scene analysis +# --------------------------------------------------------------------------- + + +@dataclass +class ExportItem: + """A scene item ready for export.""" + + label: str + x_mm: float + y_mm: float + angle_deg: float + step_file_path: str | None + step_filename: str | None + interfaces: list[dict[str, Any]] + is_source: bool = False + wavelength_nm: float = 633.0 + item_type: str = "component" + + +def analyse_scene(scene_data: dict[str, Any]) -> tuple[list[ExportItem], list[str]]: + """Parse a serialised Optiverse scene into ExportItems. + + Returns: + (items, warnings) where *warnings* lists components missing STEP files. + """ + items: list[ExportItem] = [] + warnings: list[str] = [] + + for item_data in scene_data.get("items", []): + item_type = item_data.get("_type", "") + + if item_type == "source": + items.append(ExportItem( + label=f"Source ({item_data.get('wavelength_nm', 633.0):.0f} nm)", + x_mm=item_data.get("x_mm", 0.0), + y_mm=item_data.get("y_mm", 0.0), + angle_deg=item_data.get("angle_deg", 0.0), + step_file_path=None, + step_filename=None, + interfaces=[], + is_source=True, + wavelength_nm=item_data.get("wavelength_nm", 633.0), + item_type="source", + )) + continue + + if item_type == "component": + name = item_data.get("name") or "Component" + step = item_data.get("step_file_path") or "" + interfaces = item_data.get("interfaces", []) + + if not step: + warnings.append(name) + + items.append(ExportItem( + label=name, + x_mm=item_data.get("x_mm", 0.0), + y_mm=item_data.get("y_mm", 0.0), + angle_deg=item_data.get("angle_deg", 0.0), + step_file_path=step or None, + step_filename=os.path.basename(step) if step else None, + interfaces=interfaces if isinstance(interfaces, list) else [], + item_type="component", + )) + + return items, warnings + + +# --------------------------------------------------------------------------- +# Coordinate transforms +# --------------------------------------------------------------------------- + + +def _compute_baseplate_bounds( + items: list[ExportItem], gap_mm: float +) -> tuple[float, float, float, float]: + """Return (x_offset, y_offset, width, height) for the baseplate.""" + if not items: + return 0.0, 0.0, 200.0, 150.0 + + xs = [it.x_mm for it in items] + ys = [it.y_mm for it in items] + + x_min = min(xs) - gap_mm - 25.0 # 25 mm margin + y_min = min(ys) - gap_mm - 25.0 + x_max = max(xs) + gap_mm + 25.0 + y_max = max(ys) + gap_mm + 25.0 + + # Round up to nearest inch grid + inch = 25.4 + width = math.ceil((x_max - x_min) / inch) * inch + height = math.ceil((y_max - y_min) / inch) * inch + + return x_min, y_min, max(width, inch), max(height, inch) + + +def _optiverse_angle_to_pyopticl(angle_deg: float) -> float: + """Convert Optiverse angle convention to PyOpticL rotation (degrees around Z).""" + # Optiverse: 0° = right (+X), 90° = down (+Y in display / -Y in storage) + # PyOpticL: rotation around Z in standard math convention + return -angle_deg + + +# --------------------------------------------------------------------------- +# Script generation +# --------------------------------------------------------------------------- + + +def generate_script( + items: list[ExportItem], + options: BaseplateOptions, +) -> str: + """Generate a PyOpticL v2 Python script from analysed scene items.""" + + x_off, y_off, auto_w, auto_h = _compute_baseplate_bounds(items, options.gap_mm) + bp_w = options.width_mm if options.width_mm > 0 else auto_w + bp_h = options.height_mm if options.height_mm > 0 else auto_h + + lines: list[str] = [] + + # Header + lines.append('"""') + lines.append("PyOpticL layout exported from Optiverse") + lines.append(f"Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}") + lines.append('"""') + lines.append("") + lines.append( + "from PyOpticL.beam_path import BeamPath, Lens, Reflection, Stop, Waveplate" + ) + lines.append("from PyOpticL.layout import Component") + lines.append("from PyOpticL.library import baseplate") + lines.append( + "from PyOpticL.utils import Dimension as dim, import_model, " + "fix_relative_imports" + ) + lines.append("from PyOpticL.utils import cylinder_shape, box_shape") + lines.append("") + lines.append("fix_relative_imports()") + lines.append("") + + if options.metric: + lines.append("from PyOpticL.settings import set_measurement_system") + lines.append('set_measurement_system("metric")') + lines.append("") + + # Warn about identity orientation on imported models + has_step = any( + not it.is_source and it.step_file_path for it in items + ) + if has_step: + lines.append( + "# NOTE: Imported STEP model orientations are set to identity." + ) + lines.append( + "# Open each model's .json file in models/ and use PyOpticL's" + ) + lines.append( + "# 'Get Orientation' tool in FreeCAD to set correct values." + ) + lines.append("") + + lines.append("") + + # Component definitions + comp_class_map: dict[int, str] = {} + class_counter = 0 + + for idx, item in enumerate(items): + if item.is_source: + continue + + # Determine if this component has exportable interfaces + iface_strs: list[str] = [] + for iface in item.interfaces: + code = _interface_to_pyopticl(iface) + if code: + iface_strs.append(code) + + if item.step_file_path: + # Component with STEP file: use mesh class variable + class_counter += 1 + class_name = f"component_{class_counter}_def" + comp_class_map[idx] = class_name + + step_stem = _sanitize_stem( + os.path.splitext(item.step_filename or "part")[0] + ) + + lines.append(f"class {class_name}:") + lines.append(f' """Definition for: {item.label}"""') + lines.append(' object_group = "optics"') + lines.append(" object_color = (0.5, 0.5, 0.8)") + lines.append( + f' mesh = import_model("{step_stem}", directory="models")' + ) + lines.append("") + + if iface_strs: + lines.append(" def interfaces(self):") + lines.append(" return [") + for s in iface_strs: + lines.append(f" {s},") + lines.append(" ]") + lines.append("") + lines.append("") + + elif iface_strs: + # Component without STEP but with exportable interfaces: + # generate a definition with primitive geometry so beam + # path simulation still works. + class_counter += 1 + class_name = f"component_{class_counter}_def" + comp_class_map[idx] = class_name + + # Use the first interface's diameter for simple geometry + first_iface = item.interfaces[0] if item.interfaces else {} + x1 = first_iface.get("x1_mm", 0.0) + y1 = first_iface.get("y1_mm", 0.0) + x2 = first_iface.get("x2_mm", 0.0) + y2 = first_iface.get("y2_mm", 0.0) + iface_len = math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + if iface_len < 1.0: + iface_len = 25.4 # default 1 inch + + lines.append(f"class {class_name}:") + lines.append(f' """Definition for: {item.label} (no 3D model)"""') + lines.append(' object_group = "optics"') + lines.append(" object_color = (0.5, 0.5, 0.8)") + lines.append(" object_transparency = 50") + lines.append("") + lines.append(" def shape(self):") + lines.append( + f" return cylinder_shape(" + f"diameter={iface_len:.1f}, height=3.0, " + f"rotation=(0, 90, 0))" + ) + lines.append("") + lines.append(" def interfaces(self):") + lines.append(" return [") + for s in iface_strs: + lines.append(f" {s},") + lines.append(" ]") + lines.append("") + lines.append("") + + # Layout function + lines.append("def exported_layout(x=0, y=0, angle=0):") + lines.append(" bp = Component(") + lines.append(f' label="{options.label}",') + lines.append(" definition=baseplate(") + lines.append(f' dimensions=(dim({bp_w:.1f}, "mm"), dim({bp_h:.1f}, "mm"), ' + f'dim({options.thickness_mm:.1f}, "mm")),') + lines.append(f' optical_height=dim({options.optical_height_mm:.1f}, "mm"),') + lines.append(" ),") + lines.append(" )") + lines.append("") + + # Place sources as BeamPaths + beam_counter = 0 + for item in items: + if not item.is_source: + continue + beam_counter += 1 + bx = item.x_mm - x_off + by = item.y_mm - y_off + rot = _optiverse_angle_to_pyopticl(item.angle_deg) + lines.append(f" beam_{beam_counter} = bp.add(") + lines.append( + f' BeamPath(label="{item.label}", wavelength={item.wavelength_nm:.0f}),' + ) + lines.append(f" position=({bx:.2f}, {by:.2f}, 0),") + lines.append(f" rotation={rot:.2f},") + lines.append(" )") + lines.append("") + + # Place components + for idx, item in enumerate(items): + if item.is_source: + continue + cx = item.x_mm - x_off + cy = item.y_mm - y_off + rot = _optiverse_angle_to_pyopticl(item.angle_deg) + + if idx in comp_class_map: + lines.append(" bp.add(") + lines.append(f' Component(label="{item.label}", ' + f'definition={comp_class_map[idx]}()),') + lines.append(f" position=({cx:.2f}, {cy:.2f}, 0),") + lines.append(f" rotation={rot:.2f},") + lines.append(" )") + else: + lines.append( + f" # SKIPPED: {item.label} (no STEP file or interfaces)" + ) + lines.append("") + + lines.append(" return bp") + lines.append("") + lines.append("") + lines.append('if __name__ == "__main__":') + lines.append(" exported_layout().recompute()") + lines.append("") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Full export orchestration +# --------------------------------------------------------------------------- + + +def export_scene( + scene_data: dict[str, Any], + export_dir: str, + options: BaseplateOptions, +) -> tuple[bool, list[str]]: + """Export an Optiverse scene to a PyOpticL layout folder. + + Produces the folder layout expected by ``PyOpticL.utils.import_model``: + + /.py + /models//.step + /models//.json + + The per-model ``.json`` file is required by PyOpticL and stores the + translation/rotation applied to the imported mesh; Optiverse has no + additional STEP orientation data, so identity transforms are written. + + Args: + scene_data: Serialised scene dict (from SceneFileManager.serialize_scene). + export_dir: Destination folder. Created if it doesn't exist. + options: Baseplate configuration. + + Returns: + ``(success, warnings)`` where warnings lists component names that were + skipped due to missing STEP files. + """ + items, warnings = analyse_scene(scene_data) + + script = generate_script(items, options) + + folder_name = os.path.basename(os.path.normpath(export_dir)) or "pyopticl_export" + script_path = os.path.join(export_dir, f"{_sanitize_stem(folder_name)}.py") + models_root = os.path.join(export_dir, "models") + + try: + os.makedirs(export_dir, exist_ok=True) + with open(script_path, "w", encoding="utf-8") as f: + f.write(script) + except OSError: + _logger.exception("Failed to write PyOpticL script: %s", script_path) + return False, warnings + + for item in items: + if not item.step_file_path: + _logger.info("STEP skip (no path): %s", item.label) + continue + if not os.path.isfile(item.step_file_path): + _logger.info( + "STEP skip (file missing): %s -> %s", + item.label, item.step_file_path, + ) + continue + stem = _sanitize_stem(os.path.splitext(item.step_filename or "part")[0]) + model_dir = os.path.join(models_root, stem) + try: + os.makedirs(model_dir, exist_ok=True) + shutil.copy2(item.step_file_path, os.path.join(model_dir, f"{stem}.step")) + _logger.info("STEP copied: %s -> %s", item.label, model_dir) + with open(os.path.join(model_dir, f"{stem}.json"), "w", encoding="utf-8") as f: + json.dump( + { + "translation": [0.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0], + "_note": ( + "Run PyOpticL 'Get Orientation' in FreeCAD" + " to set correct values" + ), + }, + f, + indent=2, + ) + except OSError: + _logger.warning("Could not write model files for: %s", item.step_file_path) + + return True, warnings diff --git a/src/optiverse/objects/component_factory.py b/src/optiverse/objects/component_factory.py index 507e4a23..5f8c1f16 100644 --- a/src/optiverse/objects/component_factory.py +++ b/src/optiverse/objects/component_factory.py @@ -159,6 +159,7 @@ def create_item_from_dict(data: dict, x_mm: float, y_mm: float) -> BaseObj | Non name=name, category=data.get("category"), notes=data.get("notes"), + step_file_path=data.get("step_file_path"), ) # Store reference line if present (proper field, not dynamic attribute) diff --git a/src/optiverse/objects/definitions_loader.py b/src/optiverse/objects/definitions_loader.py index aa826908..85741540 100644 --- a/src/optiverse/objects/definitions_loader.py +++ b/src/optiverse/objects/definitions_loader.py @@ -30,7 +30,10 @@ def _iter_component_json_files(library_path: Path | None = None) -> list[Path]: root = library_path if library_path else _library_root() if not root.exists(): return [] - return [p for p in root.iterdir() if p.is_dir() and (p / "component.json").exists()] + return sorted( + (p for p in root.iterdir() if p.is_dir() and (p / "component.json").exists()), + key=lambda p: p.name.casefold(), + ) def load_component_records( @@ -108,6 +111,10 @@ def load_component_dicts(library_path: Path | None = None) -> list[dict[str, Any if rec.category: component_dict["category"] = rec.category + # Include STEP file path if present + if rec.step_file_path: + component_dict["step_file_path"] = rec.step_file_path + # Serialize interfaces if rec.interfaces: component_dict["interfaces"] = [iface.to_dict() for iface in rec.interfaces] diff --git a/src/optiverse/objects/generic/component_item.py b/src/optiverse/objects/generic/component_item.py index b6246ae0..c5ccd4e7 100644 --- a/src/optiverse/objects/generic/component_item.py +++ b/src/optiverse/objects/generic/component_item.py @@ -519,6 +519,8 @@ def to_component_dict(self) -> dict[str, Any]: "notes": self.params.notes or "", "angle_deg": self.params.angle_deg, } + if self.params.step_file_path: + data["step_file_path"] = self.params.step_file_path if self.params.interfaces: data["interfaces"] = [iface.to_dict() for iface in self.params.interfaces] return data diff --git a/src/optiverse/objects/type_registry.py b/src/optiverse/objects/type_registry.py index c33a0079..f664651f 100644 --- a/src/optiverse/objects/type_registry.py +++ b/src/optiverse/objects/type_registry.py @@ -170,42 +170,47 @@ def serialize_item(item: Serializable) -> dict[str, Any]: # Add type marker d["_type"] = item.type_name + # Resolve library roots once (used for both image_path and step_file_path) + library_roots = None + try: + if hasattr(item, "scene") and callable(item.scene): + scene = item.scene() + else: + scene = None + if scene: + views = scene.views() + if views: + view = views[0] + main_window = view.window() + if isinstance(main_window, HasSettings): + library_roots = get_all_library_roots(main_window.settings) + except (AttributeError, RuntimeError): + pass + # Convert image path to portable format (component-relative preferred) if "image_path" in d and d["image_path"]: - # Try to get library roots from the item's scene/view context - library_roots = None - try: - # Get the scene from the item - if hasattr(item, "scene") and callable(item.scene): - scene = item.scene() - else: - scene = None - if scene: - # Get all views for this scene - views = scene.views() - if views: - # Get the main window from the view - view = views[0] - main_window = view.window() - if isinstance(main_window, HasSettings): - library_roots = get_all_library_roots(main_window.settings) - except (AttributeError, RuntimeError): - # If we can't get library roots, that's okay - will use defaults - pass - - # Try component-relative first (PREFERRED - library name independent) component_relative = make_component_relative(d["image_path"], library_roots) if component_relative: d["image_path"] = component_relative else: - # Try library-relative (backward compatibility) lib_relative = make_library_relative(d["image_path"], library_roots) if lib_relative: d["image_path"] = lib_relative else: - # Fall back to package-relative (for built-in components) d["image_path"] = to_relative_path(d["image_path"]) + # Convert STEP file path to portable format (same strategy as image_path) + if "step_file_path" in d and d["step_file_path"]: + step_comp_rel = make_component_relative(d["step_file_path"], library_roots) + if step_comp_rel: + d["step_file_path"] = step_comp_rel + else: + step_lib_rel = make_library_relative(d["step_file_path"], library_roots) + if step_lib_rel: + d["step_file_path"] = step_lib_rel + else: + d["step_file_path"] = to_relative_path(d["step_file_path"]) + # Explicitly serialize interfaces using their to_dict() method if "interfaces" in d and d["interfaces"]: d["interfaces"] = [iface.to_dict() for iface in d["interfaces"]] @@ -259,10 +264,13 @@ def deserialize_item( d = data.copy() # Convert library-relative or package-relative path to absolute + roots = library_roots if library_roots is not None else get_all_library_roots() if "image_path" in d and d["image_path"]: - roots = library_roots if library_roots is not None else get_all_library_roots() d["image_path"] = to_absolute_path(d["image_path"], roots) + if "step_file_path" in d and d["step_file_path"]: + d["step_file_path"] = to_absolute_path(d["step_file_path"], roots) + # Deserialize interfaces from dicts to InterfaceDefinition objects if "interfaces" in d and d["interfaces"]: d["interfaces"] = [InterfaceDefinition.from_dict(iface) for iface in d["interfaces"]] diff --git a/src/optiverse/services/storage_service.py b/src/optiverse/services/storage_service.py index 6c6ed619..90fff77c 100644 --- a/src/optiverse/services/storage_service.py +++ b/src/optiverse/services/storage_service.py @@ -60,12 +60,14 @@ def _iter_component_folders(self) -> list[Path]: if self._library_root is None or not self._library_root.exists(): return [] - folders = [] - for item in self._library_root.iterdir(): - if item.is_dir() and (item / "component.json").exists(): - folders.append(item) - - return folders + return sorted( + ( + item + for item in self._library_root.iterdir() + if item.is_dir() and (item / "component.json").exists() + ), + key=lambda item: item.name.casefold(), + ) def load_library(self) -> list[dict[str, Any]]: """ @@ -85,10 +87,15 @@ def load_library(self) -> list[dict[str, Any]]: # Resolve image path relative to component folder image_path = data.get("image_path", "") if image_path and not Path(image_path).is_absolute(): - # Convert relative path to absolute (relative to component folder) abs_image_path = (folder / image_path).resolve() data["image_path"] = str(abs_image_path) + # Resolve STEP file path relative to component folder + step_path = data.get("step_file_path", "") + if step_path and not Path(step_path).is_absolute(): + abs_step_path = (folder / step_path).resolve() + data["step_file_path"] = str(abs_step_path) + # Deserialize and re-serialize to normalize rec = deserialize_component(data, self.settings_service) if rec is None: @@ -97,7 +104,7 @@ def load_library(self) -> list[dict[str, Any]]: # Convert back to dict with absolute paths for UI component_dict = { "name": rec.name, - "image_path": rec.image_path, # Already absolute from deserialize + "image_path": rec.image_path, "object_height_mm": float(rec.object_height_mm), "angle_deg": float(rec.angle_deg), "notes": rec.notes or "", @@ -106,6 +113,9 @@ def load_library(self) -> list[dict[str, Any]]: if rec.category: component_dict["category"] = rec.category + if rec.step_file_path: + component_dict["step_file_path"] = rec.step_file_path + if rec.interfaces: component_dict["interfaces"] = [iface.to_dict() for iface in rec.interfaces] @@ -126,6 +136,8 @@ def save_component(self, rec: ComponentRecord) -> None: component.json images/ image_file.png + step/ (if STEP file attached) + model.step Args: rec: ComponentRecord to save @@ -171,9 +183,28 @@ def save_component(self, rec: ComponentRecord) -> None: saved_image_path = f"images/{dest_image.name}" - # Create a copy of the record with relative image path + # Handle STEP file path + saved_step_path = "" + if rec.step_file_path: + source_step = Path(rec.step_file_path) + if source_step.exists(): + step_folder = component_folder / "step" + step_folder.mkdir(exist_ok=True) + + try: + source_step.resolve().relative_to(step_folder.resolve()) + saved_step_path = f"step/{source_step.name}" + except ValueError: + dest_step = step_folder / source_step.name + if not self._same_file(source_step, dest_step): + shutil.copy2(source_step, dest_step) + saved_step_path = f"step/{dest_step.name}" + + # Create a copy of the record with relative paths serialized = serialize_component(rec, self.settings_service) - serialized["image_path"] = saved_image_path # Store as relative path + serialized["image_path"] = saved_image_path + if saved_step_path: + serialized["step_file_path"] = saved_step_path # Save component.json json_path = component_folder / "component.json" @@ -242,6 +273,12 @@ def get_component(self, name: str) -> dict[str, Any] | None: abs_image_path = (component_folder / image_path).resolve() data["image_path"] = str(abs_image_path) + # Resolve STEP file path + step_path = data.get("step_file_path", "") + if step_path and not Path(step_path).is_absolute(): + abs_step_path = (component_folder / step_path).resolve() + data["step_file_path"] = str(abs_step_path) + return data # type: ignore[no-any-return] except (json.JSONDecodeError, OSError, KeyError) as e: raise ComponentLoadError(str(json_path), str(e)) from e diff --git a/src/optiverse/ui/builders/action_builder.py b/src/optiverse/ui/builders/action_builder.py index 542794f6..97bf0259 100644 --- a/src/optiverse/ui/builders/action_builder.py +++ b/src/optiverse/ui/builders/action_builder.py @@ -130,6 +130,9 @@ def build_actions(self) -> None: w.act_export_pdf = QtGui.QAction("Export PDF…", w) w.act_export_pdf.triggered.connect(w.export_pdf) + w.act_export_pyopticl = QtGui.QAction("Export PyOpticL Layout\u2026", w) + w.act_export_pyopticl.triggered.connect(w.export_pyopticl) + w.act_quit = QtGui.QAction("Quit", w) w.act_quit.setShortcut(QtGui.QKeySequence.StandardKey.Quit) w.act_quit.setShortcutContext(QtCore.Qt.ShortcutContext.WindowShortcut) @@ -446,6 +449,7 @@ def build_menubar(self) -> None: mFile.addSeparator() mFile.addAction(w.act_export_image) mFile.addAction(w.act_export_pdf) + mFile.addAction(w.act_export_pyopticl) mFile.addSeparator() mFile.addAction(w.act_quit) diff --git a/src/optiverse/ui/controllers/file_controller.py b/src/optiverse/ui/controllers/file_controller.py index f021f44a..57d24967 100644 --- a/src/optiverse/ui/controllers/file_controller.py +++ b/src/optiverse/ui/controllers/file_controller.py @@ -651,6 +651,110 @@ def export_pdf(self) -> bool: return False + def export_pyopticl(self) -> bool: + """Export the scene to a PyOpticL v2 layout folder. + + Shows dialogs for missing STEP warnings and baseplate configuration, + then writes a folder containing the script plus a ``models/`` tree + with one ``.step`` and ``.json`` per referenced component, matching + the layout PyOpticL's ``import_model`` expects. + + Returns: + True if export was successful + """ + from ...export.pyopticl_dialogs import BaseplateOptionsDialog, MissingStepWarningDialog + from ...export.pyopticl_exporter import ( + BaseplateOptions, + _sanitize_stem, + analyse_scene, + export_scene, + ) + + with ErrorContext("while exporting PyOpticL layout", suppress=True): + from ...platform.paths import to_absolute_path + + scene_data = self.file_manager.serialize_scene() + + all_items = scene_data.get("items", []) + step_before = [ + (d.get("name", "?"), d.get("step_file_path", "")) + for d in all_items if d.get("step_file_path") + ] + self._log_service.info( + f"PyOpticL export: {len(all_items)} items, " + f"{len(step_before)} with step_file_path before resolution", + "Export", + ) + for name, sfp in step_before: + self._log_service.info( + f" STEP (raw): {name!r} -> {sfp!r}", "Export" + ) + + # Resolve portable step_file_path values (@component/..., + # @library/..., relative) back to absolute paths so the + # exporter can find the actual STEP files on disk. + roots = ( + self._library_service.get_all_roots() + if self._library_service + else None + ) + self._log_service.info( + f" Library roots for resolution: {roots}", "Export" + ) + for item_data in all_items: + sfp = item_data.get("step_file_path") + if sfp: + resolved = to_absolute_path(sfp, roots) or "" + # If the resolved path does not exist on disk, try to + # find the file by scanning library component folders + # (handles bare relative paths like "step/KS05.step"). + if resolved and not os.path.isfile(resolved) and roots: + basename = os.path.basename(sfp) + for lib_root in roots: + for candidate in lib_root.rglob(basename): + if candidate.is_file(): + resolved = str(candidate) + break + if os.path.isfile(resolved): + break + self._log_service.info( + f" STEP resolve: {sfp!r} -> {resolved!r} " + f"(exists={os.path.isfile(resolved)})", + "Export", + ) + item_data["step_file_path"] = resolved + + items, warnings = analyse_scene(scene_data) + + if warnings: + dlg = MissingStepWarningDialog(warnings, parent=self._parent) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return False + + opts_dlg = BaseplateOptionsDialog(BaseplateOptions(), parent=self._parent) + if opts_dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return False + options = opts_dlg.get_options() + + default_name = _sanitize_stem(options.label) or "pyopticl_export" + export_dir, _ = QtWidgets.QFileDialog.getSaveFileName( + self._parent, + "Save PyOpticL Layout Folder As", + default_name, + "PyOpticL Layout Folder (*)", + ) + if not export_dir: + return False + if export_dir.endswith(".py"): + export_dir = export_dir[:-3] + + success, skipped = export_scene(scene_data, export_dir, options) + if success: + self._show_export_success(export_dir, "PyOpticL Layout") + return success + + return False + def import_as_layer(self) -> bool: """ Import an assembly file as a new layer (group). diff --git a/src/optiverse/ui/styles/dark_theme.qss b/src/optiverse/ui/styles/dark_theme.qss index 79887ea7..5826c64b 100644 --- a/src/optiverse/ui/styles/dark_theme.qss +++ b/src/optiverse/ui/styles/dark_theme.qss @@ -283,6 +283,25 @@ QDialog { color: white; } +/* File Dialog */ +QFileDialog QToolButton { + background-color: #2d2f36; + color: white; + border: 1px solid #3d3f46; + border-radius: 3px; + padding: 3px; + min-width: 24px; + min-height: 24px; +} + +QFileDialog QToolButton:hover { + background-color: #3d3f46; +} + +QFileDialog QToolButton:pressed { + background-color: #23252b; +} + /* Message Box */ QMessageBox { background-color: #1a1c21; @@ -400,4 +419,3 @@ QLabel#placeholderLabel { color: gray; font-style: italic; } - diff --git a/src/optiverse/ui/styles/light_theme.qss b/src/optiverse/ui/styles/light_theme.qss index 43749a84..39e54cd2 100644 --- a/src/optiverse/ui/styles/light_theme.qss +++ b/src/optiverse/ui/styles/light_theme.qss @@ -283,6 +283,29 @@ QDialog { color: black; } +/* File Dialog + * KDE/Wayland can provide light dialog backgrounds with light icon themes, + * making navigation buttons nearly invisible. Give only file-dialog toolbar + * buttons a dark surface so the system icons remain legible. + */ +QFileDialog QToolButton { + background-color: #2d2f36; + color: white; + border: 1px solid #3d3f46; + border-radius: 3px; + padding: 3px; + min-width: 24px; + min-height: 24px; +} + +QFileDialog QToolButton:hover { + background-color: #3d3f46; +} + +QFileDialog QToolButton:pressed { + background-color: #23252b; +} + /* Text Edit */ QTextEdit, QPlainTextEdit { background-color: white; @@ -370,4 +393,3 @@ QLabel#placeholderLabel { color: gray; font-style: italic; } - diff --git a/src/optiverse/ui/theme_manager.py b/src/optiverse/ui/theme_manager.py index 5a504f65..1913f7a3 100644 --- a/src/optiverse/ui/theme_manager.py +++ b/src/optiverse/ui/theme_manager.py @@ -54,6 +54,12 @@ QListWidget { background-color: #1a1c21; color: white; border: 1px solid #3d3f46; } QListWidget::item:selected { background-color: #3d5a80; color: white; } QDialog { background-color: #1a1c21; color: white; } +QFileDialog QToolButton { + background-color: #2d2f36; color: white; border: 1px solid #3d3f46; + border-radius: 3px; padding: 3px; min-width: 24px; min-height: 24px; +} +QFileDialog QToolButton:hover { background-color: #3d3f46; } +QFileDialog QToolButton:pressed { background-color: #23252b; } """ _LIGHT_STYLESHEET_FALLBACK = """ @@ -85,6 +91,12 @@ QListWidget { background-color: white; color: black; border: 1px solid #c0c0c0; } QListWidget::item:selected { background-color: #0A84FF; color: white; } QDialog { background-color: white; color: black; } +QFileDialog QToolButton { + background-color: #2d2f36; color: white; border: 1px solid #3d3f46; + border-radius: 3px; padding: 3px; min-width: 24px; min-height: 24px; +} +QFileDialog QToolButton:hover { background-color: #3d3f46; } +QFileDialog QToolButton:pressed { background-color: #23252b; } """ diff --git a/src/optiverse/ui/views/component_canvas_sync.py b/src/optiverse/ui/views/component_canvas_sync.py index 6ab5a3de..744763ee 100644 --- a/src/optiverse/ui/views/component_canvas_sync.py +++ b/src/optiverse/ui/views/component_canvas_sync.py @@ -42,6 +42,7 @@ def apply_record_to_component_item( item.params.notes = rec.notes or "" if rec.image_path: item.params.image_path = rec.image_path + item.params.step_file_path = rec.step_file_path or None item._update_geom() item._maybe_attach_sprite() diff --git a/src/optiverse/ui/views/component_editor_dialog.py b/src/optiverse/ui/views/component_editor_dialog.py index 8a8476c0..95144bb7 100644 --- a/src/optiverse/ui/views/component_editor_dialog.py +++ b/src/optiverse/ui/views/component_editor_dialog.py @@ -16,7 +16,7 @@ from ..widgets.ruler_widget import CanvasWithRulers from ..widgets.smart_spinbox import SmartDoubleSpinBox from .component_image_handler import ComponentImageHandler -from .component_library_io import ComponentLibraryIO, _pick_existing_directory +from .component_library_io import ComponentLibraryIO, _pick_existing_directory, _pick_open_file from .zemax_importer import ZemaxImporter _logger = logging.getLogger(__name__) @@ -110,6 +110,9 @@ def __init__( self._original_name: str | None = None self._component_source: str | None = None # "builtin", "user", or None (new) + # STEP file attached during STEP import (persisted in component folder on save) + self._step_file_path: str | None = None + # Back-reference to main window (set externally after construction) self._main_window: QtWidgets.QWidget | None = None @@ -180,6 +183,10 @@ def _build_menu_bar(self): if act: act.triggered.connect(self._import_zemax) + act = file_menu.addAction("Import &STEP\u2026") + if act: + act.triggered.connect(self._import_step) + act = file_menu.addAction("&Import Component\u2026") if act: act.triggered.connect(self.import_component) @@ -567,6 +574,7 @@ def _new_component(self): # Reset editing context self._original_name = None self._component_source = None + self._step_file_path = None # Status message status_bar = self.statusBar() @@ -726,6 +734,63 @@ def _import_zemax(self): if status_bar is not None: status_bar.showMessage(f"Imported {num_interfaces} interfaces from Zemax") + def _import_step(self): + """Import a STEP file as the component image via 3-D preview.""" + from ...cad.step_preview_dialog import StepPreviewDialog + from ...cad.step_renderer import ( + is_cad_available, + load_step_mesh, + missing_dependency_message, + ) + + if not is_cad_available(): + QtWidgets.QMessageBox.warning( + self, + "Missing Dependencies", + missing_dependency_message() + or "cadquery/OCP is required for STEP import.", + ) + return + + path, _ = _pick_open_file( + self, + "Import STEP File", + "", + "STEP Files (*.step *.stp);;All Files (*)", + ) + if not path: + return + + result = load_step_mesh(path) + if result is None: + QtWidgets.QMessageBox.warning( + self, + "Import Failed", + f"Could not load or tessellate:\n{path}", + ) + return + + vertices, faces, face_colors = result + dlg = StepPreviewDialog( + vertices, + faces, + face_colors=face_colors, + parent=self, + ) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + + if dlg.result_pixmap and not dlg.result_pixmap.isNull(): + self._set_image(dlg.result_pixmap, path) + self._step_file_path = path + if dlg.result_height_mm > 0: + self.object_height_mm.setValue(dlg.result_height_mm) + status_bar = self.statusBar() + if status_bar is not None: + status_bar.showMessage( + f"Imported STEP projection from {Path(path).name}", 5000 + ) + def _load_component_record(self, component: ComponentRecord): """Load a ComponentRecord into the editor.""" # Clear existing @@ -831,6 +896,7 @@ def _build_record_from_ui(self) -> ComponentRecord | None: interfaces=interfaces, category=category, notes=self.notes.toPlainText().strip(), + step_file_path=self._step_file_path or "", ) def copy_component_json(self): @@ -931,6 +997,9 @@ def _load_from_dict(self, data: dict): self._original_name = rec.name self._component_source = data.get("_source") + # Restore attached STEP file path + self._step_file_path = rec.step_file_path or None + # Load image if available if rec.image_path and os.path.exists(rec.image_path): if rec.image_path.lower().endswith(".svg"): diff --git a/src/optiverse/ui/views/main_window.py b/src/optiverse/ui/views/main_window.py index ae98dc09..a42a9f2b 100644 --- a/src/optiverse/ui/views/main_window.py +++ b/src/optiverse/ui/views/main_window.py @@ -67,6 +67,7 @@ class MainWindow(QtWidgets.QMainWindow): act_close: QtGui.QAction act_export_image: QtGui.QAction act_export_pdf: QtGui.QAction + act_export_pyopticl: QtGui.QAction act_quit: QtGui.QAction act_link_assembly: QtGui.QAction menu_recent: QtWidgets.QMenu @@ -700,6 +701,10 @@ def export_pdf(self): """Export scene to PDF (delegated to file controller).""" self.file_controller.export_pdf() + def export_pyopticl(self): + """Export scene to PyOpticL script (delegated to file controller).""" + self.file_controller.export_pyopticl() + def quit_application(self): """Quit the application (triggers close event which handles unsaved changes).""" self.close() diff --git a/src/optiverse/ui/widgets/interface_widgets.py b/src/optiverse/ui/widgets/interface_widgets.py index 6e36f72f..53234356 100644 --- a/src/optiverse/ui/widgets/interface_widgets.py +++ b/src/optiverse/ui/widgets/interface_widgets.py @@ -41,6 +41,19 @@ def keyPressEvent(self, event: QtGui.QKeyEvent | None): # Pass to parent for all other keys or when editing super().keyPressEvent(event) + def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent | None) -> None: + """Ignore tree double-click behavior to avoid scroll jumps. + + The interface panel embeds spin boxes, combo boxes, and checkboxes inside + tree rows. Letting QTreeWidget process double-clicks can call scrollTo() + while those child editors are receiving focus, which causes the component + editor panel to jump. Expansion by double-click is disabled separately; + F2 remains the explicit rename path. + """ + if event is not None: + event.accept() + return + def scrollTo( self, index: QtCore.QModelIndex, diff --git a/tests/cad/__init__.py b/tests/cad/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cad/test_step_renderer.py b/tests/cad/test_step_renderer.py new file mode 100644 index 00000000..2b09986a --- /dev/null +++ b/tests/cad/test_step_renderer.py @@ -0,0 +1,71 @@ +"""Tests for the STEP renderer module (parts that don't require OCP).""" + +import numpy as np + +from optiverse.cad.step_renderer import ( + PRESET_VIEWS, + is_cad_available, + is_viewer_available, + mesh_bounding_box, + missing_dependency_message, +) + + +class TestDependencyProbing: + def test_is_cad_available_returns_bool(self): + result = is_cad_available() + assert isinstance(result, bool) + + def test_is_viewer_available_returns_bool(self): + result = is_viewer_available() + assert isinstance(result, bool) + + def test_missing_dependency_message_type(self): + msg = missing_dependency_message() + assert isinstance(msg, str) + + +class TestMeshBoundingBox: + def test_simple_cube(self): + verts = np.array([ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [1, 1, 1], + ], dtype=np.float64) + bb_min, bb_max = mesh_bounding_box(verts) + np.testing.assert_array_equal(bb_min, [0, 0, 0]) + np.testing.assert_array_equal(bb_max, [1, 1, 1]) + + def test_centred_mesh(self): + verts = np.array([ + [-5, -5, -5], + [5, 5, 5], + ], dtype=np.float64) + bb_min, bb_max = mesh_bounding_box(verts) + np.testing.assert_array_equal(bb_min, [-5, -5, -5]) + np.testing.assert_array_equal(bb_max, [5, 5, 5]) + + +class TestPresetViews: + def test_all_presets_are_3x3(self): + for name, mat in PRESET_VIEWS.items(): + assert mat.shape == (3, 3), f"{name} has wrong shape" + + def test_all_presets_are_orthogonal(self): + for name, mat in PRESET_VIEWS.items(): + product = mat @ mat.T + np.testing.assert_allclose( + product, np.eye(3), atol=1e-10, + err_msg=f"{name} is not orthogonal", + ) + + def test_all_presets_have_det_plus_or_minus_one(self): + for name, mat in PRESET_VIEWS.items(): + det = np.linalg.det(mat) + assert abs(abs(det) - 1.0) < 1e-10, f"{name} det = {det}" + + def test_preset_dict_has_six_views(self): + assert len(PRESET_VIEWS) == 6 + assert set(PRESET_VIEWS.keys()) == {"Front", "Back", "Top", "Bottom", "Left", "Right"} diff --git a/tests/core/test_component_registry.py b/tests/core/test_component_registry.py index 7c67b469..84c60692 100644 --- a/tests/core/test_component_registry.py +++ b/tests/core/test_component_registry.py @@ -31,6 +31,23 @@ def test_get_standard_components_returns_list(self): components = ComponentRegistry.get_standard_components() assert isinstance(components, list) and len(components) > 0 + def test_component_definition_discovery_order_is_deterministic(self, tmp_path): + """Component discovery should not depend on filesystem iteration order.""" + from optiverse.objects.definitions_loader import _iter_component_json_files + + for folder_name in ["z_component", "A_component", "m_component"]: + folder = tmp_path / folder_name + folder.mkdir() + (folder / "component.json").write_text("{}", encoding="utf-8") + + folders = _iter_component_json_files(tmp_path) + + assert [folder.name for folder in folders] == [ + "A_component", + "m_component", + "z_component", + ] + def test_standard_components_have_required_fields(self): """All standard components should have required v2 fields.""" from optiverse.objects.component_registry import ComponentRegistry diff --git a/tests/export/__init__.py b/tests/export/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/export/test_pyopticl_exporter.py b/tests/export/test_pyopticl_exporter.py new file mode 100644 index 00000000..862c5537 --- /dev/null +++ b/tests/export/test_pyopticl_exporter.py @@ -0,0 +1,562 @@ +"""Tests for the PyOpticL exporter module.""" + +import json +import runpy +import shutil +import sys +import types +from pathlib import Path + +from optiverse.export.pyopticl_exporter import ( + BaseplateOptions, + ExportItem, + _compute_baseplate_bounds, + _interface_to_pyopticl, + _optiverse_angle_to_pyopticl, + _sanitize_stem, + analyse_scene, + export_scene, + generate_script, +) + +# --------------------------------------------------------------------------- +# Interface mapping +# --------------------------------------------------------------------------- + + +class TestInterfaceMapping: + def test_mirror_interface(self): + result = _interface_to_pyopticl({ + "element_type": "mirror", + "x1_mm": 0.0, "y1_mm": -15.0, + "x2_mm": 0.0, "y2_mm": 15.0, + }) + assert result is not None + assert "Reflection" in result + assert "30.0" in result # diameter = 30mm + + def test_lens_interface(self): + result = _interface_to_pyopticl({ + "element_type": "lens", + "x1_mm": 0.0, "y1_mm": -10.0, + "x2_mm": 0.0, "y2_mm": 10.0, + "efl_mm": 75.0, + }) + assert result is not None + assert "Lens" in result + assert "75.0" in result + + def test_beam_splitter_interface(self): + result = _interface_to_pyopticl({ + "element_type": "beam_splitter", + "x1_mm": 0.0, "y1_mm": -12.0, + "x2_mm": 0.0, "y2_mm": 12.0, + "split_R": 30.0, + "is_polarizing": False, + }) + assert result is not None + assert "Reflection" in result + assert "0.300" in result + + def test_beam_splitter_uses_precomputed_diagonal(self): + """dim(diag, 'mm') should appear directly, not dim(...) * 1.414.""" + result = _interface_to_pyopticl({ + "element_type": "beam_splitter", + "x1_mm": 0.0, "y1_mm": -12.0, + "x2_mm": 0.0, "y2_mm": 12.0, + "split_R": 50.0, + "is_polarizing": False, + }) + assert result is not None + assert "* 1.414" not in result + assert 'dim(33.9, "mm")' in result # 24 * sqrt(2) ≈ 33.9 + + def test_dichroic_longpass(self): + result = _interface_to_pyopticl({ + "element_type": "dichroic", + "x1_mm": 0.0, "y1_mm": -12.0, + "x2_mm": 0.0, "y2_mm": 12.0, + "cutoff_wavelength_nm": 550.0, + "pass_type": "longpass", + }) + assert result is not None + assert "550" in result + assert "None" in result + + def test_waveplate_interface(self): + result = _interface_to_pyopticl({ + "element_type": "polarizing_interface", + "x1_mm": 0.0, "y1_mm": -10.0, + "x2_mm": 0.0, "y2_mm": 10.0, + "polarizer_subtype": "waveplate", + "phase_shift_deg": 90.0, + "fast_axis_deg": 45.0, + }) + assert result is not None + assert "Waveplate" in result + assert "0.2500" in result # 90/360 + + def test_beam_block_interface(self): + result = _interface_to_pyopticl({ + "element_type": "beam_block", + "x1_mm": 0.0, "y1_mm": -5.0, + "x2_mm": 0.0, "y2_mm": 5.0, + }) + assert result is not None + assert "Stop" in result + assert "10.0" in result # diameter = 10mm + + def test_unknown_type_returns_none(self): + assert _interface_to_pyopticl({"element_type": "unknown"}) is None + + def test_empty_type_returns_none(self): + assert _interface_to_pyopticl({}) is None + + +# --------------------------------------------------------------------------- +# Coordinate transforms +# --------------------------------------------------------------------------- + + +class TestCoordinateTransforms: + def test_angle_conversion_zero(self): + assert _optiverse_angle_to_pyopticl(0.0) == 0.0 + + def test_angle_conversion_45(self): + assert _optiverse_angle_to_pyopticl(45.0) == -45.0 + + def test_angle_conversion_negative(self): + assert _optiverse_angle_to_pyopticl(-30.0) == 30.0 + + def test_baseplate_bounds_empty(self): + x, y, w, h = _compute_baseplate_bounds([], 3.175) + assert w > 0 + assert h > 0 + + def test_baseplate_bounds_single_item(self): + items = [ExportItem( + label="M", x_mm=50, y_mm=50, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + )] + x_off, y_off, w, h = _compute_baseplate_bounds(items, 3.175) + # Must be >= 1 inch + assert w >= 25.4 + assert h >= 25.4 + + def test_baseplate_bounds_multiple_items(self): + items = [ + ExportItem(label="A", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[]), + ExportItem(label="B", x_mm=200, y_mm=100, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[]), + ] + _, _, w, h = _compute_baseplate_bounds(items, 3.175) + assert w >= 200 + assert h >= 100 + + +# --------------------------------------------------------------------------- +# Scene analysis +# --------------------------------------------------------------------------- + + +class TestAnalyseScene: + def test_empty_scene(self): + items, warnings = analyse_scene({"items": []}) + assert items == [] + assert warnings == [] + + def test_source_extraction(self): + scene = {"items": [{ + "_type": "source", + "x_mm": 10.0, "y_mm": 20.0, "angle_deg": 0.0, + "wavelength_nm": 780.0, + }]} + items, warnings = analyse_scene(scene) + assert len(items) == 1 + assert items[0].is_source + assert items[0].wavelength_nm == 780.0 + + def test_component_with_step(self): + scene = {"items": [{ + "_type": "component", + "name": "Mirror Mount", + "x_mm": 50.0, "y_mm": 30.0, "angle_deg": 45.0, + "step_file_path": "/path/to/mount.step", + "interfaces": [{"element_type": "mirror", "x1_mm": 0, "y1_mm": -10, + "x2_mm": 0, "y2_mm": 10}], + }]} + items, warnings = analyse_scene(scene) + assert len(items) == 1 + assert not items[0].is_source + assert items[0].step_file_path == "/path/to/mount.step" + assert warnings == [] + + def test_component_without_step_warns(self): + scene = {"items": [{ + "_type": "component", + "name": "Bare Mirror", + "x_mm": 50.0, "y_mm": 30.0, "angle_deg": 45.0, + }]} + items, warnings = analyse_scene(scene) + assert len(items) == 1 + assert warnings == ["Bare Mirror"] + + +# --------------------------------------------------------------------------- +# Script generation +# --------------------------------------------------------------------------- + + +class TestGenerateScript: + def test_script_has_imports(self): + items = [ExportItem( + label="Source (633 nm)", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + is_source=True, wavelength_nm=633.0, + )] + script = generate_script(items, BaseplateOptions()) + assert "from PyOpticL" in script + assert "import_model" in script + assert "BeamPath" in script + assert "fix_relative_imports" in script + assert "Stop" in script + + def test_script_has_layout_function(self): + items = [ExportItem( + label="Source", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + is_source=True, + )] + script = generate_script(items, BaseplateOptions()) + assert "def exported_layout" in script + assert "if __name__" in script + + def test_component_with_step_uses_mesh_class_var(self): + """STEP-imported components must use `mesh = import_model(...)` class var.""" + items = [ + ExportItem( + label="Source", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + is_source=True, + ), + ExportItem( + label="Mirror 1", x_mm=80, y_mm=0, angle_deg=45, + step_file_path="/models/mirror.step", + step_filename="mirror.step", + interfaces=[{"element_type": "mirror", + "x1_mm": 0, "y1_mm": -15, + "x2_mm": 0, "y2_mm": 15}], + ), + ] + script = generate_script(items, BaseplateOptions(label="Test Layout")) + assert "class component_1_def" in script + assert 'mesh = import_model("mirror"' in script + assert "def shape(self):" not in script or "no 3D model" in script + assert "Reflection" in script + assert "Test Layout" in script + + def test_component_without_step_but_with_interfaces_generates_class(self): + """Components without STEP but with interfaces get primitive geometry.""" + items = [ + ExportItem( + label="Source", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + is_source=True, + ), + ExportItem( + label="Thin Lens", x_mm=50, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, + interfaces=[{"element_type": "lens", + "x1_mm": 0, "y1_mm": -12.7, + "x2_mm": 0, "y2_mm": 12.7, + "efl_mm": 100.0}], + ), + ] + script = generate_script(items, BaseplateOptions()) + assert "class component_1_def" in script + assert "no 3D model" in script + assert "cylinder_shape" in script + assert "Lens" in script + assert "SKIPPED" not in script + + def test_missing_step_and_no_interfaces_shows_skip(self): + items = [ExportItem( + label="No Step", x_mm=10, y_mm=10, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + )] + script = generate_script(items, BaseplateOptions()) + assert "SKIPPED" in script + + def test_metric_option_generates_settings_call(self): + items = [ExportItem( + label="Source", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + is_source=True, + )] + script = generate_script(items, BaseplateOptions(metric=True)) + assert "set_measurement_system" in script + assert '"metric"' in script + + def test_imperial_option_omits_settings_call(self): + items = [ExportItem( + label="Source", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + is_source=True, + )] + script = generate_script(items, BaseplateOptions(metric=False)) + assert "set_measurement_system" not in script + + def test_step_orientation_note_present(self): + items = [ + ExportItem( + label="Mirror", x_mm=0, y_mm=0, angle_deg=0, + step_file_path="/m.step", step_filename="m.step", + interfaces=[], + ), + ] + script = generate_script(items, BaseplateOptions()) + assert "Get Orientation" in script + + def test_script_is_valid_python(self): + """The generated script should be syntactically valid Python.""" + items = [ + ExportItem( + label="Source", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + is_source=True, wavelength_nm=633.0, + ), + ExportItem( + label="Mirror", x_mm=80, y_mm=50, angle_deg=45, + step_file_path="/m.step", step_filename="m.step", + interfaces=[{"element_type": "mirror", + "x1_mm": 0, "y1_mm": -10, + "x2_mm": 0, "y2_mm": 10}], + ), + ] + script = generate_script(items, BaseplateOptions()) + compile(script, "", "exec") + + def test_script_with_no_step_interfaces_is_valid_python(self): + """Script with primitive-geometry components must also be valid Python.""" + items = [ + ExportItem( + label="Source", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + is_source=True, wavelength_nm=633.0, + ), + ExportItem( + label="Lens", x_mm=50, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, + interfaces=[{"element_type": "lens", + "x1_mm": 0, "y1_mm": -10, + "x2_mm": 0, "y2_mm": 10, + "efl_mm": 100.0}], + ), + ] + script = generate_script(items, BaseplateOptions()) + compile(script, "", "exec") + + def test_script_with_metric_is_valid_python(self): + items = [ExportItem( + label="Source", x_mm=0, y_mm=0, angle_deg=0, + step_file_path=None, step_filename=None, interfaces=[], + is_source=True, + )] + script = generate_script(items, BaseplateOptions(metric=True)) + compile(script, "", "exec") + + +# --------------------------------------------------------------------------- +# ComponentRecord step_file_path round-trip +# --------------------------------------------------------------------------- + + +class TestComponentRecordStepPath: + def test_step_field_defaults_empty(self): + from optiverse.core.models import ComponentRecord + rec = ComponentRecord(name="Test") + assert rec.step_file_path == "" + + def test_step_field_serialization(self): + from optiverse.core.models import ( + ComponentRecord, + serialize_component, + ) + + rec = ComponentRecord( + name="Mirror Mount", + step_file_path="/tmp/mount.step", + ) + data = serialize_component(rec) + assert data.get("step_file_path") is not None + + def test_step_field_deserialization(self): + from optiverse.core.models import deserialize_component + + data = { + "name": "Mount", + "step_file_path": "/tmp/mount.step", + } + rec = deserialize_component(data) + assert rec is not None + assert rec.step_file_path != "" + + +# --------------------------------------------------------------------------- +# Folder-based export (matches PyOpticL.utils.import_model layout) +# --------------------------------------------------------------------------- + + +class TestExportSceneFolderLayout: + def _scene_with_one_step(self, step_src: str) -> dict: + return { + "items": [ + { + "_type": "source", + "x_mm": 0.0, "y_mm": 0.0, "angle_deg": 0.0, + "wavelength_nm": 633.0, + }, + { + "_type": "component", + "name": "Mirror Mount", + "x_mm": 50.0, "y_mm": 30.0, "angle_deg": 0.0, + "step_file_path": step_src, + "interfaces": [{"element_type": "mirror", + "x1_mm": 0, "y1_mm": -10, + "x2_mm": 0, "y2_mm": 10}], + }, + ] + } + + def test_writes_script_and_per_model_step_and_json(self, tmp_path): + step_src = tmp_path / "Thorlabs KM05.STEP" + step_src.write_bytes(b"ISO-10303-21 fake step content") + + export_dir = tmp_path / "my_layout" + success, _ = export_scene( + self._scene_with_one_step(str(step_src)), + str(export_dir), + BaseplateOptions(), + ) + assert success is True + + assert (export_dir / "my_layout.py").is_file() + + stem = "Thorlabs_KM05" + model_dir = export_dir / "models" / stem + assert model_dir.is_dir() + assert (model_dir / f"{stem}.step").is_file() + assert (model_dir / f"{stem}.json").is_file() + + info = json.loads((model_dir / f"{stem}.json").read_text()) + assert info["translation"] == [0.0, 0.0, 0.0] + assert info["rotation"] == [0.0, 0.0, 0.0] + assert "_note" in info + + script = (export_dir / "my_layout.py").read_text() + assert f'import_model("{stem}"' in script + assert f'mesh = import_model("{stem}"' in script + + def test_exported_script_runs_after_folder_is_moved(self, tmp_path, monkeypatch): + step_src = tmp_path / "Thorlabs KM05.STEP" + step_src.write_bytes(b"ISO-10303-21 fake step content") + + export_dir = tmp_path / "my_layout" + success, _ = export_scene( + self._scene_with_one_step(str(step_src)), + str(export_dir), + BaseplateOptions(), + ) + assert success is True + + moved_dir = tmp_path / "relocated" / "my_layout" + shutil.copytree(export_dir, moved_dir) + + script_path = moved_dir / "my_layout.py" + script_text = script_path.read_text(encoding="utf-8") + assert str(step_src) not in script_text + assert str(export_dir) not in script_text + assert 'import_model("Thorlabs_KM05", directory="models")' in script_text + + import_calls: list[tuple[str, str]] = [] + _install_pyopticl_stubs(monkeypatch, import_calls) + + monkeypatch.chdir(moved_dir) + runpy.run_path(str(script_path), run_name="__main__") + + assert import_calls == [("Thorlabs_KM05", "models")] + + def test_missing_step_file_does_not_fail_export(self, tmp_path): + scene = self._scene_with_one_step("/nonexistent/file.step") + export_dir = tmp_path / "out" + success, _ = export_scene(scene, str(export_dir), BaseplateOptions()) + assert success is True + assert (export_dir / "out.py").is_file() + assert not (export_dir / "models").exists() + + def test_sanitize_stem_handles_dodgy_names(self): + assert _sanitize_stem("foo bar/baz") == "foo_bar_baz" + assert _sanitize_stem("") == "part" + assert _sanitize_stem("OK-name_1.0") == "OK-name_1.0" + + +def _install_pyopticl_stubs(monkeypatch, import_calls: list[tuple[str, str]]) -> None: + class _Component: + def __init__(self, *args, **kwargs): + pass + + def add(self, item, *args, **kwargs): + return item + + def recompute(self): + pass + + class _BeamPath: + def __init__(self, *args, **kwargs): + pass + + class _Interface: + def __init__(self, *args, **kwargs): + pass + + def _dim(value, unit): + return (value, unit) + + def _import_model(name, directory="models"): + model_dir = Path.cwd() / directory / name + assert model_dir.is_dir() + assert (model_dir / f"{name}.step").is_file() + assert (model_dir / f"{name}.json").is_file() + import_calls.append((name, directory)) + return object() + + def _fix_relative_imports(): + pass + + pyopticl = types.ModuleType("PyOpticL") + beam_path = types.ModuleType("PyOpticL.beam_path") + beam_path.BeamPath = _BeamPath + beam_path.Lens = _Interface + beam_path.Reflection = _Interface + beam_path.Stop = _Interface + beam_path.Waveplate = _Interface + + layout = types.ModuleType("PyOpticL.layout") + layout.Component = _Component + + library = types.ModuleType("PyOpticL.library") + library.baseplate = lambda *args, **kwargs: object() + + utils = types.ModuleType("PyOpticL.utils") + utils.Dimension = _dim + utils.import_model = _import_model + utils.fix_relative_imports = _fix_relative_imports + utils.cylinder_shape = lambda *args, **kwargs: object() + utils.box_shape = lambda *args, **kwargs: object() + + monkeypatch.setitem(sys.modules, "PyOpticL", pyopticl) + monkeypatch.setitem(sys.modules, "PyOpticL.beam_path", beam_path) + monkeypatch.setitem(sys.modules, "PyOpticL.layout", layout) + monkeypatch.setitem(sys.modules, "PyOpticL.library", library) + monkeypatch.setitem(sys.modules, "PyOpticL.utils", utils) diff --git a/tests/services/test_storage_service.py b/tests/services/test_storage_service.py index 3ff9a16d..3e9eea6b 100644 --- a/tests/services/test_storage_service.py +++ b/tests/services/test_storage_service.py @@ -1,3 +1,6 @@ +from pathlib import Path + + def test_storage_library_roundtrip(tmp_path, monkeypatch): # Force paths under tmp monkeypatch.setenv("HOME", str(tmp_path)) @@ -23,3 +26,47 @@ def test_storage_library_roundtrip(tmp_path, monkeypatch): matching = [item for item in items if item.get("name") == "lens100"] assert len(matching) >= 1 assert matching[0]["name"] == "lens100" + + +def test_storage_roundtrips_attached_step_file(tmp_path): + from optiverse.core.models import ComponentRecord + from optiverse.services.storage_service import StorageService + + source_step = tmp_path / "model.step" + source_step.write_text("ISO-10303-21 fake step content", encoding="utf-8") + + library = tmp_path / "library" + library.mkdir() + svc = StorageService(str(library)) + svc.save_component(ComponentRecord(name="STEP Component", step_file_path=str(source_step))) + + saved = svc.get_component("STEP Component") + + assert saved is not None + saved_step = saved["step_file_path"] + assert Path(saved_step).parts[-2:] == ("step", "model.step") + assert (library / "step_component" / "step" / "model.step").is_file() + + loaded = svc.load_library() + matching = [item for item in loaded if item.get("name") == "STEP Component"] + assert len(matching) == 1 + assert matching[0]["step_file_path"] == saved_step + + +def test_storage_component_folders_are_deterministic(tmp_path): + from optiverse.services.storage_service import StorageService + + library = tmp_path / "library" + library.mkdir() + for folder_name in ["z_component", "A_component", "m_component"]: + folder = library / folder_name + folder.mkdir(parents=True) + (folder / "component.json").write_text("{}", encoding="utf-8") + + svc = StorageService(str(library)) + + assert [folder.name for folder in svc._iter_component_folders()] == [ + "A_component", + "m_component", + "z_component", + ] diff --git a/tests/ui/test_component_editor.py b/tests/ui/test_component_editor.py index f5599415..e14ef637 100644 --- a/tests/ui/test_component_editor.py +++ b/tests/ui/test_component_editor.py @@ -176,3 +176,22 @@ def test_component_editor_backward_compat_name(qtbot): assert editor is not None assert hasattr(editor, "saved") # New feature should be present + + +@pytest.mark.skipif(not HAVE_PYQT6, reason="PyQt6 not available") +def test_cancel_step_import_keeps_component_editor_open(qtbot, monkeypatch): + """Canceling the STEP file picker should not close the component editor.""" + from optiverse.services.storage_service import StorageService + from optiverse.ui.views import component_editor_dialog as dialog_mod + from optiverse.ui.views.component_editor_dialog import ComponentEditor + + editor = ComponentEditor(storage=StorageService()) + qtbot.addWidget(editor) + editor.show() + + monkeypatch.setattr("optiverse.cad.step_renderer.is_cad_available", lambda: True) + monkeypatch.setattr(dialog_mod, "_pick_open_file", lambda *args, **kwargs: ("", "")) + + editor._import_step() + + assert editor.isVisible() diff --git a/tests/ui/test_interface_tree_widget.py b/tests/ui/test_interface_tree_widget.py new file mode 100644 index 00000000..aa7ba570 --- /dev/null +++ b/tests/ui/test_interface_tree_widget.py @@ -0,0 +1,36 @@ +"""Tests for the component-editor interface tree widget.""" + +from __future__ import annotations + +import pytest + +try: + from PyQt6 import QtCore, QtWidgets + + HAVE_PYQT6 = True +except ImportError: + HAVE_PYQT6 = False + + +pytestmark = pytest.mark.skipif(not HAVE_PYQT6, reason="PyQt6 not available") + + +def test_double_click_does_not_trigger_tree_scroll(qtbot, monkeypatch): + from optiverse.ui.widgets.interface_widgets import InterfaceTreeWidget + + tree = InterfaceTreeWidget() + qtbot.addWidget(tree) + tree.addTopLevelItem(QtWidgets.QTreeWidgetItem(["Interface"])) + tree.resize(240, 120) + tree.show() + + scroll_calls = [] + monkeypatch.setattr(tree, "scrollTo", lambda *args, **kwargs: scroll_calls.append(args)) + + qtbot.mouseDClick( + tree.viewport(), + QtCore.Qt.MouseButton.LeftButton, + pos=tree.visualItemRect(tree.topLevelItem(0)).center(), + ) + + assert scroll_calls == [] diff --git a/tests/ui/test_theme_stylesheets.py b/tests/ui/test_theme_stylesheets.py new file mode 100644 index 00000000..cb2070f4 --- /dev/null +++ b/tests/ui/test_theme_stylesheets.py @@ -0,0 +1,22 @@ +"""Tests for application theme stylesheet coverage.""" + +from __future__ import annotations + +import pytest + +try: + from optiverse.ui.theme_manager import get_dark_stylesheet, get_light_stylesheet + + HAVE_PYQT6 = True +except ImportError: + HAVE_PYQT6 = False + + +pytestmark = pytest.mark.skipif(not HAVE_PYQT6, reason="PyQt6 not available") + + +def test_file_dialog_toolbar_buttons_have_explicit_theme_styles(): + for stylesheet in (get_light_stylesheet(), get_dark_stylesheet()): + assert "QFileDialog QToolButton" in stylesheet + assert "min-width: 24px" in stylesheet + assert "background-color: #2d2f36" in stylesheet