diff --git a/CHANGELOG.md b/CHANGELOG.md index 9610a915..dad1d502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,22 @@ 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) +- **Zemax `UNIT` card** (#95): Parser handles `UNIT MM | CM | M | IN`; lengths are normalised to mm at parse time (`DISZ`/`DIAM`/`ENPD` multiplied, `CURV` divided). Inch-unit prescriptions now import at their correct physical size +- **Zemax `COORDBRK` surfaces** (#95): Converter applies in-plane decenter (PARM 1) and tilt (PARM 4) so subsequent surfaces are placed on a rotated/translated optical axis. Multi-mirror systems (e.g. periscopes) unfold correctly in the editor +- **Zemax `GLAS MIRROR` mapping** (#95): Mirror surfaces import as `element_type=mirror`; propagation direction flips after a planar reflection so folded systems lay out with the right geometry +- **Asphere and aperture-stop annotations** (#95): `EVENASPH` / `ODDASPHE` surfaces import at their base radius and are flagged "(Asphere approx.)"; `STOP` surfaces that survive the dummy filter get an "(Aperture Stop)" annotation in the interface name ### Changed - **Zoom-invariant ruler rendering** (#80): Ruler endpoint bars, labels, selection bounds, and hit-test areas now maintain constant screen size regardless of zoom level, matching the existing cosmetic-pen line behavior +- **Zemax import preserves `COMM`** (#95): Zemax surface comments (often part numbers) are now prefixed to the descriptive auto-generated name instead of replacing it, so material/curvature info isn't lost +- **`examples/zemax_parse_simple.py`** (#95): Replaced a 263-line inline copy-pasted parser (which still had the original `DIAM`-as-full-diameter bug) with a thin wrapper over the canonical `ZemaxParser` + `ZemaxToInterfaceConverter` + +### Fixed + +- **Zemax `DIAM` interpreted as full diameter** (#95): The Zemax `DIAM` card stores the surface *semi*-diameter, but the importer was treating it as a full diameter and then halving it again. Imported lenses rendered at half their real size. The field is now `ZemaxSurface.semi_diameter_mm`; a 1″ achromat imports as a 25.4 mm-tall component with a ±12.7 mm aperture +- **Zemax import emits ghost interfaces** (#95): The image plane (last surface) and flat air-to-air dummy / aperture-stop surfaces are no longer emitted as `n=1→n=1` interfaces. The image-plane heuristic uses position; dummies are detected by `is_flat AND |n1−n2| < 1e-4` +- **Zemax import test coverage** (#95): 17 parser/converter tests that had been silently skipped on every run (because `tests/fixtures/sample.zmx` wasn't checked in) now execute. Fixture added, plus 35 new tests covering UNIT scaling, mirror handling, dummy filtering, COMM preservation, COORDBRK rotation, periscope geometry, and asphere/stop annotations. Active Zemax tests: 15 → 56 ## [0.3.4] - 2026-04-30 diff --git a/examples/zemax_parse_simple.py b/examples/zemax_parse_simple.py index 8a85bacb..f351ffd7 100644 --- a/examples/zemax_parse_simple.py +++ b/examples/zemax_parse_simple.py @@ -1,262 +1,69 @@ -""" -Simple Zemax parser demonstration - no heavy dependencies. +"""Demo: parse a Zemax ZMX file and show the resulting OptiVerse component. + +Uses the canonical :mod:`optiverse.services.zemax_parser` and +:class:`optiverse.services.zemax_converter.ZemaxToInterfaceConverter` rather +than an inline reimplementation, so this demo can't drift away from the +real import behaviour. -This script demonstrates parsing a Zemax file and showing how it -maps to the OptiVerse interface concept. +Usage:: -Usage: python examples/zemax_parse_simple.py /path/to/file.zmx """ -import sys - - -# Simple inline parser (no imports) -def parse_zemax_simple(filepath): - """Simple Zemax ZMX parser.""" - surfaces = [] - name = "" - wavelengths = [] - primary_wl_idx = 1 - - with open(filepath) as f: - lines = f.readlines() - - current_surface = None - surf_num = None - - for line in lines: - line_stripped = line.strip() - - if line_stripped.startswith("NAME "): - name = line_stripped[5:].strip() - - elif line_stripped.startswith("WAVM "): - parts = line_stripped[5:].split() - if len(parts) >= 2: - wavelengths.append(float(parts[1])) - - elif line_stripped.startswith("PWAV "): - primary_wl_idx = int(line_stripped[5:].strip()) - - elif line_stripped.startswith("SURF "): - if current_surface: - surfaces.append(current_surface) - surf_num = int(line_stripped[5:].strip()) - current_surface = { - "number": surf_num, - "curvature": 0.0, - "thickness": 0.0, - "glass": "", - "diameter": 0.0, - "coating": "", - "comment": "", - } - - elif current_surface and line.startswith(" "): # Check indentation before stripping - if line_stripped.startswith("CURV "): - try: - current_surface["curvature"] = float(line_stripped[5:].split()[0]) - except (ValueError, IndexError): - pass - elif line_stripped.startswith("DISZ "): - val = line_stripped[5:].split()[0] - if val.upper() != "INFINITY": - try: - current_surface["thickness"] = float(val) - except (ValueError, IndexError): - pass - elif line_stripped.startswith("GLAS "): - try: - current_surface["glass"] = line_stripped[5:].split()[0] - except (ValueError, IndexError): - pass - elif line_stripped.startswith("DIAM "): - try: - current_surface["diameter"] = float(line_stripped[5:].split()[0]) - except (ValueError, IndexError): - pass - elif line_stripped.startswith("COAT "): - try: - current_surface["coating"] = line_stripped[5:].split()[0] - except (ValueError, IndexError): - pass - elif line_stripped.startswith("COMM "): - current_surface["comment"] = line_stripped[5:].strip() - - if current_surface: - surfaces.append(current_surface) +from __future__ import annotations - primary_wl_um = wavelengths[primary_wl_idx - 1] if wavelengths else 0.55 - - return {"name": name, "surfaces": surfaces, "primary_wavelength_um": primary_wl_um} - - -# Minimal glass catalog -GLASS_INDICES = { - "N-BK7": 1.517, - "N-LAK22": 1.651, - "N-SF6HT": 1.805, - "N-SF11": 1.785, - "N-F2": 1.620, -} - - -def get_index(material): - """Get refractive index.""" - if not material or material.upper() in ["", "AIR", "VACUUM"]: - return 1.0 - return GLASS_INDICES.get(material.upper(), 1.5) +import sys +from optiverse.services.glass_catalog import GlassCatalog +from optiverse.services.zemax_converter import ZemaxToInterfaceConverter +from optiverse.services.zemax_parser import ZemaxParser -def main(): - if len(sys.argv) < 2: - print("Usage: python zemax_parse_simple.py ") - return - filepath = sys.argv[1] +def _print_summary(filepath: str) -> int: + parser = ZemaxParser() + zemax_data = parser.parse(filepath) + if zemax_data is None: + print(f"Could not parse Zemax file: {filepath}", file=sys.stderr) + return 1 print("=" * 70) - print("ZEMAX FILE PARSER - Simple Demonstration") + print("ZEMAX FILE PARSER — Simple Demonstration") print("=" * 70) print() - - # Parse - data = parse_zemax_simple(filepath) - - print(f"Name: {data['name']}") - print(f"Primary wavelength: {data['primary_wavelength_um'] * 1000:.1f} nm") - print(f"Number of surfaces: {len(data['surfaces'])}") + print(parser.format_summary(zemax_data)) print() - print("-" * 70) - print("SURFACES:") + print("CONVERSION TO OPTIVERSE COMPONENT") print("-" * 70) - cumulative_x = 0.0 - current_material = "" - - for surf in data["surfaces"]: - num = surf["number"] - curv = surf["curvature"] - thick = surf["thickness"] - glass = surf["glass"] - diam = surf["diameter"] - - radius = 1.0 / curv if abs(curv) > 1e-10 else float("inf") - radius_str = f"{radius:.2f}" if radius != float("inf") else "∞" - - print(f"\nSurface {num}:") - if num == 0: - print(" Type: Object surface (at infinity)") - else: - print(f" Radius: {radius_str} mm") - print(f" Thickness to next: {thick:.2f} mm") - print(f" Material: {glass if glass else 'Air'}") - print(f" Diameter: {diam:.2f} mm") - - # Show as interface - if num > 0 and num < len(data["surfaces"]) - 1: - n1 = get_index(current_material) - n2 = get_index(glass) - mat1 = current_material if current_material else "Air" - mat2 = glass if glass else "Air" - - # Calculate sag for curved surfaces - is_curved = radius != float("inf") - sag = 0.0 - if is_curved and diam > 0: - R_abs = abs(radius) - h = diam / 2.0 - if h**2 < R_abs**2: - sag = R_abs - (R_abs**2 - h**2) ** 0.5 - if radius < 0: - sag = -sag - - print("\n → OptiVerse Interface:") - print(f" Position: x={cumulative_x:.2f} mm (vertex)") - print(f" Height: {diam:.2f} mm (±{diam / 2:.2f} mm from axis)") - print(f" Indices: n₁={n1:.3f} ({mat1}) → n₂={n2:.3f} ({mat2})") - if is_curved: - print(f" Curvature: R={radius_str} mm") - convex_concave = "(convex)" if radius > 0 else "(concave)" - print(f" Sag (edge): {abs(sag):.3f} mm {convex_concave}") - print(" Type: curved refractive_interface") - else: - print(" Type: flat refractive_interface") - - cumulative_x += thick - current_material = glass - - print() - print("=" * 70) - print("MAPPING TO OPTIVERSE") - print("=" * 70) - print() - - print("This Zemax file defines a multi-element lens system that maps to") - print("OptiVerse's interface-based component model as follows:") - print() - - # Count real interfaces (exclude object and image) - real_surfaces = [s for s in data["surfaces"] if 0 < s["number"] < len(data["surfaces"]) - 1] - - print("ComponentRecord(") - print(f' name="{data["name"]}",') - print(' kind="multi_element",') - print(f" object_height_mm={real_surfaces[0]['diameter'] if real_surfaces else 25.4:.2f},") - print(" interfaces_v2=[") - - cumulative_x = 0.0 - current_material = "" - - for surf in data["surfaces"]: - if surf["number"] == 0 or surf["number"] >= len(data["surfaces"]) - 1: - continue - - n1 = get_index(current_material) - n2 = get_index(surf["glass"]) - half_diam = surf["diameter"] / 2.0 - - mat1 = current_material if current_material else "Air" - mat2 = surf["glass"] if surf["glass"] else "Air" - - # Get radius info - radius = 1.0 / surf["curvature"] if abs(surf["curvature"]) > 1e-10 else float("inf") - is_curved = radius != float("inf") - - print(" InterfaceDefinition(") - print(f" x1_mm={cumulative_x:.2f}, y1_mm={-half_diam:.2f},") - print(f" x2_mm={cumulative_x:.2f}, y2_mm={half_diam:.2f},") - print(" element_type='refractive_interface',") - print(f" name='S{surf['number']}: {mat1} → {mat2}',") - print(f" n1={n1:.4f}, n2={n2:.4f},") - if is_curved: - print(" is_curved=True,") - print(f" radius_of_curvature_mm={radius:.2f}") - else: - print(" is_curved=False") - print(" ),") - - cumulative_x += surf["thickness"] - current_material = surf["glass"] - - print(" ]") - print(")") - print() - - print("=" * 70) - print("NEXT STEPS") - print("=" * 70) - print() - print("To use this in OptiVerse:") - print(" 1. Implement Zemax import in Component Editor") - print(" 2. Parse ZMX file using zemax_parser.py") - print(" 3. Convert to InterfaceDefinition objects") - print(" 4. Create ComponentRecord with interfaces_v2") - print(" 5. Save to library or use directly in raytracing") + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zemax_data) + print(f"Name: {component.name}") + print(f"Object height: {component.object_height_mm:.2f} mm (full diameter)") + print(f"Interfaces: {len(component.interfaces or [])}") print() + for idx, iface in enumerate(component.interfaces or [], start=1): + radius = ( + f"R={iface.radius_of_curvature_mm:+.2f} mm" if iface.is_curved else "flat" + ) + print( + f" [{idx}] {iface.name}\n" + f" pos=({iface.x1_mm:.2f}, {iface.y1_mm:.2f}) → " + f"({iface.x2_mm:.2f}, {iface.y2_mm:.2f}) {radius}" + ) + if component.notes: + print() + print("Notes:") + for line in component.notes.splitlines(): + print(f" {line}") + return 0 + + +def main() -> int: + if len(sys.argv) < 2: + print("Usage: python zemax_parse_simple.py ", file=sys.stderr) + return 2 + return _print_summary(sys.argv[1]) if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/src/optiverse/services/zemax_converter.py b/src/optiverse/services/zemax_converter.py index 3069fc10..aacce277 100644 --- a/src/optiverse/services/zemax_converter.py +++ b/src/optiverse/services/zemax_converter.py @@ -21,10 +21,17 @@ class ZemaxToInterfaceConverter: Convert Zemax surfaces to InterfaceDefinition objects. Mapping strategy: - - Each Zemax surface → refractive_interface - - Sequential DISZ → x-position in mm + - Each refracting Zemax surface → ``refractive_interface`` + - Surface with ``GLAS MIRROR`` → ``mirror`` element, and the optical-axis + direction flips after it (a folded system unfolds into a single line + in the editor with positions reflected across the mirror). + - Sequential DISZ → x-position in mm (direction-aware) - GLAS materials → n1, n2 refractive indices - - DIAM → interface line length + - DIAM (semi-diameter) → interface half-extent in y + - The first surface (``SURF 0``) is the object; the last surface is the + image plane — neither emits an interface. + - Dummy / aperture-stop surfaces that don't refract (flat AND same + material on both sides) are skipped to avoid cluttering the import. Example usage: catalog = GlassCatalog() @@ -33,6 +40,10 @@ class ZemaxToInterfaceConverter: # component.interfaces now contains all optical interfaces """ + # If |n1 - n2| is below this, a flat surface is treated as a dummy + # (no optical effect) and skipped during conversion. + _DUMMY_INDEX_TOL = 1e-4 + def __init__(self, glass_catalog: GlassCatalog | None = None): """ Initialize converter. @@ -47,6 +58,14 @@ def convert(self, zemax_file: ZemaxFile) -> ComponentRecord: """ Convert Zemax file to ComponentRecord with interfaces. + The converter tracks an oriented 2D frame ``(pos_x, pos_y, angle_rad)`` + as it walks the sequential surface list. Each surface is placed at the + current ``pos``; its interface line is drawn perpendicular to the + current propagation direction. Thicknesses advance ``pos`` along the + current direction. ``COORDBRK`` surfaces translate / rotate the frame + (in-plane axes only — out-of-plane tilts are ignored), and mirrors + reverse the propagation direction. + Args: zemax_file: Parsed Zemax data @@ -54,37 +73,70 @@ def convert(self, zemax_file: ZemaxFile) -> ComponentRecord: ComponentRecord with interfaces populated """ interfaces: list[InterfaceDefinition] = [] - cumulative_x = 0.0 # Position along optical axis - current_material = "" # Start in air + # 2D frame: position (mm) of the current axis vertex, plus the + # propagation direction (radians, 0 = +x). + pos_x = 0.0 + pos_y = 0.0 + angle_rad = 0.0 + current_material = "" # Material on the n1 side of the next surface (air to start) + approximated_aspheres: list[int] = [] + + wavelength_um = zemax_file.primary_wavelength_um + last_index = len(zemax_file.surfaces) - 1 - # Process each surface (skip surface 0, which is object at infinity) for i, surf in enumerate(zemax_file.surfaces): if surf.number == 0: - # Object surface - skip + # Object surface — never emits an interface. continue - # Check if this is the image surface (last surface) - is_image_surface = i == len(zemax_file.surfaces) - 1 + is_image_surface = i == last_index - # Get next surface to determine material after this interface - next_material = surf.glass if surf.glass else "" + if is_image_surface: + # Image / sensor plane: don't emit an interface, and don't + # update materials. We do not advance past it either. + continue - if not is_image_surface: - # Get refractive indices - n1 = self._get_index(current_material, zemax_file.primary_wavelength_um) - n2 = self._get_index(next_material, zemax_file.primary_wavelength_um) + if surf.is_coordinate_break: + pos_x, pos_y, angle_rad = self._apply_coordinate_break( + surf, pos_x, pos_y, angle_rad + ) + # COORDBRK itself has no aperture; just advance along the + # (possibly rotated) axis by its DISZ thickness. + pos_x, pos_y = self._advance(pos_x, pos_y, angle_rad, surf.thickness) + continue - # Create interface - interface = self._create_interface( - surf, cumulative_x, n1, n2, zemax_file.primary_wavelength_um + if surf.is_aspheric: + # Aspheric coefficients aren't represented in the 2D editor; + # the renderer will treat the surface as a sphere at its + # base radius. Log + annotate so the approximation is visible. + approximated_aspheres.append(surf.number) + + if surf.is_mirror: + interfaces.append( + self._create_mirror_interface(surf, pos_x, pos_y, angle_rad) ) - interfaces.append(interface) + pos_x, pos_y = self._advance(pos_x, pos_y, angle_rad, surf.thickness) + # Light reverses along the optical axis after a planar reflection. + angle_rad += math.pi + continue - # Advance position for next surface - if not math.isinf(surf.thickness): - cumulative_x += surf.thickness + next_material = surf.glass if surf.glass else "" + n1 = self._get_index(current_material, wavelength_um) + n2 = self._get_index(next_material, wavelength_um) + + # Skip dummy / aperture-stop surfaces that don't change the + # medium and have no curvature — they have no optical effect + # and only clutter the imported component. + if surf.is_flat and abs(n1 - n2) < self._DUMMY_INDEX_TOL: + pass + else: + interfaces.append( + self._create_refractive_interface( + surf, pos_x, pos_y, angle_rad, n1, n2 + ) + ) - # Update current material for next iteration + pos_x, pos_y = self._advance(pos_x, pos_y, angle_rad, surf.thickness) current_material = next_material # Determine component properties @@ -98,6 +150,17 @@ def convert(self, zemax_file: ZemaxFile) -> ComponentRecord: "Imported from Zemax", f"Primary wavelength: {zemax_file.primary_wavelength_um * 1000:.1f} nm", ] + if approximated_aspheres: + ids = ", ".join(f"S{n}" for n in approximated_aspheres) + warning = ( + f"Note: aspheric surface(s) {ids} approximated as spheres at " + "their base radius — aspheric coefficients are not represented." + ) + notes_lines.append(warning) + try: + get_log_service().warning(warning, "Zemax") + except Exception: # pragma: no cover — log service absent in headless tests + _logger.warning(warning) if zemax_file.notes: # Include first note (often contains part number/description) notes_lines.append(zemax_file.notes[0][:200]) # Limit length @@ -107,81 +170,187 @@ def convert(self, zemax_file: ZemaxFile) -> ComponentRecord: name=name, interfaces=interfaces, object_height_mm=diameter_mm, notes=notes ) - def _create_interface( - self, surf: ZemaxSurface, x_pos: float, n1: float, n2: float, wavelength_um: float + @staticmethod + def _advance( + pos_x: float, pos_y: float, angle_rad: float, thickness: float + ) -> tuple[float, float]: + """Move ``pos`` forward along ``angle_rad`` by ``thickness`` mm. + + Infinite thicknesses (Zemax ``DISZ INFINITY``, used on the object + surface) leave the position unchanged. + """ + if math.isinf(thickness): + return pos_x, pos_y + return ( + pos_x + thickness * math.cos(angle_rad), + pos_y + thickness * math.sin(angle_rad), + ) + + @staticmethod + def _apply_coordinate_break( + surf: ZemaxSurface, pos_x: float, pos_y: float, angle_rad: float + ) -> tuple[float, float, float]: + """Apply a Zemax COORDBRK surface's in-plane decenter and tilt. + + Maps Zemax PARM 1 (x-decenter) to a shift perpendicular to the + current propagation direction, and PARM 4 (tilt about y) to a + rotation in the cross-section plane. Out-of-plane PARMs (2 / 3 / 5) + are silently dropped — they can't be represented in a 2D editor. + Uses the default Zemax order: decenter, then tilt. + """ + decenter_x = surf.parm.get(1, 0.0) + tilt_y_deg = surf.parm.get(4, 0.0) + + # Decenter is "x" in the Zemax pre-tilt frame, which maps to the + # direction perpendicular to current propagation (i.e. across the + # optical axis, in the cross-section plane). + if decenter_x: + perp_x = -math.sin(angle_rad) + perp_y = math.cos(angle_rad) + pos_x += decenter_x * perp_x + pos_y += decenter_x * perp_y + + angle_rad += math.radians(tilt_y_deg) + return pos_x, pos_y, angle_rad + + def _create_refractive_interface( + self, + surf: ZemaxSurface, + pos_x: float, + pos_y: float, + angle_rad: float, + n1: float, + n2: float, ) -> InterfaceDefinition: """ - Create InterfaceDefinition from Zemax surface. + Build a refractive InterfaceDefinition from a Zemax surface. - Handles both flat and curved surfaces. For curved surfaces: - - Zemax 3D (rotationally symmetric) → OptiVerse 2D (cross-section) - - Radius of curvature preserved - - Surface endpoints show aperture extent + The interface is a line segment perpendicular to the current + propagation direction. Curvature, if any, is conveyed via + ``is_curved`` / ``radius_of_curvature_mm`` — the renderer derives + sag from those, working from the chord normal so rotated surfaces + bulge in the right direction automatically. Args: - surf: Zemax surface data - x_pos: Position along optical axis (mm) - n1: Refractive index before interface - n2: Refractive index after interface - wavelength_um: Wavelength for index calculation - - Returns: - InterfaceDefinition object with curved surface properties + surf: Zemax surface data. + pos_x, pos_y: Position of the surface vertex (mm). + angle_rad: Current propagation direction (radians; 0 = +x). + n1: Refractive index on the incident side. + n2: Refractive index on the transmitted side. """ - # Use diameter if available, otherwise use entrance pupil or default - diameter = surf.diameter if surf.diameter > 0 else 25.4 - half_diameter = diameter / 2.0 + x1, y1, x2, y2 = self._line_endpoints(surf, pos_x, pos_y, angle_rad) + is_curved = not surf.is_flat + radius_mm = surf.radius_mm if is_curved else 0.0 - # Determine if surface is curved + return InterfaceDefinition( + x1_mm=x1, + y1_mm=y1, + x2_mm=x2, + y2_mm=y2, + element_type="refractive_interface", + name=self._refractive_name(surf, n1, n2, is_curved, radius_mm), + n1=n1, + n2=n2, + is_curved=is_curved, + radius_of_curvature_mm=radius_mm, + ) + + def _create_mirror_interface( + self, + surf: ZemaxSurface, + pos_x: float, + pos_y: float, + angle_rad: float, + ) -> InterfaceDefinition: + """Build a mirror InterfaceDefinition from a Zemax mirror surface.""" + x1, y1, x2, y2 = self._line_endpoints(surf, pos_x, pos_y, angle_rad) is_curved = not surf.is_flat - radius_mm = surf.radius_mm if not surf.is_flat else 0.0 - - # Calculate surface sag for curved surfaces (3D→2D projection) - # The sag is how much the curved surface deviates from flat at the edge - sag = 0.0 - if is_curved and abs(radius_mm) > 1e-6: - # Sag formula: s = R - sqrt(R² - h²) - # where h is the semi-diameter (half_diameter) - R_abs = abs(radius_mm) - h_sq = half_diameter**2 - if h_sq < R_abs**2: - sag = R_abs - math.sqrt(R_abs**2 - h_sq) - if radius_mm < 0: - sag = -sag # Concave from left - - # Position of interface center (vertex) - # For 2D cross-section, we show the interface as a line - # from -half_diameter to +half_diameter - # The x-position represents the vertex (center point) of the curved surface - - # Generate descriptive name - material_before = self._material_name(n1) - material_after = self._material_name(n2) + radius_mm = surf.radius_mm if is_curved else 0.0 - curvature_str = "" + descriptive = f"S{surf.number}: Mirror" if is_curved: - if radius_mm > 0: - curvature_str = f" [R=+{radius_mm:.1f}mm]" - else: - curvature_str = f" [R={radius_mm:.1f}mm]" - - name = f"S{surf.number}: {material_before} → {material_after}{curvature_str}" - if surf.comment: - name = f"{surf.comment} (S{surf.number})" + sign = "+" if radius_mm > 0 else "" + descriptive = f"S{surf.number}: Mirror [R={sign}{radius_mm:.1f}mm]" + if surf.is_aspheric: + descriptive = f"{descriptive} (Asphere approx.)" + if surf.is_stop: + descriptive = f"{descriptive} (Aperture Stop)" + name = f"{surf.comment} | {descriptive}" if surf.comment else descriptive return InterfaceDefinition( - x1_mm=x_pos, - y1_mm=-half_diameter, - x2_mm=x_pos, - y2_mm=half_diameter, - element_type="refractive_interface", + x1_mm=x1, + y1_mm=y1, + x2_mm=x2, + y2_mm=y2, + element_type="mirror", name=name, - n1=n1, - n2=n2, + reflectivity=100.0, is_curved=is_curved, radius_of_curvature_mm=radius_mm, ) + def _line_endpoints( + self, + surf: ZemaxSurface, + pos_x: float, + pos_y: float, + angle_rad: float, + ) -> tuple[float, float, float, float]: + """Endpoints of the interface line at ``pos``, perpendicular to ``angle_rad``. + + Returns ``(x1, y1, x2, y2)``. For axis-aligned propagation (``angle_rad + == 0``) this matches the previous axis-aligned convention: + ``x1 == x2 == pos_x``, ``y1 == -half_d``, ``y2 == +half_d``. + """ + half_diameter = self._half_diameter_mm(surf) + perp_x = -math.sin(angle_rad) + perp_y = math.cos(angle_rad) + return ( + pos_x - half_diameter * perp_x, + pos_y - half_diameter * perp_y, + pos_x + half_diameter * perp_x, + pos_y + half_diameter * perp_y, + ) + + # Default semi-diameter when DIAM is omitted: 1/2" → a 1" full aperture. + _DEFAULT_SEMI_DIAMETER_MM = 12.7 + + def _half_diameter_mm(self, surf: ZemaxSurface) -> float: + """Semi-diameter (mm) for the interface line, with a sane default.""" + if surf.semi_diameter_mm > 0: + return surf.semi_diameter_mm + return self._DEFAULT_SEMI_DIAMETER_MM + + def _refractive_name( + self, + surf: ZemaxSurface, + n1: float, + n2: float, + is_curved: bool, + radius_mm: float, + ) -> str: + """Compose a descriptive name, prefixing any Zemax COMM verbatim.""" + material_before = self._material_name(n1) + material_after = self._material_name(n2) + + curvature_str = "" + if is_curved: + sign = "+" if radius_mm > 0 else "" + curvature_str = f" [R={sign}{radius_mm:.1f}mm]" + + descriptive = ( + f"S{surf.number}: {material_before} → {material_after}{curvature_str}" + ) + if surf.is_aspheric: + descriptive = f"{descriptive} (Asphere approx.)" + if surf.is_stop: + descriptive = f"{descriptive} (Aperture Stop)" + if surf.comment: + # Preserve Zemax COMM (often a part number) without dropping the + # auto-generated material/curvature info. + return f"{surf.comment} | {descriptive}" + return descriptive + def _get_index(self, material: str, wavelength_um: float) -> float: """ Get refractive index for material. @@ -229,19 +398,22 @@ def _material_name(self, n: float) -> str: def _get_max_diameter(self, zemax_file: ZemaxFile) -> float: """ - Get maximum diameter from all surfaces. + Get the maximum full-aperture diameter across all surfaces. + + Zemax DIAM records are semi-diameters, so the full diameter is + ``2 * semi_diameter_mm``. Args: zemax_file: Zemax data Returns: - Maximum diameter in mm (default 25.4mm if none found) + Maximum full diameter in mm (default 25.4mm if none found) """ - diameters = [s.diameter for s in zemax_file.surfaces if s.diameter > 0] - if diameters: - return max(diameters) + semi_diameters = [s.semi_diameter_mm for s in zemax_file.surfaces if s.semi_diameter_mm > 0] + if semi_diameters: + return 2.0 * max(semi_diameters) - # Fallback: use entrance pupil diameter + # Fallback: use entrance pupil diameter (already a full diameter) if zemax_file.entrance_pupil_diameter > 0: return zemax_file.entrance_pupil_diameter diff --git a/src/optiverse/services/zemax_parser.py b/src/optiverse/services/zemax_parser.py index d15ae0b4..c6499afc 100644 --- a/src/optiverse/services/zemax_parser.py +++ b/src/optiverse/services/zemax_parser.py @@ -4,8 +4,9 @@ Supports: - Sequential mode (MODE SEQ) - Standard surfaces -- Glass materials -- Coatings and diameters +- Glass materials (including ``MIRROR`` for reflective surfaces) +- Coatings and semi-diameters +- Lens units (``UNIT MM | CM | M | IN``) — all lengths returned in mm """ from __future__ import annotations @@ -15,20 +16,50 @@ _logger = logging.getLogger(__name__) +# Zemax PARM cards on a COORDBRK surface: +# PARM 1: x-decenter (length, in lens units) +# PARM 2: y-decenter (length, in lens units) +# PARM 3: tilt about x (degrees) +# PARM 4: tilt about y (degrees) +# PARM 5: tilt about z (degrees) +# PARM 6: order flag (0 = decenter-then-tilt, 1 = tilt-then-decenter) +# Only PARM 1 and PARM 2 are length quantities and require UNIT scaling. +_COORDBRK_LENGTH_PARMS = (1, 2) + +# Conversion factor from each supported Zemax lens-unit to millimetres. +# Lengths (DISZ, DIAM, ENPD) are *multiplied* by this, curvatures (CURV) are +# *divided* by it (since CURV is 1/length). +_UNIT_TO_MM: dict[str, float] = { + "MM": 1.0, + "CM": 10.0, + "M": 1000.0, + "IN": 25.4, + "INCH": 25.4, +} + @dataclass class ZemaxSurface: - """Parsed Zemax surface data.""" + """Parsed Zemax surface data. + + `semi_diameter_mm` reflects Zemax's DIAM card, which stores the surface + *semi*-diameter (half-aperture) in lens units. The full clear aperture + diameter of the surface is therefore ``2 * semi_diameter_mm``. + """ number: int type: str = "STANDARD" curvature: float = 0.0 # 1/mm thickness: float = 0.0 # mm to next surface glass: str = "" - diameter: float = 0.0 # mm + semi_diameter_mm: float = 0.0 # mm (DIAM card; semi-diameter, not full diameter) coating: str = "" comment: str = "" is_stop: bool = False + # PARM cards by index (1-based). Length-valued parms (1, 2 on COORDBRK) + # are already converted to millimetres at parse time; angle-valued parms + # (3, 4, 5 on COORDBRK) remain in degrees. + parm: dict[int, float] = field(default_factory=dict) @property def radius_mm(self) -> float: @@ -42,6 +73,26 @@ def is_flat(self) -> bool: """Check if surface is flat (infinite radius).""" return abs(self.curvature) < 1e-10 + @property + def is_mirror(self) -> bool: + """True if this surface is reflective (Zemax ``GLAS MIRROR``).""" + return self.glass.strip().upper() == "MIRROR" + + @property + def is_coordinate_break(self) -> bool: + """True if this surface is a Zemax coordinate break (TYPE COORDBRK).""" + return self.type.strip().upper() == "COORDBRK" + + @property + def is_aspheric(self) -> bool: + """True if this surface is an even or odd aspheric (TYPE EVENASPH / ODDASPHE). + + The parser does not capture aspheric coefficients; the converter + treats the surface as a sphere at its base radius and flags it. + """ + t = self.type.strip().upper() + return t in {"EVENASPH", "ODDASPHE", "ASPHERIC"} + @dataclass class ZemaxFile: @@ -107,6 +158,11 @@ def _parse_lines(self, lines: list[str]) -> ZemaxFile: """Parse lines from Zemax file.""" zemax = ZemaxFile() + # First pass: pick up the UNIT card so we can normalise lengths to mm + # as we parse surface blocks below. Zemax convention puts UNIT before + # any SURF, but scanning first is robust to other orderings. + length_scale_mm = self._extract_length_scale_mm(lines) + i = 0 while i < len(lines): line = lines[i].strip() @@ -131,9 +187,11 @@ def _parse_lines(self, lines: list[str]) -> ZemaxFile: zemax.notes.append(note_content) elif line.startswith("ENPD "): - # Entrance pupil diameter + # Entrance pupil diameter (full diameter, in file units → mm) try: - zemax.entrance_pupil_diameter = self._parse_float(line[5:]) + zemax.entrance_pupil_diameter = ( + self._parse_float(line[5:]) * length_scale_mm + ) except ValueError: pass @@ -157,7 +215,9 @@ def _parse_lines(self, lines: list[str]) -> ZemaxFile: elif line.startswith("SURF "): # Surface definition surf_num = int(line[5:].strip()) - i, surface = self._parse_surface_block(lines, i + 1, surf_num) + i, surface = self._parse_surface_block( + lines, i + 1, surf_num, length_scale_mm + ) zemax.surfaces.append(surface) continue # _parse_surface_block already advances i @@ -165,12 +225,40 @@ def _parse_lines(self, lines: list[str]) -> ZemaxFile: return zemax + def _extract_length_scale_mm(self, lines: list[str]) -> float: + """Scan for the UNIT card and return the mm-per-file-unit factor. + + Defaults to mm (1.0) when UNIT is absent or specifies an unknown unit. + """ + for raw in lines: + stripped = raw.strip() + if not stripped.startswith("UNIT "): + continue + parts = stripped[5:].split() + if not parts: + continue + unit = parts[0].upper() + scale = _UNIT_TO_MM.get(unit) + if scale is None: + _logger.warning("Unknown Zemax UNIT '%s'; assuming mm", unit) + return 1.0 + return scale + return 1.0 + def _parse_surface_block( - self, lines: list[str], start_idx: int, surf_num: int + self, + lines: list[str], + start_idx: int, + surf_num: int, + length_scale_mm: float = 1.0, ) -> tuple[int, ZemaxSurface]: """ Parse a SURF block. + ``length_scale_mm`` is the mm-per-file-unit factor from the UNIT card. + Lengths (DISZ, DIAM) are multiplied by it; curvatures (CURV) are + divided by it (since 1/file-unit → 1/mm requires dividing by mm/unit). + Returns: (next_line_index, ZemaxSurface) """ @@ -195,20 +283,20 @@ def _parse_surface_block( surface.type = line[5:].split()[0] elif line.startswith("CURV "): - # CURV ... + # CURV ... (file units of 1/length → 1/mm) parts = line[5:].split() if parts: - surface.curvature = self._parse_float(parts[0]) + surface.curvature = self._parse_float(parts[0]) / length_scale_mm elif line.startswith("DISZ "): - # DISZ + # DISZ (file units of length → mm) parts = line[5:].split() if parts: val_str = parts[0] if val_str.upper() == "INFINITY": surface.thickness = float("inf") else: - surface.thickness = self._parse_float(val_str) + surface.thickness = self._parse_float(val_str) * length_scale_mm elif line.startswith("GLAS "): # GLAS ... @@ -217,10 +305,10 @@ def _parse_surface_block( surface.glass = parts[0] elif line.startswith("DIAM "): - # DIAM ... + # DIAM ... (Zemax DIAM is a semi-diameter, in file units) parts = line[5:].split() if parts: - surface.diameter = self._parse_float(parts[0]) + surface.semi_diameter_mm = self._parse_float(parts[0]) * length_scale_mm elif line.startswith("COAT "): # COAT @@ -235,8 +323,29 @@ def _parse_surface_block( elif line.startswith("STOP"): surface.is_stop = True + elif line.startswith("PARM "): + # PARM ... + # Stored raw here; UNIT scaling for length-valued PARMs is + # applied below once TYPE is known (TYPE may follow PARM). + parts = line[5:].split() + if len(parts) >= 2: + try: + idx = int(parts[0]) + val = self._parse_float(parts[1]) + except ValueError: + pass + else: + surface.parm[idx] = val + i += 1 + # Apply UNIT scaling to length-valued PARMs (only meaningful for + # COORDBRK surfaces, where PARMs 1 and 2 are decenters). + if length_scale_mm != 1.0 and surface.is_coordinate_break: + for parm_idx in _COORDBRK_LENGTH_PARMS: + if parm_idx in surface.parm: + surface.parm[parm_idx] *= length_scale_mm + return i, surface def _parse_float(self, s: str) -> float: @@ -265,7 +374,7 @@ def format_summary(self, zemax: ZemaxFile) -> str: f" S{surf.number}: R={r_str}mm, " f"t={surf.thickness:.2f}mm, " f"mat={glass_str}, " - f"d={surf.diameter:.2f}mm" + f"semi_d={surf.semi_diameter_mm:.2f}mm" ) return "\n".join(lines) diff --git a/tests/fixtures/sample.zmx b/tests/fixtures/sample.zmx new file mode 100644 index 00000000..2f632000 --- /dev/null +++ b/tests/fixtures/sample.zmx @@ -0,0 +1,41 @@ +VERS 200000 0 0 +MODE SEQ +NAME AC254-100-B AC254-100-B NEAR IR ACHROMATS: Infinite Conjugate 100 +UNIT MM X W X CM MR CPMM +ENPD 25.4 +WAVL 0 1 0 +WAVM 1 0.5876 1 +WAVM 2 0.855 1 +WAVM 3 1.014 1 +PWAV 2 +NOTE 0 Synthetic AC254-100-B-shaped achromat prescription used as test fixture. +SURF 0 + TYPE STANDARD + CURV 0 0 0 0 0 0 0 + DISZ INFINITY +SURF 1 + TYPE STANDARD + COMM AC254-100-B + CURV 0.014997 0 0 0 0 0 0 + DISZ 4.0 + GLAS N-LAK22 0 0 1.65113 36.16 + COAT THORB + DIAM 12.7 0 0 0 1 "" +SURF 2 + TYPE STANDARD + CURV -0.018622 0 0 0 0 0 0 + DISZ 1.5 + GLAS N-SF6HT 0 0 1.80518 25.36 + DIAM 12.7 0 0 0 1 "" +SURF 3 + TYPE STANDARD + CURV -0.003854 0 0 0 0 0 0 + DISZ 97.09 + COAT THORBSLAH64 + DIAM 12.7 0 0 0 1 "" +SURF 4 + TYPE STANDARD + CURV 0 0 0 0 0 0 0 + DISZ 0 + DIAM 0.0052 0 0 0 1 "" +BLNK diff --git a/tests/services/test_zemax_converter.py b/tests/services/test_zemax_converter.py index 7b131502..34a0a489 100644 --- a/tests/services/test_zemax_converter.py +++ b/tests/services/test_zemax_converter.py @@ -37,24 +37,24 @@ def test_converter_basic(zemax_data, converter): assert component is not None assert component.name == "AC254-100-B AC254-100-B NEAR IR ACHROMATS: Infinite Conjugate 100" - assert component.kind == "multi_element" - assert component.interfaces_v2 is not None - assert len(component.interfaces_v2) == 3 # 3 optical interfaces (excluding object and image) - assert abs(component.object_height_mm - 12.7) < 0.1 + assert component.interfaces is not None + assert len(component.interfaces) == 3 # 3 optical interfaces (excluding object and image) + # 1" achromat: full aperture = 2 * semi_diameter = 25.4 mm + assert abs(component.object_height_mm - 25.4) < 0.1 def test_converter_interface1_entry(zemax_data, converter): """Test first interface (S1: Air → N-LAK22).""" component = converter.convert(zemax_data) - iface = component.interfaces_v2[0] + iface = component.interfaces[0] # Position assert abs(iface.x1_mm - 0.0) < 0.01 assert abs(iface.x2_mm - 0.0) < 0.01 - # Aperture (diameter = 12.7mm → half = 6.35mm) - assert abs(iface.y1_mm - (-6.35)) < 0.01 - assert abs(iface.y2_mm - 6.35) < 0.01 + # Aperture (Zemax DIAM = semi-diameter = 12.7 mm → interface spans ±12.7 mm) + assert abs(iface.y1_mm - (-12.7)) < 0.01 + assert abs(iface.y2_mm - 12.7) < 0.01 # Refractive indices assert abs(iface.n1 - 1.0) < 0.01 # Air @@ -71,15 +71,15 @@ def test_converter_interface1_entry(zemax_data, converter): def test_converter_interface2_cemented(zemax_data, converter): """Test second interface (S2: N-LAK22 → N-SF6HT).""" component = converter.convert(zemax_data) - iface = component.interfaces_v2[1] + iface = component.interfaces[1] # Position (0 + 4mm thickness of S1) assert abs(iface.x1_mm - 4.0) < 0.01 assert abs(iface.x2_mm - 4.0) < 0.01 - # Aperture - assert abs(iface.y1_mm - (-6.35)) < 0.01 - assert abs(iface.y2_mm - 6.35) < 0.01 + # Aperture (DIAM = semi-diameter) + assert abs(iface.y1_mm - (-12.7)) < 0.01 + assert abs(iface.y2_mm - 12.7) < 0.01 # Refractive indices assert abs(iface.n1 - 1.641) < 0.01 # N-LAK22 @@ -93,15 +93,15 @@ def test_converter_interface2_cemented(zemax_data, converter): def test_converter_interface3_exit(zemax_data, converter): """Test third interface (S3: N-SF6HT → Air).""" component = converter.convert(zemax_data) - iface = component.interfaces_v2[2] + iface = component.interfaces[2] # Position (4mm + 1.5mm thickness of S2) assert abs(iface.x1_mm - 5.5) < 0.01 assert abs(iface.x2_mm - 5.5) < 0.01 - # Aperture - assert abs(iface.y1_mm - (-6.35)) < 0.01 - assert abs(iface.y2_mm - 6.35) < 0.01 + # Aperture (DIAM = semi-diameter) + assert abs(iface.y1_mm - (-12.7)) < 0.01 + assert abs(iface.y2_mm - 12.7) < 0.01 # Refractive indices assert abs(iface.n1 - 1.781) < 0.01 # N-SF6HT @@ -121,9 +121,9 @@ def test_converter_cumulative_positions(zemax_data, converter): # S2: x=0+4mm = 4mm # S3: x=4+1.5mm = 5.5mm - assert abs(component.interfaces_v2[0].x1_mm - 0.0) < 0.01 - assert abs(component.interfaces_v2[1].x1_mm - 4.0) < 0.01 - assert abs(component.interfaces_v2[2].x1_mm - 5.5) < 0.01 + assert abs(component.interfaces[0].x1_mm - 0.0) < 0.01 + assert abs(component.interfaces[1].x1_mm - 4.0) < 0.01 + assert abs(component.interfaces[2].x1_mm - 5.5) < 0.01 def test_converter_refractive_index_progression(zemax_data, converter): @@ -135,15 +135,15 @@ def test_converter_refractive_index_progression(zemax_data, converter): # S3: N-SF6HT (1.781) → Air (1.0) # Check that n2 of interface i matches n1 of interface i+1 - assert abs(component.interfaces_v2[0].n2 - component.interfaces_v2[1].n1) < 0.01 - assert abs(component.interfaces_v2[1].n2 - component.interfaces_v2[2].n1) < 0.01 + assert abs(component.interfaces[0].n2 - component.interfaces[1].n1) < 0.01 + assert abs(component.interfaces[1].n2 - component.interfaces[2].n1) < 0.01 def test_converter_all_curved(zemax_data, converter): """Test that all interfaces are correctly identified as curved.""" component = converter.convert(zemax_data) - for iface in component.interfaces_v2: + for iface in component.interfaces: assert iface.is_curved assert abs(iface.radius_of_curvature_mm) > 1.0 # Not flat @@ -153,13 +153,13 @@ def test_converter_center_of_curvature(zemax_data, converter): component = converter.convert(zemax_data) # Interface 1: R=+66.68mm, center should be to the right - iface1 = component.interfaces_v2[0] + iface1 = component.interfaces[0] center_x, center_y = iface1.center_of_curvature_mm() assert center_x > iface1.x1_mm # Center is to the right assert abs(center_x - (iface1.x1_mm + 66.68)) < 0.1 # Interface 2: R=-53.70mm, center should be to the left - iface2 = component.interfaces_v2[1] + iface2 = component.interfaces[1] center_x, center_y = iface2.center_of_curvature_mm() assert center_x < iface2.x1_mm # Center is to the left assert abs(center_x - (iface2.x1_mm - 53.70)) < 0.1 @@ -170,7 +170,7 @@ def test_converter_surface_sag(zemax_data, converter): component = converter.convert(zemax_data) # Interface 1: R=+66.68mm, diameter=12.7mm - iface1 = component.interfaces_v2[0] + iface1 = component.interfaces[0] # At center (y=0), sag should be 0 assert abs(iface1.surface_sag_at_y(0.0)) < 0.01 diff --git a/tests/services/test_zemax_import.py b/tests/services/test_zemax_import.py index 0cf23069..ec513cc6 100644 --- a/tests/services/test_zemax_import.py +++ b/tests/services/test_zemax_import.py @@ -2,12 +2,21 @@ Tests for Zemax file import functionality. """ +import math +import textwrap + import pytest from optiverse.core.interface_definition import InterfaceDefinition from optiverse.services.glass_catalog import GlassCatalog from optiverse.services.zemax_converter import ZemaxToInterfaceConverter -from optiverse.services.zemax_parser import ZemaxFile, ZemaxSurface +from optiverse.services.zemax_parser import ZemaxFile, ZemaxParser, ZemaxSurface + + +def _parse_zmx_text(text: str) -> ZemaxFile: + """Helper: feed an in-memory .zmx string through ZemaxParser.""" + parser = ZemaxParser() + return parser._parse_lines(textwrap.dedent(text).splitlines(keepends=True)) class TestZemaxParser: @@ -15,14 +24,16 @@ class TestZemaxParser: def test_zemax_surface_creation(self): """Test creating a Zemax surface.""" - surf = ZemaxSurface(number=1, curvature=0.015, thickness=4.0, glass="N-BK7", diameter=12.7) + surf = ZemaxSurface( + number=1, curvature=0.015, thickness=4.0, glass="N-BK7", semi_diameter_mm=12.7 + ) assert surf.number == 1 assert surf.curvature == 0.015 assert abs(surf.radius_mm - 66.67) < 0.1 # 1/0.015 assert surf.thickness == 4.0 assert surf.glass == "N-BK7" - assert surf.diameter == 12.7 + assert surf.semi_diameter_mm == 12.7 assert not surf.is_flat def test_flat_surface(self): @@ -177,12 +188,19 @@ def test_integration_example(): # Create a simple Zemax file programmatically zmx = ZemaxFile(name="Test Doublet", wavelengths_um=[0.5876], primary_wavelength_idx=1) - # Add surfaces + # Add surfaces — DIAM in Zemax is the *semi*-diameter, so a 1" achromat + # has semi_diameter_mm = 12.7 (full aperture = 25.4 mm). zmx.surfaces = [ ZemaxSurface(number=0), # Object - ZemaxSurface(number=1, curvature=0.015, thickness=4.0, glass="N-BK7", diameter=12.7), - ZemaxSurface(number=2, curvature=-0.02, thickness=1.5, glass="N-SF11", diameter=12.7), - ZemaxSurface(number=3, curvature=-0.004, thickness=100.0, glass="", diameter=12.7), + ZemaxSurface( + number=1, curvature=0.015, thickness=4.0, glass="N-BK7", semi_diameter_mm=12.7 + ), + ZemaxSurface( + number=2, curvature=-0.02, thickness=1.5, glass="N-SF11", semi_diameter_mm=12.7 + ), + ZemaxSurface( + number=3, curvature=-0.004, thickness=100.0, glass="", semi_diameter_mm=12.7 + ), ZemaxSurface(number=4), # Image ] @@ -193,7 +211,8 @@ def test_integration_example(): # Verify assert component.name == "Test Doublet" - assert component.object_height_mm == 12.7 + # 1" achromat should import as 25.4 mm tall, not 12.7 mm. + assert component.object_height_mm == 25.4 assert len(component.interfaces) == 3 # Check first interface @@ -202,6 +221,584 @@ def test_integration_example(): assert 1.51 < iface1.n2 < 1.52 # BK7 assert iface1.is_curved assert iface1.radius_of_curvature_mm > 0 # Convex + # Interface should span the full clear aperture: y from -12.7 to +12.7 mm. + assert iface1.y1_mm == pytest.approx(-12.7) + assert iface1.y2_mm == pytest.approx(12.7) + assert iface1.length_mm() == pytest.approx(25.4) + + +class TestUnitParsing: + """UNIT card scales all lengths so downstream code sees mm.""" + + def test_default_unit_is_mm(self): + zmx = _parse_zmx_text( + """\ + MODE SEQ + SURF 0 + TYPE STANDARD + CURV 0 + DISZ INFINITY + SURF 1 + TYPE STANDARD + CURV 0.01 + DISZ 5.0 + DIAM 12.7 + """ + ) + s1 = zmx.surfaces[1] + assert s1.curvature == pytest.approx(0.01) # 1/mm + assert s1.thickness == pytest.approx(5.0) + assert s1.semi_diameter_mm == pytest.approx(12.7) + + def test_unit_cm_scales_lengths(self): + zmx = _parse_zmx_text( + """\ + MODE SEQ + UNIT CM X W X CM MR CPMM + SURF 0 + TYPE STANDARD + DISZ INFINITY + SURF 1 + TYPE STANDARD + CURV 0.1 + DISZ 0.5 + DIAM 1.27 + """ + ) + s1 = zmx.surfaces[1] + # 1 cm = 10 mm: lengths × 10, curvatures / 10 + assert s1.curvature == pytest.approx(0.01) # 0.1/cm = 0.01/mm + assert s1.thickness == pytest.approx(5.0) # 0.5 cm = 5 mm + assert s1.semi_diameter_mm == pytest.approx(12.7) # 1.27 cm = 12.7 mm + + def test_unit_inches_converts_to_mm(self): + zmx = _parse_zmx_text( + """\ + MODE SEQ + UNIT IN X W X CM MR CPMM + ENPD 1.0 + SURF 0 + TYPE STANDARD + DISZ INFINITY + SURF 1 + TYPE STANDARD + CURV 0.0254 + DISZ 0.157480315 + DIAM 0.5 + """ + ) + s1 = zmx.surfaces[1] + # 1 in = 25.4 mm + assert s1.curvature == pytest.approx(0.001, rel=1e-3) # 0.0254/in → 0.001/mm + assert s1.thickness == pytest.approx(4.0, rel=1e-3) # 0.1575 in → 4.0 mm + assert s1.semi_diameter_mm == pytest.approx(12.7) # 0.5 in semi-d → 12.7 mm + assert zmx.entrance_pupil_diameter == pytest.approx(25.4) # 1 in → 25.4 mm + + def test_unknown_unit_falls_back_to_mm(self): + zmx = _parse_zmx_text( + """\ + MODE SEQ + UNIT BOGUS X + SURF 0 + DISZ INFINITY + SURF 1 + CURV 0.01 + DISZ 3.0 + DIAM 5.0 + """ + ) + s1 = zmx.surfaces[1] + assert s1.thickness == pytest.approx(3.0) + assert s1.semi_diameter_mm == pytest.approx(5.0) + + def test_inch_units_end_to_end_through_converter(self): + """An inch-unit prescription imports at the right physical size.""" + zmx = _parse_zmx_text( + """\ + MODE SEQ + UNIT IN X W X CM MR CPMM + WAVM 1 0.5876 1 + PWAV 1 + SURF 0 + TYPE STANDARD + DISZ INFINITY + SURF 1 + TYPE STANDARD + CURV 0.005905512 + DISZ 0.157480315 + GLAS N-BK7 + DIAM 0.5 + SURF 2 + TYPE STANDARD + CURV -0.005905512 + DISZ 4.0 + DIAM 0.5 + SURF 3 + TYPE STANDARD + CURV 0 + DISZ 0 + DIAM 0.0001 + """ + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + # 1" lens (semi_d = 0.5 in = 12.7 mm) → full aperture 25.4 mm + assert component.object_height_mm == pytest.approx(25.4, abs=0.01) + # 4 mm centre thickness preserved + assert len(component.interfaces) == 2 + assert component.interfaces[1].x1_mm == pytest.approx(4.0, abs=0.01) + + +class TestMirrorHandling: + """``GLAS MIRROR`` produces a mirror element and flips axis direction.""" + + def _fold_mirror_zmx(self) -> ZemaxFile: + # Lens at x=0, fold mirror at x=20 mm, then 30 mm back to detector. + # After the mirror the propagation direction flips, so the detector + # plane lands at x = 20 - 30 = -10 mm in unfolded coordinates. + return _parse_zmx_text( + """\ + MODE SEQ + UNIT MM X W X CM MR CPMM + WAVM 1 0.5876 1 + PWAV 1 + SURF 0 + TYPE STANDARD + DISZ INFINITY + SURF 1 + TYPE STANDARD + CURV 0.01 + DISZ 5.0 + GLAS N-BK7 + DIAM 12.7 + SURF 2 + TYPE STANDARD + CURV 0 + DISZ 15.0 + DIAM 12.7 + SURF 3 + TYPE STANDARD + CURV 0 + DISZ 30.0 + GLAS MIRROR + DIAM 25.0 + SURF 4 + TYPE STANDARD + CURV 0 + DISZ 0 + DIAM 0.0001 + """ + ) + + def test_mirror_glass_becomes_mirror_element(self): + zmx = self._fold_mirror_zmx() + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + mirror_ifaces = [i for i in component.interfaces if i.element_type == "mirror"] + assert len(mirror_ifaces) == 1 + assert mirror_ifaces[0].reflectivity == pytest.approx(100.0) + + def test_mirror_flips_subsequent_position_direction(self): + zmx = self._fold_mirror_zmx() + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + # S1 at x=0, S2 at x=5 (S1 thickness), mirror S3 at x=5+15=20, then + # direction flips. No surfaces after the mirror emit interfaces (S4 + # is the image), so we only check the mirror position itself. + mirror = next(i for i in component.interfaces if i.element_type == "mirror") + assert mirror.x1_mm == pytest.approx(20.0) + + def test_mirror_with_curvature_is_curved(self): + zmx = _parse_zmx_text( + """\ + MODE SEQ + WAVM 1 0.5876 1 + PWAV 1 + SURF 0 + DISZ INFINITY + SURF 1 + CURV 0.005 + DISZ 100.0 + GLAS MIRROR + DIAM 25.0 + SURF 2 + DISZ 0 + DIAM 0.001 + """ + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + mirror = component.interfaces[0] + assert mirror.element_type == "mirror" + assert mirror.is_curved + assert mirror.radius_of_curvature_mm == pytest.approx(200.0) # 1 / 0.005 + + +class TestSurfaceFiltering: + """Image / dummy / stop surfaces shouldn't produce ghost interfaces.""" + + def test_image_surface_omitted(self): + zmx = ZemaxFile( + name="x", + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface(number=1, curvature=0.01, glass="N-BK7", semi_diameter_mm=12.7), + ZemaxSurface(number=2, curvature=-0.01, glass="", semi_diameter_mm=12.7), + ZemaxSurface(number=3, curvature=0.0, semi_diameter_mm=0.001), # image + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + assert len(component.interfaces) == 2 + + def test_dummy_flat_air_to_air_surface_filtered(self): + zmx = ZemaxFile( + name="x", + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface(number=1, curvature=0.01, glass="N-BK7", semi_diameter_mm=12.7), + ZemaxSurface(number=2, curvature=-0.01, glass="", semi_diameter_mm=12.7), + # Dummy: flat, air → air → should be skipped + ZemaxSurface(number=3, curvature=0.0, glass="", semi_diameter_mm=12.7), + ZemaxSurface(number=4, curvature=0.0, semi_diameter_mm=0.001), # image + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + assert len(component.interfaces) == 2 + + def test_flat_refractive_surface_is_kept(self): + """A flat surface with a real material change is still an interface.""" + zmx = ZemaxFile( + name="x", + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + # Flat air → glass (e.g. front face of a window) + ZemaxSurface(number=1, curvature=0.0, glass="N-BK7", semi_diameter_mm=12.7), + # Flat glass → air (back face) + ZemaxSurface(number=2, curvature=0.0, glass="", semi_diameter_mm=12.7), + ZemaxSurface(number=3, curvature=0.0, semi_diameter_mm=0.001), # image + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + assert len(component.interfaces) == 2 + # Both flat refractive interfaces should be preserved + for iface in component.interfaces: + assert iface.element_type == "refractive_interface" + assert not iface.is_curved + + +class TestNamePreservation: + """Zemax COMM is preserved without dropping descriptive info.""" + + def test_comm_prefixes_descriptive_name(self): + zmx = ZemaxFile( + name="x", + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface( + number=1, + curvature=0.015, + glass="N-BK7", + semi_diameter_mm=12.7, + comment="AC254-100-B", + ), + ZemaxSurface(number=2, curvature=0.0, semi_diameter_mm=0.001), # image + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + name = component.interfaces[0].name + # COMM survives, but so does the auto-generated material/curvature info + assert "AC254-100-B" in name + assert "Air" in name # material before + assert "BK7" in name or "n=" in name # material after + assert "R=" in name # curvature info + + def test_no_comm_uses_descriptive_name(self): + zmx = ZemaxFile( + name="x", + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface( + number=1, curvature=0.015, glass="N-BK7", semi_diameter_mm=12.7 + ), + ZemaxSurface(number=2, curvature=0.0, semi_diameter_mm=0.001), + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + name = component.interfaces[0].name + assert name.startswith("S1:") + assert "Air" in name + + +class TestCoordinateBreak: + """COORDBRK PARMs rotate / decenter the optical axis in-plane.""" + + def test_parm_parsing_and_unit_scaling(self): + """PARM 1 / PARM 2 on COORDBRK are length-valued and scaled by UNIT.""" + zmx = _parse_zmx_text( + """\ + MODE SEQ + UNIT CM X W X CM MR CPMM + SURF 0 + DISZ INFINITY + SURF 1 + TYPE COORDBRK + PARM 1 0.5 + PARM 2 0.3 + PARM 3 5.0 + PARM 4 10.0 + PARM 5 0.0 + PARM 6 0 + DISZ 1.0 + """ + ) + cb = zmx.surfaces[1] + assert cb.is_coordinate_break + # 0.5 cm → 5 mm, 0.3 cm → 3 mm + assert cb.parm[1] == pytest.approx(5.0) + assert cb.parm[2] == pytest.approx(3.0) + # Tilts (degrees) are NOT scaled by UNIT. + assert cb.parm[3] == pytest.approx(5.0) + assert cb.parm[4] == pytest.approx(10.0) + # DISZ on a coord break is a length: scaled. + assert cb.thickness == pytest.approx(10.0) + + def test_coordbrk_does_not_emit_an_interface(self): + """A coord break shouldn't appear as an interface in the output.""" + zmx = ZemaxFile( + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface(number=1, curvature=0.01, glass="N-BK7", semi_diameter_mm=12.7), + ZemaxSurface( + number=2, type="COORDBRK", thickness=10.0, parm={4: 30.0} + ), + ZemaxSurface( + number=3, curvature=-0.01, semi_diameter_mm=12.7 + ), + ZemaxSurface(number=4, semi_diameter_mm=0.001), # image + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + # 2 refractive interfaces — the COORDBRK is consumed silently. + assert len(component.interfaces) == 2 + + def test_tilt_rotates_subsequent_surface_orientation(self): + """A 30° tilt about y rotates the next interface line in-plane.""" + zmx = ZemaxFile( + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + # First surface at origin, axis-aligned. + ZemaxSurface(number=1, curvature=0.01, glass="N-BK7", semi_diameter_mm=10.0), + # Coord break: no decenter, +30° tilt, advance 5 mm along new axis. + ZemaxSurface( + number=2, type="COORDBRK", thickness=5.0, parm={4: 30.0} + ), + ZemaxSurface( + number=3, curvature=-0.01, semi_diameter_mm=10.0 + ), + ZemaxSurface(number=4, semi_diameter_mm=0.001), + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + first, second = component.interfaces[0], component.interfaces[1] + # First surface is axis-aligned: vertical line at x=0 + assert first.x1_mm == pytest.approx(0.0) + assert first.x2_mm == pytest.approx(0.0) + # Second is rotated 30°: its line is perpendicular to the +30° axis. + assert second.angle_deg() == pytest.approx(120.0, abs=0.01) # 90° + 30° + # And its vertex sits ~5 mm down the rotated axis from the origin. + assert second.midpoint_mm()[0] == pytest.approx(5.0 * math.cos(math.radians(30))) + assert second.midpoint_mm()[1] == pytest.approx(5.0 * math.sin(math.radians(30))) + + def test_decenter_shifts_perpendicular_to_axis(self): + """PARM 1 decenter shifts vertex perpendicular to current direction.""" + zmx = ZemaxFile( + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + # Decenter +5 mm in +y (axis is +x at this point). + ZemaxSurface( + number=1, type="COORDBRK", thickness=0.0, parm={1: 5.0} + ), + ZemaxSurface(number=2, curvature=0.0, glass="N-BK7", semi_diameter_mm=10.0), + ZemaxSurface(number=3, semi_diameter_mm=0.001), + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + iface = component.interfaces[0] + # Vertex shifted by +5 mm in y, line still axis-aligned vertical. + assert iface.midpoint_mm() == pytest.approx((0.0, 5.0)) + + +class TestPeriscope: + """A two-mirror periscope ends with the axis parallel-offset to entry.""" + + def test_periscope_returns_to_parallel_axis(self): + """Two 45° fold mirrors should leave the optical axis parallel to entry.""" + # Walk a periscope geometry by hand: + # S1 entry window (air → BK7), then BK7 → air on S2 so the rest of + # the system propagates in air. After this point, only mirrors + # change the axis direction. + # S3..S5: first fold (tilt +45°, mirror, tilt +45°) — axis now -y. + # S6..S8: second fold (tilt -45°, mirror, tilt -45°) — axis +x again. + # S9 exit window (air → BK7), S10 BK7 → air — both flat refractives + # that survive the dummy filter so we can measure exit orientation. + zmx = ZemaxFile( + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface(number=1, curvature=0.0, glass="N-BK7", semi_diameter_mm=10.0), + ZemaxSurface(number=2, curvature=0.0, glass="", semi_diameter_mm=10.0), + ZemaxSurface(number=3, type="COORDBRK", thickness=0.0, parm={4: 45.0}), + ZemaxSurface(number=4, curvature=0.0, glass="MIRROR", semi_diameter_mm=15.0), + ZemaxSurface(number=5, type="COORDBRK", thickness=20.0, parm={4: 45.0}), + ZemaxSurface(number=6, type="COORDBRK", thickness=0.0, parm={4: -45.0}), + ZemaxSurface(number=7, curvature=0.0, glass="MIRROR", semi_diameter_mm=15.0), + ZemaxSurface(number=8, type="COORDBRK", thickness=10.0, parm={4: -45.0}), + ZemaxSurface(number=9, curvature=0.0, glass="N-BK7", semi_diameter_mm=10.0), + ZemaxSurface(number=10, curvature=0.0, glass="", semi_diameter_mm=10.0), + ZemaxSurface(number=11, semi_diameter_mm=0.001), # image + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + + # 4 refractives (S1, S2, S9, S10) + 2 mirrors (S4, S7) = 6. + assert len(component.interfaces) == 6 + entry = component.interfaces[0] + exit_ = component.interfaces[-1] + + # Entry and exit lines must be parallel (axis-aligned, line angle ≡ 90°). + # Compare modulo 180° because angle_deg can be ±90° depending on + # which endpoint is y1. + delta = (exit_.angle_deg() - entry.angle_deg()) % 180 + assert delta == pytest.approx(0, abs=0.5) or delta == pytest.approx( + 180, abs=0.5 + ) + + # The exit axis is offset in y from the entry — that's the whole point + # of a periscope. + assert abs(exit_.midpoint_mm()[1] - entry.midpoint_mm()[1]) > 1.0 + + # And the two mirrors should be oriented at ±45° to the entry axis + # (their interface lines are perpendicular to a 45°-tilted optical + # axis, so the line angle is 90° + 45° = 135°, modulo 180°). + mirrors = [i for i in component.interfaces if i.element_type == "mirror"] + assert len(mirrors) == 2 + for m in mirrors: + assert m.angle_deg() % 180 == pytest.approx(135.0, abs=0.5) + + +class TestAsphereAnnotation: + """EVENASPH surfaces are approximated as spheres and annotated.""" + + def test_even_asphere_imports_with_base_radius(self): + zmx = ZemaxFile( + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface( + number=1, + type="EVENASPH", + curvature=0.01, # base R = 100 mm + glass="N-BK7", + semi_diameter_mm=12.7, + parm={1: 0.0, 2: 0.001, 3: 1e-5}, # conic + aspheric coeffs + ), + ZemaxSurface(number=2, semi_diameter_mm=0.001), + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + iface = component.interfaces[0] + assert iface.is_curved + assert iface.radius_of_curvature_mm == pytest.approx(100.0) + assert "Asphere approx." in iface.name + + def test_asphere_note_added_to_component(self): + zmx = ZemaxFile( + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface( + number=1, + type="EVENASPH", + curvature=0.01, + glass="N-BK7", + semi_diameter_mm=12.7, + ), + ZemaxSurface(number=2, semi_diameter_mm=0.001), + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + assert "aspheric surface" in component.notes.lower() + assert "S1" in component.notes + + +class TestStopAnnotation: + """Aperture stops are annotated in the interface name when kept.""" + + def test_stop_surface_with_material_change_is_annotated(self): + zmx = ZemaxFile( + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface( + number=1, + curvature=0.01, + glass="N-BK7", + semi_diameter_mm=12.7, + is_stop=True, + ), + ZemaxSurface(number=2, semi_diameter_mm=0.001), + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + assert "Aperture Stop" in component.interfaces[0].name + + def test_dummy_stop_surface_still_filtered(self): + """A flat air-to-air stop has no optical effect and is still skipped.""" + zmx = ZemaxFile( + wavelengths_um=[0.5876], + primary_wavelength_idx=1, + surfaces=[ + ZemaxSurface(number=0), + ZemaxSurface(number=1, curvature=0.01, glass="N-BK7", semi_diameter_mm=12.7), + ZemaxSurface(number=2, curvature=-0.01, glass="", semi_diameter_mm=12.7), + # Aperture stop after the lens (air → air, flat) — filtered. + ZemaxSurface( + number=3, curvature=0.0, glass="", semi_diameter_mm=10.0, is_stop=True + ), + ZemaxSurface(number=4, semi_diameter_mm=0.001), + ], + ) + component = ZemaxToInterfaceConverter(GlassCatalog()).convert(zmx) + assert len(component.interfaces) == 2 + + +class TestMirrorFlag: + """ZemaxSurface.is_mirror returns True only for GLAS MIRROR.""" + + def test_is_mirror_true_for_mirror_glass(self): + assert ZemaxSurface(number=1, glass="MIRROR").is_mirror + assert ZemaxSurface(number=1, glass="mirror").is_mirror + assert ZemaxSurface(number=1, glass=" Mirror ").is_mirror + + def test_is_mirror_false_for_other_glass(self): + assert not ZemaxSurface(number=1, glass="N-BK7").is_mirror + assert not ZemaxSurface(number=1, glass="").is_mirror if __name__ == "__main__": diff --git a/tests/services/test_zemax_parser.py b/tests/services/test_zemax_parser.py index 94dcc55f..25cbeee9 100644 --- a/tests/services/test_zemax_parser.py +++ b/tests/services/test_zemax_parser.py @@ -70,7 +70,7 @@ def test_zemax_parser_surface1_entry(): assert not surf1.is_flat assert abs(surf1.thickness - 4.0) < 0.01 assert surf1.glass == "N-LAK22" - assert abs(surf1.diameter - 12.7) < 0.1 + assert abs(surf1.semi_diameter_mm - 12.7) < 0.1 assert surf1.coating == "THORB" assert surf1.comment == "AC254-100-B" @@ -88,7 +88,7 @@ def test_zemax_parser_surface2_cemented(): assert not surf2.is_flat assert abs(surf2.thickness - 1.5) < 0.01 assert surf2.glass == "N-SF6HT" - assert abs(surf2.diameter - 12.7) < 0.1 + assert abs(surf2.semi_diameter_mm - 12.7) < 0.1 def test_zemax_parser_surface3_exit(): @@ -104,7 +104,7 @@ def test_zemax_parser_surface3_exit(): assert not surf3.is_flat assert abs(surf3.thickness - 97.09) < 0.01 assert surf3.glass == "" # Air - assert abs(surf3.diameter - 12.7) < 0.1 + assert abs(surf3.semi_diameter_mm - 12.7) < 0.1 assert surf3.coating == "THORBSLAH64" @@ -117,4 +117,4 @@ def test_zemax_parser_surface4_image(): assert surf4.number == 4 assert surf4.is_flat - assert abs(surf4.diameter - 0.0052) < 0.001 # Very small diameter + assert abs(surf4.semi_diameter_mm - 0.0052) < 0.001 # Very small semi-diameter diff --git a/tests/ui/test_component_editor.py b/tests/ui/test_component_editor.py index f5599415..03017541 100644 --- a/tests/ui/test_component_editor.py +++ b/tests/ui/test_component_editor.py @@ -1,10 +1,12 @@ """Tests for ComponentEditor (upgraded from ComponentEditorDialog).""" +from pathlib import Path + import pytest # Note: These tests require PyQt6 to be properly installed try: - from PyQt6 import QtCore, QtGui + from PyQt6 import QtCore, QtGui, QtWidgets HAVE_PYQT6 = True except ImportError: @@ -176,3 +178,49 @@ 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_component_editor_imports_zemax_fixture(qtbot, monkeypatch): + """Smoke-test the File -> Import Zemax path with the checked-in fixture.""" + from optiverse.services.storage_service import StorageService + from optiverse.ui.views.component_editor_dialog import ComponentEditor + + sample_zmx = Path(__file__).parents[1] / "fixtures" / "sample.zmx" + + class FakeFileDialog: + FileMode = QtWidgets.QFileDialog.FileMode + + def __init__(self, *args, **kwargs): + self._selected_files = [str(sample_zmx)] + + def setFileMode(self, *args, **kwargs): + pass + + def setNameFilter(self, *args, **kwargs): + pass + + def setAttribute(self, *args, **kwargs): + pass + + def exec(self): + return QtWidgets.QDialog.DialogCode.Accepted + + def selectedFiles(self): + return self._selected_files + + monkeypatch.setattr(QtWidgets, "QFileDialog", FakeFileDialog) + monkeypatch.setattr(QtWidgets.QMessageBox, "information", lambda *args, **kwargs: None) + + editor = ComponentEditor(storage=StorageService()) + qtbot.addWidget(editor) + + try: + editor._import_zemax() + + interfaces = editor.interface_panel.get_interfaces() + assert len(interfaces) == 3 + assert editor.name_edit.text().startswith("AC254-100-B") + assert editor.object_height_mm.value() == pytest.approx(25.4) + finally: + editor.close()