|
14 | 14 | "\n", |
15 | 15 | "**Key demonstrations**:\n", |
16 | 16 | "1. **Frame transformations simplify optics**: Position/rotate lenses by modifying frames - the raytracing algorithm stays unchanged\n", |
17 | | - "2. **Meridional plane reduction**: Transform 3D intersection problem to 2D by exploiting rotational symmetry\n", |
18 | | - "3. **Interchangeable profiles**: Swap optical surfaces (spherical, hyperbolic) without changing the tracing code\n", |
| 17 | + "2. **Interchangeable profiles**: Swap optical surfaces (spherical, hyperbolic) without changing the tracing code\n", |
19 | 18 | "\n", |
20 | 19 | "**Note**: This is a simplified raytracer for demonstration. Production implementations would handle:\n", |
21 | 20 | " - Rays missing the surface (returns error instead of graceful fallback)\n", |
22 | 21 | " - Rays starting inside the surface\n", |
23 | | - " - Numerical stability near grazing incidence" |
| 22 | + " - Numerical stability near grazing incidence\n", |
| 23 | + " - concave surfaces" |
24 | 24 | ] |
25 | 25 | }, |
26 | 26 | { |
|
264 | 264 | " \"\"\"\n", |
265 | 265 | " return self.profile(np.asarray(r))\n", |
266 | 266 | "\n", |
267 | | - " def intersect(self, ray: Ray) -> tuple[float, Point, Vector] | None:\n", |
| 267 | + " def intersect(self, ray: Ray) -> tuple[float, Point, Vector]:\n", |
268 | 268 | " \"\"\"Find ray-surface intersection.\n", |
269 | 269 | "\n", |
270 | | - " Strategy: Transform to meridional plane to reduce 3D problem to 2D.\n", |
271 | | - " The meridional plane contains the optical axis (x) and the ray origin.\n", |
272 | | - " In this plane, we only need to solve for the axial coordinate x.\n", |
| 270 | + " Strategy: Transform the ray to the optics local coordinate system (frame).\n", |
| 271 | + " Reduce the problem to 2d by exploiting rotational symmetry of the optical element in its frame.\n", |
273 | 272 | "\n", |
274 | 273 | " Args:\n", |
275 | 274 | " ray: Incident ray\n", |
|
280 | 279 | " \"\"\"\n", |
281 | 280 | " local_ray = ray.to_frame(self.frame)\n", |
282 | 281 | "\n", |
283 | | - " # Create meridional plane: contains optical axis and ray origin\n", |
284 | | - " # This reduces the 3D intersection to a 2D problem (x, y_meridional)\n", |
285 | | - " phi = np.arctan2(local_ray.origin.z, local_ray.origin.y)\n", |
286 | | - " meridional = self.frame.make_child(\"meridional\").rotate_euler(x=phi)\n", |
287 | | - " meri_ray = ray.to_frame(meridional)\n", |
288 | | - "\n", |
289 | | - " # In meridional plane: only solve for x coordinate\n", |
290 | 282 | " def objective_function(t: float) -> float:\n", |
291 | | - " y = meri_ray.origin.y + t * meri_ray.direction.y\n", |
292 | | - " r = abs(y)\n", |
| 283 | + " point = local_ray.origin + t * local_ray.direction\n", |
| 284 | + " r = np.sqrt(point.y**2 + point.z**2)\n", |
293 | 285 | "\n", |
294 | 286 | " if r > self.aperture_radius:\n", |
295 | 287 | " return 1e10 if t > 0 else -1e10\n", |
296 | 288 | "\n", |
297 | | - " x_ray = meri_ray.origin.x + t * meri_ray.direction.x\n", |
298 | 289 | " x_surface = self.profile(np.array([r]))[0]\n", |
299 | 290 | "\n", |
300 | 291 | " if np.isnan(x_surface):\n", |
301 | 292 | " return 1e10 if t > 0 else -1e10\n", |
302 | 293 | "\n", |
303 | | - " return x_ray - x_surface\n", |
| 294 | + " return point.x - x_surface\n", |
304 | 295 | "\n", |
305 | | - " # Use bracketing method (robust for 1D problems)\n", |
306 | 296 | " # Search for sign change between t=0 and t=100\n", |
307 | 297 | " try:\n", |
308 | 298 | " t_hit, res = brentq(\n", |
309 | | - " objective_function, 0.1, 100.0, xtol=1e-10, full_output=True\n", |
| 299 | + " objective_function, 1e-3, 100.0, xtol=1e-10, full_output=True\n", |
310 | 300 | " )\n", |
311 | | - " except ValueError:\n", |
312 | | - " return None\n", |
| 301 | + " except ValueError as err:\n", |
| 302 | + " raise ValueError(\n", |
| 303 | + " \"No valid intersection between ray and surface found\"\n", |
| 304 | + " ) from err\n", |
313 | 305 | "\n", |
314 | | - " propagated_ray = meri_ray.propagate(t_hit)\n", |
| 306 | + " propagated_ray = local_ray.propagate(t_hit)\n", |
315 | 307 | " hit_local = propagated_ray.origin.to_frame(self.frame)\n", |
316 | 308 | "\n", |
317 | 309 | " # Compute surface normal in local frame\n", |
|
366 | 358 | " y = np.linspace(-self.aperture_radius, self.aperture_radius, n_points)\n", |
367 | 359 | " x = self.profile(np.abs(y))\n", |
368 | 360 | "\n", |
369 | | - " defaults = {\"linewidth\": 2}\n", |
| 361 | + " defaults: dict = {\"linewidth\": 2}\n", |
370 | 362 | " defaults.update(kwargs)\n", |
371 | 363 | " ax.plot(x, y, **defaults)\n", |
372 | 364 | "\n", |
|
582 | 574 | " surface: AxisymmetricSurface,\n", |
583 | 575 | " n_rays: int = 10,\n", |
584 | 576 | " inner_radius: float = 10.0,\n", |
585 | | - " outer_radius: float = 22.0,\n", |
| 577 | + " outer_radius: float = 20.0,\n", |
586 | 578 | " n1: float = 1.0,\n", |
587 | 579 | " n2: float = 1.5,\n", |
588 | | - " final_x: float = 33.0,\n", |
| 580 | + " final_x: float = 38.0,\n", |
589 | 581 | ") -> tuple[dict[int, RayHistory], tuple]:\n", |
590 | 582 | " \"\"\"Trace parallel rays through an optical surface.\n", |
591 | 583 | "\n", |
|
615 | 607 | " # Generate rays in circular pattern\n", |
616 | 608 | " phis = np.linspace(0, 2 * np.pi, n_rays, endpoint=False)\n", |
617 | 609 | "\n", |
618 | | - " for radius, cmap in zip([inner_radius, outer_radius], [plt.cm.Blues, plt.cm.Reds]):\n", |
| 610 | + " for radius, cmap in zip([inner_radius, outer_radius], [plt.cm.Reds, plt.cm.Blues]):\n", |
619 | 611 | " rays = defaultdict(RayHistory)\n", |
620 | | - " colors = cmap(np.linspace(0.3, 0.9, n_rays))\n", |
| 612 | + " colors = cmap(np.linspace(0.2, 0.8, n_rays))\n", |
621 | 613 | " for i, phi in enumerate(phis):\n", |
622 | 614 | " z = radius * np.cos(phi)\n", |
623 | 615 | " y = radius * np.sin(phi)\n", |
|
654 | 646 | " *ray_history.history[0].point[[1, 2]],\n", |
655 | 647 | " color=color,\n", |
656 | 648 | " s=80,\n", |
657 | | - " alpha=0.3,\n", |
| 649 | + " alpha=0.5,\n", |
658 | 650 | " edgecolors=\"none\",\n", |
659 | 651 | " )\n", |
660 | 652 | " ax_2d.scatter(\n", |
661 | 653 | " *ray_history.history[-1].point[[1, 2]],\n", |
662 | 654 | " color=color,\n", |
663 | 655 | " s=80,\n", |
664 | | - " edgecolors=\"white\",\n", |
| 656 | + " edgecolors=\"black\",\n", |
665 | 657 | " linewidth=1.5,\n", |
666 | 658 | " )\n", |
667 | 659 | "\n", |
|
681 | 673 | "id": "10", |
682 | 674 | "metadata": {}, |
683 | 675 | "source": [ |
684 | | - "# Spherical Aberration Example\n", |
| 676 | + "## Example: Raytracing a lens\n", |
685 | 677 | "\n", |
686 | 678 | "This example demonstrates how coordinate frame transformations simplify optical raytracing.\n", |
687 | 679 | "\n", |
|
695 | 687 | "id": "11", |
696 | 688 | "metadata": {}, |
697 | 689 | "source": [ |
698 | | - "## Create root frame" |
| 690 | + "### Create root frame" |
699 | 691 | ] |
700 | 692 | }, |
701 | 693 | { |
|
713 | 705 | "id": "13", |
714 | 706 | "metadata": {}, |
715 | 707 | "source": [ |
716 | | - "## Aligned spherical lens\n", |
| 708 | + "### Aligned spherical lens\n", |
717 | 709 | "\n", |
718 | 710 | "Start with a perfectly aligned spherical lens. Notice the **spherical aberration**: rays at different radial distances don't focus to the same point - the footprint spreads out." |
719 | 711 | ] |
|
749 | 741 | "id": "15", |
750 | 742 | "metadata": {}, |
751 | 743 | "source": [ |
752 | | - "## Misalign the lens - only one line changes!\n", |
| 744 | + "### Misalign the lens - only one line changes!\n", |
753 | 745 | "\n", |
754 | 746 | "Now rotate the lens 5 degrees around the y-axis. **Look closely**: Only the `lens_frame` definition changes - the entire raytracing algorithm (intersection, refraction, propagation) stays identical.\n", |
755 | 747 | "\n", |
|
765 | 757 | "source": [ |
766 | 758 | "# Misaligned case: lens rotated 5 degrees\n", |
767 | 759 | "# ↓↓↓ ONLY THIS LINE CHANGES - everything else is identical! ↓↓↓\n", |
768 | | - "lens_frame = root.make_child(\"lens\").translate(x=-20).rotate_euler(y=5, degrees=True)\n", |
| 760 | + "lens_frame = root.make_child(\"lens\").translate(x=-20).rotate_euler(y=10, degrees=True)\n", |
769 | 761 | "\n", |
770 | 762 | "profile, derivative = spherical_profile(R=25)\n", |
771 | 763 | "\n", |
|
787 | 779 | "id": "17", |
788 | 780 | "metadata": {}, |
789 | 781 | "source": [ |
790 | | - "## Swap the surface profile - only the profile changes!\n", |
| 782 | + "### Swap the surface profile - only the profile changes!\n", |
791 | 783 | "\n", |
792 | 784 | "Replace the spherical lens with a hyperbolic one. Again, **only the profile function changes** - frame setup and raytracing code stay identical.\n", |
793 | 785 | "\n", |
|
805 | 797 | "# ↓↓↓ ONLY THE PROFILE CHANGES - frame, raytracing, everything else identical! ↓↓↓\n", |
806 | 798 | "lens_frame = root.make_child(\"hyperbolic_lens\").translate(x=-25)\n", |
807 | 799 | "\n", |
808 | | - "profile, derivative = hyperbolic_profile(R=25, conic_constant=-1.0)\n", |
| 800 | + "profile, derivative = hyperbolic_profile(R=25, conic_constant=-2)\n", |
809 | 801 | "\n", |
810 | 802 | "surface = AxisymmetricSurface(\n", |
811 | 803 | " frame=lens_frame,\n", |
|
829 | 821 | "\n", |
830 | 822 | "**Frame transformations for optics**: Optical elements (lenses, mirrors) live in their own frames. Repositioning or rotating them requires changing only the frame definition - all geometric calculations (intersection finding, normal computation, refraction) work in local coordinates and remain unchanged.\n", |
831 | 823 | "\n", |
832 | | - "**Meridional plane reduction**: For rotationally symmetric surfaces, we reduce the 3D ray-surface intersection to a 1D root-finding problem by:\n", |
833 | | - "1. Transforming the ray to a meridional plane (contains optical axis + ray origin)\n", |
834 | | - "2. Solving for the single parameter `t` (propagation distance) where ray hits surface\n", |
835 | | - "This exploits symmetry to simplify the problem from 3 unknowns to 1.\n", |
836 | | - "\n", |
837 | 824 | "**Interchangeable profiles**: Each surface profile (`spherical`, `hyperbolic`, etc.) is just a function `x = f(r)`. Swapping profiles means changing the function - the `AxisymmetricSurface` class and raytracing algorithm stay identical. This modularity allows easy exploration of different optical designs.\n", |
838 | 825 | "\n", |
839 | 826 | "**Immutable rays**: Like `Point` and `Vector`, `Ray` is immutable - all operations (`propagate`, `refract`, `to_frame`) return new instances. This matches hazy's philosophy: geometric primitives are values, not mutable state.\n", |
840 | 827 | "\n", |
841 | | - "**Real raytracing**: This isn't a toy example - the meridional plane technique and conic surface formulas are used in real optical design software. Frame-based coordinates make the code match how optical engineers think about lens systems.\n", |
842 | | - "\n", |
843 | | - "**Without hazy**: Manually compose transformation matrices for each lens position, track multiplication order for nested frames (root → lens → meridional), explicitly transform every point and vector, rewrite intersection code when repositioning optics.\n", |
| 828 | + "**Without hazy**: Manually compose transformation matrices for each lens position, track multiplication order for nested frames, explicitly transform every point and vector, rewrite intersection code when repositioning optics.\n", |
844 | 829 | "\n", |
845 | 830 | "**With hazy**: Define surfaces in natural local coordinates (`origin=(0,0,0)`, x-axis = optical axis), modify frames to position/orient elements, call `.to_frame()` for automatic transformation through arbitrarily deep hierarchies." |
846 | 831 | ] |
|
0 commit comments