Skip to content

Commit 3f6ddce

Browse files
committed
docs(example-04): fixed meridional plane reduction and updated text and visualizations
1 parent c6f54f3 commit 3f6ddce

File tree

2 files changed

+31
-45
lines changed

2 files changed

+31
-45
lines changed

docs/examples/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ Interactive examples demonstrating coordinate frame transformations.
99
01-surface_intersections
1010
02-frame-hierarchies
1111
03-simple-robot-arm
12+
04-simple-raytracing

examples/04-simple-raytracing.ipynb

Lines changed: 30 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414
"\n",
1515
"**Key demonstrations**:\n",
1616
"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",
1918
"\n",
2019
"**Note**: This is a simplified raytracer for demonstration. Production implementations would handle:\n",
2120
" - Rays missing the surface (returns error instead of graceful fallback)\n",
2221
" - Rays starting inside the surface\n",
23-
" - Numerical stability near grazing incidence"
22+
" - Numerical stability near grazing incidence\n",
23+
" - concave surfaces"
2424
]
2525
},
2626
{
@@ -264,12 +264,11 @@
264264
" \"\"\"\n",
265265
" return self.profile(np.asarray(r))\n",
266266
"\n",
267-
" def intersect(self, ray: Ray) -> tuple[float, Point, Vector] | None:\n",
267+
" def intersect(self, ray: Ray) -> tuple[float, Point, Vector]:\n",
268268
" \"\"\"Find ray-surface intersection.\n",
269269
"\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",
273272
"\n",
274273
" Args:\n",
275274
" ray: Incident ray\n",
@@ -280,38 +279,31 @@
280279
" \"\"\"\n",
281280
" local_ray = ray.to_frame(self.frame)\n",
282281
"\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",
290282
" 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",
293285
"\n",
294286
" if r > self.aperture_radius:\n",
295287
" return 1e10 if t > 0 else -1e10\n",
296288
"\n",
297-
" x_ray = meri_ray.origin.x + t * meri_ray.direction.x\n",
298289
" x_surface = self.profile(np.array([r]))[0]\n",
299290
"\n",
300291
" if np.isnan(x_surface):\n",
301292
" return 1e10 if t > 0 else -1e10\n",
302293
"\n",
303-
" return x_ray - x_surface\n",
294+
" return point.x - x_surface\n",
304295
"\n",
305-
" # Use bracketing method (robust for 1D problems)\n",
306296
" # Search for sign change between t=0 and t=100\n",
307297
" try:\n",
308298
" 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",
310300
" )\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",
313305
"\n",
314-
" propagated_ray = meri_ray.propagate(t_hit)\n",
306+
" propagated_ray = local_ray.propagate(t_hit)\n",
315307
" hit_local = propagated_ray.origin.to_frame(self.frame)\n",
316308
"\n",
317309
" # Compute surface normal in local frame\n",
@@ -366,7 +358,7 @@
366358
" y = np.linspace(-self.aperture_radius, self.aperture_radius, n_points)\n",
367359
" x = self.profile(np.abs(y))\n",
368360
"\n",
369-
" defaults = {\"linewidth\": 2}\n",
361+
" defaults: dict = {\"linewidth\": 2}\n",
370362
" defaults.update(kwargs)\n",
371363
" ax.plot(x, y, **defaults)\n",
372364
"\n",
@@ -582,10 +574,10 @@
582574
" surface: AxisymmetricSurface,\n",
583575
" n_rays: int = 10,\n",
584576
" inner_radius: float = 10.0,\n",
585-
" outer_radius: float = 22.0,\n",
577+
" outer_radius: float = 20.0,\n",
586578
" n1: float = 1.0,\n",
587579
" n2: float = 1.5,\n",
588-
" final_x: float = 33.0,\n",
580+
" final_x: float = 38.0,\n",
589581
") -> tuple[dict[int, RayHistory], tuple]:\n",
590582
" \"\"\"Trace parallel rays through an optical surface.\n",
591583
"\n",
@@ -615,9 +607,9 @@
615607
" # Generate rays in circular pattern\n",
616608
" phis = np.linspace(0, 2 * np.pi, n_rays, endpoint=False)\n",
617609
"\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",
619611
" 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",
621613
" for i, phi in enumerate(phis):\n",
622614
" z = radius * np.cos(phi)\n",
623615
" y = radius * np.sin(phi)\n",
@@ -654,14 +646,14 @@
654646
" *ray_history.history[0].point[[1, 2]],\n",
655647
" color=color,\n",
656648
" s=80,\n",
657-
" alpha=0.3,\n",
649+
" alpha=0.5,\n",
658650
" edgecolors=\"none\",\n",
659651
" )\n",
660652
" ax_2d.scatter(\n",
661653
" *ray_history.history[-1].point[[1, 2]],\n",
662654
" color=color,\n",
663655
" s=80,\n",
664-
" edgecolors=\"white\",\n",
656+
" edgecolors=\"black\",\n",
665657
" linewidth=1.5,\n",
666658
" )\n",
667659
"\n",
@@ -681,7 +673,7 @@
681673
"id": "10",
682674
"metadata": {},
683675
"source": [
684-
"# Spherical Aberration Example\n",
676+
"## Example: Raytracing a lens\n",
685677
"\n",
686678
"This example demonstrates how coordinate frame transformations simplify optical raytracing.\n",
687679
"\n",
@@ -695,7 +687,7 @@
695687
"id": "11",
696688
"metadata": {},
697689
"source": [
698-
"## Create root frame"
690+
"### Create root frame"
699691
]
700692
},
701693
{
@@ -713,7 +705,7 @@
713705
"id": "13",
714706
"metadata": {},
715707
"source": [
716-
"## Aligned spherical lens\n",
708+
"### Aligned spherical lens\n",
717709
"\n",
718710
"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."
719711
]
@@ -749,7 +741,7 @@
749741
"id": "15",
750742
"metadata": {},
751743
"source": [
752-
"## Misalign the lens - only one line changes!\n",
744+
"### Misalign the lens - only one line changes!\n",
753745
"\n",
754746
"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",
755747
"\n",
@@ -765,7 +757,7 @@
765757
"source": [
766758
"# Misaligned case: lens rotated 5 degrees\n",
767759
"# ↓↓↓ 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",
769761
"\n",
770762
"profile, derivative = spherical_profile(R=25)\n",
771763
"\n",
@@ -787,7 +779,7 @@
787779
"id": "17",
788780
"metadata": {},
789781
"source": [
790-
"## Swap the surface profile - only the profile changes!\n",
782+
"### Swap the surface profile - only the profile changes!\n",
791783
"\n",
792784
"Replace the spherical lens with a hyperbolic one. Again, **only the profile function changes** - frame setup and raytracing code stay identical.\n",
793785
"\n",
@@ -805,7 +797,7 @@
805797
"# ↓↓↓ ONLY THE PROFILE CHANGES - frame, raytracing, everything else identical! ↓↓↓\n",
806798
"lens_frame = root.make_child(\"hyperbolic_lens\").translate(x=-25)\n",
807799
"\n",
808-
"profile, derivative = hyperbolic_profile(R=25, conic_constant=-1.0)\n",
800+
"profile, derivative = hyperbolic_profile(R=25, conic_constant=-2)\n",
809801
"\n",
810802
"surface = AxisymmetricSurface(\n",
811803
" frame=lens_frame,\n",
@@ -829,18 +821,11 @@
829821
"\n",
830822
"**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",
831823
"\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",
837824
"**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",
838825
"\n",
839826
"**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",
840827
"\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",
844829
"\n",
845830
"**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."
846831
]

0 commit comments

Comments
 (0)