{"id":4926,"date":"2026-04-06T16:52:20","date_gmt":"2026-04-06T20:52:20","guid":{"rendered":"https:\/\/faculty.fiu.edu\/~theobald\/?page_id=4926"},"modified":"2026-04-09T09:22:49","modified_gmt":"2026-04-09T13:22:49","slug":"light_capture","status":"publish","type":"page","link":"https:\/\/faculty.fiu.edu\/~theobald\/fun\/light_capture\/","title":{"rendered":"light_capture"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"4926\" class=\"elementor elementor-4926\">\n\t\t\t\t\t\t\t\t\t<section class=\"elementor-section elementor-top-section elementor-element elementor-element-d04152d elementor-section-full_width elementor-section-height-default elementor-section-height-default\" data-id=\"d04152d\" data-element_type=\"section\">\n\t\t\t\t\t\t<div class=\"elementor-container elementor-column-gap-default\">\n\t\t\t\t\t<div class=\"elementor-column elementor-col-100 elementor-top-column elementor-element elementor-element-8332fb6\" data-id=\"8332fb6\" data-element_type=\"column\">\n\t\t\t<div class=\"elementor-widget-wrap elementor-element-populated\">\n\t\t\t\t\t\t\t\t<div class=\"elementor-element elementor-element-87fd104 elementor-widget__width-inherit elementor-widget elementor-widget-html\" data-id=\"87fd104\" data-element_type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\"\/>\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\/>\n  <title>Ommatrix 2D<\/title>\n  <link href=\"https:\/\/fonts.googleapis.com\/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;600&display=swap\" rel=\"stylesheet\"\/>\n  <style>\n    :root {\n      --bg:        #080d12;\n      --panel:     #0d1520;\n      --panel2:    #111e2e;\n      --border:    #1d2e45;\n      --text:      #c8d8ee;\n      --muted:     #5a7898;\n      --accent:    #38c8ff;\n      --font-ui:   'IBM Plex Sans', sans-serif;\n      --font-mono: 'IBM Plex Mono', monospace;\n    }\n    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n    html, body {\n\theight: auto;\n\tbackground: var(--bg);\n\tcolor: var(--text);\n\tfont-family: var(--font-ui);\n\toverflow: auto;\n    }\n\n    .app {\n\tdisplay: grid;\n\tgrid-template-columns: 284px minmax(0, 1fr);\n\tgrid-template-rows: minmax(0, 1fr) minmax(220px, 28vh);\n\tgap: 10px;\n\theight: clamp(640px, 78vh, 920px);\n\tpadding: 10px;\n    }\n    .sidebar {\n      grid-row: 1 \/ span 2;\n      background: var(--panel);\n      border: 1px solid var(--border);\n      border-radius: 10px;\n      overflow-y: auto;\n      padding: 14px 12px 18px;\n    }\n    .main-wrap, .plot-panel {\n      background: var(--panel);\n      border: 1px solid var(--border);\n      border-radius: 10px;\n      min-height: 0;\n    }\n    .main-wrap {\n      display: flex;\n      flex-direction: column;\n      gap: 6px;\n      padding: 8px;\n    }\n    .bottom-panels {\n      display: grid;\n      grid-template-columns: repeat(2, minmax(0, 1fr));\n      gap: 10px;\n      min-height: 0;\n    }\n    .plot-panel {\n      padding: 8px;\n      display: flex;\n      flex-direction: column;\n      gap: 4px;\n      overflow: hidden;\n    }\n    .plot-title, .status-bar, .cg-title {\n      font-family: var(--font-mono);\n    }\n    .plot-title {\n      font-size: 10px;\n      color: var(--muted);\n      text-transform: uppercase;\n      letter-spacing: .08em;\n    }\n    .status-bar {\n      font-size: 11px;\n      color: var(--muted);\n      padding: 0 2px;\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n    canvas { display: block; border-radius: 6px; }\n    #mainCanvas { flex: 1; width: 100%; min-height: 0; background: #060b10; }\n    #retinaCanvas, #summaryCanvas, #heatmapCanvas {\n      flex: 1;\n      width: 100%;\n      height: 100%;\n      min-height: 0;\n      background: #060b10;\n    }\n    .brand {\n      margin-bottom: 14px;\n      border-bottom: 1px solid var(--border);\n      padding-bottom: 12px;\n    }\n    .brand h1 {\n      font-family: var(--font-mono);\n      font-size: 16px;\n      color: var(--accent);\n      letter-spacing: .04em;\n    }\n    .brand p {\n      font-size: 11px;\n      color: var(--muted);\n      margin-top: 4px;\n      line-height: 1.4;\n    }\n    .cg {\n      margin-bottom: 12px;\n      padding-bottom: 12px;\n      border-bottom: 1px solid var(--border);\n    }\n    .cg:last-child { border-bottom: none; margin-bottom: 0; }\n    .cg-title {\n      font-size: 10px;\n      font-weight: 600;\n      color: var(--muted);\n      text-transform: uppercase;\n      letter-spacing: .1em;\n      margin-bottom: 8px;\n    }\n    .crow {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      margin-bottom: 2px;\n    }\n    .clabel { font-size: 12px; color: var(--text); }\n    .cval {\n      font-family: var(--font-mono);\n      font-size: 11px;\n      color: var(--accent);\n      min-width: 54px;\n      text-align: right;\n    }\n    input[type=\"range\"] {\n      width: 100%;\n      accent-color: var(--accent);\n      margin-bottom: 8px;\n      cursor: pointer;\n    }\n    select, button {\n      width: 100%;\n      background: var(--panel2);\n      color: var(--text);\n      border: 1px solid var(--border);\n      border-radius: 6px;\n      padding: 7px 8px;\n      font-size: 12px;\n      margin-bottom: 8px;\n      cursor: pointer;\n    }\n    button:hover { background: #162438; border-color: #2a4261; }\n    .legend {\n      display: flex;\n      flex-wrap: wrap;\n      gap: 6px;\n      margin-top: 4px;\n    }\n    .li {\n      display: flex;\n      align-items: center;\n      gap: 5px;\n      font-size: 11px;\n      color: var(--muted);\n    }\n    .ls {\n      width: 10px;\n      height: 10px;\n      border-radius: 2px;\n      flex-shrink: 0;\n    }\n    .stats {\n      font-family: var(--font-mono);\n      font-size: 11px;\n      color: var(--muted);\n      margin-top: 8px;\n      line-height: 1.8;\n    }\n    .stats span { color: var(--text); }\n    .hidden { display: none !important; }\n    @media (max-width: 900px) {\n      .app {\n        grid-template-columns: 1fr;\n        grid-template-rows: auto minmax(320px, 1fr) auto;\n        height: auto;\n        \/* min-height: 100dvh; *\/\n        min-height: 640px;\n        overflow-y: auto;\n      }\n      .sidebar { grid-row: auto; max-height: 360px; }\n      .bottom-panels { grid-template-columns: 1fr; grid-auto-rows: minmax(180px, 32vh); }\n      html, body { overflow: auto; }\n    }\n  <\/style>\n<\/head>\n<body>\n<div class=\"app\">\n  <aside class=\"sidebar\">\n    <div class=\"brand\">\n      <h1>OMMATRIX 2D<\/h1>\n    <\/div>\n\n    <div class=\"cg\">\n      <div class=\"cg-title\">Eye type<\/div>\n      <select id=\"eyeType\">\n        <option value=\"apposition\">Apposition<\/option>\n        <option value=\"superposition\">Superposition<\/option>\n        <option value=\"camera\">Camera<\/option>\n      <\/select>\n    <\/div>\n\n    <div class=\"cg\" id=\"compoundGeometryControls\">\n      <div class=\"cg-title\">Eye geometry<\/div>\n      <div class=\"crow\"><span class=\"clabel\">Eye radius<\/span><span class=\"cval\" id=\"v_eyeRadius\"><\/span><\/div>\n      <input type=\"range\" id=\"eyeRadius\" min=\"90\" max=\"360\" step=\"2\" value=\"200\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Start interommatidial angle<\/span><span class=\"cval\" id=\"v_startIoAngleDeg\"><\/span><\/div>\n      <input type=\"range\" id=\"startIoAngleDeg\" min=\"0.5\" max=\"8\" step=\"0.1\" value=\"2.0\"\/>\n      <div class=\"crow\"><span class=\"clabel\">End interommatidial angle<\/span><span class=\"cval\" id=\"v_endIoAngleDeg\"><\/span><\/div>\n      <input type=\"range\" id=\"endIoAngleDeg\" min=\"0.5\" max=\"8\" step=\"0.1\" value=\"2.0\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Acute side<\/span><span class=\"cval\" id=\"v_acuteSide\"><\/span><\/div>\n      <select id=\"acuteSide\">\n        <option value=\"right\">Right<\/option>\n        <option value=\"left\">Left<\/option>\n      <\/select>\n    <\/div>\n\n    <div class=\"cg\" id=\"compoundFacetControls\">\n      <div class=\"cg-title\">Facets<\/div>\n      <div class=\"crow\"><span class=\"clabel\">Number of facets<\/span><span class=\"cval\" id=\"v_nFacets\"><\/span><\/div>\n      <input type=\"range\" id=\"nFacets\" min=\"3\" max=\"61\" step=\"2\" value=\"21\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Facet depth<\/span><span class=\"cval\" id=\"v_facetDepth\"><\/span><\/div>\n      <input type=\"range\" id=\"facetDepth\" min=\"10\" max=\"90\" step=\"1\" value=\"36\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Cone taper ratio<\/span><span class=\"cval\" id=\"v_taperRatio\"><\/span><\/div>\n      <input type=\"range\" id=\"taperRatio\" min=\"0.15\" max=\"0.95\" step=\"0.01\" value=\"0.40\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Retina inset<\/span><span class=\"cval\" id=\"v_retinaInset\"><\/span><\/div>\n      <input type=\"range\" id=\"retinaInset\" min=\"20\" max=\"320\" step=\"2\" value=\"90\"\/>\n    <\/div>\n\n    <div class=\"cg\" id=\"opticsControls\">\n      <div class=\"cg-title\">Optics<\/div>\n      <div class=\"crow\"><span class=\"clabel\">Cornea n<\/span><span class=\"cval\" id=\"v_lensN\"><\/span><\/div>\n      <input type=\"range\" id=\"lensN\" min=\"1.0\" max=\"1.7\" step=\"0.01\" value=\"1.14\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Cone outer n<\/span><span class=\"cval\" id=\"v_coneN\"><\/span><\/div>\n      <input type=\"range\" id=\"coneN\" min=\"1.0\" max=\"1.7\" step=\"0.01\" value=\"1.20\"\/>\n    <\/div>\n\n    <div class=\"cg hidden\" id=\"superpositionControls\">\n      <div class=\"cg-title\">Superposition<\/div>\n      <div class=\"crow\"><span class=\"clabel\">Pigment depth<\/span><span class=\"cval\" id=\"v_pigmentDepth\"><\/span><\/div>\n      <input type=\"range\" id=\"pigmentDepth\" min=\"0\" max=\"1\" step=\"0.02\" value=\"0.20\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Cone inner n<\/span><span class=\"cval\" id=\"v_innerConeN\"><\/span><\/div>\n      <input type=\"range\" id=\"innerConeN\" min=\"1.0\" max=\"1.8\" step=\"0.01\" value=\"1.30\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Clear zone fraction<\/span><span class=\"cval\" id=\"v_clearZoneFrac\"><\/span><\/div>\n      <input type=\"range\" id=\"clearZoneFrac\" min=\"0.00\" max=\"0.95\" step=\"0.01\" value=\"0.80\"\/>\n    <\/div>\n    <div class=\"cg hidden\" id=\"cameraControls\">\n      <div class=\"cg-title\">Camera<\/div>\n      <div class=\"crow\"><span class=\"clabel\">Lens curvature<\/span><span class=\"cval\" id=\"v_cameraLensCurvature\"><\/span><\/div>\n      <input type=\"range\" id=\"cameraLensCurvature\" min=\"0.00\" max=\"0.85\" step=\"0.01\" value=\"0.28\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Aperture diameter<\/span><span class=\"cval\" id=\"v_cameraAperture\"><\/span><\/div>\n      <input type=\"range\" id=\"cameraAperture\" min=\"6\" max=\"120\" step=\"1\" value=\"34\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Retina arc<\/span><span class=\"cval\" id=\"v_cameraRetinaArcDeg\"><\/span><\/div>\n      <input type=\"range\" id=\"cameraRetinaArcDeg\" min=\"40\" max=\"170\" step=\"1\" value=\"110\"\/>\n    <\/div>\n\n    <div class=\"cg\">\n      <div class=\"cg-title\">Incoming light<\/div>\n      <div class=\"crow\"><span class=\"clabel\">Light angle<\/span><span class=\"cval\" id=\"v_lightAngle\"><\/span><\/div>\n      <input type=\"range\" id=\"lightAngle\" min=\"-70\" max=\"70\" step=\"1\" value=\"0\"\/>\n      <div class=\"crow\"><span class=\"clabel\">Ray spacing<\/span><span class=\"cval\" id=\"v_raySpacing\"><\/span><\/div>\n      <input type=\"range\" id=\"raySpacing\" min=\"2\" max=\"6\" step=\"1\" value=\"2\"\/>\n    <\/div>\n\n    <div class=\"cg\">\n      <div class=\"cg-title\">Legend<\/div>\n      <div class=\"legend\">\n        <div class=\"li\"><div class=\"ls\" style=\"background:rgba(68,221,176,0.45)\"><\/div>Cornea<\/div>\n        <div class=\"li\"><div class=\"ls\" style=\"background:rgba(30,80,180,0.55)\"><\/div>Cone<\/div>\n        <div class=\"li\"><div class=\"ls\" style=\"background:rgba(120,110,255,0.95)\"><\/div>Pigment<\/div>\n        <div class=\"li\"><div class=\"ls\" style=\"background:#ff5566\"><\/div>Retina<\/div>\n        <div class=\"li\"><div class=\"ls\" style=\"background:#ffe566\"><\/div>Ray<\/div>\n      <\/div>\n      <div class=\"stats\">\n        Retina hits: <span id=\"s_hit\">\u2014<\/span><br>\n        Absorbed: <span id=\"s_abs\">\u2014<\/span><br>\n        Escaped: <span id=\"s_esc\">\u2014<\/span><br>\n        Mean GRIN steps: <span id=\"s_steps\">\u2014<\/span>\n      <\/div>\n    <\/div>\n\n    <div class=\"cg\">\n      <button id=\"resetButton\" type=\"button\">Reset controls<\/button>\n    <\/div>\n  <\/aside>\n\n  <div class=\"main-wrap\">\n    <div class=\"status-bar\" id=\"statusBar\">Ready.<\/div>\n    <canvas id=\"mainCanvas\"><\/canvas>\n  <\/div>\n\n  <div class=\"bottom-panels\">\n    <div class=\"plot-panel\">\n      <div class=\"plot-title\">Retinal absorption profile<\/div>\n      <canvas id=\"retinaCanvas\"><\/canvas>\n    <\/div>\n    <div class=\"plot-panel\">\n      <div class=\"plot-title\">Retinal angle map<\/div>\n      <canvas id=\"heatmapCanvas\"><\/canvas>\n    <\/div>\n  <\/div>\n<\/div>\n\n<script>\n'use strict';\n\nconst V = {\n  add: (a,b)=>({x:a.x+b.x,y:a.y+b.y}),\n  sub: (a,b)=>({x:a.x-b.x,y:a.y-b.y}),\n  mul: (v,s)=>({x:v.x*s,y:v.y*s}),\n  dot: (a,b)=>a.x*b.x+a.y*b.y,\n  len: v=>Math.hypot(v.x,v.y),\n  norm: v=>{ const n=Math.hypot(v.x,v.y)||1e-12; return {x:v.x\/n,y:v.y\/n}; },\n  perp: v=>({x:-v.y,y:v.x}),\n  rot: (v,a)=>({x:v.x*Math.cos(a)-v.y*Math.sin(a),y:v.x*Math.sin(a)+v.y*Math.cos(a)}),\n  lerp: (a,b,t)=>({x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t}),\n};\n\nfunction cross2(a,b){ return a.x*b.y-a.y*b.x; }\n\nfunction snell(dirIn, surfNormal, n1, n2) {\n  const n = V.dot(dirIn, surfNormal) < 0 ? surfNormal : V.mul(surfNormal, -1);\n  const cosI = -V.dot(dirIn, n);\n  const sin2T = (n1\/n2) * (n1\/n2) * (1 - cosI*cosI);\n  if (sin2T > 1) return null;\n  const cosT = Math.sqrt(1 - sin2T);\n  return V.norm(V.add(V.mul(dirIn, n1\/n2), V.mul(n, n1\/n2*cosI - cosT)));\n}\n\nfunction hitSeg(ro, rd, a, b) {\n  const ab = V.sub(b, a);\n  const ar = V.sub(a, ro);\n  const D = cross2(rd, ab);\n  if (Math.abs(D) < 1e-12) return null;\n  const t = cross2(ar, ab) \/ D;\n  const u = cross2(ar, rd) \/ D;\n  if (t < 1e-5 || u < -1e-6 || u > 1 + 1e-6) return null;\n  return t;\n}\n\nfunction hitPolyline(ro, rd, pts, normalHint=null) {\n  let best = null;\n  for (let i = 0; i + 1 < pts.length; i++) {\n    const a = pts[i], b = pts[i+1];\n    const t = hitSeg(ro, rd, a, b);\n    if (t === null) continue;\n    const p = V.add(ro, V.mul(rd, t));\n    const seg = V.sub(b, a);\n    let normal = V.norm({x: seg.y, y: -seg.x});\n    if (normalHint && V.dot(normal, normalHint) < 0) normal = V.mul(normal, -1);\n    if (!best || t < best.t) best = { t, point: p, normal, segIndex: i };\n  }\n  return best;\n}\n\nfunction quadPoint(p0, p1, p2, t) {\n  const u = 1 - t;\n  return {\n    x: u*u*p0.x + 2*u*t*p1.x + t*t*p2.x,\n    y: u*u*p0.y + 2*u*t*p1.y + t*t*p2.y,\n  };\n}\n\nfunction buildCorneaPolyline(outerL, outerR, outward, sagFrac=0.28, steps=28) {\n  const halfLen = V.len(V.sub(outerR, outerL)) \/ 2;\n  const sag = halfLen * sagFrac * 2;\n  const mid = V.mul(V.add(outerL, outerR), 0.5);\n  const cp = V.add(mid, V.mul(outward, sag));\n  const pts = [outerL];\n  for (let i = 1; i < steps; i++) pts.push(quadPoint(outerL, cp, outerR, i \/ steps));\n  pts.push(outerR);\n  return pts;\n}\n\nfunction buildNestedConeWallPolyline(f, frac, side='left', steps=22) {\n  const t = Math.max(0, Math.min(1, frac));\n  const sgn = side === 'left' ? -1 : 1;\n\n  const p0 = V.add(f.lcCenter, V.mul(f.tangent, sgn * f.lcHalfW * t));\n  const p2 = V.add(f.coneEndCenter, V.mul(f.tangent, sgn * f.coneEndHalfW * t));\n\n  const midCenter = V.lerp(f.lcCenter, f.coneEndCenter, 0.5);\n  const midHalfW = (f.lcHalfW + f.coneEndHalfW) * 0.5 * t;\n  const axialBow = (1 - t) * Math.max(3.0, 0.22 * f.facetDepth);\n  const p1 = V.add(\n    V.add(midCenter, V.mul(f.tangent, sgn * midHalfW)),\n    V.mul(f.inward, axialBow)\n  );\n\n  const pts = [p0];\n  for (let i = 1; i < steps; i++) pts.push(quadPoint(p0, p1, p2, i \/ steps));\n  pts.push(p2);\n  return pts;\n}\n\n\nconst DEFAULT_STATE = {\n  eyeType: 'apposition',\n  eyeRadius: 200,\n  startIoAngleDeg: 2.0,\n  endIoAngleDeg: 2.0,\n  acuteSide: 'right',\n  nFacets: 11,\n  facetDepth: 36,\n  taperRatio: 0.40,\n  retinaInset: 90,\n  lensN: 1.34,\n  coneN: 1.40,\n  pigmentDepth: 0.00,\n  innerConeN: 1.65,\n  clearZoneFrac: 0.35,\n  cameraLensCurvature: 0.28,\n  cameraAperture: 34,\n  cameraRetinaArcDeg: 110,\n  lightAngle: 0,\n  raySpacing: 5,\n};\n\nconst state = { ...DEFAULT_STATE };\n\nconst ctrlMap = {\n  eyeType: { fmt: v => v },\n  eyeRadius: { fmt: v => v.toFixed(0) },\n  startIoAngleDeg: { fmt: v => v.toFixed(1) + '\u00b0' },\n  endIoAngleDeg: { fmt: v => v.toFixed(1) + '\u00b0' },\n  acuteSide: { fmt: v => v },\n  nFacets: { fmt: v => v.toFixed(0) },\n  facetDepth: { fmt: v => v.toFixed(0) },\n  taperRatio: { fmt: v => v.toFixed(2) },\n  retinaInset: { fmt: v => v.toFixed(0) },\n  lensN: { fmt: v => v.toFixed(2) },\n  coneN: { fmt: v => v.toFixed(2) },\n  pigmentDepth: { fmt: v => v.toFixed(2) },\n  innerConeN: { fmt: v => v.toFixed(2) },\n  clearZoneFrac: { fmt: v => v.toFixed(2) },\n  cameraLensCurvature: { fmt: v => v.toFixed(2) },\n  cameraAperture: { fmt: v => v.toFixed(0) },\n  cameraRetinaArcDeg: { fmt: v => v.toFixed(0) + '\u00b0' },\n  lightAngle: { fmt: v => (v >= 0 ? '+' : '') + v.toFixed(0) + '\u00b0' },\n  raySpacing: { fmt: v => v.toFixed(0) },\n};\n\nfunction updateConditionalControls() {\n  const sup = document.getElementById('superpositionControls');\n  const cam = document.getElementById('cameraControls');\n  const compGeom = document.getElementById('compoundGeometryControls');\n  const compFacet = document.getElementById('compoundFacetControls');\n\n  const isSup = state.eyeType === 'superposition';\n  const isCam = state.eyeType === 'camera';\n\n  sup.classList.toggle('hidden', !isSup);\n  cam.classList.toggle('hidden', !isCam);\n  compFacet.classList.toggle('hidden', isCam);\n\n  for (const id of ['startIoAngleDeg','endIoAngleDeg','acuteSide']) {\n    const el = document.getElementById(id);\n    const row = el?.previousElementSibling;\n    if (el) el.classList.toggle('hidden', isCam);\n    if (row) row.classList.toggle('hidden', isCam);\n  }\n\n  \/\/ Cone outer n is only relevant to compound-eye cone optics.\n  const coneOuter = document.getElementById('coneN');\n  const coneOuterRow = coneOuter?.previousElementSibling;\n  if (coneOuter) coneOuter.classList.toggle('hidden', isCam);\n  if (coneOuterRow) coneOuterRow.classList.toggle('hidden', isCam);\n}\n\nfunction syncState() {\n  for (const key of Object.keys(ctrlMap)) {\n    const el = document.getElementById(key);\n    state[key] = el.tagName === 'SELECT' ? el.value : Number(el.value);\n    const vEl = document.getElementById('v_' + key);\n    if (vEl) vEl.textContent = ctrlMap[key].fmt(state[key]);\n  }\n  updateConditionalControls();\n}\n\nfunction resetControls() {\n  for (const key of Object.keys(ctrlMap)) {\n    const el = document.getElementById(key);\n    if (!el) continue;\n    if (el.tagName === 'SELECT') {\n      const firstSelected = Array.from(el.options).find(opt => opt.defaultSelected);\n      el.value = firstSelected ? firstSelected.value : el.options[0].value;\n    } else {\n      el.value = el.defaultValue;\n    }\n  }\n  syncState();\n  requestRender();\n}\n\nfunction bindControls() {\n  for (const key of Object.keys(ctrlMap)) {\n    const el = document.getElementById(key);\n    el.addEventListener('input', () => { syncState(); requestRender(); });\n  }\n  document.getElementById('resetButton').addEventListener('click', resetControls);\n}\n\n\nfunction hitCircle(ro, rd, center, radius) {\n  const oc = V.sub(ro, center);\n  const b = 2 * V.dot(rd, oc);\n  const c = V.dot(oc, oc) - radius * radius;\n  const disc = b * b - 4 * c;\n  if (disc < 0) return null;\n  const s = Math.sqrt(disc);\n  const t1 = (-b - s) \/ 2;\n  const t2 = (-b + s) \/ 2;\n  let t = null;\n  if (t1 > 1e-5) t = t1;\n  else if (t2 > 1e-5) t = t2;\n  if (t === null) return null;\n  const point = V.add(ro, V.mul(rd, t));\n  const normal = V.norm(V.sub(point, center));\n  return { t, point, normal };\n}\n\n\nfunction hitCircleFar(ro, rd, center, radius) {\n  const oc = V.sub(ro, center);\n  const b = 2 * V.dot(rd, oc);\n  const c = V.dot(oc, oc) - radius * radius;\n  const disc = b * b - 4 * c;\n  if (disc < 0) return null;\n  const s = Math.sqrt(disc);\n  const t1 = (-b - s) \/ 2;\n  const t2 = (-b + s) \/ 2;\n  const ts = [t1, t2].filter(t => t > 1e-5).sort((a, b) => a - b);\n  if (!ts.length) return null;\n  const t = ts[ts.length - 1];\n  const point = V.add(ro, V.mul(rd, t));\n  const normal = V.norm(V.sub(point, center));\n  return { t, point, normal };\n}\n\nfunction pointOnCameraRetina(p, geom) {\n  const rel = V.sub(p, geom.center);\n  const r = V.len(rel);\n  if (Math.abs(r - geom.retinaRadius) > 0.6) return false;\n  const phi = Math.atan2(rel.x, -rel.y);\n  return Math.abs(phi) <= geom.retinaHalfArc + 1e-6;\n}\n\nfunction cameraRetinaPos(p, geom) {\n  const rel = V.sub(p, geom.center);\n  const phi = Math.atan2(rel.x, -rel.y);\n  return (phi + geom.retinaHalfArc) \/ Math.max(2 * geom.retinaHalfArc, 1e-6);\n}\n\nfunction buildCompoundEye() {\n  const {\n    eyeRadius, startIoAngleDeg, endIoAngleDeg, acuteSide,\n    nFacets, facetDepth, taperRatio, retinaInset, pigmentDepth\n  } = state;\n  const effectivePigmentDepth = state.eyeType === 'superposition' ? pigmentDepth : 1.0;\n  const startGap = startIoAngleDeg * Math.PI \/ 180;\n  const endGap = endIoAngleDeg * Math.PI \/ 180;\n\n  function lerp(a,b,t){ return a + (b-a)*t; }\n  function arcPt(theta, r=eyeRadius) { return { x: Math.sin(theta)*r, y: Math.cos(theta)*r }; }\n  function surfaceFrame(theta) {\n    const outward = V.norm({ x: Math.sin(theta), y: Math.cos(theta) });\n    const tangent = V.perp(outward);\n    return { pt: V.mul(outward, eyeRadius), outward, inward: V.mul(outward, -1), tangent };\n  }\n\n  const gaps = [];\n  for (let j = 0; j < Math.max(0, nFacets - 1); j++) {\n    const t = nFacets <= 2 ? 0 : j \/ (nFacets - 2);\n    gaps.push(lerp(startGap, endGap, acuteSide === 'left' ? t : 1 - t));\n  }\n\n  const thetaCenters = [0];\n  for (let j = 0; j < gaps.length; j++) thetaCenters.push(thetaCenters[j] + gaps[j]);\n  const thetaMid = 0.5 * (thetaCenters[0] + thetaCenters[thetaCenters.length - 1]);\n  for (let i = 0; i < thetaCenters.length; i++) thetaCenters[i] -= thetaMid;\n\n  const facets = [];\n  for (let i = 0; i < nFacets; i++) {\n    const thetaC = thetaCenters[i];\n    const frame = surfaceFrame(thetaC);\n    const { outward, inward, tangent } = frame;\n    const frontCenter = frame.pt;\n\n    const leftGap = i > 0 ? (thetaCenters[i] - thetaCenters[i-1]) : (gaps[0] ?? startGap);\n    const rightGap = i < nFacets - 1 ? (thetaCenters[i+1] - thetaCenters[i]) : (gaps[gaps.length-1] ?? endGap);\n    const thetaL = thetaC - leftGap \/ 2;\n    const thetaR = thetaC + rightGap \/ 2;\n    const halfW = 0.5 * (\n      Math.abs(V.dot(V.sub(arcPt(thetaR), frontCenter), tangent)) +\n      Math.abs(V.dot(V.sub(arcPt(thetaL), frontCenter), tangent))\n    );\n\n    const outerL = V.add(frontCenter, V.mul(tangent, -halfW));\n    const outerR = V.add(frontCenter, V.mul(tangent,  halfW));\n\n    const backCenter = V.add(frontCenter, V.mul(inward, facetDepth));\n    const innerHalfW = halfW * taperRatio;\n    const innerL = V.add(backCenter, V.mul(tangent, -innerHalfW));\n    const innerR = V.add(backCenter, V.mul(tangent,  innerHalfW));\n\n    const lcFrac = 0.33;\n    const lcCenter = V.add(frontCenter, V.mul(inward, facetDepth * lcFrac));\n    const lcHalfW = halfW + (innerHalfW - halfW) * lcFrac;\n    const lcL = V.add(lcCenter, V.mul(tangent, -lcHalfW));\n    const lcR = V.add(lcCenter, V.mul(tangent,  lcHalfW));\n\n    const retCenter = V.add(frontCenter, V.mul(inward, retinaInset));\n    const retHalfW = innerHalfW * 0.65;\n    const retL = V.add(retCenter, V.mul(tangent, -retHalfW));\n    const retR = V.add(retCenter, V.mul(tangent,  retHalfW));\n\n    const clearZoneFrac = state.eyeType === 'superposition' ? Math.max(0, Math.min(0.85, state.clearZoneFrac)) : 0;\n    const coneEndCenter = V.lerp(lcCenter, retCenter, 1 - clearZoneFrac);\n    const coneEndHalfW = lcHalfW + (retHalfW - lcHalfW) * (1 - clearZoneFrac);\n    const coneEndL = V.add(coneEndCenter, V.mul(tangent, -coneEndHalfW));\n    const coneEndR = V.add(coneEndCenter, V.mul(tangent,  coneEndHalfW));\n\n    const pigmentFrac = Math.max(0, Math.min(1, effectivePigmentDepth));\n    const pigmentL = V.lerp(outerL, retL, pigmentFrac);\n    const pigmentR = V.lerp(outerR, retR, pigmentFrac);\n\n    facets.push({\n      i, thetaC,\n      outward, inward, tangent,\n      frontCenter, backCenter,\n      outerL, outerR,\n      innerL, innerR,\n      lcCenter, lcHalfW, lcL, lcR,\n      coneEndCenter, coneEndHalfW, coneEndL, coneEndR,\n      retCenter, retHalfW, retL, retR,\n      pigmentL, pigmentR, pigmentFrac,\n      halfW, innerHalfW, clearZoneFrac,\n      corneaPts: buildCorneaPolyline(outerL, outerR, outward, 0.28, 28),\n      seg: {\n        lc: [lcL, lcR],\n        ret: [retL, retR],\n        pigmentL: [outerL, pigmentL],\n        pigmentR: [outerR, pigmentR],\n      }\n    });\n  }\n\n  let leftFacet = facets[0];\n  let rightFacet = facets[facets.length - 1];\n  for (const f of facets) {\n    if (Math.min(f.outerL.x, f.retL.x) < Math.min(leftFacet.outerL.x, leftFacet.retL.x)) leftFacet = f;\n    if (Math.max(f.outerR.x, f.retR.x) > Math.max(rightFacet.outerR.x, rightFacet.retR.x)) rightFacet = f;\n  }\n  const barrierOffset = 4;\n\n  const leftWallA = leftFacet.outerL;\n  const leftWallB = leftFacet.retL;\n  const leftSeg = V.sub(leftWallB, leftWallA);\n  let leftNormal = V.norm(V.perp(leftSeg));\n  if (leftNormal.x > 0) leftNormal = V.mul(leftNormal, -1);\n\n  const rightWallA = rightFacet.outerR;\n  const rightWallB = rightFacet.retR;\n  const rightSeg = V.sub(rightWallB, rightWallA);\n  let rightNormal = V.norm(V.perp(rightSeg));\n  if (rightNormal.x < 0) rightNormal = V.mul(rightNormal, -1);\n\n  const outerBarriers = {\n    left: [\n      V.add(leftWallA, V.mul(leftNormal, barrierOffset)),\n      V.add(leftWallB, V.mul(leftNormal, barrierOffset))\n    ],\n    right: [\n      V.add(rightWallA, V.mul(rightNormal, barrierOffset)),\n      V.add(rightWallB, V.mul(rightNormal, barrierOffset))\n    ],\n  };\n\n  const covRad = thetaCenters.length > 1\n    ? (thetaCenters[thetaCenters.length-1] - thetaCenters[0]) + 0.5 * ((gaps[0] ?? startGap) + (gaps[gaps.length-1] ?? endGap))\n    : startGap;\n\n  return { kind:'compound', facets, covRad, outerBarriers };\n}\n\nfunction buildCameraEye() {\n  const eyeRadius = state.eyeRadius;\n  const center = { x:0, y:0 };\n  const pupilHalfWidth = Math.max(3, state.cameraAperture * 0.5);\n\n  \/\/ Camera retina should hug the inside of the globe, not sit far inward like\n  \/\/ the compound-eye retina inset control.\n  const retinaGap = 8;\n  const retinaRadius = Math.max(8, eyeRadius - retinaGap);\n  const retinaHalfArc = state.cameraRetinaArcDeg * Math.PI \/ 180 * 0.5;\n\n  const pupilHalfPhi = Math.asin(Math.min(0.999, pupilHalfWidth \/ eyeRadius));\n  const pupilY = Math.sqrt(Math.max(eyeRadius*eyeRadius - pupilHalfWidth*pupilHalfWidth, 0));\n  const lensPts = buildCorneaPolyline(\n    { x: -pupilHalfWidth, y: pupilY },\n    { x:  pupilHalfWidth, y: pupilY },\n    { x:0, y:1 },\n    state.cameraLensCurvature,\n    28\n  );\n\n  const shellPts = [];\n  const nShell = 160;\n  for (let i = 0; i <= nShell; i++) {\n    const phi = -Math.PI + i * (2 * Math.PI \/ nShell);\n    if (Math.abs(phi) < pupilHalfPhi) continue;\n    shellPts.push({ x: eyeRadius * Math.sin(phi), y: eyeRadius * Math.cos(phi) });\n  }\n\n  const retinaPts = [];\n  const nRet = 120;\n  for (let i = 0; i <= nRet; i++) {\n    const phi = -retinaHalfArc + i * (2 * retinaHalfArc \/ nRet);\n    retinaPts.push({ x: retinaRadius * Math.sin(phi), y: -retinaRadius * Math.cos(phi) });\n  }\n\n  return {\n    kind:'camera',\n    center,\n    eyeRadius,\n    pupilHalfWidth,\n    pupilHalfPhi,\n    lensPts,\n    shellPts,\n    retinaPts,\n    retinaRadius,\n    retinaHalfArc,\n    allPoints: [...lensPts, ...shellPts, ...retinaPts]\n  };\n}\n\nfunction buildEye() {\n  return state.eyeType === 'camera' ? buildCameraEye() : buildCompoundEye();\n}\n\nfunction nearestBlockingHit(ro, rd, geom) {\n  let best = null;\n\n  if (geom.kind === 'compound' && geom.outerBarriers) {\n    const tBL = hitSeg(ro, rd, geom.outerBarriers.left[0], geom.outerBarriers.left[1]);\n    const tBR = hitSeg(ro, rd, geom.outerBarriers.right[0], geom.outerBarriers.right[1]);\n    if (tBL !== null) best = { kind:'pigment', t: tBL, facet: geom.facets[0] };\n    if (tBR !== null && (!best || tBR < best.t)) best = { kind:'pigment', t: tBR, facet: geom.facets[geom.facets.length - 1] };\n  }\n\n  for (const f of geom.facets) {\n    const candidates = [\n      { kind:'pigment', t: hitSeg(ro, rd, f.seg.pigmentL[0], f.seg.pigmentL[1]), facet:f },\n      { kind:'pigment', t: hitSeg(ro, rd, f.seg.pigmentR[0], f.seg.pigmentR[1]), facet:f },\n      { kind:'retina',  t: hitSeg(ro, rd, f.seg.ret[0],      f.seg.ret[1]),      facet:f },\n    ];\n    for (const cand of candidates) {\n      if (cand.t === null) continue;\n      if (!best || cand.t < best.t) best = cand;\n    }\n  }\n  return best;\n}\n\nfunction castRays(geom) {\n  const points = geom.kind === 'camera'\n    ? geom.allPoints\n    : geom.facets.flatMap(f => [f.outerL, f.outerR, f.innerL, f.innerR, f.retL, f.retR]);\n\n  const lightDir = V.norm(V.rot({x:0,y:-1}, state.lightAngle * Math.PI \/ 180));\n  const perpDir = V.perp(lightDir);\n\n  let pMin = Infinity, pMax = -Infinity;\n  let aMin = Infinity, aMax = -Infinity;\n  for (const pt of points) {\n    const p = V.dot(pt, perpDir);\n    const a = V.dot(pt, lightDir);\n    if (p < pMin) pMin = p;\n    if (p > pMax) pMax = p;\n    if (a < aMin) aMin = a;\n    if (a > aMax) aMax = a;\n  }\n  const perpSpan = pMax - pMin;\n  const alongSpan = aMax - aMin;\n  pMin -= Math.max(18, perpSpan * 0.18);\n  pMax += Math.max(18, perpSpan * 0.18);\n  const startAhead = Math.max(60, alongSpan * 0.75 + 50);\n  const startPlane = aMin - startAhead;\n\n  const rays = [];\n  const span = pMax - pMin;\n  const count = Math.max(2, Math.ceil(span \/ state.raySpacing));\n  for (let k = 0; k <= count; k++) {\n    const perpOffset = pMin + k * (span \/ count);\n    const start = V.add(V.mul(perpDir, perpOffset), V.mul(lightDir, startPlane));\n    rays.push(traceOneRay(start, lightDir, geom, state.lensN, state.coneN));\n  }\n  return rays;\n}\n\nfunction pointInFacetCone(p, f) {\n  const rel = V.sub(p, f.lcCenter);\n  const axial = V.dot(rel, f.inward);\n  const coneLen = Math.max(1, V.dot(V.sub(f.coneEndCenter, f.lcCenter), f.inward));\n  const t = axial \/ coneLen;\n  if (t < 0 || t > 1) return null;\n  const center = V.lerp(f.lcCenter, f.coneEndCenter, t);\n  const halfW = f.lcHalfW + (f.coneEndHalfW - f.lcHalfW) * t;\n  const lateral = V.dot(V.sub(p, center), f.tangent);\n  if (Math.abs(lateral) > halfW) return null;\n  return { t, center, halfW, lateral, coneLen, facet: f };\n}\n\nfunction grinIndexAtPoint(p, geom) {\n  const innerN = Math.max(state.coneN, state.innerConeN);\n  let best = null;\n\n  for (const f of geom.facets) {\n    const info = pointInFacetCone(p, f);\n    if (!info) continue;\n    const rNorm = Math.min(1, Math.abs(info.lateral) \/ Math.max(info.halfW, 1e-6));\n    const profile = 1 - rNorm*rNorm;\n    const n = state.coneN + (innerN - state.coneN) * profile;\n    if (!best || rNorm < best.rNorm) {\n      best = { ...info, rNorm, n };\n    }\n  }\n  return best;\n}\n\nfunction grinStepDirection(p, rd, geom) {\n  const sample = grinIndexAtPoint(p, geom);\n  if (!sample) return { dir: rd, n: null, sample: null };\n\n  \/\/ Radial GRIN only:\n  \/\/ highest n on the cone axis, lower n toward the walls.\n  \/\/ Therefore the local bending should always be TOWARD the axis.\n  \/\/ If lateral > 0, bend along -tangent; if lateral < 0, bend along +tangent.\n  const deltaN = Math.max(0, Math.max(state.coneN, state.innerConeN) - state.coneN);\n  const towardAxis = V.mul(sample.facet.tangent, sample.lateral > 0 ? -1 : 1);\n\n  \/\/ With n(r) = n_outer + deltaN * (1 - r^2), the radial slope magnitude is\n  \/\/ proportional to |lateral| \/ halfW^2 and vanishes on the axis.\n  const slopeMag = (2 * deltaN * Math.abs(sample.lateral)) \/ Math.max(sample.halfW * sample.halfW, 1e-6);\n\n  \/\/ No radial offset -> no radial bend.\n  if (Math.abs(sample.lateral) < 1e-6 || slopeMag <= 0) {\n    return { dir: rd, n: sample.n, sample };\n  }\n\n  const GRIN_STEP = 0.45;\n  const GRIN_GAIN = 1.30;\n  const bendVec = V.mul(\n    towardAxis,\n    slopeMag * GRIN_GAIN * GRIN_STEP \/ Math.max(sample.n, 1e-6)\n  );\n\n  const dir = V.norm(V.add(rd, bendVec));\n  return { dir, n: sample.n, sample };\n}\n\nfunction facetCorneaHit(ro, rd, facets) {\n  let best = null, bestFacet = null;\n  for (const f of facets) {\n    const hit = hitPolyline(ro, rd, f.corneaPts, f.outward);\n    if (hit && (!best || hit.t < best.t)) { best = hit; bestFacet = f; }\n  }\n  return bestFacet ? { facet: bestFacet, hit: best } : null;\n}\n\nfunction traceCompoundRay(start, lightDir, geom, lensN, coneN) {\n  const path = [start];\n  const media = ['air'];\n  const debug = { grinSamples: [], grinSteps: 0 };\n  let ro = start;\n  let rd = lightDir;\n\n  const entry = facetCorneaHit(ro, rd, geom.facets);\n  const sideBlock0 = nearestBlockingHit(ro, rd, geom);\n\n  if (sideBlock0 && (!entry || sideBlock0.t < entry.hit.t)) {\n    const pBlock = V.add(ro, V.mul(rd, sideBlock0.t));\n    path.push(pBlock);\n    media.push('air');\n    return sideBlock0.kind === 'retina'\n      ? { path, media, debug, fate:'retina', retinalHit:{ point:pBlock, facetIndex:sideBlock0.facet.i } }\n      : { path, media, debug, fate:'absorbed' };\n  }\n\n  if (!entry) {\n    path.push(V.add(ro, V.mul(rd, 4000)));\n    media.push('air');\n    return { path, media, debug, fate:'air' };\n  }\n\n  const f = entry.facet;\n  const p1 = entry.hit.point;\n  path.push(p1);\n  media.push('air');\n\n  const rdLens = snell(rd, entry.hit.normal, 1.0, lensN);\n  if (!rdLens) return { path, media, debug, fate:'absorbed' };\n\n  ro = V.add(p1, V.mul(rdLens, 1e-4));\n  rd = rdLens;\n\n  const localLensHits = [\n    { kind:'lc', t: hitSeg(ro, rd, f.seg.lc[0], f.seg.lc[1]) },\n    { kind:'pigment', t: hitSeg(ro, rd, f.seg.pigmentL[0], f.seg.pigmentL[1]) },\n    { kind:'pigment', t: hitSeg(ro, rd, f.seg.pigmentR[0], f.seg.pigmentR[1]) },\n  ].filter(v => v.t !== null).sort((a,b) => a.t - b.t);\n\n  const globalLensBlock = nearestBlockingHit(ro, rd, geom);\n  const firstLensHit = localLensHits.length ? localLensHits[0] : null;\n\n  if (globalLensBlock && (!firstLensHit || globalLensBlock.t < firstLensHit.t)) {\n    const pBlock = V.add(ro, V.mul(rd, globalLensBlock.t));\n    path.push(pBlock);\n    media.push('lens');\n    return globalLensBlock.kind === 'retina'\n      ? { path, media, debug, fate:'retina', retinalHit:{ point:pBlock, facetIndex:globalLensBlock.facet.i } }\n      : { path, media, debug, fate:'absorbed' };\n  }\n\n  if (!firstLensHit) {\n    path.push(V.add(ro, V.mul(rd, 4000)));\n    media.push('lens');\n    return { path, media, debug, fate:'escaped' };\n  }\n\n  const p2 = V.add(ro, V.mul(rd, firstLensHit.t));\n  path.push(p2);\n  media.push('lens');\n  if (firstLensHit.kind === 'pigment') return { path, media, debug, fate:'absorbed' };\n\n  const rdCone0 = snell(rd, f.inward, lensN, coneN);\n  if (!rdCone0) return { path, media, debug, fate:'absorbed' };\n\n  if (state.eyeType === 'apposition') {\n    ro = V.add(p2, V.mul(rdCone0, 1e-4));\n    rd = rdCone0;\n    const finalBlock = nearestBlockingHit(ro, rd, geom);\n    if (finalBlock) {\n      const pFinal = V.add(ro, V.mul(rd, finalBlock.t));\n      path.push(pFinal);\n      media.push('cone');\n      return finalBlock.kind === 'retina'\n        ? { path, media, debug, fate:'retina', retinalHit:{ point:pFinal, facetIndex:finalBlock.facet.i } }\n        : { path, media, debug, fate:'absorbed' };\n    }\n    path.push(V.add(ro, V.mul(rd, 4000)));\n    media.push('cone');\n    return { path, media, debug, fate:'escaped' };\n  }\n\n  ro = V.add(p2, V.mul(rdCone0, 1e-4));\n  rd = rdCone0;\n  const ds = 0.45;\n  const maxSteps = 600;\n\n  for (let step = 0; step < maxSteps; step++) {\n    const grinInfo = grinIndexAtPoint(ro, geom);\n    if (!grinInfo) break;\n\n    const block = nearestBlockingHit(ro, rd, geom);\n    if (block && block.t <= ds) {\n      const pBlock = V.add(ro, V.mul(rd, block.t));\n      path.push(pBlock);\n      media.push('cone');\n      return block.kind === 'retina'\n        ? { path, media, debug, fate:'retina', retinalHit:{ point:pBlock, facetIndex:block.facet.i } }\n        : { path, media, debug, fate:'absorbed' };\n    }\n\n    const sample = grinStepDirection(ro, rd, geom);\n    if (sample.sample) {\n      debug.grinSteps += 1;\n      if (debug.grinSamples.length < 160) debug.grinSamples.push({ point: ro, n: sample.n });\n      rd = sample.dir;\n    }\n\n    ro = V.add(ro, V.mul(rd, ds));\n    path.push(ro);\n    media.push('cone');\n    if (V.len(ro) > state.eyeRadius + 200) break;\n  }\n\n  const finalBlock = nearestBlockingHit(ro, rd, geom);\n  if (finalBlock) {\n    const pFinal = V.add(ro, V.mul(rd, finalBlock.t));\n    path.push(pFinal);\n    media.push('clear');\n    return finalBlock.kind === 'retina'\n      ? { path, media, debug, fate:'retina', retinalHit:{ point:pFinal, facetIndex:finalBlock.facet.i } }\n      : { path, media, debug, fate:'absorbed' };\n  }\n\n  path.push(V.add(ro, V.mul(rd, 4000)));\n  media.push('cone');\n  return { path, media, debug, fate:'escaped' };\n}\n\nfunction traceCameraRay(start, lightDir, geom, lensN, coneN) {\n  const path = [start];\n  const media = ['air'];\n  const debug = { grinSamples: [], grinSteps: 0 };\n  let ro = start;\n  let rd = lightDir;\n\n  const shellHit = hitCircle(ro, rd, geom.center, geom.eyeRadius);\n  if (!shellHit) {\n    path.push(V.add(ro, V.mul(rd, 4000)));\n    media.push('air');\n    return { path, media, debug, fate:'air' };\n  }\n\n  const phiShell = Math.atan2(shellHit.point.x, shellHit.point.y);\n  const inPupil = Math.abs(phiShell) <= geom.pupilHalfPhi + 1e-6 && shellHit.point.y > 0;\n  if (!inPupil) {\n    path.push(shellHit.point);\n    media.push('air');\n    return { path, media, debug, fate:'absorbed' };\n  }\n\n  path.push(shellHit.point);\n  media.push('air');\n\n  let lensEntry = hitPolyline(start, lightDir, geom.lensPts, {x:0, y:1});\n  if (!lensEntry) lensEntry = { point: shellHit.point, normal: {x:0, y:1} };\n\n  if (V.len(V.sub(lensEntry.point, shellHit.point)) > 1e-3) {\n    path.push(lensEntry.point);\n    media.push('air');\n  } else {\n    path[path.length - 1] = lensEntry.point;\n  }\n\n  if (lensN > 1.000001) {\n    const refr = snell(lightDir, lensEntry.normal, 1.0, lensN);\n    if (refr) rd = refr;\n  }\n\n  ro = V.add(lensEntry.point, V.mul(rd, 1e-4));\n\n  const retinaSegHit = hitPolyline(ro, rd, geom.retinaPts, {x:0, y:-1});\n  if (retinaSegHit) {\n    path.push(retinaSegHit.point);\n    media.push('lens');\n    const retinaPos = cameraRetinaPos(retinaSegHit.point, geom);\n    return {\n      path, media, debug, fate:'retina',\n      retinalHit:{ point: retinaSegHit.point, facetIndex: 0, retinaPos }\n    };\n  }\n\n  \/\/ Missed retina: always draw one more interior segment to the far globe\n  \/\/ intersection, then absorb there.\n  const farShell = hitCircleFar(ro, rd, geom.center, geom.eyeRadius);\n  if (farShell) {\n    path.push(farShell.point);\n    media.push('lens');\n    return { path, media, debug, fate:'absorbed' };\n  }\n\n  path.push(V.add(ro, V.mul(rd, 4000)));\n  media.push('lens');\n  return { path, media, debug, fate:'escaped' };\n}\n\nfunction traceOneRay(start, lightDir, geom, lensN, coneN) {\n  return geom.kind === 'camera'\n    ? traceCameraRay(start, lightDir, geom, lensN, coneN)\n    : traceCompoundRay(start, lightDir, geom, lensN, coneN);\n}\n\nconst mainCanvas = document.getElementById('mainCanvas');\nconst retinaCanvas = document.getElementById('retinaCanvas');\nconst heatmapCanvas = document.getElementById('heatmapCanvas');\nconst ctx = mainCanvas.getContext('2d');\nconst rctx = retinaCanvas.getContext('2d');\nconst hctx = heatmapCanvas.getContext('2d');\n\nfunction fitCanvas(c) {\n  const dpr = window.devicePixelRatio || 1;\n  const r = c.getBoundingClientRect();\n  const w = Math.floor(r.width * dpr);\n  const h = Math.floor(r.height * dpr);\n  if (c.width !== w || c.height !== h) { c.width = w; c.height = h; }\n  return { w, h };\n}\n\nfunction makeProjection(geom, w, h) {\n  const points = geom.kind === 'camera'\n    ? geom.allPoints\n    : geom.facets.flatMap(f => [f.outerL, f.outerR, f.retL, f.retR, f.innerL, f.innerR]);\n\n  let minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity;\n  for (const pt of points) {\n    if (pt.x < minX) minX = pt.x;\n    if (pt.x > maxX) maxX = pt.x;\n    if (pt.y < minY) minY = pt.y;\n    if (pt.y > maxY) maxY = pt.y;\n  }\n  const eyeW = maxX - minX;\n  const eyeH = maxY - minY;\n  const pad  = 28;\n  const scaleX = (w - 2*pad) \/ Math.max(eyeW, 1);\n  const scaleY = (h - 2*pad) * 0.60 \/ Math.max(eyeH, 1);\n  const scale  = Math.min(scaleX, scaleY);\n  const eyeBottomY = h - pad - 6;\n  const offX = w\/2 - (minX+maxX)\/2 * scale;\n  const offY = eyeBottomY + minY * scale;\n  return p => ({ x: offX + p.x * scale, y: offY - p.y * scale });\n}\n\nfunction clearBg(c, w, h) {\n  c.fillStyle = '#060b10';\n  c.fillRect(0, 0, w, h);\n}\n\nfunction drawOutwardArc(c, pL, pR, worldOutward, sagFrac) {\n  const canvOut = { x: worldOutward.x, y: -worldOutward.y };\n  const halfLen = Math.hypot(pR.x-pL.x, pR.y-pL.y) \/ 2;\n  const sag = halfLen * sagFrac;\n  const mx = (pL.x+pR.x)\/2, my = (pL.y+pR.y)\/2;\n  const cpx = mx + canvOut.x * sag * 2;\n  const cpy = my + canvOut.y * sag * 2;\n  c.moveTo(pL.x, pL.y);\n  c.quadraticCurveTo(cpx, cpy, pR.x, pR.y);\n}\n\nfunction drawCompoundEye(proj, geom) {\n  for (const f of geom.facets) {\n    const oL = proj(f.outerL), oR = proj(f.outerR);\n    const lcL = proj(f.lcL), lcR = proj(f.lcR);\n    const coneL = proj(f.coneEndL), coneR = proj(f.coneEndR);\n    const iL = proj(f.innerL), iR = proj(f.innerR);\n\n    ctx.beginPath();\n    ctx.moveTo(lcL.x, lcL.y);\n    ctx.lineTo(lcR.x, lcR.y);\n    ctx.lineTo(iR.x, iR.y);\n    ctx.lineTo(coneR.x, coneR.y);\n    ctx.lineTo(coneL.x, coneL.y);\n    ctx.lineTo(iL.x, iL.y);\n    ctx.closePath();\n    ctx.fillStyle = 'rgba(30, 80, 180, 0.40)';\n    ctx.fill();\n\n    ctx.beginPath();\n    drawOutwardArc(ctx, oL, oR, f.outward, 0.28);\n    ctx.lineTo(lcR.x, lcR.y);\n    ctx.lineTo(lcL.x, lcL.y);\n    ctx.closePath();\n    ctx.fillStyle = 'rgba(68, 221, 176, 0.20)';\n    ctx.fill();\n  }\n\n  ctx.lineWidth = 3.2;\n  ctx.strokeStyle = 'rgba(120,110,255,0.98)';\n  for (const f of geom.facets) {\n    const oL = proj(f.outerL), pL = proj(f.pigmentL);\n    const oR = proj(f.outerR), pR = proj(f.pigmentR);\n    ctx.beginPath(); ctx.moveTo(oL.x, oL.y); ctx.lineTo(pL.x, pL.y); ctx.stroke();\n    ctx.beginPath(); ctx.moveTo(oR.x, oR.y); ctx.lineTo(pR.x, pR.y); ctx.stroke();\n  }\n\n  if (geom.outerBarriers) {\n    const bL0 = proj(geom.outerBarriers.left[0]), bL1 = proj(geom.outerBarriers.left[1]);\n    const bR0 = proj(geom.outerBarriers.right[0]), bR1 = proj(geom.outerBarriers.right[1]);\n    ctx.lineWidth = 3.0;\n    ctx.strokeStyle = 'rgba(140,120,255,0.98)';\n    ctx.beginPath(); ctx.moveTo(bL0.x, bL0.y); ctx.lineTo(bL1.x, bL1.y); ctx.stroke();\n    ctx.beginPath(); ctx.moveTo(bR0.x, bR0.y); ctx.lineTo(bR1.x, bR1.y); ctx.stroke();\n  }\n\n  ctx.lineWidth = 1.0;\n  ctx.strokeStyle = 'rgba(68,221,176,0.32)';\n  for (const f of geom.facets) {\n    const lcL = proj(f.lcL), lcR = proj(f.lcR);\n    ctx.beginPath(); ctx.moveTo(lcL.x, lcL.y); ctx.lineTo(lcR.x, lcR.y); ctx.stroke();\n  }\n\n  if (state.eyeType === 'superposition') {\n    for (const f of geom.facets) {\n      const cL = proj(f.coneEndL), cR = proj(f.coneEndR);\n      const rL = proj(f.retL), rR = proj(f.retR);\n      ctx.fillStyle = 'rgba(140, 200, 255, 0.05)';\n      ctx.beginPath();\n      ctx.moveTo(cL.x, cL.y);\n      ctx.lineTo(cR.x, cR.y);\n      ctx.lineTo(rR.x, rR.y);\n      ctx.lineTo(rL.x, rL.y);\n      ctx.closePath();\n      ctx.fill();\n    }\n  }\n\n  ctx.lineWidth = 2.1;\n  ctx.strokeStyle = '#66ccff';\n  for (const f of geom.facets) {\n    const oL = proj(f.outerL), oR = proj(f.outerR);\n    ctx.beginPath();\n    drawOutwardArc(ctx, oL, oR, f.outward, 0.28);\n    ctx.stroke();\n  }\n\n  ctx.lineCap = 'round';\n  for (const f of geom.facets) {\n    const rL = proj(f.retL), rR = proj(f.retR);\n    ctx.lineWidth = 12;\n    ctx.strokeStyle = 'rgba(255,85,102,0.92)';\n    ctx.beginPath(); ctx.moveTo(rL.x,rL.y); ctx.lineTo(rR.x,rR.y); ctx.stroke();\n    ctx.lineWidth = 4.0;\n    ctx.strokeStyle = '#ff5566';\n    ctx.beginPath(); ctx.moveTo(rL.x,rL.y); ctx.lineTo(rR.x,rR.y); ctx.stroke();\n  }\n  ctx.lineCap = 'butt';\n}\n\nfunction drawCameraEye(proj, geom) {\n  const shell = geom.shellPts.map(proj);\n  if (shell.length > 1) {\n    ctx.beginPath();\n    ctx.moveTo(shell[0].x, shell[0].y);\n    for (let i = 1; i < shell.length; i++) ctx.lineTo(shell[i].x, shell[i].y);\n    ctx.lineWidth = 3.2;\n    ctx.strokeStyle = 'rgba(120,110,255,1)';\n    ctx.stroke();\n  }\n\n  const lens = geom.lensPts.map(proj);\n  ctx.beginPath();\n  ctx.moveTo(lens[0].x, lens[0].y);\n  for (let i = 1; i < lens.length; i++) ctx.lineTo(lens[i].x, lens[i].y);\n  ctx.closePath();\n  ctx.fillStyle = 'rgba(68, 221, 176, 0.20)';\n  ctx.fill();\n  ctx.lineWidth = 2.1;\n  ctx.strokeStyle = '#66ccff';\n  ctx.stroke();\n\n  const retina = geom.retinaPts.map(proj);\n  ctx.lineCap = 'round';\n  ctx.lineWidth = 12;\n  ctx.strokeStyle = 'rgba(255,85,102,0.92)';\n  ctx.beginPath();\n  ctx.moveTo(retina[0].x, retina[0].y);\n  for (let i = 1; i < retina.length; i++) ctx.lineTo(retina[i].x, retina[i].y);\n  ctx.stroke();\n  ctx.lineWidth = 4;\n  ctx.strokeStyle = '#ff5566';\n  ctx.beginPath();\n  ctx.moveTo(retina[0].x, retina[0].y);\n  for (let i = 1; i < retina.length; i++) ctx.lineTo(retina[i].x, retina[i].y);\n  ctx.stroke();\n  ctx.lineCap = 'butt';\n}\n\nfunction drawEye(proj, geom) {\n  if (geom.kind === 'camera') drawCameraEye(proj, geom);\n  else drawCompoundEye(proj, geom);\n}\n\nfunction drawRays(proj, rays) {\n  const trimTerminal = 0.8;\n  for (const ray of rays) {\n    if (ray.path.length < 2) continue;\n    for (let k = 0; k + 1 < ray.path.length; k++) {\n      let pA = ray.path[k];\n      let pB = ray.path[k+1];\n      const medium = ray.media[Math.min(k, ray.media.length - 1)] || 'air';\n      if (k === ray.path.length - 2 && (ray.fate === 'absorbed' || ray.fate === 'retina')) {\n        const seg = V.sub(pB, pA);\n        const segLen = V.len(seg);\n        if (segLen > trimTerminal) pB = V.add(pA, V.mul(seg, (segLen - trimTerminal) \/ segLen));\n      }\n      const a = proj(pA), b = proj(pB);\n      let lw = 2.4, alpha = 0.96;\n      if (medium === 'lens' || medium === 'cone') { lw = 2.7; alpha = 1.0; }\n      if (medium === 'clear') { lw = 2.1; alpha = 0.92; }\n      ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y);\n      ctx.strokeStyle = 'rgba(255,230,90,0.38)';\n      ctx.lineWidth = lw + 3.8;\n      ctx.globalAlpha = alpha * 0.52;\n      ctx.stroke();\n      ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y);\n      ctx.strokeStyle = '#ffe566';\n      ctx.lineWidth = lw;\n      ctx.globalAlpha = alpha;\n      ctx.stroke();\n    }\n    ctx.globalAlpha = 1;\n  }\n}\n\nfunction accumulateRetinalHitsByFacet(rays, geom) {\n  const hits = new Array(geom.facets.length).fill(0);\n  for (const ray of rays) {\n    if (ray.fate !== 'retina' || !ray.retinalHit) continue;\n    const idx = Math.max(0, Math.min(geom.facets.length - 1, ray.retinalHit.facetIndex));\n    hits[idx] += 1;\n  }\n  return hits;\n}\n\nfunction drawRetinaPlot(rays, geom) {\n  const { w, h } = fitCanvas(retinaCanvas);\n  rctx.fillStyle = state.eyeType === 'superposition' ? '#101820' : '#060b10';\n  rctx.fillRect(0, 0, w, h);\n  const n = geom.kind === 'camera' ? 80 : geom.facets.length;\n\n  const hits = new Array(n).fill(0);\n  for (const ray of rays) {\n    if (ray.fate !== 'retina') continue;\n    let idx = 0;\n    if (geom.kind === 'camera') {\n      const pos = ray.retinalHit?.retinaPos;\n      if (pos == null || !Number.isFinite(pos)) continue;\n      idx = Math.max(0, Math.min(n - 1, Math.floor(pos * n)));\n    } else {\n      const last = ray.path[ray.path.length-1];\n      let bestD = Infinity, bestI = 0;\n      for (let fi=0; fi<n; fi++) {\n        const d = V.len(V.sub(last, geom.facets[fi].retCenter));\n        if (d < bestD) { bestD=d; bestI=fi; }\n      }\n      idx = bestI;\n    }\n    hits[idx]++;\n  }\n\n  const maxH = Math.max(...hits, 1);\n  const padL=32, padR=10, padT=14, padB=26;\n  const plotW=w-padL-padR, plotH=h-padT-padB;\n  const barW=plotW\/n*0.75;\n\n  rctx.strokeStyle='#1d2e45'; rctx.lineWidth=1;\n  rctx.beginPath(); rctx.moveTo(padL,padT); rctx.lineTo(padL,h-padB); rctx.lineTo(w-padR,h-padB); rctx.stroke();\n\n  hits.forEach((v,i) => {\n    const x=padL+(i+0.5)*plotW\/n-barW\/2, bh=(v\/maxH)*plotH;\n    rctx.fillStyle=`rgba(255,229,102,${0.25+0.75*v\/maxH})`;\n    rctx.fillRect(x, h-padB-bh, barW, bh);\n  });\n\n  rctx.fillStyle='#5a7898';\n  rctx.font=`${Math.max(9,Math.floor(h\/14))}px IBM Plex Mono,monospace`;\n  rctx.fillText('0', padL-16, h-padB+4);\n  rctx.fillText(maxH, padL-20, padT+8);\n}\n\n\nfunction accumulateRetinalHitsContinuous(rays, geom, bins = null) {\n  const nBins = bins || geom.facets.length;\n  const retinaPts = geom.facets.map(f => f.retCenter);\n  const chain = [0];\n  for (let i = 1; i < retinaPts.length; i++) {\n    chain.push(chain[i - 1] + V.len(V.sub(retinaPts[i], retinaPts[i - 1])));\n  }\n  const totalLen = Math.max(chain[chain.length - 1], 1e-6);\n  const values = new Array(nBins).fill(0);\n\n  for (const ray of rays) {\n    if (ray.fate !== 'retina' || !ray.retinalHit) continue;\n    const p = ray.retinalHit.point;\n    let bestI = 0;\n    let bestD = Infinity;\n    for (let i = 0; i < geom.facets.length; i++) {\n      const d = V.len(V.sub(p, geom.facets[i].retCenter));\n      if (d < bestD) { bestD = d; bestI = i; }\n    }\n    const leftI = Math.max(0, bestI - 1);\n    const rightI = Math.min(geom.facets.length - 1, bestI + 1);\n    const base = chain[bestI];\n    let s = base;\n    if (rightI !== leftI) {\n      const a = geom.facets[leftI].retCenter;\n      const b = geom.facets[rightI].retCenter;\n      const ab = V.sub(b, a);\n      const denom = V.dot(ab, ab) || 1e-12;\n      const t = Math.max(0, Math.min(1, V.dot(V.sub(p, a), ab) \/ denom));\n      s = chain[leftI] + t * (chain[rightI] - chain[leftI]);\n    }\n    const bin = Math.max(0, Math.min(nBins - 1, Math.floor((s \/ totalLen) * nBins)));\n    values[bin] += 1;\n  }\n  return values;\n}\n\nfunction drawHeatmapPlot(geom) {\n  const { w, h } = fitCanvas(heatmapCanvas);\n  hctx.fillStyle='#060b10';\n  hctx.fillRect(0,0,w,h);\n  const padL=42, padR=12, padT=14, padB=30;\n  const plotW=Math.max(1,w-padL-padR), plotH=Math.max(1,h-padT-padB);\n  const angleMin=-90, angleMax=90, angleSteps=50;\n  const nBins = geom.kind === 'camera' ? 80 : geom.facets.length;\n  const heat=Array.from({length:angleSteps},()=>new Array(nBins).fill(0));\n  const savedAngle = state.lightAngle;\n  let globalMax = 0;\n\n  for (let yi=0; yi<angleSteps; yi++) {\n    const ang = angleMax - yi*((angleMax-angleMin)\/(angleSteps-1));\n    state.lightAngle = ang;\n    const rays = castRays(geom);\n    const rowHits = new Array(nBins).fill(0);\n\n    for (const ray of rays) {\n      if (ray.fate !== 'retina') continue;\n      let idx = 0;\n      if (geom.kind === 'camera') {\n        const pos = ray.retinalHit?.retinaPos;\n        if (pos == null || !Number.isFinite(pos)) continue;\n        idx = Math.max(0, Math.min(nBins - 1, Math.floor(pos * nBins)));\n      } else {\n        const last = ray.path[ray.path.length-1];\n        let bestD = Infinity, bestI = 0;\n        for (let fi=0; fi<nBins; fi++) {\n          const d = V.len(V.sub(last, geom.facets[fi].retCenter));\n          if (d < bestD) { bestD=d; bestI=fi; }\n        }\n        idx = bestI;\n      }\n      rowHits[idx] += 1;\n    }\n\n    heat[yi] = rowHits;\n    for (const v of rowHits) globalMax = Math.max(globalMax, v);\n  }\n\n  state.lightAngle = savedAngle;\n  globalMax = Math.max(globalMax, 1);\n\n  const cellW = plotW \/ nBins, cellH = plotH \/ angleSteps;\n  for (let yi=0; yi<angleSteps; yi++) {\n    for (let xi=0; xi<nBins; xi++) {\n      const v = heat[yi][xi] \/ globalMax;\n      const g = Math.round(215*v + 20);\n      hctx.fillStyle = v <= 0 ? '#000000' : `rgb(${g}, ${g}, 0)`;\n      const x = padL + xi*cellW, y = padT + yi*cellH;\n      hctx.fillRect(x, y, Math.ceil(cellW)+0.5, Math.ceil(cellH)+0.5);\n    }\n  }\n\n  hctx.strokeStyle='#1d2e45'; hctx.lineWidth=1;\n  hctx.strokeRect(padL,padT,plotW,plotH);\n  const curY = padT + ((angleMax - state.lightAngle)\/(angleMax-angleMin))*plotH;\n  hctx.strokeStyle = 'rgba(255,220,100,0.85)';\n  hctx.lineWidth = 1.2;\n  hctx.beginPath(); hctx.moveTo(padL, curY); hctx.lineTo(padL+plotW, curY); hctx.stroke();\n\n  hctx.fillStyle='#5a7898';\n  hctx.font=`${Math.max(9,Math.floor(h\/14))}px IBM Plex Mono,monospace`;\n  hctx.fillText('+90\u00b0', 8, padT+8);\n  hctx.fillText('0\u00b0', 18, padT+plotH\/2+4);\n  hctx.fillText('\u221290\u00b0', 4, padT+plotH);\n  hctx.fillText('left', padL, h-10);\n  hctx.fillText('right', w-padR-34, h-10);\n}\n\nlet rafPending = false;\nfunction requestRender() {\n  if (!rafPending) { rafPending = true; requestAnimationFrame(render); }\n}\n\nfunction render() {\n  rafPending = false;\n  const { w, h } = fitCanvas(mainCanvas);\n  const geom = buildEye();\n  const rays = castRays(geom);\n  const proj = makeProjection(geom, w, h);\n\n  let nHit=0, nAbs=0, nEsc=0, totalSteps=0, nConeRays=0;\n  for (const r of rays) {\n    if (r.fate === 'retina') nHit++;\n    else if (r.fate === 'absorbed') nAbs++;\n    else if (r.fate === 'escaped') nEsc++;\n    totalSteps += r.debug.grinSteps || 0;\n    if ((r.debug.grinSteps || 0) > 0) nConeRays++;\n  }\n  const nEye = nHit + nAbs + nEsc;\n  document.getElementById('s_hit').textContent = `${nHit} (${nEye ? Math.round(100*nHit\/nEye) : 0}%)`;\n  document.getElementById('s_abs').textContent = `${nAbs} (${nEye ? Math.round(100*nAbs\/nEye) : 0}%)`;\n  document.getElementById('s_esc').textContent = `${nEsc} (${nEye ? Math.round(100*nEsc\/nEye) : 0}%)`;\n  document.getElementById('s_steps').textContent = nConeRays ? (totalSteps \/ nConeRays).toFixed(1) : '0';\n\n  const effInner = Math.max(state.coneN, state.innerConeN);\n  document.getElementById('statusBar').textContent =\n    `CAM-V8`\n    + ` | ${state.eyeType === 'camera' ? 'Camera' : (state.eyeType === 'superposition' ? 'Superposition' : 'Apposition')}`\n    + ` | ${state.eyeType === 'camera' ? 'single eye' : `${state.nFacets} facets`}`\n    + ` | IO ${state.startIoAngleDeg.toFixed(1)}\u00b0 \u2192 ${state.endIoAngleDeg.toFixed(1)}\u00b0`\n    + ` | coverage ${(geom.covRad * 180 \/ Math.PI).toFixed(1)}\u00b0`\n    + ` | light ${state.lightAngle >= 0 ? '+' : ''}${state.lightAngle}\u00b0`\n    + ` | n(cornea) ${state.lensN.toFixed(2)}`\n    + ` | n(cone outer) ${state.coneN.toFixed(2)}`\n    + (state.eyeType === 'superposition' ? ` | n(cone inner eff) ${effInner.toFixed(2)} | clear ${state.clearZoneFrac.toFixed(2)}` : '')\n    + ` | ${nHit} \u2192 retina`;\n\n  clearBg(ctx, w, h);\n  drawRays(proj, rays);\n  drawEye(proj, geom);\n\n  drawRetinaPlot(rays, geom);\n  drawHeatmapPlot(geom);\n}\n\nsyncState();\nbindControls();\nwindow.addEventListener('resize', requestRender);\nrequestRender();\n<\/script>\n<\/body>\n<\/html>\n\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t<\/div>\n\t\t\t\t\t\t\t<\/div>\n\t\t<\/section>\n\t\t\t\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"<p>Ommatrix 2D OMMATRIX 2D Eye type AppositionSuperpositionCamera Eye geometry Eye radius Start interommatidial angle End interommatidial angle Acute side RightLeft Facets Number of facets Facet depth Cone taper ratio Retina inset Optics Cornea n Cone outer n Superposition Pigment depth Cone inner n Clear zone fraction Camera Lens curvature Aperture diameter Retina arc Incoming light [&hellip;]<\/p>\n","protected":false},"author":3,"featured_media":0,"parent":4040,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-4926","page","type-page","status-publish","hentry","entry"],"_links":{"self":[{"href":"https:\/\/faculty.fiu.edu\/~theobald\/wp-json\/wp\/v2\/pages\/4926","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/faculty.fiu.edu\/~theobald\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/faculty.fiu.edu\/~theobald\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/faculty.fiu.edu\/~theobald\/wp-json\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/faculty.fiu.edu\/~theobald\/wp-json\/wp\/v2\/comments?post=4926"}],"version-history":[{"count":40,"href":"https:\/\/faculty.fiu.edu\/~theobald\/wp-json\/wp\/v2\/pages\/4926\/revisions"}],"predecessor-version":[{"id":4974,"href":"https:\/\/faculty.fiu.edu\/~theobald\/wp-json\/wp\/v2\/pages\/4926\/revisions\/4974"}],"up":[{"embeddable":true,"href":"https:\/\/faculty.fiu.edu\/~theobald\/wp-json\/wp\/v2\/pages\/4040"}],"wp:attachment":[{"href":"https:\/\/faculty.fiu.edu\/~theobald\/wp-json\/wp\/v2\/media?parent=4926"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}