diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..26ea3ebc --- /dev/null +++ b/.envrc @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +export DIRENV_WARN_TIMEOUT=20s + +eval "$(devenv direnvrc)" + +use devenv + +if [[ -d .devenv/state/venv/bin ]]; then + PATH_add .devenv/state/venv/bin +fi diff --git a/.gitignore b/.gitignore index f694320f..d2b1fe94 100644 --- a/.gitignore +++ b/.gitignore @@ -196,3 +196,5 @@ fabric.properties env-*/ venv/ raytracing/_version.py +.devenv/ +.direnv/ diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 00000000..febdb3b3 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,65 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1774957469, + "narHash": "sha256-YkzJi44ntRLRNTmkQoABnQU0oxQjKaOHRm0bp0uxUuI=", + "owner": "cachix", + "repo": "devenv", + "rev": "7ea7c651664ea1526387a68d66a9f4e7f17dc146", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1774287239, + "narHash": "sha256-W3krsWcDwYuA3gPWsFA24YAXxOFUL6iIlT6IknAoNSE=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1773840656, + "narHash": "sha256-9tpvMGFteZnd3gRQZFlRCohVpqooygFuy9yjuyRL2C0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9cf7092bdd603554bd8b63c216e8943cf9b12512", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 00000000..586045f8 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,57 @@ +{ pkgs, lib, ... }: + +let + python = pkgs.python312; + pythonPackages = python.pkgs; +in +{ + packages = [ + python + pythonPackages.tkinter + pkgs.tk + pkgs.tcl + pkgs.stdenv.cc.cc.lib + pkgs.zlib + pkgs.libGL + pkgs.xorg.libX11 + pkgs.xorg.libXext + pkgs.xorg.libXrender + pkgs.xorg.libSM + pkgs.xorg.libICE + ]; + + env = { + VENV_DIR = ".devenv/state/venv"; + PIP_DISABLE_PIP_VERSION_CHECK = "1"; + PYTHONNOUSERSITE = "1"; + TCL_LIBRARY = "${pkgs.tcl}/lib/tcl${pkgs.tcl.version}"; + TK_LIBRARY = "${pkgs.tk}/lib/tk${pkgs.tk.version}"; + LD_LIBRARY_PATH = lib.makeLibraryPath [ + pkgs.stdenv.cc.cc.lib + pkgs.zlib + pkgs.libGL + pkgs.xorg.libX11 + pkgs.xorg.libXext + pkgs.xorg.libXrender + pkgs.xorg.libSM + pkgs.xorg.libICE + ]; + }; + + enterShell = '' + echo "RayTracing devenv" + echo "Python $(${python}/bin/python --version | cut -d' ' -f2)" + + if [ ! -x "$VENV_DIR/bin/python" ]; then + ${python}/bin/python -m venv --system-site-packages "$VENV_DIR" + fi + + . "$VENV_DIR/bin/activate" + + python -m ensurepip --upgrade >/dev/null 2>&1 || true + python -m pip install --upgrade pip setuptools wheel build >/dev/null + python -m pip install --upgrade -e ".[gui]" pillow basedpyright ruff >/dev/null + + export PATH="$PWD/$VENV_DIR/bin:$PATH" + ''; +} diff --git a/raytracing/common-optical-layouts/__init__.py b/raytracing/common-optical-layouts/__init__.py new file mode 100644 index 00000000..94450f0c --- /dev/null +++ b/raytracing/common-optical-layouts/__init__.py @@ -0,0 +1 @@ +"""Common optical layout starter files for the RayTracing UI.""" diff --git a/raytracing/common-optical-layouts/_template.py b/raytracing/common-optical-layouts/_template.py new file mode 100644 index 00000000..22da61fb --- /dev/null +++ b/raytracing/common-optical-layouts/_template.py @@ -0,0 +1,19 @@ +from raytracing import * + +TITLE = "New Layout" +OBJECT_THICKNESS = "Finite" # or "Infinity" +IMAGE_THICKNESS = 100 # or "Infinity" + + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + + # Example sequence: + # path.append(Space(d=50)) + # path.append(Lens(f=100, diameter=25.4)) + # path.append(Space(d=100)) + # path.append(Aperture(diameter=10)) + # path.append(Space(d=100)) + + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/cooke_triplet.py b/raytracing/common-optical-layouts/cooke_triplet.py new file mode 100644 index 00000000..310fcde4 --- /dev/null +++ b/raytracing/common-optical-layouts/cooke_triplet.py @@ -0,0 +1,19 @@ +from raytracing import * + +TITLE = "Cooke Triplet" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 38.0 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Space(d=7.5)) + path.append(ThickLens(n=1.69, R1=38.0, R2=150.0, thickness=6.0, diameter=34)) + path.append(Space(d=4.0)) + path.append(ThickLens(n=1.67, R1=-28.0, R2=24.0, thickness=2.5, diameter=20)) + path.append(Space(d=10.0)) + path.append(Aperture(diameter=11)) + path.append(Space(d=32.0)) + path.append(ThickLens(n=1.72, R1=65.0, R2=-32.0, thickness=6.5, diameter=30)) + path.append(Space(d=38.0)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/double_gauss_lens.py b/raytracing/common-optical-layouts/double_gauss_lens.py new file mode 100644 index 00000000..bc587aed --- /dev/null +++ b/raytracing/common-optical-layouts/double_gauss_lens.py @@ -0,0 +1,21 @@ +from raytracing import * + +TITLE = "Double Gauss Lens" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 48.0 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Space(d=8.0)) + path.append(ThickLens(n=1.67, R1=55.0, R2=180.0, thickness=7.0, diameter=40)) + path.append(Space(d=4.0)) + path.append(ThickLens(n=1.62, R1=-42.0, R2=28.0, thickness=3.0, diameter=28)) + path.append(Space(d=10.0)) + path.append(Aperture(diameter=18)) + path.append(Space(d=4.0)) + path.append(ThickLens(n=1.62, R1=28.0, R2=-42.0, thickness=3.0, diameter=28)) + path.append(Space(d=28.0)) + path.append(ThickLens(n=1.67, R1=180.0, R2=-55.0, thickness=7.0, diameter=40)) + path.append(Space(d=48.0)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/double_sided_telecentric.py b/raytracing/common-optical-layouts/double_sided_telecentric.py new file mode 100644 index 00000000..4d2bf01c --- /dev/null +++ b/raytracing/common-optical-layouts/double_sided_telecentric.py @@ -0,0 +1,17 @@ +from raytracing import * + +TITLE = "Double-Sided Telecentric" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 80.0 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Space(d=20)) + path.append(Lens(f=80, diameter=32)) + path.append(Space(d=80)) + path.append(Aperture(diameter=10)) + path.append(Space(d=80)) + path.append(Lens(f=80, diameter=32)) + path.append(Space(d=80)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/finite_conjugate_objective.py b/raytracing/common-optical-layouts/finite_conjugate_objective.py new file mode 100644 index 00000000..02bb6178 --- /dev/null +++ b/raytracing/common-optical-layouts/finite_conjugate_objective.py @@ -0,0 +1,16 @@ +from raytracing import * + +TITLE = "Finite Conjugate Objective" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 140.0 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Aperture(diameter=12)) + path.append(Space(d=18.0)) + path.append(ThickLens(n=1.72, R1=32.0, R2=-45.0, thickness=5.0, diameter=24)) + path.append(Space(d=6.0)) + path.append(ThickLens(n=1.67, R1=48.0, R2=-120.0, thickness=4.0, diameter=22)) + path.append(Space(d=140.0)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/galilean_beam_expander.py b/raytracing/common-optical-layouts/galilean_beam_expander.py new file mode 100644 index 00000000..e3e363e6 --- /dev/null +++ b/raytracing/common-optical-layouts/galilean_beam_expander.py @@ -0,0 +1,15 @@ +from raytracing import * + +TITLE = "Galilean Beam Expander" +OBJECT_THICKNESS = "Infinity" +IMAGE_THICKNESS = "Infinity" + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Aperture(diameter=8)) + path.append(Space(d=25)) + path.append(Lens(f=-25, diameter=14)) + path.append(Space(d=75)) + path.append(Lens(f=100, diameter=40)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/infinity_objective_plus_200mm_tube_lens.py b/raytracing/common-optical-layouts/infinity_objective_plus_200mm_tube_lens.py new file mode 100644 index 00000000..7b4685d6 --- /dev/null +++ b/raytracing/common-optical-layouts/infinity_objective_plus_200mm_tube_lens.py @@ -0,0 +1,16 @@ +from raytracing import * + +TITLE = "20x Infinity Objective + 200 mm Tube Lens" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 200.0 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Space(d=10.0)) + path.append(Lens(f=10, diameter=8)) + path.append(Aperture(diameter=6)) + path.append(Space(d=60.0)) + path.append(Lens(f=200, diameter=30)) + path.append(Space(d=200.0)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/keplerian_beam_expander.py b/raytracing/common-optical-layouts/keplerian_beam_expander.py new file mode 100644 index 00000000..9325a49d --- /dev/null +++ b/raytracing/common-optical-layouts/keplerian_beam_expander.py @@ -0,0 +1,17 @@ +from raytracing import * + +TITLE = "Keplerian Beam Expander" +OBJECT_THICKNESS = "Infinity" +IMAGE_THICKNESS = "Infinity" + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Aperture(diameter=12)) + path.append(Space(d=40)) + path.append(Lens(f=40, diameter=20)) + path.append(Space(d=40)) + path.append(Aperture(diameter=10)) + path.append(Space(d=120)) + path.append(Lens(f=120, diameter=42)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/layout_2f.py b/raytracing/common-optical-layouts/layout_2f.py new file mode 100644 index 00000000..759c6c84 --- /dev/null +++ b/raytracing/common-optical-layouts/layout_2f.py @@ -0,0 +1,14 @@ +from raytracing import * + +TITLE = "2f" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 200 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Aperture(diameter=25.4)) + path.append(Space(d=200)) + path.append(Lens(f=100, diameter=25.4)) + path.append(Space(d=200)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/layout_4f.py b/raytracing/common-optical-layouts/layout_4f.py new file mode 100644 index 00000000..9d29b7d7 --- /dev/null +++ b/raytracing/common-optical-layouts/layout_4f.py @@ -0,0 +1,17 @@ +from raytracing import * + +TITLE = "4f" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 100 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Space(d=100)) + path.append(Lens(f=100, diameter=30)) + path.append(Space(d=100)) + path.append(Aperture(diameter=18)) + path.append(Space(d=100)) + path.append(Lens(f=100, diameter=30)) + path.append(Space(d=100)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/object_space_telecentric.py b/raytracing/common-optical-layouts/object_space_telecentric.py new file mode 100644 index 00000000..9a60bfd2 --- /dev/null +++ b/raytracing/common-optical-layouts/object_space_telecentric.py @@ -0,0 +1,17 @@ +from raytracing import * + +TITLE = "Object-Space Telecentric" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 120.0 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Space(d=20)) + path.append(Lens(f=80, diameter=32)) + path.append(Space(d=80)) + path.append(Aperture(diameter=12)) + path.append(Space(d=70)) + path.append(Lens(f=120, diameter=34)) + path.append(Space(d=120)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/retrofocus_lenses.py b/raytracing/common-optical-layouts/retrofocus_lenses.py new file mode 100644 index 00000000..12a2cfbb --- /dev/null +++ b/raytracing/common-optical-layouts/retrofocus_lenses.py @@ -0,0 +1,19 @@ +from raytracing import * + +TITLE = "Retrofocus Lenses" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 36.0 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Space(d=12.0)) + path.append(ThickLens(n=1.61, R1=-65.0, R2=34.0, thickness=7.0, diameter=44)) + path.append(Space(d=20.0)) + path.append(Aperture(diameter=18)) + path.append(Space(d=8.0)) + path.append(ThickLens(n=1.70, R1=42.0, R2=-120.0, thickness=8.0, diameter=34)) + path.append(Space(d=26.0)) + path.append(ThickLens(n=1.72, R1=58.0, R2=-44.0, thickness=6.5, diameter=30)) + path.append(Space(d=36.0)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/single_lens.py b/raytracing/common-optical-layouts/single_lens.py new file mode 100644 index 00000000..04fb4433 --- /dev/null +++ b/raytracing/common-optical-layouts/single_lens.py @@ -0,0 +1,12 @@ +from raytracing import * + +TITLE = "Single Lens" +OBJECT_THICKNESS = "Finite" +IMAGE_THICKNESS = 200 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Lens(f=100, diameter=25.4)) + path.append(Space(d=200)) + path.display(comments=comments) diff --git a/raytracing/common-optical-layouts/telephoto_infinity_focus.py b/raytracing/common-optical-layouts/telephoto_infinity_focus.py new file mode 100644 index 00000000..b9f70b5d --- /dev/null +++ b/raytracing/common-optical-layouts/telephoto_infinity_focus.py @@ -0,0 +1,17 @@ +from raytracing import * + +TITLE = "Telephoto (Infinity Focus)" +OBJECT_THICKNESS = "Infinity" +IMAGE_THICKNESS = 12.0 + +def exampleCode(comments=None): + path = ImagingPath() + path.label = TITLE + path.append(Space(d=20.0)) + path.append(ThickLens(n=1.69, R1=140.0, R2=-80.0, thickness=8.0, diameter=42)) + path.append(Space(d=8.0)) + path.append(Aperture(diameter=20)) + path.append(Space(d=55.0)) + path.append(ThickLens(n=1.72, R1=-35.0, R2=140.0, thickness=4.0, diameter=26)) + path.append(Space(d=12.0)) + path.display(comments=comments) diff --git a/raytracing/imagingpath.py b/raytracing/imagingpath.py index e6f4f3f0..93af7b46 100644 --- a/raytracing/imagingpath.py +++ b/raytracing/imagingpath.py @@ -539,6 +539,55 @@ def entrancePupil(self): else: return Stop(None, None) + def exitPupil(self): + """The exit pupil is the image of the aperture stop as seen from image space. + + Returns + ------- + exitPupil : (float,float) + the position of the pupil relative to input reference plane + (positive means to the right) and its diameter. + """ + + if not self.hasFiniteApertureDiameter(): + return Stop(None, None) + stop = self.apertureStop() + if stop.z is None or stop.diameter is None: + return Stop(None, None) + + suffix_path = ImagingPath() + z = 0.0 + epsilon = 1e-9 + + for element in self.elements: + next_z = z + element.L + + if next_z <= stop.z + epsilon: + z = next_z + continue + + if z < stop.z - epsilon < next_z: + partial_length = next_z - stop.z + suffix_path.append(element.transferMatrix(upTo=partial_length)) + elif z >= stop.z + epsilon: + suffix_path.append(element) + + z = next_z + + if suffix_path.D == 0: + return Stop(None, None) + + conjugate_distance = -suffix_path.B / suffix_path.D + determinant = suffix_path.frontIndex / suffix_path.backIndex + magnification = determinant / suffix_path.D + if magnification == 0: + pupil_diameter = float("+inf") + else: + pupil_diameter = stop.diameter * abs(magnification) + + pupil_position = stop.z + suffix_path.L + conjugate_distance + return Stop(pupil_position, pupil_diameter) + def fieldStop(self): """ The field stop is the aperture that limits the image size (or field of view) It is possible to have finite diameter elements but diff --git a/raytracing/ui/raytracing_app.py b/raytracing/ui/raytracing_app.py index 1dacf5e2..231c45e4 100644 --- a/raytracing/ui/raytracing_app.py +++ b/raytracing/ui/raytracing_app.py @@ -1,12 +1,14 @@ import sys try: - from tkinter import DoubleVar + from tkinter import DoubleVar, StringVar, Menu from tkinter import filedialog + import tkinter.ttk as ttk from mytk import * from mytk.base import BaseNotification from mytk.canvasview import * from mytk.dataviews import * + from mytk.tableview import CellEntry, TableView from mytk.vectors import Point, PointDefault, DynamicBasis from mytk.labels import Label from mytk.notificationcenter import NotificationCenter @@ -21,17 +23,314 @@ import time import ast import inspect +import traceback +import runpy +from pathlib import Path from numpy import linspace, isfinite from raytracing import * import colorsys import pyperclip from contextlib import suppress +ELEMENT_DEFAULTS = { + "Thin Lens": "f=50, diameter=25.4", + "Aperture": "diameter=25.4", + "CurvedMirror": "R=100, diameter=25.4", + "DielectricInterface": "n1=1.0, n2=1.5, R=100, diameter=25.4", + "ThickLens": "n=1.5, R1=100, R2=-100, thickness=10, diameter=25.4", + "DielectricSlab": "n=1.5, thickness=10, diameter=25.4", + "Axicon": "alpha=2.0, n=1.5, diameter=25.4", +} + +ELEMENT_ALIASES = { + "Thin Lens": "Lens", + "Lens": "Lens", + "AS": "Aperture", + "FS": "Aperture", + "Aperture Stop": "Aperture", + "Field Stop": "Aperture", +} + +CLASS_TO_ELEMENT_LABEL = { + "Lens": "Thin Lens", + "Aperture": "Aperture", + "CurvedMirror": "CurvedMirror", + "DielectricInterface": "DielectricInterface", + "ThickLens": "ThickLens", + "DielectricSlab": "DielectricSlab", + "Axicon": "Axicon", +} + + +EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples" +COMMON_LAYOUTS_DIR = Path(__file__).resolve().parent.parent / "common-optical-layouts" +ALL_EXAMPLE_NAMES = sorted( + example_path.stem + for example_path in EXAMPLES_DIR.glob("*.py") + if example_path.stem != "__init__" +) +ALL_COMMON_LAYOUT_FILES = sorted( + layout_path + for layout_path in COMMON_LAYOUTS_DIR.glob("*.py") + if layout_path.stem != "__init__" and not layout_path.stem.startswith("_") +) + + +class CapturedDisplayedPath(RuntimeError): + def __init__(self, path): + super().__init__("captured displayed path") + self.path = path + + +def file_title(file_path: Path) -> str: + module = ast.parse(file_path.read_text()) + for node in module.body: + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "TITLE" + ): + with suppress(ValueError): + return ast.literal_eval(node.value) + return file_path.stem.replace("_", " ") + + +def path_title(file_path: Path) -> str: + return file_path.stem.replace("_", " ") + + +def pad_menu_items(values, pad=" "): + return [f"{pad}{value}{pad}" for value in values] + + +class ElementCellEditor(CellEntry): + def create_widget(self, master): + record = self.tableview.data_source.record(self.item_id) + + self.parent = master + self.value_variable = StringVar() + self.widget = ttk.Combobox( + master, + textvariable=self.value_variable, + values=pad_menu_items(list(ELEMENT_DEFAULTS.keys())), + state="readonly", + style="RoundedPadding.TCombobox", + ) + self.widget.bind("<>", self.event_return_callback) + self.widget.bind("", self.event_focusout_callback) + self.widget.set(str(record[self.column_name])) + + def event_return_callback(self, event): + record = dict(self.tableview.data_source.record(self.item_id)) + new_element = self.value_variable.get().strip() + old_element = record.get(self.column_name) + record[self.column_name] = new_element + + if new_element != old_element: + default_arguments = ELEMENT_DEFAULTS.get(new_element) + if default_arguments is not None: + record["arguments"] = default_arguments + + self.tableview.item_modified(item_id=self.item_id, modified_record=record) + self.event_generate("") + + def event_focusout_callback(self, event): + self.widget.destroy() + + +class ObjectThicknessCellEditor(CellEntry): + def create_widget(self, master): + record = self.tableview.data_source.record(self.item_id) + + self.parent = master + self.value_variable = StringVar() + self.widget = ttk.Combobox( + master, + textvariable=self.value_variable, + values=pad_menu_items(["Finite", "Infinity"]), + state="readonly", + style="RoundedPadding.TCombobox", + ) + self.widget.bind("<>", self.event_return_callback) + self.widget.bind("", self.event_focusout_callback) + current_value = str(record.get(self.column_name, "Finite")) + if current_value not in {"Finite", "Infinity"}: + current_value = "Finite" + self.widget.set(current_value) + + def event_return_callback(self, event): + record = dict(self.tableview.data_source.record(self.item_id)) + record[self.column_name] = self.value_variable.get().strip() + self.tableview.item_modified(item_id=self.item_id, modified_record=record) + self.event_generate("") + + def event_focusout_callback(self, event): + self.widget.destroy() + + +class SmoothedPolygon(CanvasElement): + def __init__(self, points, smooth=1, **kwargs): + super().__init__(**kwargs) + self.points = points + self.smooth = smooth + + def create(self, canvas, position=None): + if position is None: + position = Point(0, 0, basis=self.basis) + + translated_points = [ + (position + point).standard_tuple() for point in self.points + ] + self.id = canvas.widget.create_polygon( + translated_points, + smooth=self.smooth, + **self._element_kwargs, + ) + return self.id + + +class ElementTableView(TableView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.active_editor_item_id = None + self.active_editor_column_name = None + + def dismiss_active_editors(self): + for child in self.widget.winfo_children(): + with suppress(Exception): + child.destroy() + self.active_editor_item_id = None + self.active_editor_column_name = None + + def click_cell(self, item_id, column_name): + if column_name == "solve": + record = dict(self.data_source.record(item_id)) + current_value = record.get("solve", "") + new_value = "" if current_value == "*" else "*" + + for other_record in list(self.data_source.records): + other_update = {"solve": ""} + if other_record["__uuid"] == item_id: + other_update["solve"] = new_value + self.item_modified(other_record["__uuid"], {**dict(other_record), **other_update}) + return True + + if ( + self.active_editor_item_id is not None + and ( + item_id != self.active_editor_item_id + or column_name != self.active_editor_column_name + ) + ): + self.dismiss_active_editors() + return super().click_cell(item_id, column_name) + + def focus_edit_cell(self, item_id, column_name): + assert isinstance(column_name, str) + record = dict(self.data_source.record(item_id)) + if column_name == "solve": + return + if ( + record.get("element") == "Image" + and column_name == "thickness" + and record.get("solve") == "*" + ): + return + if record.get("element") in {"Object", "Image"} and column_name in {"element", "arguments"}: + return + if ( + column_name == "thickness" + and hasattr(self.delegate, "first_real_element_record") + and hasattr(self.delegate, "object_record") + ): + first_real = self.delegate.first_real_element_record() + object_record = self.delegate.object_record() + if ( + first_real is not None + and object_record is not None + and record.get("__uuid") == first_real.get("__uuid") + and str(object_record.get("thickness", "Finite")) == "Infinity" + ): + return + + self.dismiss_active_editors() + self.active_editor_item_id = item_id + self.active_editor_column_name = column_name + bbox = self.widget.bbox(item_id, column=column_name) + if record.get("element") == "Object" and column_name == "thickness": + entry_box = ObjectThicknessCellEditor( + tableview=self, + item_id=item_id, + column_name=column_name, + ) + elif column_name == "element": + entry_box = ElementCellEditor( + tableview=self, + item_id=item_id, + column_name=column_name, + ) + else: + entry_box = CellEntry( + tableview=self, + item_id=item_id, + column_name=column_name, + ) + + entry_box.place_into( + parent=self, + x=bbox[0] - 2, + y=bbox[1] - 2, + width=bbox[2] + 4, + height=bbox[3] + 4, + ) + entry_box.widget.focus() + + +class BiconcaveLens(CanvasElement): + def __init__(self, lens_width, height, basis=None, **kwargs): + super().__init__(basis=basis, **kwargs) + self.lens_width = lens_width + self.height = height + + def create(self, canvas, position=None): + if position is None: + position = Point(0, 0, basis=self.basis) + self.canvas = canvas + + half_width = self.lens_width / 2 + half_height = self.height / 2 + + outline_points = ( + position + Point(-half_width, -half_height, basis=self.basis), + position + Point(-half_width, -half_height, basis=self.basis), + position + Point(half_width, -half_height, basis=self.basis), + position + Point(half_width, -half_height, basis=self.basis), + position + Point(half_width * 0.45, -half_height * 0.45, basis=self.basis), + position + Point(half_width * 0.45, 0, basis=self.basis), + position + Point(half_width * 0.45, half_height * 0.45, basis=self.basis), + position + Point(half_width, half_height, basis=self.basis), + position + Point(half_width, half_height, basis=self.basis), + position + Point(-half_width, half_height, basis=self.basis), + position + Point(-half_width, half_height, basis=self.basis), + position + Point(-half_width * 0.45, half_height * 0.45, basis=self.basis), + position + Point(-half_width * 0.45, 0, basis=self.basis), + position + Point(-half_width * 0.45, -half_height * 0.45, basis=self.basis), + ) + + self.id = canvas.widget.create_polygon( + [point.standard_tuple() for point in outline_points], + smooth=1, + **self._element_kwargs, + ) + return self.id + class RaytracingApp(App): def __init__(self): App.__init__(self, name="Raytracing Application") self.window.widget.title("Raytracing") + self.current_layout_file = None self.number_of_heights = 5 self.max_height = 5 @@ -43,15 +342,101 @@ def __init__(self): self.show_labels = True self.show_principal_rays = 1 self.show_conjugates = True + self.show_principal_planes = False self.show_intermediate_conjugates = False self.maximum_x = 60 self.initialization_completed = False self.path_has_field_stop = True + self.object_conjugate_mode = "Preset: finite object" + self.image_conjugate_mode = "Preset: finite image" + self.conjugation_status_message = "" + self.solver_status_message = "" + self.loading_layout_preset = False + self.create_menu_bar() self.create_window_widgets() self.refresh() + def create_menu_bar(self): + menu_name = self.window.widget.cget("menu") + menubar = self.window.widget.nametowidget(menu_name) if menu_name else Menu(self.window.widget) + file_menu = None + + end_index = menubar.index("end") + if end_index is not None: + for index in range(end_index + 1): + if menubar.type(index) != "cascade": + continue + if menubar.entrycget(index, "label") == "File": + submenu_name = menubar.entrycget(index, "menu") + file_menu = menubar.nametowidget(submenu_name) + break + + if file_menu is None: + file_menu = Menu(menubar, tearoff=0) + menubar.add_cascade(label="File", menu=file_menu) + + file_menu.insert_command(0, label="Open", command=self.open) + file_menu.insert_command(2, label="Save As", command=self.save_as) + self.window.widget.config(menu=menubar) + self.menubar = menubar + self.file_menu = file_menu + + @staticmethod + def safe_int(value, default): + try: + if value == "": + return default + return int(value) + except (TypeError, ValueError): + return default + + @staticmethod + def safe_float(value, default): + try: + if value == "": + return default + return float(value) + except (TypeError, ValueError): + return default + + @staticmethod + def parse_thickness(value): + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if text == "": + return 0.0 + if text.lower() == "finite": + return 0.0 + if text.lower() in {"inf", "+inf", "infinity", "+infinity", "∞", "+∞"}: + return float("inf") + if text.lower() in {"-inf", "-infinity", "-∞"}: + return float("-inf") + return float(text) + + @staticmethod + def normalized_thickness_text(value): + try: + numeric_value = RaytracingApp.parse_thickness(value) + except (TypeError, ValueError): + return value + + if str(value).strip().lower() == "finite": + return "Finite" + if numeric_value == float("inf"): + return "Infinity" + if numeric_value == float("-inf"): + return "-Infinity" + return f"{numeric_value:g}" + def create_window_widgets(self): + self.combobox_style = ttk.Style() + self.combobox_style.configure( + "RoundedPadding.TCombobox", + padding=(12, 6, 12, 6), + ) + self.table_group = View(width=300, height=300) self.table_group.grid_into( self.window, row=0, column=1, pady=5, padx=5, sticky="nsew" @@ -63,11 +448,46 @@ def create_window_widgets(self): self.table_group, row=1, column=0, pady=5, padx=5, sticky="nsew" ) + self.layout_files = {} + layout_values = self.refresh_layout_file_menu() + default_layout = "Common Optical Layout" + self.layout_preset_variable = StringVar(value=default_layout) + self.example_names = self.loadable_example_names() + all_dropdown_names = [default_layout, "Examples", *layout_values, *self.example_names] + dropdown_width = max(len(name) for name in all_dropdown_names) + 2 if all_dropdown_names else 12 + self.layout_preset_menu = ttk.Combobox( + self.button_group.widget, + textvariable=self.layout_preset_variable, + values=pad_menu_items([default_layout, *layout_values]), + state="readonly", + width=dropdown_width, + style="RoundedPadding.TCombobox", + ) + self.layout_preset_menu.grid( + row=0, column=0, columnspan=2, pady=5, padx=5, sticky="ew" + ) + self.layout_preset_menu.bind("<>", self.load_selected_layout) + + self.example_variable = StringVar(value="Examples") + example_values = ["Examples", *self.example_names] + self.example_menu = ttk.Combobox( + self.button_group.widget, + textvariable=self.example_variable, + values=pad_menu_items(example_values), + state="readonly", + width=dropdown_width, + style="RoundedPadding.TCombobox", + ) + self.example_menu.grid( + row=1, column=0, columnspan=2, pady=5, padx=5, sticky="ew" + ) + self.example_menu.bind("<>", self.load_selected_example) + self.add_lens_button = Button( "Add element", user_event_callback=self.click_table_buttons ) self.add_lens_button.grid_into( - self.button_group, row=0, column=0, pady=5, padx=5 + self.button_group, row=0, column=2, pady=5, padx=5 ) # self.add_aperture_button = Button( # "Add Aperture", user_event_callback=self.click_table_buttons @@ -79,28 +499,42 @@ def create_window_widgets(self): self.delete_button = Button( "Delete element", user_event_callback=self.click_table_buttons ) - self.delete_button.grid_into(self.button_group, row=0, column=2, pady=5, padx=5) + self.delete_button.grid_into(self.button_group, row=0, column=3, pady=5, padx=5) + + self.move_up_button = Button( + "▲", user_event_callback=self.click_table_buttons, width=2 + ) + self.move_up_button.grid_into(self.button_group, row=0, column=4, pady=5, padx=2) + + self.move_down_button = Button( + "▼", user_event_callback=self.click_table_buttons, width=2 + ) + self.move_down_button.grid_into( + self.button_group, row=0, column=5, pady=5, padx=2 + ) self.copy_code_button = Button( "Copy script", user_event_callback=self.click_copy_buttons ) self.copy_code_button.grid_into( - self.button_group, row=0, column=3, pady=5, padx=5 + self.button_group, row=0, column=6, pady=5, padx=5 ) - self.tableview = TableView( + self.solve_button = Button( + "Solve", user_event_callback=self.click_table_buttons + ) + self.solve_button.grid_into( + self.button_group, row=0, column=7, pady=5, padx=5 + ) + + self.tableview = ElementTableView( columns_labels={ + "thickness": "Thickness [mm]", "element": "Element", "arguments": "Properties", - "position": "Position [mm]", + "solve": "Variable", } ) - self.tableview.column_formats["position"] = { - "format_string": "{0:g}", - "multiplier": 1, - "anchor": "", - } - self.tableview.data_source.update_field_properties("position", {"type": float}) self.tableview.grid_into( self.table_group, @@ -112,23 +546,42 @@ def create_window_widgets(self): sticky="nsew", ) self.tableview.displaycolumns = [ - "position", + "thickness", "element", "arguments", + "solve", ] for column in self.tableview.displaycolumns: widths = { - "position": 5, + "thickness": 8, "element": 5, "arguments": 150, + "solve": 5, } self.tableview.widget.column(column, width=widths[column], anchor=W) self.tableview.data_source.append_record( { - "element": "Lens", - "arguments": "f=100", - "position": 200, + "element": "Object", + "arguments": "", + "thickness": "Finite", + "solve": "", + } + ) + self.tableview.data_source.append_record( + { + "element": "Thin Lens", + "arguments": "f=100, diameter=25.4", + "thickness": 0, + "solve": "", + } + ) + self.tableview.data_source.append_record( + { + "element": "Image", + "arguments": "", + "thickness": 200, + "solve": "*", } ) # self.tableview.data_source.append_record( @@ -141,6 +594,10 @@ def create_window_widgets(self): # } # ) self.tableview.delegate = self + self.update_variable_row_styles() + if layout_values: + self.load_selected_layout(layout_values[0]) + self.layout_preset_menu.set(default_layout) self.results_tableview = TableView( columns_labels={ @@ -194,7 +651,9 @@ def create_window_widgets(self): self.control_input_rays, column=0, row=1, pady=5, padx=5, sticky="e" ) - self.number_heights_entry = IntEntry(minimum=1, maximum=100, width=3) + self.number_heights_entry = Entry( + value=str(self.number_of_heights), character_width=3 + ) self.number_heights_entry.grid_into( self.control_input_rays, column=1, row=1, pady=5, padx=5, sticky="w" ) @@ -204,7 +663,9 @@ def create_window_widgets(self): self.control_input_rays, column=0, row=2, pady=5, padx=5, sticky="e" ) - self.number_angles_entry = IntEntry(minimum=1, maximum=100, width=3) + self.number_angles_entry = Entry( + value=str(self.number_of_angles), character_width=3 + ) self.number_angles_entry.grid_into( self.control_input_rays, column=1, row=2, pady=5, padx=5, sticky="w" ) @@ -214,7 +675,7 @@ def create_window_widgets(self): self.control_input_rays, column=3, row=1, pady=5, padx=5, sticky="w" ) - self.max_heights_entry = Entry(character_width=3) + self.max_heights_entry = Entry(value=str(self.max_height), character_width=3) self.max_heights_entry.grid_into( self.control_input_rays, column=4, row=1, pady=5, padx=5, sticky="w" ) @@ -224,7 +685,9 @@ def create_window_widgets(self): self.control_input_rays, column=3, row=2, pady=5, padx=5, sticky="w" ) - self.fan_angles_entry = Entry(character_width=3) + self.fan_angles_entry = Entry( + value=str(self.max_fan_angle), character_width=3 + ) self.fan_angles_entry.grid_into( self.control_input_rays, column=4, row=2, pady=5, padx=5, sticky="w" ) @@ -234,6 +697,11 @@ def create_window_widgets(self): self.controls, column=0, row=1, columnspan=4, pady=5, padx=5, sticky="w" ) + self.show_principal_planes_checkbox = Checkbox(label="Show principal planes") + self.show_principal_planes_checkbox.grid_into( + self.controls, column=0, row=2, columnspan=4, pady=5, padx=5, sticky="w" + ) + self.apertures_checkbox = Checkbox( label="Show Aperture stop (AS) and field stop (FS)" ) @@ -251,25 +719,6 @@ def create_window_widgets(self): self.controls, column=0, row=5, columnspan=4, pady=5, padx=5, sticky="w" ) - self.conjugation_box = Box(label="Conjugation") - self.conjugation_box.grid_into( - self.controls, column=0, row=6, pady=5, padx=5, sticky="nsew" - ) - - self.object_conjugate = PopupMenu( - menu_items=["Finite object", "Infinite object"] - ) - self.object_conjugate.grid_into( - self.conjugation_box, column=0, row=0, pady=5, padx=5, sticky="w" - ) - self.object_conjugate.selection_changed(0) - - self.image_conjugate = PopupMenu(menu_items=["Finite image", "Infinite image"]) - self.image_conjugate.grid_into( - self.conjugation_box, column=1, row=0, pady=5, padx=5, sticky="w" - ) - self.image_conjugate.selection_changed(0) - self.canvas = CanvasView(width=1000, height=400, background="white") self.canvas.grid_into( self.window, column=0, row=1, columnspan=3, pady=5, padx=5, sticky="nsew" @@ -321,6 +770,11 @@ def create_window_widgets(self): self.bind_properties( "show_conjugates", self.show_conjugates_checkbox, "value_variable" ) + self.bind_properties( + "show_principal_planes", + self.show_principal_planes_checkbox, + "value_variable", + ) self.bind_properties("max_height", self.max_heights_entry, "value_variable") self.bind_properties("max_fan_angle", self.fan_angles_entry, "value_variable") self.number_heights_entry.bind_properties( @@ -341,17 +795,112 @@ def create_window_widgets(self): self.add_observer(self, "number_of_heights") self.add_observer(self, "number_of_angles") + self.add_observer(self, "max_height") + self.add_observer(self, "max_fan_angle") self.add_observer(self, "dont_show_blocked_rays") self.add_observer(self, "show_apertures") self.add_observer(self, "show_principal_rays") self.add_observer(self, "show_labels") self.add_observer(self, "show_conjugates") + self.add_observer(self, "show_principal_planes") self.initialization_completed = True + def refresh_layout_file_menu(self): + self.layout_files = { + file_title(layout_file): layout_file + for layout_file in sorted(COMMON_LAYOUTS_DIR.glob("*.py")) + if layout_file.stem != "__init__" and not layout_file.stem.startswith("_") + } + values = list(self.layout_files.keys()) + if hasattr(self, "layout_preset_menu"): + self.layout_preset_menu["values"] = pad_menu_items(["Common Optical Layout", *values]) + self.layout_preset_menu.configure( + width=max(len(name) for name in values) + 2 if values else 12 + ) + return values + def canvas_did_resize(self, notification): self.refresh() + def infinite_object_axis_offset(self, x_reference=None): + if x_reference is None: + x_reference = max(self.coords.axes_limits[0][1], 1.0) + return max(20.0, min(60.0, x_reference * 0.18)) + + def infinite_object_axis_x(self, x_reference=None): + x_min, _x_max = self.coords.axes_limits[0] + try: + negative_ticks = [tick for tick in self.coords.x_major_ticks() if tick < 0] + except Exception: + negative_ticks = [] + if negative_ticks: + return max(negative_ticks) + return x_min + + def infinite_object_plot_min_x(self, x_reference=None): + offset = self.infinite_object_axis_offset(x_reference) + return -offset + + def relabel_infinite_x_axis_ends(self): + if ( + self.object_conjugate_mode != "Preset: object at infinity" + and self.image_conjugate_mode != "Preset: image at infinity" + ): + return + + for item_id in [ + item_id + for item_id in self.canvas.widget.find_withtag("tick-label") + if "x-axis" in self.canvas.widget.gettags(item_id) + ]: + self.canvas.widget.delete(item_id) + + origin = self.coords.reference_point + y_lims = self.coords.axes_limits[1] + if self.coords.x_axis_at_bottom: + origin = origin + Point(0, y_lims[0], basis=self.coords.basis) + + tick_basis = Basis(e0=self.coords.basis.e0, e1=self.coords.basis.e1.normalized()) + tick_values = list(self.coords.x_major_ticks()) + width = self.coords._element_kwargs.get("width", 1) + + for tick_value in tick_values: + label_text = self.coords.x_format.format(tick_value) + if ( + self.object_conjugate_mode == "Preset: object at infinity" + and abs(tick_value - self.infinite_object_axis_x()) < 1e-9 + ): + label_text = "−∞" + elif ( + self.image_conjugate_mode == "Preset: image at infinity" + and tick_value == max(tick_values) + ): + label_text = "+∞" + + tick_start = Point(tick_value, 0, basis=tick_basis) + tick_start = tick_start + Vector( + 0, + self.coords.major_length * width * self.coords.tick_value_offset, + tick_basis, + ) + + value = CanvasLabel( + text=label_text, + font_size=self.coords.tick_text_size * width, + anchor="center", + ) + value.create(self.canvas, position=origin + tick_start) + value.add_tag(f"group-{self.coords.id}") + value.add_tag("x-axis") + value.add_tag("tick-label") + + def reposition_y_axis_for_infinite_object(self): + return + + def add_infinite_object_x_marker(self): + return + def observed_property_changed( self, observed_object, observed_property_name, new_value, context ): @@ -365,15 +914,225 @@ def observed_property_changed( self.refresh() def source_data_changed(self, tableview): + if self.loading_layout_preset: + return + self.normalize_special_rows() + self.ensure_image_row_last() + self.update_variable_row_styles() + self.solver_status_message = "" self.refresh() + def ordered_table_records(self): + return [dict(self.tableview.data_source.record(item_id)) for item_id in self.tableview.items_ids()] + + def image_record(self): + for record in self.ordered_table_records(): + if record.get("element") == "Image": + return record + return None + + def object_record(self): + for record in self.ordered_table_records(): + if record.get("element") == "Object": + return record + return None + + def first_real_element_record(self): + for record in self.ordered_table_records(): + if record.get("element") not in {"Object", "Image"}: + return record + return None + + def normalize_special_rows(self): + for record in self.ordered_table_records(): + if "thickness" not in record: + continue + normalized_value = self.normalized_thickness_text(record["thickness"]) + if normalized_value != record["thickness"]: + self.tableview.data_source.update_record( + record["__uuid"], {"thickness": normalized_value} + ) + + object_record = self.object_record() + image_record = self.image_record() + self.conjugation_status_message = "" + if object_record is not None: + object_is_infinite = ( + self.parse_thickness(object_record.get("thickness", "Finite")) == float("inf") + ) + self.object_conjugate_mode = ( + "Preset: object at infinity" if object_is_infinite else "Preset: finite object" + ) + first_real_element = self.first_real_element_record() + if first_real_element is not None: + if object_is_infinite: + updates = {} + try: + first_thickness = self.parse_thickness( + first_real_element.get("thickness", 0) + ) + except (TypeError, ValueError): + first_thickness = 0.0 + if isfinite(first_thickness): + updates["__finite_thickness_backup"] = ( + self.normalized_thickness_text( + first_real_element.get("thickness", 0) + ) + ) + if str(first_real_element.get("thickness", "")) != "Infinity": + updates["thickness"] = "Infinity" + if updates: + self.tableview.data_source.update_record( + first_real_element["__uuid"], updates + ) + else: + if str(first_real_element.get("thickness", "")) == "Infinity": + restored_value = first_real_element.get( + "__finite_thickness_backup", "200" + ) + self.tableview.data_source.update_record( + first_real_element["__uuid"], + {"thickness": restored_value}, + ) + if image_record is not None: + image_is_infinite = self.parse_thickness(image_record.get("thickness", 0)) == float("inf") + self.image_conjugate_mode = ( + "Preset: image at infinity" if image_is_infinite else "Preset: finite image" + ) + + def ensure_image_row_last(self): + records = self.ordered_table_records() + if not records: + return + + object_records = [record for record in records if record.get("element") == "Object"] + non_special_records = [ + record for record in records if record.get("element") not in {"Object", "Image"} + ] + image_records = [record for record in records if record.get("element") == "Image"] + if len(image_records) != 1 or len(object_records) != 1: + return + + desired_records = object_records + non_special_records + image_records + current_ids = [record["__uuid"] for record in records] + desired_ids = [record["__uuid"] for record in desired_records] + if current_ids == desired_ids: + return + + for index, record in enumerate(desired_records): + self.tableview.widget.move(record["__uuid"], "", index) + + def update_variable_row_styles(self): + bold_font = ("TkDefaultFont", 9, "bold") + self.tableview.widget.tag_configure( + "solved-variable", background="#fff2b3", font=bold_font + ) + self.tableview.widget.tag_configure( + "normal-row", background="", font=("TkDefaultFont", 9) + ) + + for item_id in self.tableview.items_ids(): + record = dict(self.tableview.data_source.record(item_id)) + if record.get("solve") == "*": + self.tableview.widget.item(item_id, tags=("solved-variable",)) + else: + self.tableview.widget.item(item_id, tags=("normal-row",)) + + def reorder_table_rows(self, tableview, dragged_item_id, target_item_id, insert_after): + records = [ + dict(tableview.data_source.record(item_id)) + for item_id in tableview.items_ids() + ] + item_ids = [record["__uuid"] for record in records] + if dragged_item_id not in item_ids or target_item_id not in item_ids: + return + + dragged_index = item_ids.index(dragged_item_id) + target_index = item_ids.index(target_item_id) + first_real_element = self.first_real_element_record() + object_is_infinite = ( + self.object_record() is not None + and self.parse_thickness(self.object_record().get("thickness", "Finite")) == float("inf") + ) + + if records[dragged_index].get("element") in {"Object", "Image"}: + return + if ( + object_is_infinite + and first_real_element is not None + and records[dragged_index].get("__uuid") == first_real_element.get("__uuid") + ): + return + if records[target_index].get("element") in {"Object", "Image"}: + return + if ( + object_is_infinite + and first_real_element is not None + and records[target_index].get("__uuid") == first_real_element.get("__uuid") + ): + return + if insert_after: + target_index += 1 + tableview.widget.move(dragged_item_id, "", target_index) + self.ensure_image_row_last() + + def move_selected_row(self, offset): + selected_items = list(self.tableview.widget.selection()) + if not selected_items: + return + + records = self.ordered_table_records() + item_ids = [record["__uuid"] for record in records] + selected_item_id = selected_items[0] + if selected_item_id not in item_ids: + return + selected_index = item_ids.index(selected_item_id) + first_real_element = self.first_real_element_record() + object_is_infinite = ( + self.object_record() is not None + and self.parse_thickness(self.object_record().get("thickness", "Finite")) == float("inf") + ) + if records[selected_index].get("element") in {"Object", "Image"}: + return + if ( + object_is_infinite + and first_real_element is not None + and records[selected_index].get("__uuid") == first_real_element.get("__uuid") + ): + return + target_index = selected_index + offset + + if target_index < 0 or target_index >= len(records): + return + if records[target_index].get("element") in {"Object", "Image"}: + return + if ( + object_is_infinite + and first_real_element is not None + and records[target_index].get("__uuid") == first_real_element.get("__uuid") + ): + return + + target_item_id = item_ids[target_index] + insert_index = target_index + if offset > 0: + insert_index = target_index + 1 + self.tableview.widget.move(selected_item_id, "", insert_index) + self.ensure_image_row_last() + + self.tableview.widget.selection_set(selected_item_id) + def validate_source_data(self, tableview): try: - user_provided_path = self.get_path_from_ui( - without_apertures=True, max_position=None + self.get_path_from_ui( + without_apertures=True, max_position=None, include_image_plane=False ) return False except Exception as err: + if not hasattr(err, "details") or not isinstance(err.details, dict): + traceback.print_exc() + return True + mandatory_arguments = [ f"{k}=?" for k, v in err.details.items() if v is inspect._empty ] @@ -401,26 +1160,580 @@ def click_copy_buttons(self, event, button): script = self.get_path_script() pyperclip.copy(script) + def base_object_record(self): + return { + "element": "Object", + "arguments": "", + "thickness": "Finite", + "solve": "", + } + + def image_record_template(self): + return { + "element": "Image", + "arguments": "", + "thickness": 200, + "solve": "*", + } + + def load_selected_layout(self, event=None): + preset_name = event if isinstance(event, str) else self.layout_preset_variable.get().strip() + if preset_name not in self.layout_files: + return + + layout_path = self.layout_files[preset_name] + try: + object_record, rows = self.load_example_rows(layout_path) + except ValueError as err: + self.solver_status_message = f"Could not load layout {preset_name}: {err}" + self.refresh() + return + + self.tableview.dismiss_active_editors() + self.loading_layout_preset = True + try: + for item_id in list(self.tableview.items_ids()): + self.tableview.data_source.remove_record(item_id) + self.tableview.data_source.append_record(object_record) + for row in rows: + self.tableview.data_source.append_record(dict(row)) + + if self.ordered_table_records()[-1].get("element") != "Image": + self.tableview.data_source.append_record(self.image_record_template()) + finally: + self.loading_layout_preset = False + + self.layout_preset_menu.set(preset_name) + self.current_layout_file = layout_path + self.normalize_special_rows() + self.ensure_image_row_last() + self.update_variable_row_styles() + self.solver_status_message = f"Loaded {preset_name} layout." + self.refresh() + + def load_selected_example(self, event=None): + example_name = self.example_variable.get().strip() + if example_name not in self.example_names: + return + + example_path = EXAMPLES_DIR / f"{example_name}.py" + try: + object_record, rows = self.load_example_rows(example_path) + except ValueError as err: + self.solver_status_message = f"Could not load example {example_name}: {err}" + self.refresh() + return + + self.tableview.dismiss_active_editors() + self.loading_layout_preset = True + try: + for item_id in list(self.tableview.items_ids()): + self.tableview.data_source.remove_record(item_id) + self.tableview.data_source.append_record(object_record) + for row in rows: + self.tableview.data_source.append_record(row) + finally: + self.loading_layout_preset = False + + self.example_menu.set(example_name) + self.current_layout_file = None + self.normalize_special_rows() + self.ensure_image_row_last() + self.update_variable_row_styles() + self.solver_status_message = f"Loaded example {example_name}." + self.refresh() + + @staticmethod + def dialog_path(value): + if isinstance(value, tuple): + return value[0] if value else "" + return value + + def open(self): + filepath = self.dialog_path( + filedialog.askopenfilename( + title="Open RayTracing layout", + initialdir=str(COMMON_LAYOUTS_DIR), + filetypes=[("Python layout", "*.py"), ("All files", "*.*")], + ) + ) + if not filepath: + return + + path = Path(filepath) + try: + object_record, rows = self.load_example_rows(path) + except ValueError as err: + self.solver_status_message = f"Could not open {path.name}: {err}" + self.refresh() + return + + self.tableview.dismiss_active_editors() + self.loading_layout_preset = True + try: + for item_id in list(self.tableview.items_ids()): + self.tableview.data_source.remove_record(item_id) + self.tableview.data_source.append_record(object_record) + for row in rows: + self.tableview.data_source.append_record(dict(row)) + finally: + self.loading_layout_preset = False + + title = file_title(path) + self.refresh_layout_file_menu() + if title in self.layout_files: + self.layout_preset_menu.set(title) + self.current_layout_file = path + self.normalize_special_rows() + self.ensure_image_row_last() + self.update_variable_row_styles() + self.solver_status_message = f"Opened {path.name}." + self.refresh() + + def save(self): + if self.current_layout_file is None: + self.save_as() + return + self.write_current_layout_file(self.current_layout_file) + self.solver_status_message = f"Saved {self.current_layout_file.name}." + self.refresh() + + def save_as(self): + filepath = self.dialog_path( + filedialog.asksaveasfilename( + title="Save RayTracing layout", + initialdir=str(COMMON_LAYOUTS_DIR), + defaultextension=".py", + filetypes=[("Python layout", "*.py"), ("All files", "*.*")], + ) + ) + if not filepath: + return + path = Path(filepath) + title_override = path_title(path) + self.write_current_layout_file(path, title_override=title_override) + self.refresh_layout_file_menu() + self.current_layout_file = path + title = file_title(path) + if title in self.layout_files: + self.layout_preset_menu.set(title) + self.solver_status_message = f"Saved {path.name}." + self.refresh() + + def loadable_example_names(self): + names = [] + for example_name in ALL_EXAMPLE_NAMES: + try: + self.parse_example_file(EXAMPLES_DIR / f"{example_name}.py") + except Exception: + continue + names.append(example_name) + return names + + def load_example_rows(self, example_path): + try: + return self.parse_example_file(example_path) + except Exception: + return self.load_example_via_execution(example_path) + + def load_example_via_execution(self, example_path): + patched_methods = {} + + def capture_display(path, *args, **kwargs): + raise CapturedDisplayedPath(path) + + for cls_name in ("ImagingPath", "OpticalPath"): + cls = globals().get(cls_name) + if cls is None: + continue + for method_name in ("display", "displayWithObject"): + if hasattr(cls, method_name): + patched_methods[(cls, method_name)] = getattr(cls, method_name) + setattr(cls, method_name, capture_display) + + try: + module_globals = runpy.run_path(str(example_path)) + example_code = module_globals.get("exampleCode") + if example_code is None: + raise ValueError("no exampleCode function found") + + try: + signature = inspect.signature(example_code) + if len(signature.parameters) == 0: + example_code() + else: + example_code(comments=None) + except CapturedDisplayedPath as captured: + return self.rows_from_path(captured.path) + finally: + for (cls, method_name), original_method in patched_methods.items(): + setattr(cls, method_name, original_method) + + raise ValueError("example did not display a supported path") + + def parse_example_file(self, example_path): + module = ast.parse(example_path.read_text()) + module_env = self.collect_example_assignments(module.body, {}) + append_calls = None + + for node in module.body: + if isinstance(node, ast.FunctionDef): + function_env = self.collect_example_assignments(node.body, module_env) + append_calls = self.extract_imaging_path_calls(node, function_env) + if append_calls: + break + + if not append_calls: + raise ValueError("no simple ImagingPath append sequence found") + + object_record = self.base_object_record() + if "OBJECT_THICKNESS" in module_env: + object_record["thickness"] = self.normalized_thickness_text( + module_env["OBJECT_THICKNESS"] + ) + rows = [] + pending_thickness = 0.0 + + for call, env in append_calls: + element_name, arguments = self.parse_example_append_call(call, env) + if element_name == "Space": + pending_thickness += self.parse_example_space_distance(arguments) + continue + if element_name == "System4f": + expanded_rows, pending_thickness = self.expand_system4f_rows( + arguments, pending_thickness + ) + rows.extend(expanded_rows) + continue + + ui_element_name = CLASS_TO_ELEMENT_LABEL.get(element_name) + if ui_element_name is None: + raise ValueError(f"unsupported element {element_name}") + + rows.append( + { + "element": ui_element_name, + "arguments": arguments, + "thickness": pending_thickness, + "solve": "", + } + ) + pending_thickness = 0.0 + + image_record = self.image_record_template() + image_thickness = module_env.get("IMAGE_THICKNESS", pending_thickness if pending_thickness else 0) + image_record["thickness"] = self.normalized_thickness_text(image_thickness) + rows.append(image_record) + return object_record, rows + + def rows_from_path(self, path): + object_record = self.base_object_record() + rows = [] + pending_thickness = 0.0 + + def append_element_row(element, thickness): + ui_element_name = CLASS_TO_ELEMENT_LABEL.get(type(element).__name__) + if ui_element_name is None: + raise ValueError(f"unsupported element {type(element).__name__}") + rows.append( + { + "element": ui_element_name, + "arguments": self.arguments_from_element(element), + "thickness": thickness, + "solve": "", + } + ) + + def walk_elements(elements): + nonlocal pending_thickness + for element in elements: + if type(element).__name__ == "Space": + pending_thickness += float(element.L) + elif isinstance(element, MatrixGroup): + walk_elements(element.elements) + else: + append_element_row(element, pending_thickness) + pending_thickness = 0.0 + + walk_elements(path.elements) + + if rows: + object_record["thickness"] = ( + "Finite" + if rows[0]["thickness"] == 0 + else self.normalized_thickness_text(rows[0]["thickness"]) + ) + rows[0]["thickness"] = 0.0 + + image_record = self.image_record_template() + image_record["thickness"] = pending_thickness if pending_thickness else 0 + rows.append(image_record) + return object_record, rows + + def arguments_from_element(self, element): + type_name = type(element).__name__ + parts = [] + + def add_arg(name, value): + if value is None: + return + if isinstance(value, float) and isfinite(value): + parts.append(f"{name}={value:g}") + elif isinstance(value, float): + parts.append(f"{name}=float('{value}')") + else: + parts.append(f"{name}={value!r}") + + if type_name == "Lens": + add_arg("f", element.f) + add_arg("diameter", element.apertureDiameter) + elif type_name == "Aperture": + add_arg("diameter", element.apertureDiameter) + elif type_name == "ThickLens": + add_arg("n", element.n) + add_arg("R1", element.R1) + add_arg("R2", element.R2) + add_arg("thickness", element.L) + add_arg("diameter", element.apertureDiameter) + elif type_name == "DielectricInterface": + add_arg("n1", element.n1) + add_arg("n2", element.n2) + add_arg("R", element.R) + add_arg("diameter", element.apertureDiameter) + elif type_name == "DielectricSlab": + add_arg("n", element.n) + add_arg("thickness", element.L) + add_arg("diameter", element.apertureDiameter) + elif type_name == "CurvedMirror": + radius = None if element.C == 0 else 2 / element.C + add_arg("R", radius) + add_arg("diameter", element.apertureDiameter) + elif type_name == "Axicon": + add_arg("alpha", element.alpha) + add_arg("n", element.n) + add_arg("diameter", element.apertureDiameter) + else: + raise ValueError(f"unsupported element {type_name}") + + if getattr(element, "label", ""): + add_arg("label", element.label) + + return ", ".join(parts) + + def collect_example_assignments(self, statements, base_env): + env = dict(base_env) + for statement in statements: + if not (isinstance(statement, ast.Assign) and len(statement.targets) == 1): + continue + target = statement.targets[0] + try: + value = self.evaluate_example_node(statement.value, env) + except ValueError: + continue + + if isinstance(target, ast.Name): + env[target.id] = value + elif isinstance(target, ast.Tuple) and isinstance(value, tuple): + if len(target.elts) != len(value): + continue + for elt, item in zip(target.elts, value): + if isinstance(elt, ast.Name): + env[elt.id] = item + return env + + def expand_system4f_rows(self, arguments, pending_thickness): + class_name, kwargs = self.parse_element_call(f"System4f({arguments})") + if class_name != "System4f": + raise ValueError("invalid System4f expansion") + + f1 = float(kwargs["f1"]) + f2 = float(kwargs["f2"]) + diameter1 = kwargs.get("diameter1", float("inf")) + diameter2 = kwargs.get("diameter2", float("inf")) + + rows = [ + { + "element": "Thin Lens", + "arguments": f"f={f1:g}, diameter={diameter1}", + "thickness": pending_thickness + f1, + "solve": "", + }, + { + "element": "Thin Lens", + "arguments": f"f={f2:g}, diameter={diameter2}", + "thickness": f1 + f2, + "solve": "", + }, + ] + return rows, f2 + + def extract_imaging_path_calls(self, function_def, env): + path_name = None + append_calls = [] + + for statement in function_def.body: + if ( + isinstance(statement, ast.Assign) + and len(statement.targets) == 1 + and isinstance(statement.targets[0], ast.Name) + and isinstance(statement.value, ast.Call) + and isinstance(statement.value.func, ast.Name) + and statement.value.func.id in {"ImagingPath", "OpticalPath"} + ): + path_name = statement.targets[0].id + continue + + if path_name is None: + continue + + if ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Call) + and isinstance(statement.value.func, ast.Attribute) + and isinstance(statement.value.func.value, ast.Name) + and statement.value.func.value.id == path_name + and statement.value.func.attr == "append" + ): + append_calls.append((statement.value.args[0], env)) + + return append_calls + + def parse_example_append_call(self, call_node, env): + if not isinstance(call_node, ast.Call) or not isinstance(call_node.func, ast.Name): + raise ValueError("example contains non-constructor append calls") + + element_name = call_node.func.id + cls = globals().get(ELEMENT_ALIASES.get(element_name, element_name)) + if cls is None: + raise ValueError(f"unsupported element {element_name}") + + parameter_names = [ + name + for name in inspect.signature(cls.__init__).parameters + if name != "self" + ] + arguments = [] + + for index, argument in enumerate(call_node.args): + if index >= len(parameter_names): + raise ValueError(f"too many positional arguments for {element_name}") + arguments.append( + f"{parameter_names[index]}={self.normalize_example_argument(argument, env)}" + ) + for keyword in call_node.keywords: + if keyword.arg is None: + raise ValueError("example contains unsupported **kwargs append call") + arguments.append( + f"{keyword.arg}={self.normalize_example_argument(keyword.value, env)}" + ) + + return element_name, ", ".join(arguments) + + def normalize_example_argument(self, argument_node, env): + with suppress(ValueError, TypeError): + return repr(self.evaluate_example_node(argument_node, env)) + return ast.unparse(argument_node) + + def evaluate_example_node(self, node, env=None): + env = {} if env is None else env + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Tuple): + return tuple(self.evaluate_example_node(elt, env) for elt in node.elts) + if isinstance(node, ast.Name) and node.id in env: + return env[node.id] + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): + operand = self.evaluate_example_node(node.operand, env) + return +operand if isinstance(node.op, ast.UAdd) else -operand + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)): + left = self.evaluate_example_node(node.left, env) + right = self.evaluate_example_node(node.right, env) + operations = { + ast.Add: lambda a, b: a + b, + ast.Sub: lambda a, b: a - b, + ast.Mult: lambda a, b: a * b, + ast.Div: lambda a, b: a / b, + } + return operations[type(node.op)](left, right) + raise ValueError("unsupported expression") + + def parse_example_space_distance(self, arguments): + if not arguments: + raise ValueError("Space() without distance is unsupported") + + parsed = self.parse_element_call(f"Space({arguments})") + _name, kwargs = parsed + + if "d" not in kwargs: + raise ValueError("Space() must use a literal distance") + + distance = kwargs["d"] + if not isinstance(distance, (int, float)): + raise ValueError("Space() distance must be numeric") + return float(distance) + def click_table_buttons(self, event, button): - path = self.get_path_from_ui(without_apertures=False) + path = self.get_path_from_ui( + without_apertures=False, include_image_plane=False + ) if button == self.delete_button: + self.tableview.dismiss_active_editors() + self.tableview.widget.focus_set() for selected_item in self.tableview.widget.selection(): - record = self.tableview.data_source.record(selected_item) - self.tableview.data_source.remove_record(selected_item) + record = dict(self.tableview.data_source.record(selected_item)) + if record.get("element") not in {"Object", "Image"}: + self.tableview.data_source.remove_record(selected_item) + self.tableview.widget.update_idletasks() elif button == self.add_lens_button: record = self.tableview.data_source.empty_record() - record["element"] = "Lens" + record["element"] = "Thin Lens" record["arguments"] = "f=50, diameter=25.4" - record["position"] = position = path.L + 50 + record["thickness"] = 50 + record["solve"] = "" self.tableview.data_source.append_record(record) + self.ensure_image_row_last() + elif button == self.move_up_button: + self.move_selected_row(-1) + elif button == self.move_down_button: + self.move_selected_row(1) + elif button == self.solve_button: + self.solve_marked_variable() elif button == self.add_aperture_button: record = self.tableview.data_source.empty_record() record["element"] = "Aperture" record["arguments"] = "diameter=25.4" - record["position"] = position = path.L + 50 + record["thickness"] = 50 + record["solve"] = "" self.tableview.data_source.append_record(record) + def solve_marked_variable(self): + variable_records = [ + record for record in self.ordered_table_records() if record.get("solve") == "*" + ] + if len(variable_records) != 1: + self.solver_status_message = "Mark exactly one variable row." + self.refresh() + return + + variable_record = variable_records[0] + if variable_record.get("element") != "Image": + self.solver_status_message = "First pass only solves Image thickness." + self.refresh() + return + + solved_image_distance = self.solved_image_thickness() + if solved_image_distance is None or not isfinite(solved_image_distance): + self.solver_status_message = "Image distance is not finite for this system." + self.refresh() + return + self.tableview.data_source.update_record( + variable_record["__uuid"], {"thickness": solved_image_distance} + ) + self.update_variable_row_styles() + self.solver_status_message = f"Solved image distance = {solved_image_distance:.4g} mm." + self.refresh() + def refresh(self): if not self.initialization_completed: return @@ -428,13 +1741,13 @@ def refresh(self): if self.validate_source_data(self.tableview): return - self.tableview.sort_column(column_name="position") - self.canvas.widget.delete("ray") self.canvas.widget.delete("optics") self.canvas.widget.delete("apertures") self.canvas.widget.delete("labels") self.canvas.widget.delete("conjugates") + self.canvas.widget.delete("principal-planes") + self.canvas.widget.delete("pupils") self.canvas.widget.delete("x-axis") self.canvas.widget.delete("y-axis") self.canvas.widget.delete("tick") @@ -442,53 +1755,137 @@ def refresh(self): try: user_provided_path = self.get_path_from_ui( - without_apertures=True, max_position=None + without_apertures=True, max_position=None, include_image_plane=False ) finite_imaging_path = None finite_path = None - conjugate = user_provided_path.forwardConjugate() + if ( + self.object_conjugate_mode == "Preset: finite object" + and self.image_conjugate_mode == "Preset: finite image" + ): + finite_path = self.get_path_from_ui( + without_apertures=False, max_position=None, include_image_plane=True + ) + finite_imaging_path = finite_path - if isfinite(conjugate.d): - image_position = user_provided_path.L + conjugate.d - finite_imaging_path = self.get_path_from_ui( - without_apertures=False, max_position=image_position + elif ( + self.object_conjugate_mode == "Preset: object at infinity" + and self.image_conjugate_mode == "Preset: finite image" + ): + finite_path = self.get_path_from_ui( + without_apertures=False, max_position=None, include_image_plane=True + ) + finite_imaging_path = finite_path + else: + conjugate = user_provided_path.forwardConjugate() + + if isfinite(conjugate.d): + image_position = user_provided_path.L + conjugate.d + if image_position > 0: + finite_imaging_path = self.get_path_from_ui( + without_apertures=False, max_position=image_position + ) + else: + finite_imaging_path = None + + if ( + self.object_conjugate_mode == "Preset: finite object" + and self.image_conjugate_mode == "Preset: image at infinity" + ): + if finite_path is None: + finite_path = self.get_path_from_ui( + without_apertures=False, max_position=None + ) + finite_path.append( + Space(d=self.infinite_image_display_extension(finite_path)) ) - finite_path = finite_imaging_path + if finite_path is None: + finite_path = finite_imaging_path if finite_path is None: finite_path = self.get_path_from_ui( without_apertures=False, max_position=self.coords.axes_limits[0][1] ) - self.path_has_field_stop = finite_path.hasFieldStop() + display_path = finite_path + if ( + self.object_conjugate_mode == "Preset: object at infinity" + and self.image_conjugate_mode == "Preset: finite image" + and finite_imaging_path is not None + ): + display_path = finite_imaging_path - self.adjust_axes_limits(finite_path) + self.path_has_field_stop = display_path.hasFieldStop() + self.adjust_axes_limits(display_path) self.coords.create_x_axis() self.coords.create_x_major_ticks() self.coords.create_x_major_ticks_labels() self.coords.create_y_axis() self.coords.create_y_major_ticks() self.coords.create_y_major_ticks_labels() + self.relabel_infinite_x_axis_ends() + self.reposition_y_axis_for_infinite_object() + self.add_infinite_object_x_marker() self.calculate_imaging_path_results(finite_imaging_path) - self.create_optical_path(finite_path, self.coords) + self.create_optical_path(display_path, self.coords) if self.show_raytraces: - self.create_all_traces(finite_path) + self.create_all_traces(display_path) if self.show_conjugates: - self.create_conjugate_planes(finite_path) + self.create_conjugate_planes( + finite_imaging_path if finite_imaging_path is not None else finite_path + ) + + if self.show_principal_planes: + self.create_principal_planes(finite_path) if self.show_apertures: - self.create_apertures_labels(finite_path) + self.create_apertures_labels(display_path) + self.create_pupil_planes( + finite_imaging_path if finite_imaging_path is not None else finite_path + ) + + if self.object_conjugate_mode == "Preset: object at infinity": + self.canvas.widget.tag_raise("conjugates") if self.show_labels: - self.create_object_labels(finite_path) - except ValueError as err: - pass + self.create_object_labels(display_path) + except Exception: + traceback.print_exc() + + def system_path_without_image_plane(self): + return self.get_path_from_ui( + without_apertures=False, max_position=None, include_image_plane=False + ) + + def solved_image_axis_position(self): + system_path = self.system_path_without_image_plane() + if self.object_conjugate_mode == "Preset: object at infinity": + back_focus = system_path.backFocalLength() + if back_focus is None or not isfinite(back_focus): + return None + return system_path.L + back_focus + + conjugate = system_path.forwardConjugate() + if ( + conjugate.transferMatrix is None + or conjugate.d is None + or not isfinite(conjugate.d) + ): + return None + return system_path.L + conjugate.d + + def solved_image_thickness(self): + system_path = self.system_path_without_image_plane() + image_axis_position = self.solved_image_axis_position() + if image_axis_position is None or not isfinite(image_axis_position): + return None + return image_axis_position - system_path.L def adjust_axes_limits(self, path): # half_diameter = ( @@ -505,80 +1902,159 @@ def adjust_axes_limits(self, path): raytraces = self.raytraces_to_display(path) y_min, y_max = self.raytraces_limits(raytraces) + x_max = max(float(path.L), 1.0) + pupil_positions = [] + + for pupil in (path.entrancePupil(), path.exitPupil()): + if pupil.z is not None and isfinite(pupil.z): + pupil_positions.append(pupil.z) + + if pupil_positions: + x_max = max(x_max, max(pupil_positions)) + + solved_image_position = self.solved_image_axis_position() + if solved_image_position is not None and isfinite(solved_image_position): + x_max = max(x_max, solved_image_position) + + x_min = 0.0 + if self.object_conjugate_mode == "Preset: object at infinity": + x_min = self.infinite_object_plot_min_x(x_max) + if pupil_positions: + x_min = min(x_min, min(pupil_positions)) + if solved_image_position is not None and solved_image_position < x_min: + x_min = min(x_min, solved_image_position * 1.1) + + x_span = max(x_max - x_min, 1.0) + left_padding = max(90.0, min(260.0, x_span * 0.48)) + right_padding = max(35.0, min(110.0, x_span * 0.18)) + self.coords.axes_limits = ( - (0, path.L), + (x_min - left_padding, x_max + right_padding), (min(y_min, -half_diameter) * 1.1, max(y_max, half_diameter) * 1.1), ) def raytraces_limits(self, raytraces): + if not raytraces: + return (-1.0, 1.0) ys = [] for raytrace in raytraces: ys.extend([ray.y for ray in raytrace]) + if not ys: + return (-1.0, 1.0) y_max = max(ys) y_min = min(ys) return y_min, y_max + def visible_image_size(self, path): + raytraces = self.raytraces_to_display(path) + output_ys = [ + raytrace[-1].y for raytrace in raytraces if raytrace and not raytrace[-1].isBlocked + ] + if not output_ys: + return None + return max(output_ys) - min(output_ys) + + def input_rays_to_display(self): + M = self.safe_int(self.number_of_heights, 5) + N = self.safe_int(self.number_of_angles, 5) + yMax = self.safe_float(self.max_height, 5.0) + thetaMax = self.safe_float(self.max_fan_angle, 0.1) + + if M == 1: + yMax = 0 + if N == 1: + thetaMax = 0 + + if self.object_conjugate_mode == "Preset: object at infinity": + # On-axis object at infinity: incoming rays are parallel before the first element. + return UniformRays(yMax=yMax, yMin=-yMax, thetaMax=0, thetaMin=0, M=M, N=1) + + return UniformRays(yMax=yMax, thetaMax=thetaMax, M=M, N=N) + + def infinite_image_display_extension(self, path): + if path is None: + return 100.0 + return max(100.0, path.L * 0.5) + def raytraces_to_display(self, path): if self.show_principal_rays: + raytraces = [] principal_ray = path.principalRay() if principal_ray is not None: principal_raytrace = path.trace(principal_ray) - axial_ray = path.axialRay() + raytraces.append(principal_raytrace) + axial_ray = path.axialRay() + if axial_ray is not None: axial_raytrace = path.trace(axial_ray) - return [principal_raytrace, axial_raytrace] - else: - M = int(self.number_of_heights) - N = int(self.number_of_angles) - yMax = float(self.max_height) - thetaMax = float(self.max_fan_angle) - - if M == 1: - yMax = 0 - if N == 1: - thetaMax = 0 - rays = UniformRays(yMax=yMax, thetaMax=thetaMax, M=M, N=N) - return path.traceMany(rays) + raytraces.append(axial_raytrace) + if raytraces: + return raytraces + rays = self.input_rays_to_display() + return path.traceMany(rays) def create_all_traces(self, path): if self.show_principal_rays: + line_traces = [] principal_ray = path.principalRay() if principal_ray is not None: principal_raytrace = path.trace(principal_ray) - line_trace = self.create_line_from_raytrace( - principal_raytrace, - basis=DynamicBasis(self.coords, "basis"), - color="green", + line_traces.extend( + self.create_line_segments_from_raytrace( + principal_raytrace, + basis=DynamicBasis(self.coords, "basis"), + color="green", + ) ) - self.coords.place(line_trace, position=Point(0, 0)) - axial_ray = path.axialRay() + axial_ray = path.axialRay() + if axial_ray is not None: axial_raytrace = path.trace(axial_ray) - line_trace = self.create_line_from_raytrace( - axial_raytrace, - basis=DynamicBasis(self.coords, "basis"), - color="red", + line_traces.extend( + self.create_line_segments_from_raytrace( + axial_raytrace, + basis=DynamicBasis(self.coords, "basis"), + color="red", + ) ) - self.coords.place(line_trace, position=Point(0, 0)) + + if not line_traces: + rays = self.input_rays_to_display() + self.create_raytraces_lines(path, rays) + return + + for line_trace in line_traces: + self.canvas.place(line_trace, position=self.coords_origin) + self.canvas.widget.tag_lower(line_trace.id) else: - M = int(self.number_of_heights) - N = int(self.number_of_angles) - yMax = float(self.max_height) - thetaMax = float(self.max_fan_angle) - - if M == 1: - yMax = 0 - if N == 1: - thetaMax = 0 - rays = UniformRays(yMax=yMax, thetaMax=thetaMax, M=M, N=N) + rays = self.input_rays_to_display() self.create_raytraces_lines(path, rays) + def displayed_image_height(self, path, default_height): + image_height = self.visible_image_size(path) + if image_height is None: + image_height = path.imageSize() + if image_height is None: + return default_height + try: + if isfinite(image_height): + return image_height + except TypeError: + pass + return default_height + def create_conjugate_planes(self, path): arrow_width = 10 - object_z = 0 - object_height = float(self.max_height) * 2 + object_z = ( + self.infinite_object_axis_x() + if self.object_conjugate_mode == "Preset: object at infinity" + else 0 + ) + object_height = self.safe_float(self.max_height, 5.0) * 2 if self.show_principal_rays: object_height = path.fieldOfView() + if not isfinite(object_height): + object_height = self.safe_float(self.max_height, 5.0) * 2 basis = DynamicBasis(self.coords, "basis") canvas_object = Arrow( @@ -590,36 +2066,135 @@ def create_conjugate_planes(self, path): ) self.coords.place(canvas_object, position=Point(0, 0)) - conjugate = path.forwardConjugate() - if conjugate.transferMatrix is not None: - image_z = conjugate.transferMatrix.L - magnification = conjugate.transferMatrix.magnification().transverse - image_height = magnification * object_height + if self.object_conjugate_mode == "Preset: object at infinity": + if self.image_conjugate_mode == "Preset: finite image": + image_z = path.L + image_height = self.displayed_image_height(path, object_height) + else: + image_z = self.solved_image_axis_position() + if image_z is not None and isfinite(image_z): + image_height = self.displayed_image_height(path, object_height) + else: + image_z = None + else: + conjugate = path.forwardConjugate() + if conjugate.transferMatrix is not None: + image_z = conjugate.transferMatrix.L + magnification = conjugate.transferMatrix.magnification().transverse + image_height = magnification * object_height + else: + image_z = None + + if image_z is not None: + min_display_height = max((self.coords.axes_limits[1][1] - self.coords.axes_limits[1][0]) * 0.015, 0.5) + if image_height is None or not isfinite(image_height): + image_height = min_display_height + image_arrow_height = image_height + image_arrow_width = arrow_width + if abs(image_arrow_height) < min_display_height: + image_arrow_height = min_display_height if image_arrow_height >= 0 else -min_display_height + image_arrow_width = 2 canvas_image = Arrow( - start=Point(image_z, -image_height / 2, basis=basis), - end=Point(image_z, image_height / 2, basis=basis), + start=Point(image_z, -image_arrow_height / 2, basis=basis), + end=Point(image_z, image_arrow_height / 2, basis=basis), fill="red", - width=arrow_width, + width=image_arrow_width, tag=("conjugates"), ) self.coords.place(canvas_image, position=Point(0, 0)) + def create_principal_planes(self, path): + principal_planes = path.principalPlanePositions(z=0) + y_lims = self.coords.axes_limits[1] + line_height = y_lims[1] - y_lims[0] + label_y = y_lims[1] * 1.2 + plane_labels = [] + if principal_planes.z1 is not None and isfinite(principal_planes.z1): + plane_labels.append((principal_planes.z1, "H1")) + if principal_planes.z2 is not None and isfinite(principal_planes.z2): + plane_labels.append((principal_planes.z2, "H2")) + + if len(plane_labels) == 2 and abs(plane_labels[0][0] - plane_labels[1][0]) < 1e-9: + plane_labels = [(plane_labels[0][0], "H1 = H2")] + + for z_value, label_text in plane_labels: + if z_value is None or not isfinite(z_value): + continue + + line = Line( + points=( + Point(0, -line_height / 2, basis=self.coords.basis), + Point(0, line_height / 2, basis=self.coords.basis), + ), + fill="purple", + width=2, + tag=("principal-planes"), + ) + self.coords.place(line, position=Point(z_value, 0, basis=self.coords.basis)) + self.coords.place( + CanvasLabel(text=label_text, tag=("principal-planes")), + position=Point(z_value, label_y), + ) + def create_apertures_labels(self, path): - position = path.apertureStop() y_lims = self.coords.axes_limits[1] label_position = y_lims[1] * 1.4 + labels_by_z = {} + + aperture_stop = path.apertureStop() + if aperture_stop.z is not None and isfinite(aperture_stop.z): + labels_by_z.setdefault(round(aperture_stop.z, 6), []).append(("AS", aperture_stop.z)) - if position.z is not None: - aperture_stop_label = CanvasLabel(text="AS", tag=("apertures")) + field_stop = path.fieldStop() + if field_stop.z is not None and isfinite(field_stop.z): + labels_by_z.setdefault(round(field_stop.z, 6), []).append(("FS", field_stop.z)) + + for group in labels_by_z.values(): + z_value = group[0][1] + label_text = " = ".join(label for label, _ in group) self.coords.place( - aperture_stop_label, position=Point(position.z, label_position) + CanvasLabel(text=label_text, tag=("apertures")), + position=Point(z_value, label_position), ) - position = path.fieldStop() - if position.z is not None: - field_stop_label = CanvasLabel(text="FS", tag=("apertures")) + def create_pupil_planes(self, path): + y_lims = self.coords.axes_limits[1] + label_position = y_lims[1] * 1.25 + basis = DynamicBasis(self.coords, "basis") + pupil_specs = [ + ("EP", path.entrancePupil(), "#d97706"), + ("XP", path.exitPupil(), "#0f766e"), + ] + labels_by_z = {} + + for label_text, pupil, color in pupil_specs: + if ( + pupil.z is None + or pupil.diameter is None + or not isfinite(pupil.z) + or not isfinite(pupil.diameter) + ): + continue + + half_height = pupil.diameter / 2.0 + line = Line( + points=( + Point(0, -half_height, basis=basis), + Point(0, half_height, basis=basis), + ), + fill=color, + width=3, + tag=("pupils"), + ) + self.coords.place(line, position=Point(pupil.z, 0, basis=self.coords.basis)) + labels_by_z.setdefault(round(pupil.z, 6), []).append((label_text, pupil.z)) + + for group in labels_by_z.values(): + z_value = group[0][1] + label_text = " = ".join(label for label, _ in group) self.coords.place( - field_stop_label, position=Point(position.z, label_position) + CanvasLabel(text=label_text, tag=("pupils")), + position=Point(z_value, label_position), ) def create_object_labels(self, path): @@ -649,6 +2224,12 @@ def create_raytraces_lines(self, path, rays): self.canvas.place(line_trace, position=self.coords_origin) self.canvas.widget.tag_lower(line_trace.id) + if not line_traces: + self.coords.place( + CanvasLabel(text="No drawable rays", tag=("labels")), + position=Point(10, self.coords.axes_limits[1][1] * 0.85), + ) + def fill_color_for_index(self, n): n_max = 1.6 t = (n - 1) / (n_max - 1) @@ -660,12 +2241,66 @@ def fill_color_for_index(self, n): return f"#{r:02x}{g:02x}{b:02x}" + @staticmethod + def thick_surface_sag(radius, semi_diameter, thickness): + if radius is None or not isfinite(radius) or radius == 0: + return 0.0 + + usable_height = min(abs(semi_diameter), abs(radius) * 0.98) + if usable_height <= 0: + return 0.0 + + sag = abs(radius) - (abs(radius) ** 2 - usable_height ** 2) ** 0.5 + return min(max(sag, 0.0), max(thickness * 0.45, 0.0)) + + def thick_lens_outline_points(self, element, diameter, basis): + half_height = diameter / 2 + thickness = max(element.L, 1e-6) + + front_sag = self.thick_surface_sag(element.R1, half_height, thickness) + back_sag = self.thick_surface_sag(element.R2, half_height, thickness) + + if element.R1 > 0: + front_corner_x = front_sag + front_mid_x = 0 + else: + front_corner_x = 0 + front_mid_x = front_sag + + if element.R2 < 0: + back_corner_x = thickness - back_sag + back_mid_x = thickness + else: + back_corner_x = thickness + back_mid_x = thickness - back_sag + + return ( + Point(front_corner_x, -half_height, basis=basis), + Point(front_corner_x, -half_height, basis=basis), + Point(back_corner_x, -half_height, basis=basis), + Point(back_corner_x, -half_height, basis=basis), + Point(back_mid_x, -half_height * 0.45, basis=basis), + Point(back_mid_x, 0, basis=basis), + Point(back_mid_x, half_height * 0.45, basis=basis), + Point(back_corner_x, half_height, basis=basis), + Point(back_corner_x, half_height, basis=basis), + Point(front_corner_x, half_height, basis=basis), + Point(front_corner_x, half_height, basis=basis), + Point(front_mid_x, half_height * 0.45, basis=basis), + Point(front_mid_x, 0, basis=basis), + Point(front_mid_x, -half_height * 0.45, basis=basis), + ) + def create_optical_path(self, path, coords): z = 0 - thickness = 3 + x_lims = self.coords.axes_limits[0] + x_span = max(x_lims[1] - x_lims[0], 1.0) + thickness = 0.003 * x_span + drew_optics = False for element in path: if type(element) is Lens: diameter = element.apertureDiameter + lens_half_width = 0.005 * x_span if not isfinite(diameter): y_lims = self.coords.axes_limits[1] diameter = 0.98 * (y_lims[1] - y_lims[0]) @@ -693,16 +2328,29 @@ def create_optical_path(self, path, coords): aperture_bottom, position=Point(z, 0, basis=coords.basis) ) - lens = Oval( - size=(5, diameter), - basis=coords.basis, - position_is_center=True, - fill=self.fill_color_for_index(1.5), - outline="black", - width=2, - tag=("optics"), - ) - coords.place(lens, position=Point(z, 0, basis=coords.basis)) + if getattr(element, "f", 0) < 0: + lens = BiconcaveLens( + lens_width=2 * lens_half_width, + height=diameter, + basis=coords.basis, + fill=self.fill_color_for_index(1.5), + outline="black", + width=2, + tag=("optics"), + ) + coords.place(lens, position=Point(z, 0, basis=coords.basis)) + else: + lens = Oval( + size=(2 * lens_half_width, diameter), + basis=coords.basis, + position_is_center=True, + fill=self.fill_color_for_index(1.5), + outline="black", + width=2, + tag=("optics"), + ) + coords.place(lens, position=Point(z, 0, basis=coords.basis)) + drew_optics = True elif type(element) is Aperture: diameter = element.apertureDiameter @@ -729,48 +2377,163 @@ def create_optical_path(self, path, coords): tag=("optics"), ) coords.place(aperture_bottom, position=Point(z, 0, basis=coords.basis)) + drew_optics = True - elif type(element) is ThickLens: + elif type(element) is CurvedMirror: diameter = element.apertureDiameter if not isfinite(diameter): y_lims = self.coords.axes_limits[1] diameter = 0.98 * (y_lims[1] - y_lims[0]) + + mirror_depth = 0.008 * x_span + if getattr(element, "C", 0) > 0: + points = ( + Point(0, -diameter / 2, basis=coords.basis), + Point(0, -diameter / 2, basis=coords.basis), + Point(-mirror_depth, -diameter * 0.2, basis=coords.basis), + Point(-mirror_depth, 0, basis=coords.basis), + Point(-mirror_depth, diameter * 0.2, basis=coords.basis), + Point(0, diameter / 2, basis=coords.basis), + Point(0, diameter / 2, basis=coords.basis), + ) else: - aperture_top = Line( - points=( - Point(-thickness, diameter / 2, basis=coords.basis), - Point(thickness, diameter / 2, basis=coords.basis), - ), - fill="black", - width=4, - tag=("optics"), + points = ( + Point(0, -diameter / 2, basis=coords.basis), + Point(0, -diameter / 2, basis=coords.basis), + Point(mirror_depth, -diameter * 0.2, basis=coords.basis), + Point(mirror_depth, 0, basis=coords.basis), + Point(mirror_depth, diameter * 0.2, basis=coords.basis), + Point(0, diameter / 2, basis=coords.basis), + Point(0, diameter / 2, basis=coords.basis), ) - coords.place(aperture_top, position=Point(z, 0, basis=coords.basis)) - aperture_bottom = Line( + mirror = SmoothedPolygon( + points=points, + smooth=1, + fill="", + outline="black", + width=3, + tag=("optics"), + ) + coords.place(mirror, position=Point(z, 0, basis=coords.basis)) + drew_optics = True + + elif type(element) is DielectricInterface: + diameter = element.apertureDiameter + if not isfinite(diameter): + y_lims = self.coords.axes_limits[1] + diameter = 0.98 * (y_lims[1] - y_lims[0]) + + interface_depth = 0.006 * x_span + radius = getattr(element, "R", float("inf")) + if not isfinite(radius) or radius == 0: + interface = Line( points=( - Point(-thickness, -diameter / 2, basis=coords.basis), - Point(thickness, -diameter / 2, basis=coords.basis), + Point(0, -diameter / 2, basis=coords.basis), + Point(0, diameter / 2, basis=coords.basis), ), fill="black", - width=4, + width=2, tag=("optics"), ) - coords.place( - aperture_bottom, position=Point(z, 0, basis=coords.basis) + coords.place(interface, position=Point(z, 0, basis=coords.basis)) + else: + if radius > 0: + points = ( + Point(0, -diameter / 2, basis=coords.basis), + Point(0, -diameter / 2, basis=coords.basis), + Point(interface_depth, -diameter * 0.2, basis=coords.basis), + Point(interface_depth, 0, basis=coords.basis), + Point(interface_depth, diameter * 0.2, basis=coords.basis), + Point(0, diameter / 2, basis=coords.basis), + Point(0, diameter / 2, basis=coords.basis), + ) + else: + points = ( + Point(0, -diameter / 2, basis=coords.basis), + Point(0, -diameter / 2, basis=coords.basis), + Point(-interface_depth, -diameter * 0.2, basis=coords.basis), + Point(-interface_depth, 0, basis=coords.basis), + Point(-interface_depth, diameter * 0.2, basis=coords.basis), + Point(0, diameter / 2, basis=coords.basis), + Point(0, diameter / 2, basis=coords.basis), + ) + interface = SmoothedPolygon( + points=points, + smooth=1, + fill="", + outline="black", + width=2, + tag=("optics"), ) + coords.place(interface, position=Point(z, 0, basis=coords.basis)) + drew_optics = True - lens = Oval( - size=(element.L, diameter), - basis=coords.basis, - position_is_center=True, + elif type(element) is Axicon: + diameter = element.apertureDiameter + if not isfinite(diameter): + y_lims = self.coords.axes_limits[1] + diameter = 0.98 * (y_lims[1] - y_lims[0]) + axicon_half_width = 0.006 * x_span + axicon = Line( + points=( + Point(-axicon_half_width, -diameter / 2, basis=coords.basis), + Point(0, 0, basis=coords.basis), + Point(-axicon_half_width, diameter / 2, basis=coords.basis), + Point(axicon_half_width, diameter / 2, basis=coords.basis), + Point(0, 0, basis=coords.basis), + Point(axicon_half_width, -diameter / 2, basis=coords.basis), + ), fill=self.fill_color_for_index(element.n), - outline="black", width=2, tag=("optics"), ) - coords.place( - lens, position=Point(z + element.L / 2, 0, basis=coords.basis) + coords.place(axicon, position=Point(z, 0, basis=coords.basis)) + drew_optics = True + + elif type(element) is ThickLens: + diameter = element.apertureDiameter + if not isfinite(diameter): + y_lims = self.coords.axes_limits[1] + diameter = 0.98 * (y_lims[1] - y_lims[0]) + else: + for edge_z in (z, z + element.L): + aperture_top = Line( + points=( + Point(-thickness, diameter / 2, basis=coords.basis), + Point(thickness, diameter / 2, basis=coords.basis), + ), + fill="black", + width=4, + tag=("optics"), + ) + coords.place( + aperture_top, position=Point(edge_z, 0, basis=coords.basis) + ) + aperture_bottom = Line( + points=( + Point(-thickness, -diameter / 2, basis=coords.basis), + Point(thickness, -diameter / 2, basis=coords.basis), + ), + fill="black", + width=4, + tag=("optics"), + ) + coords.place( + aperture_bottom, position=Point(edge_z, 0, basis=coords.basis) + ) + + lens = SmoothedPolygon( + points=self.thick_lens_outline_points( + element, diameter, coords.basis + ), + smooth=1, + fill=self.fill_color_for_index(element.n), + outline="black", + width=2, + tag=("optics"), ) + coords.place(lens, position=Point(z, 0, basis=coords.basis)) + drew_optics = True elif type(element) is DielectricSlab: diameter = element.apertureDiameter @@ -778,28 +2541,31 @@ def create_optical_path(self, path, coords): y_lims = self.coords.axes_limits[1] diameter = 0.98 * (y_lims[1] - y_lims[0]) else: - aperture_top = Line( - points=( - Point(-thickness, diameter / 2, basis=coords.basis), - Point(thickness, diameter / 2, basis=coords.basis), - ), - fill="black", - width=4, - tag=("optics"), - ) - coords.place(aperture_top, position=Point(z, 0, basis=coords.basis)) - aperture_bottom = Line( - points=( - Point(-thickness, -diameter / 2, basis=coords.basis), - Point(thickness, -diameter / 2, basis=coords.basis), - ), - fill="black", - width=4, - tag=("optics"), - ) - coords.place( - aperture_bottom, position=Point(z, 0, basis=coords.basis) - ) + for edge_z in (z, z + element.L): + aperture_top = Line( + points=( + Point(-thickness, diameter / 2, basis=coords.basis), + Point(thickness, diameter / 2, basis=coords.basis), + ), + fill="black", + width=4, + tag=("optics"), + ) + coords.place( + aperture_top, position=Point(edge_z, 0, basis=coords.basis) + ) + aperture_bottom = Line( + points=( + Point(-thickness, -diameter / 2, basis=coords.basis), + Point(thickness, -diameter / 2, basis=coords.basis), + ), + fill="black", + width=4, + tag=("optics"), + ) + coords.place( + aperture_bottom, position=Point(edge_z, 0, basis=coords.basis) + ) lens = Rectangle( size=(element.L, diameter), @@ -813,18 +2579,29 @@ def create_optical_path(self, path, coords): coords.place( lens, position=Point(z + element.L / 2, 0, basis=coords.basis) ) + drew_optics = True z += element.L + if not drew_optics: + self.coords.place( + CanvasLabel(text="No drawable optical elements", tag=("labels")), + position=Point(10, self.coords.axes_limits[1][1] * 0.7), + ) + def raytraces_to_lines(self, raytraces, basis): line_traces = [] - all_initial_y = [raytrace[0].y for raytrace in raytraces] + nonempty_raytraces = [raytrace for raytrace in raytraces if raytrace] + if not nonempty_raytraces: + return line_traces + + all_initial_y = [raytrace[0].y for raytrace in nonempty_raytraces] max_y = max(all_initial_y) min_y = min(all_initial_y) with PointDefault(basis=basis): - for raytrace in raytraces: + for raytrace in nonempty_raytraces: initial_y = raytrace[0].y if float(max_y - min_y) != 0: hue = (initial_y - min_y) / float(max_y - min_y) @@ -840,8 +2617,21 @@ def raytraces_to_lines(self, raytraces, basis): return line_traces def create_line_segments_from_raytrace(self, raytrace, basis, color): - points = [Point(r.z, r.y, basis=basis) for r in raytrace] - return [Line(points, tag=("ray"), fill=color, width=2)] + finite_points = [] + for ray in raytrace: + if isfinite(ray.z) and isfinite(ray.y): + finite_points.append(Point(ray.z, ray.y, basis=basis)) + + if len(finite_points) < 2: + return [] + + if self.object_conjugate_mode == "Preset: object at infinity": + left_x = self.infinite_object_axis_x() + first_point = finite_points[0] + if left_x < first_point.c0: + finite_points.insert(0, Point(left_x, first_point.c1, basis=basis)) + + return [Line(finite_points, tag=("ray"), fill=color, width=2)] def color_from_hue(self, hue): rgb = colorsys.hsv_to_rgb(hue, 1, 1) @@ -879,7 +2669,8 @@ def instantiate_element(class_name, class_kwargs) -> Any: # "DielectricSlab":DielectricSlab # } - cls = globals()[class_name] + class_name = ELEMENT_ALIASES.get(class_name, class_name) + cls = globals().get(class_name) # cls = allowed_classes.get(class_name) if cls is None: raise ValueError(f"Class {class_name} not allowed") @@ -904,33 +2695,64 @@ def instantiate_element(class_name, class_kwargs) -> Any: instance = cls(**filtered_class_kwargs) except Exception as err: instance = None + traceback.print_exc() return instance, signature_kwargs - def get_path_from_ui(self, without_apertures=True, max_position=None): + def get_path_from_ui(self, without_apertures=True, max_position=None, include_image_plane=True): path = ImagingPath() z = 0 - ordered_records = self.tableview.data_source.records - if without_apertures: - ordered_records = [ - record - for record in ordered_records - if "aperture" not in record["element"].lower() - ] - - ordered_records.sort(key=lambda e: float(e["position"])) - if max_position is not None: - ordered_records = [ - record - for record in ordered_records - if record["position"] <= max_position - ] + ordered_records = self.ordered_table_records() + first_real_element = self.first_real_element_record() + consumed_infinite_first_real_element = False + object_is_infinite = ( + self.object_record() is not None + and self.parse_thickness(self.object_record().get("thickness", "Finite")) == float("inf") + ) for element in ordered_records: + element_name = element["element"] + if element_name == "Image" and not include_image_plane: + continue + if without_apertures and "aperture" in element_name.lower(): + continue + + if element_name == "Object": + continue + + delta = self.parse_thickness(element["thickness"]) + if not isfinite(delta): + if ( + object_is_infinite + and not consumed_infinite_first_real_element + and element_name not in {"Object", "Image"} + and ( + first_real_element is None + or element.get("__uuid") == first_real_element.get("__uuid") + ) + ): + delta = 0.0 + consumed_infinite_first_real_element = True + if element_name == "Image": + continue + if not isfinite(delta): + raise ValueError("Infinite thickness is only supported on Object or Image rows.") + if max_position is not None and z + delta > max_position: + delta = max_position - z + if delta > 0: + path.append(Space(d=delta)) + z += delta + if max_position is not None and z >= max_position: + break + + if element_name == "Image": + continue + path_element = None - constructor_string = f"{element['element']}({element['arguments']})" + class_name = ELEMENT_ALIASES.get(element_name, element_name) + constructor_string = f"{class_name}({element['arguments']})" class_name, class_kwargs = self.parse_element_call(constructor_string) path_element, signature_kwargs = self.instantiate_element( @@ -943,13 +2765,24 @@ def get_path_from_ui(self, without_apertures=True, max_position=None): err.details["element"] = element raise err - next_z = float(element["position"]) - - delta = next_z - z - - path.append(Space(d=delta)) path.append(path_element) - z += delta + z += path_element.L + + if self.object_conjugate_mode == "Preset: finite object" and self.image_conjugate_mode == "Preset: image at infinity": + afocal_object_distance = None + if path.C is not None and isfinite(path.C) and abs(path.C) > 1e-12: + afocal_object_distance = -path.D / path.C + if ( + afocal_object_distance is not None + and isfinite(afocal_object_distance) + and afocal_object_distance > 0 + ): + shifted_path = ImagingPath() + shifted_path.objectHeight = path.objectHeight + shifted_path.append(Space(d=afocal_object_distance)) + for element in path: + shifted_path.append(element) + path = shifted_path if max_position is not None: if path.L < max_position: @@ -958,38 +2791,17 @@ def get_path_from_ui(self, without_apertures=True, max_position=None): return path def get_path_script(self): - z = 0 - ordered_records = self.tableview.data_source.records - ordered_records.sort(key=lambda e: float(e["position"])) - script = "from raytracing import *\n\npath = ImagingPath()\n" - for element in ordered_records: - if element["element"] == "Lens": - focal_length = float(element["focal_length"]) - label = element["label"] - next_z = float(element["position"]) - diameter = float("+inf") - if element["diameter"] != "": - diameter = float(element["diameter"]) - script_line = f"path.append(Lens(f={focal_length}, diameter={diameter}, label='{label}'))\n" - elif element["element"] == "Aperture": - label = element["label"] - next_z = float(element["position"]) - diameter = float("+inf") - if element["diameter"] != "": - diameter = float(element["diameter"]) - path_element = Aperture(diameter=diameter, label=label) - script_line = ( - f"path.append(Aperture(diameter={diameter}, label='{label}'))\n" - ) - else: - print(f"Unable to include unknown element {element['element']}") + for element in self.ordered_table_records(): + thickness = self.parse_thickness(element["thickness"]) + if isfinite(thickness) and thickness > 0: + script += f"path.append(Space(d={thickness}))\n" + if element["element"] in {"Object", "Image"}: + continue - delta = next_z - z - script += f"path.append(Space(d={delta}))\n" - script += script_line - z += delta + class_name = ELEMENT_ALIASES.get(element["element"], element["element"]) + script += f"path.append({class_name}({element['arguments']}))\n" script += "\n" @@ -1037,9 +2849,17 @@ def calculate_imaging_path_results(self, imaging_path): for uid in uuids: data_source.remove_record(uid) + if self.solver_status_message: + data_source.append_record( + { + "property": "Solve status", + "value": self.solver_status_message, + } + ) + if imaging_path is None: data_source.append_record( - {"property": "Imaging Path", "value": "Non-imaging/infinite conjugate"} + {"property": "Imaging Path", "value": "Non-imaging or virtual-image case"} ) return """ @@ -1047,13 +2867,40 @@ def calculate_imaging_path_results(self, imaging_path): """ image_position = imaging_path.L - + if self.image_conjugate_mode == "Preset: image at infinity": + solved_image_position = self.solved_image_axis_position() + if solved_image_position is not None: + image_position = solved_image_position + principal_planes = imaging_path.principalPlanePositions(z=0) + + object_position_value = "Infinity" if self.object_conjugate_mode == "Preset: object at infinity" else "0.0" + image_position_value = ( + "Infinity" + if self.image_conjugate_mode == "Preset: image at infinity" + else f"{image_position:.2f}" + ) data_source.append_record( - {"property": "Object position", "value": f"0.0 (always)"} + {"property": "Object position", "value": object_position_value} ) data_source.append_record( - {"property": "Image position", "value": f"{image_position:.2f}"} + {"property": "Image position", "value": image_position_value} ) + if principal_planes.z1 is not None and isfinite(principal_planes.z1): + data_source.append_record( + {"property": "H1 position", "value": f"{principal_planes.z1:.2f}"} + ) + else: + data_source.append_record( + {"property": "H1 position", "value": "Inexistent"} + ) + if principal_planes.z2 is not None and isfinite(principal_planes.z2): + data_source.append_record( + {"property": "H2 position", "value": f"{principal_planes.z2:.2f}"} + ) + else: + data_source.append_record( + {"property": "H2 position", "value": "Inexistent"} + ) """ Aperture Stop and Axial ray @@ -1072,20 +2919,64 @@ def calculate_imaging_path_results(self, imaging_path): {"property": "AS size", "value": f"{aperture_stop.diameter:.2f}"} ) - axial_ray = imaging_path.axialRay() - NA = imaging_path.NA() + entrance_pupil = imaging_path.entrancePupil() + exit_pupil = imaging_path.exitPupil() + + def format_position(value): + if value is None: + return "Inexistent" + if isfinite(value): + return f"{value:.2f}" + return "Infinity" + + def format_size(value): + if value is None: + return "Inexistent" + if isfinite(value): + return f"{value:.2f}" + return "Infinity" + data_source.append_record( - { - "property": "Axial ray θ_max", - "value": f"{axial_ray.theta:.2f} rad / {axial_ray.theta*180/3.1416:.2f}°", - } + {"property": "EP position", "value": format_position(entrance_pupil.z)} ) data_source.append_record( - { - "property": "NA", - "value": f"{NA:.1f}", - } + {"property": "EP size", "value": format_size(entrance_pupil.diameter)} ) + data_source.append_record( + {"property": "XP position", "value": format_position(exit_pupil.z)} + ) + data_source.append_record( + {"property": "XP size", "value": format_size(exit_pupil.diameter)} + ) + + axial_ray = imaging_path.axialRay() + if axial_ray is not None: + NA = imaging_path.NA() + data_source.append_record( + { + "property": "Axial ray θ_max", + "value": f"{axial_ray.theta:.2f} rad / {axial_ray.theta*180/3.1416:.2f}°", + } + ) + data_source.append_record( + { + "property": "NA", + "value": f"{NA:.1f}", + } + ) + else: + data_source.append_record( + { + "property": "Axial ray θ_max", + "value": "Inexistent", + } + ) + data_source.append_record( + { + "property": "NA", + "value": "Inexistent", + } + ) else: data_source.append_record( {"property": "AS position", "value": f"Inexistent"} @@ -1126,9 +3017,14 @@ def calculate_imaging_path_results(self, imaging_path): {"property": "Has vignetting [FS before image]", "value": f"False"} ) principal_ray = imaging_path.principalRay() - data_source.append_record( - {"property": "Principal ray y_max", "value": f"{principal_ray.y:.2f}"} - ) + if principal_ray is not None: + data_source.append_record( + {"property": "Principal ray y_max", "value": f"{principal_ray.y:.2f}"} + ) + else: + data_source.append_record( + {"property": "Principal ray y_max", "value": "Inexistent"} + ) else: data_source.append_record( {"property": "FS position", "value": f"Inexistent"} @@ -1146,47 +3042,140 @@ def calculate_imaging_path_results(self, imaging_path): Object [FOV] and Image Sizes, dicated by finite FOV """ fov = imaging_path.fieldOfView() + mag_tran, mag_angle = imaging_path.magnification() + has_numeric_magnification = mag_tran is not None and mag_angle is not None + visible_image_size = self.visible_image_size(imaging_path) if isfinite(fov): - mag_tran, mag_angle = imaging_path.magnification() - data_source.append_record( {"property": "Field of view [FOV]", "value": f"{fov:.2f}"} ) data_source.append_record( {"property": "Object size [same as FOV]", "value": f"{fov:.2f}"} ) + image_size_value = imaging_path.imageSize() + if visible_image_size is not None: + image_size_value = visible_image_size data_source.append_record( - {"property": "Image size", "value": f"{imaging_path.imageSize():.2f}"} - ) - data_source.append_record( - {"property": "Magnification [Transverse]", "value": f"{mag_tran:.2f}"} - ) - data_source.append_record( - {"property": "Magnification [Angular]", "value": f"{mag_angle:.2f}"} + {"property": "Image size", "value": f"{image_size_value:.2f}"} ) + if has_numeric_magnification: + data_source.append_record( + {"property": "Magnification [Transverse]", "value": f"{mag_tran:.2f}"} + ) + data_source.append_record( + {"property": "Magnification [Angular]", "value": f"{mag_angle:.2f}"} + ) + else: + data_source.append_record( + {"property": "Magnification [Transverse]", "value": "Inexistent"} + ) + data_source.append_record( + {"property": "Magnification [Angular]", "value": "Inexistent"} + ) else: data_source.append_record( {"property": "Field of view [FOV]", "value": f"Infinite [no FS]"} ) data_source.append_record( - {"property": "Object size [same as FOV]", "value": f"Infinite [no FS]"} - ) - data_source.append_record( - {"property": "Image size", "value": f"Infinite [no FS]"} - ) - data_source.append_record( - {"property": "Magnification [Transverse]", "value": f"Inexistent"} - ) - data_source.append_record( - {"property": "Magnification [Angular]", "value": f"Inexistent"} + { + "property": "Object size [current object height]", + "value": f"{imaging_path.objectHeight:.2f}", + } ) + image_size = imaging_path.imageSize(useObject=True) + if isfinite(image_size): + image_size_value = image_size + if visible_image_size is not None: + image_size_value = visible_image_size + data_source.append_record( + { + "property": "Image size", + "value": f"{image_size_value:.2f}", + } + ) + else: + data_source.append_record( + { + "property": "Image size", + "value": f"Inexistent", + } + ) + + if has_numeric_magnification: + data_source.append_record( + { + "property": "Magnification [Transverse]", + "value": f"{mag_tran:.2f}", + } + ) + data_source.append_record( + { + "property": "Magnification [Angular]", + "value": f"{mag_angle:.2f}", + } + ) + else: + data_source.append_record( + {"property": "Magnification [Transverse]", "value": f"Inexistent"} + ) + data_source.append_record( + {"property": "Magnification [Angular]", "value": f"Inexistent"} + ) self.results_tableview.sort_column(column_name="property") - def save(self): - filepath = filedialog.asksaveasfilename() - self.canvas.save_to_pdf(filepath=filepath) + def current_layout_title(self): + if self.current_layout_file is not None: + return file_title(self.current_layout_file) + current_title = self.layout_preset_variable.get() + if current_title in self.layout_files: + return current_title + return "Custom Layout" + + def current_layout_source(self, title_override=None): + object_record = self.object_record() or self.base_object_record() + image_record = self.image_record() or self.image_record_template() + title = title_override or self.current_layout_title() + + lines = [ + "from raytracing import *", + "", + f"TITLE = {title!r}", + f"OBJECT_THICKNESS = {self.normalized_thickness_text(object_record.get('thickness', 'Finite'))!r}", + f"IMAGE_THICKNESS = {self.normalized_thickness_text(image_record.get('thickness', 0))!r}", + "", + "", + "def exampleCode(comments=None):", + " path = ImagingPath()", + " path.label = TITLE", + ] + + for record in self.ordered_table_records(): + element_name = record.get("element") + thickness = self.parse_thickness(record.get("thickness", 0)) + + if element_name == "Object": + if isfinite(thickness) and thickness > 0: + lines.append(f" path.append(Space(d={thickness:g}))") + continue + + if element_name == "Image": + if isfinite(thickness) and thickness > 0: + lines.append(f" path.append(Space(d={thickness:g}))") + continue + + if isfinite(thickness) and thickness > 0: + lines.append(f" path.append(Space(d={thickness:g}))") + + class_name = ELEMENT_ALIASES.get(element_name, element_name) + lines.append(f" path.append({class_name}({record.get('arguments', '')}))") + + lines.extend([" path.display(comments=comments)", ""]) + return "\n".join(lines) + + def write_current_layout_file(self, path, title_override=None): + path.write_text(self.current_layout_source(title_override=title_override)) if __name__ == "__main__":