diff --git a/data/handouts/.gitignore b/data/handouts/.gitignore
index eb2cf65e..66a3da3e 100644
--- a/data/handouts/.gitignore
+++ b/data/handouts/.gitignore
@@ -5,4 +5,5 @@
!*.en.tex
!*.cs.tex
!Images/
-!Images/**
\ No newline at end of file
+!Images/**
+Images/texput.**
\ No newline at end of file
diff --git a/data/handouts/Images/_Export-Asy.ps1 b/data/handouts/Images/_Export-Asy.ps1
new file mode 100644
index 00000000..7233713e
--- /dev/null
+++ b/data/handouts/Images/_Export-Asy.ps1
@@ -0,0 +1,120 @@
+# The script below batch-renders Asymptote .asy files to PDF and SVG sitting next to each .asy.
+# It is meant for the handout-figure workflow: author a .asy file (typically importing _common.asy)
+# and let this script produce both a vector PDF and a plain SVG without ever opening a viewer.
+#
+# Per .asy file the pipeline is:
+# 1. Run asy with -noView to render the .asy to a PDF next to the source. asy is run with
+# -cd
so `import _common;` and other relative imports resolve next to the source file.
+# 2. Convert the PDF to SVG via Inkscape (matches the conversion path used by _Export-Ggb.ps1
+# so handout figures coming from either source render identically downstream).
+param(
+ # One or more directories, .asy files, or PowerShell wildcards (e.g. 'angles-*.asy'). Default
+ # is the folder this script lives in, which matches the typical handout-figures layout.
+ [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
+ [string[]]$Path = @(Split-Path -Parent $MyInvocation.MyCommand.Definition),
+
+ # Path to the Asymptote executable.
+ [string]$AsyExe = 'C:\Program Files\Asymptote\asy.exe'
+)
+
+# Ensure errors stop the script and suppress progress output
+$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+
+# Validate the Asymptote executable up front so we fail fast with a clear message
+if (-not (Test-Path -LiteralPath $AsyExe)) {
+ Write-Error "Asymptote executable not found at $AsyExe"
+ exit 1
+}
+
+# Validate Inkscape is on PATH (used for converting the PDF to SVG). Prefer inkscape.exe
+# (GUI subsystem) over inkscape.com (console launcher) so it doesn't attach to our terminal.
+$inkscape = Get-Command inkscape -ErrorAction SilentlyContinue
+if (-not $inkscape) {
+ Write-Error "Inkscape not found on PATH (needed for PDF -> SVG conversion)"
+ exit 1
+}
+$inkscape = $inkscape.Source
+$inkscapeExe = [System.IO.Path]::ChangeExtension($inkscape, '.exe')
+if (Test-Path -LiteralPath $inkscapeExe) { $inkscape = $inkscapeExe }
+
+# Pipeline for one .asy file: asy PDF -> Inkscape SVG. Both outputs are written next to the
+# input as .pdf / .svg.
+function Convert-OneAsy {
+ param(
+ [Parameter(Mandatory = $true)][string]$AsyPath
+ )
+
+ # Determine output paths
+ $stem = [System.IO.Path]::GetFileNameWithoutExtension($AsyPath)
+ $dir = [System.IO.Path]::GetDirectoryName($AsyPath)
+ $finalPdf = Join-Path $dir "$stem.pdf"
+ $finalSvg = Join-Path $dir "$stem.svg"
+
+ # 1. Render the .asy to PDF. -noView suppresses asy's auto-opening of the system PDF viewer.
+ # -f pdf forces PDF output (asy otherwise picks based on settings.eps / settings.outformat).
+ # -o sets the output filename stem (asy appends the format extension).
+ # -cd changes asy's working directory so `import _common;` resolves next to the file.
+ if (Test-Path -LiteralPath $finalPdf) { Remove-Item -LiteralPath $finalPdf -Force }
+ $asyArgs = @('-noView', '-f', 'pdf', '-o', $stem, '-cd', $dir, $AsyPath)
+ $proc = Start-Process -FilePath $AsyExe -ArgumentList $asyArgs -Wait -PassThru -NoNewWindow
+ if ($proc.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $finalPdf)) {
+ throw "asy failed (exit $($proc.ExitCode))"
+ }
+
+ # 2. Convert the PDF to plain SVG via Inkscape. The .com launcher on PATH is the console
+ # launcher that waits for inkscape.exe, so -Wait is enough.
+ if (Test-Path -LiteralPath $finalSvg) { Remove-Item -LiteralPath $finalSvg -Force }
+ $proc = Start-Process -FilePath $inkscape -ArgumentList @($finalPdf, '--pdf-poppler', '--export-type=svg', '--export-plain-svg', "--export-filename=$finalSvg") -Wait -PassThru
+ if ($proc.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $finalSvg)) {
+ throw "Inkscape SVG conversion failed (exit $($proc.ExitCode))"
+ }
+}
+
+# Resolve each entry in $Path into .asy files: a directory expands to *.asy in it, a single
+# file is used as-is, anything else is treated as a glob (Get-ChildItem handles wildcards).
+$asyFiles = @()
+foreach ($p in $Path) {
+ if (-not (Test-Path -LiteralPath $p -PathType Container) -and $p -notmatch '\.asy$') {
+ $p = $p + '.asy'
+ }
+ if (Test-Path -LiteralPath $p -PathType Container) {
+ $asyFiles += Get-ChildItem -LiteralPath $p -Filter '*.asy' -File
+ } elseif (Test-Path -LiteralPath $p -PathType Leaf) {
+ $asyFiles += @(Get-Item -LiteralPath $p)
+ } else {
+ $asyFiles += Get-ChildItem -Path $p -File -ErrorAction SilentlyContinue |
+ Where-Object { $_.Extension -ieq '.asy' }
+ }
+}
+
+# Skip files whose name starts with '_' (convention for shared modules like _common.asy)
+$asyFiles = @($asyFiles | Where-Object { -not $_.Name.StartsWith('_') })
+
+if ($asyFiles.Count -eq 0) {
+ Write-Error "No .asy files found for path: $Path"
+ exit 1
+}
+
+# Process each .asy, accumulate per-file status so one failure doesn't stop the batch
+$total = $asyFiles.Count
+Write-Host "Processing $total file$(if ($total -ne 1) { 's' })..." -ForegroundColor Cyan
+$ok = 0; $fail = 0; $i = 0
+foreach ($asyFile in $asyFiles) {
+ $i++
+ $prefix = "[$i/$total]"
+ try {
+ Convert-OneAsy -AsyPath $asyFile.FullName
+ Write-Host "$prefix [OK] $($asyFile.Name)" -ForegroundColor Green
+ $ok++
+ }
+ catch {
+ Write-Host "$prefix [FAIL] $($asyFile.Name): $_" -ForegroundColor Yellow
+ $fail++
+ }
+}
+
+# Final report
+Write-Host ""; Write-Host "Done. Success: $ok, Failed: $fail" -ForegroundColor Cyan; Write-Host ""
+
+if ($fail -gt 0) { exit 1 } else { exit 0 }
diff --git a/data/handouts/Images/_common.asy b/data/handouts/Images/_common.asy
new file mode 100644
index 00000000..a1afaf11
--- /dev/null
+++ b/data/handouts/Images/_common.asy
@@ -0,0 +1,383 @@
+// Shared setup for handout figures: imported with `import _common;`.
+
+// Latin Modern for label text so figures match the surrounding handout body
+texpreamble("\usepackage{lmodern}\usepackage[T1]{fontenc}");
+
+// Basic units
+unitsize(1pt);
+defaultpen(fontsize(13pt));
+
+//
+// Palette: 5 hue families × Light/Normal/Dark. Pick the closest hue and shade.
+// AngleMark / RightAngleMark fills want a `Light*` pen so the sector reads softly.
+//
+pen LightBlue = rgb(0.5, 0.5, 1);
+pen Blue = rgb(0, 0, 1);
+pen DarkBlue = rgb(0, 0, 0.5);
+
+pen LightRed = rgb(1, 0.5, 0.5);
+pen Red = rgb(1, 0, 0);
+pen DarkRed = rgb(0.5, 0, 0);
+
+pen LightGreen = rgb(0.5, 1, 0.5);
+pen Green = rgb(0, 0.5, 0);
+pen DarkGreen = rgb(0, 0.25, 0);
+
+pen LightPurple = rgb(1, 0.5, 1);
+pen Purple = rgb(0.5, 0, 0.5);
+pen DarkPurple = rgb(0.25, 0, 0.25);
+
+pen LightPink = rgb(1, 0.75, 0.85);
+pen Pink = rgb(1, 0.5, 0.75);
+pen DarkPink = rgb(0.75, 0.25, 0.5);
+
+//
+// Line-width tiers. NormalWidth is the default for edges and circles.
+//
+real ThinWidth = 0.5;
+real NormalWidth = 1.0;
+real ThickWidth = 1.5;
+
+//
+// Standard pens reused by every figure.
+//
+pen edgePen = black + linewidth(NormalWidth);
+pen vertexPen = black + linewidth(ThinWidth);
+
+//
+// Dashed linetype for auxiliary segments — combine with a colour, e.g. `Draw(A, B, black + dashedPen)`.
+//
+pen dashedPen = linetype("3 3", offset=0, scale=false);
+
+//
+// Arc-radius presets for AngleMark. Radius3 is the default; pick a different
+// preset (or pass a literal) per call when a figure needs more variety.
+// Reassign any of these per file if the figure scale calls for it.
+//
+real Radius1 = 10;
+real Radius2 = 15;
+real Radius3 = 20;
+real Radius4 = 25;
+real Radius5 = 30;
+
+//
+// Default vertex dot radius
+//
+real vertexDotRadius = 2.95;
+
+//
+// Default shift along alignDir for point labels
+//
+real pointLabelDistance = 3;
+
+//
+// Default shift along alignDir for edge labels
+//
+real edgeLabelDistance = 0;
+
+//
+// Default fraction of AngleMark's radius at which its label is placed.
+//
+real angleMarkLabelFraction = 1.5;
+
+//
+// Length of each tick segment in the parallel-lines mark.
+//
+real parallelMarkLength = 7.5;
+
+//
+// Distance between adjacent ticks in the parallel-lines mark.
+//
+real parallelMarkSpacing = 3;
+
+//
+// Rotation angle of each tick relative to the segment direction.
+//
+real parallelMarkAngle = 80;
+
+//
+// Geometric point constructors
+//
+
+//
+// Returns the midpoint of segment AB.
+//
+pair Midpoint(
+ pair A,
+ pair B)
+{
+ return (A + B) / 2;
+}
+
+//
+// Returns the point at distance r from O along the ray at angle angleDeg
+// (measured CCW from +x).
+//
+pair Polar(
+ pair O,
+ real angleDeg,
+ real r)
+{
+ return O + r * dir(angleDeg);
+}
+
+//
+// Extends ray AB past B by `length` units. The returned point lies on line AB,
+// on the far side of B from A, at distance `length` from B.
+//
+pair ExtendPast(
+ pair A,
+ pair B,
+ real length)
+{
+ return B + length * unit(B - A);
+}
+
+//
+// Returns the perpendicular projection of P onto the line through A and B.
+//
+pair Foot(
+ pair P,
+ pair A,
+ pair B)
+{
+ pair d = B - A;
+ return A + dot(P - A, d) / dot(d, d) * d;
+}
+
+//
+// Returns the reflection of P across the line through A and B.
+//
+pair ReflectAcross(
+ pair P,
+ pair A,
+ pair B)
+{
+ return 2 * Foot(P, A, B) - P;
+}
+
+//
+// Fills the angle sector ∠XYZ with vertex Y, sweeping CCW from ray YX to ray YZ.
+// Pass a `Light*` pen for `color` so the filled sector reads softly. When `lab`
+// is non-empty, "$lab$" is placed on the angular bisector at distance
+// labelFraction * radius + labelOffset from Y.
+//
+// Used global variables: Radius3, angleMarkLabelFraction
+//
+void AngleMark(
+ pair X,
+ pair Y,
+ pair Z,
+ pen color,
+ string lab = "",
+ real radius = Radius3,
+ real labelFraction = angleMarkLabelFraction,
+ real labelOffset = 0,
+ pen labelPen = black)
+{
+ // Compute start/end angles (CCW from +x)
+ real d1 = degrees(X - Y);
+ real d2 = degrees(Z - Y);
+
+ // Normalise so d2 ≥ d1 — Asymptote's arc() reverses direction (CW) when d2 < d1.
+ while (d2 < d1) d2 += 360;
+
+ // Fill the wedge from Y along the arc back to Y
+ fill(Y -- arc(Y, radius, d1, d2) -- cycle, color);
+
+ // Place the optional label on the angular bisector
+ if (lab != "") {
+ real mid = (d1 + d2) / 2;
+ real labelR = radius * labelFraction + labelOffset;
+ label("$" + lab + "$", Y + labelR * dir(mid), labelPen);
+ }
+}
+
+//
+// Draws the segment AB with the standard edge styling. Pass a solid colour
+// (e.g. Blue) to tint; linewidth is added automatically. To keep extra pen
+// attributes such as a linetype, pass a fully-formed pen (e.g. `black + dashedPen`).
+//
+// Used global variables: NormalWidth
+//
+void Draw(
+ pair A,
+ pair B,
+ pen color = black)
+{
+ draw(A -- B, color + linewidth(NormalWidth));
+}
+
+//
+// Draws a circle with the same edge styling as Draw — same colour-only convention.
+//
+// Used global variables: NormalWidth
+//
+void Circle(
+ pair center,
+ real radius,
+ pen color = black)
+{
+ draw(circle(center, radius), color + linewidth(NormalWidth));
+}
+
+//
+// Draws a single coloured-fill black-outlined dot at point P.
+//
+// Used global variables: vertexDotRadius, vertexPen
+//
+void VertexDot(
+ pair P,
+ pen fillColor = Blue,
+ real radius = vertexDotRadius)
+{
+ // Fill the disc, then outline it
+ fill(circle(P, radius), fillColor);
+ draw(circle(P, radius), vertexPen);
+}
+
+//
+// Draws the same dot at every given point. Pass `fillColor` to recolour all of them.
+//
+// Used global variables: vertexDotRadius
+//
+void VertexDots(
+ pair[] points,
+ pen fillColor = Blue,
+ real radius = vertexDotRadius)
+{
+ for (pair P : points) VertexDot(P, fillColor, radius);
+}
+
+//
+// Places "$name$" near point P with an optional compass alignment (e.g. N, SE).
+// `distanceOffset` shifts the label outward along alignDir; `offset` nudges in
+// absolute coords for the rare case the compass directions aren't enough.
+//
+// Used global variables: pointLabelDistance
+//
+void PointLabel(
+ pair P,
+ string name,
+ pair alignDir = (0, 0),
+ real distanceOffset = pointLabelDistance,
+ pair offset = (0, 0),
+ pen color = Blue)
+{
+ // Start from the anchor with the absolute offset applied
+ pair pos = P + offset;
+
+ // Push outward along the compass direction, if one was given
+ if (alignDir != (0, 0)) pos += distanceOffset * unit(alignDir);
+
+ // Render the LaTeX-wrapped name at that position
+ label("$" + name + "$", pos, alignDir, color);
+}
+
+//
+// Draws a vertex dot at P plus its "$name$" label, both in the same colour.
+// Replaces the VertexDot + PointLabel pair that appears for every named point.
+//
+// Used global variables: pointLabelDistance
+//
+void LabeledDot(
+ pair P,
+ string name,
+ pair alignDir = (0, 0),
+ real distanceOffset = pointLabelDistance,
+ pair offset = (0, 0),
+ pen color = Blue)
+{
+ // Draw the dot, then the label, using the same colour for both
+ VertexDot(P, color);
+ PointLabel(P, name, alignDir, distanceOffset, offset, color);
+}
+
+//
+// Draws a right-angle indicator at vertex O with perpendicular rays toward A and B.
+// `radius` is the distance from O to the outer corner, matching AngleMark's
+// `radius` so the two scale together when sharing Radius3. Filled by default
+// (pass a `Light*` pen for `color` so the L reads softly); pass filled=false
+// for the bare L-shape stroke.
+//
+// Used global variables: Radius3, edgePen
+//
+void RightAngleMark(
+ pair A,
+ pair O,
+ pair B,
+ real radius = Radius3,
+ pen color = LightBlue,
+ bool filled = true)
+{
+ // Leg length so the outer corner sits at distance `radius` from O
+ real legLen = radius * sqrt(2) / 2;
+
+ // Three corners of the right-angle quadrilateral (the fourth is O itself)
+ pair p1 = O + legLen * unit(A - O);
+ pair p2 = p1 + legLen * unit(B - O);
+ pair p3 = O + legLen * unit(B - O);
+
+ // Fill the L-shape or just stroke its outline
+ if (filled) {
+ fill(O -- p1 -- p2 -- p3 -- cycle, color);
+ } else {
+ draw(p1 -- p2 -- p3, edgePen);
+ }
+}
+
+//
+// Places "$name$" near the midpoint of segment AB. `alignDir` (compass: N, S,
+// E, W, …) both nudges the position `distanceOffset` units off the segment AND
+// aligns the label box on that side, so it reads cleanly. `placement` shifts
+// away from the midpoint as a parametric position in 0..1.
+//
+// Used global variables: edgeLabelDistance
+//
+void EdgeLabel(
+ pair A,
+ pair B,
+ string name,
+ pair alignDir = (0, 0),
+ real distanceOffset = edgeLabelDistance,
+ real placement = 0.5,
+ pen color = black)
+{
+ // Start from the parametric position along AB
+ pair pos = A + placement * (B - A);
+
+ // Push perpendicular off the segment if a compass direction was given
+ if (alignDir != (0, 0)) pos += distanceOffset * unit(alignDir);
+
+ // Render the LaTeX-wrapped name there
+ label("$" + name + "$", pos, alignDir, color);
+}
+
+//
+// Draws `count` short ticks along segment AB centred at parametric position
+// `placement`, each rotated `parallelMarkAngle` from the segment direction.
+//
+// Used global variables: parallelMarkLength, parallelMarkSpacing, parallelMarkAngle
+//
+void ParallelMark(
+ pair A,
+ pair B,
+ int count = 2,
+ real placement = 0.5,
+ pen color = edgePen)
+{
+ // Unit vector along AB and the centre of the tick group
+ pair u = unit(B - A);
+ pair mid = A + placement * (B - A);
+
+ // Tick direction, plus precomputed half-length and offset start
+ pair tickDir = rotate(parallelMarkAngle) * u;
+ real half = parallelMarkLength / 2;
+ real start = -(count - 1) / 2.0;
+
+ // Draw each tick centred on a point spaced parallelMarkSpacing along AB
+ for (int i = 0; i < count; ++i) {
+ pair c = mid + (start + i) * parallelMarkSpacing * u;
+ draw((c - half * tickDir) -- (c + half * tickDir), color);
+ }
+}