Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
289 changes: 48 additions & 241 deletions examples/zemax_parse_simple.py
Original file line number Diff line number Diff line change
@@ -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 <path_to_zmx_file>")
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 <path_to_zmx_file>", file=sys.stderr)
return 2
return _print_summary(sys.argv[1])


if __name__ == "__main__":
main()
sys.exit(main())
Loading
Loading