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/ui/raytracing_app.py b/raytracing/ui/raytracing_app.py index 1dacf5e2..cbba5cd5 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 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,12 +23,252 @@ import time import ast import inspect +import traceback 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 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=list(ELEMENT_DEFAULTS.keys()), + state="readonly", + ) + 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() + 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=["Finite", "Infinity"], + state="readonly", + ) + 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() + 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): @@ -43,14 +285,67 @@ 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.create_window_widgets() self.refresh() + @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.table_group = View(width=300, height=300) self.table_group.grid_into( @@ -81,26 +376,40 @@ def create_window_widgets(self): ) self.delete_button.grid_into(self.button_group, row=0, column=2, pady=5, padx=5) + self.move_up_button = Button( + "⬆", user_event_callback=self.click_table_buttons + ) + self.move_up_button.grid_into(self.button_group, row=0, column=3, pady=5, padx=5) + + self.move_down_button = Button( + "⬇", user_event_callback=self.click_table_buttons + ) + self.move_down_button.grid_into( + self.button_group, row=0, column=4, pady=5, padx=5 + ) + 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=5, 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=6, 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 +421,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 +469,7 @@ def create_window_widgets(self): # } # ) self.tableview.delegate = self + self.update_variable_row_styles() self.results_tableview = TableView( columns_labels={ @@ -194,7 +523,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 +535,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 +547,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 +557,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 +569,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 +591,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 +642,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 +667,98 @@ 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 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 +772,223 @@ def observed_property_changed( self.refresh() def source_data_changed(self, tableview): + 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 ] @@ -402,25 +1017,67 @@ def click_copy_buttons(self, event, button): pyperclip.copy(script) 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 +1085,12 @@ 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("x-axis") self.canvas.widget.delete("y-axis") self.canvas.widget.delete("tick") @@ -442,53 +1098,134 @@ 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) + + 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 +1242,149 @@ 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) + + 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 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,11 +1396,26 @@ 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: canvas_image = Arrow( start=Point(image_z, -image_height / 2, basis=basis), end=Point(image_z, image_height / 2, basis=basis), @@ -604,22 +1425,59 @@ def create_conjugate_planes(self, path): ) 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 + aperture_labels = [] - if position.z is not None: - aperture_stop_label = CanvasLabel(text="AS", tag=("apertures")) - self.coords.place( - aperture_stop_label, position=Point(position.z, label_position) - ) + aperture_stop = path.apertureStop() + if aperture_stop.z is not None and isfinite(aperture_stop.z): + aperture_labels.append((aperture_stop.z, "AS")) - position = path.fieldStop() - if position.z is not None: - field_stop_label = CanvasLabel(text="FS", tag=("apertures")) + field_stop = path.fieldStop() + if field_stop.z is not None and isfinite(field_stop.z): + aperture_labels.append((field_stop.z, "FS")) + + if len(aperture_labels) == 2 and abs(aperture_labels[0][0] - aperture_labels[1][0]) < 1e-9: + aperture_labels = [(aperture_labels[0][0], "AS = FS")] + + for z_value, label_text in aperture_labels: self.coords.place( - field_stop_label, position=Point(position.z, label_position) + CanvasLabel(text=label_text, tag=("apertures")), + position=Point(z_value, label_position), ) def create_object_labels(self, path): @@ -649,6 +1507,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 +1524,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 +1611,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 +1660,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 +1824,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 +1862,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 +1900,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 +1952,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 +1978,58 @@ 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() + 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 ( + first_real_element is not None + and element.get("__uuid") == first_real_element.get("__uuid") + and object_is_infinite + ): + delta = 0.0 + 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 +2042,18 @@ 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": + front_focus = path.frontFocalLength() + if front_focus is not None and isfinite(front_focus) and front_focus > 0: + shifted_path = ImagingPath() + shifted_path.objectHeight = path.objectHeight + shifted_path.append(Space(d=front_focus)) + for element in path: + shifted_path.append(element) + path = shifted_path if max_position is not None: if path.L < max_position: @@ -958,38 +2062,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 +2120,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 +2138,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 @@ -1073,19 +2191,33 @@ def calculate_imaging_path_results(self, imaging_path): ) axial_ray = imaging_path.axialRay() - 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}", - } - ) + 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 +2258,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,41 +2283,86 @@ 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")