← Commit history

Merge characterization into the full-page README iframe (timeline viewer, seamless self-sizing); refresh hero render

AravK ·2442da3608 ·3mo ago ·parent e28d9b2
3 files changed +2731−158
README.html+2165−158
@@ -1,39 +1,160 @@-<meta charset="utf-8">-<title>IN-S42ATR — Inolux Red 0402 LED</title>+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="UTF-8">+<meta name="viewport" content="width=device-width, initial-scale=1.0">+<title>IN-S42ATR — Component Characterization</title> <style>-  :root{--bg:#0d1117;--fg:#e6edf3;--muted:#9fb0c0;--accent:#00b8b0;--border:#30363d;--panel:#161b22;}-  body{margin:0;background:var(--bg);color:var(--fg);-    font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;padding:10px 6px 40px;}-  .wrap{max-width:940px;margin:0 auto;}-  h1{font-size:27px;margin:.2em 0 .05em;letter-spacing:-.01em;}-  h2{font-size:18px;margin:1.7em 0 .55em;border-bottom:1px solid var(--border);padding-bottom:.3em;}-  .sub{color:var(--muted);margin:.1em 0 1.3em;}-  a{color:var(--accent);text-decoration:none;} a:hover{text-decoration:underline;}-  .viewer-card{border:1px solid var(--border);border-radius:12px;overflow:hidden;-    background:#14171d;box-shadow:0 4px 18px #0007;}+  @font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2'); font-weight: 700; }+  @font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2'); font-weight: 500; }+  @font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2'); font-weight: 400; }+  @font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2'); font-weight: 500; }+  @font-face { font-family: 'JetBrains Mono'; src: url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2') format('woff2'); font-weight: 400; }++  :root {+    --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --border: #30363d; --border-muted: #21262d;+    --text: #e6edf3; --text-muted: #8b949e; --text-dim: #484f58;+    --accent: #00b8b0; --accent-bright: #00e6dc; --accent-dim: rgba(0,184,176,0.12); --accent-border: rgba(0,184,176,0.25);+    --green: #3fb950; --orange: #d29922; --red: #f85149; --purple: #8C6BF7; --blue: #58a6ff;+    --led-color: #DC143C; --led-color-dim: #DC143C1e; --led-color-border: #DC143C59;+  }+  * { margin: 0; padding: 0; box-sizing: border-box; }+  body { font-family: 'Satoshi', sans-serif; background: var(--bg); color: var(--text); font-size: 13px; line-height: 1.6; overflow-y: auto; }+  h1,h2,h3 { font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; }+  code { font-family: 'JetBrains Mono', monospace; font-size: 12px; }+  a { color: var(--accent); text-decoration: none; }+  a:hover { color: var(--accent-bright); text-decoration: underline; }++  .header { background: var(--surface); border-bottom: 1px solid var(--border-muted); padding: 20px 24px; }+  .header-top { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }+  .header .brand { color: var(--accent); font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; }+  .header .vsep { width: 1px; height: 16px; background: var(--border); }+  .header .crumb { color: var(--text-dim); font-size: 12px; }+  .header h1 { font-size: 22px; margin-bottom: 2px; }+  .header h1 .val { color: var(--led-color); }+  .header .subtitle { color: var(--text-muted); font-size: 13px; }+  .header .tags { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }+  .tag { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; }+  .tag.pkg { background: rgba(0,184,176,0.12); color: var(--accent); }+  .tag.mfr { background: rgba(88,166,255,0.12); color: var(--blue); }+  .tag.series { background: rgba(140,107,247,0.12); color: var(--purple); }+  .tag.rohs { background: rgba(63,185,80,0.12); color: var(--green); }+  .tag.app { background: var(--led-color-dim); color: var(--led-color); }++  .container { max-width: 1100px; margin: 0 auto; padding: 24px; }+  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }+  .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }+  @media (max-width: 800px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } }++  .section { margin-bottom: 28px; }+  .section-title { font-size: 14px; color: var(--text); margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border-muted); display: flex; align-items: center; gap: 8px; }+  .section-title .icon { color: var(--accent); font-size: 16px; }++  .card { background: var(--surface); border: 1px solid var(--border-muted); border-radius: 8px; padding: 16px; }+  .card:hover { border-color: var(--accent-border); }++  .spec-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }+  .spec-card { background: var(--surface); border: 1px solid var(--border-muted); border-radius: 8px; padding: 12px 14px; text-align: center; }+  .spec-card .sc-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin-bottom: 4px; }+  .spec-card .sc-value { font-size: 22px; font-weight: 700; font-family: 'Familjen Grotesk', sans-serif; }+  .spec-card .sc-detail { font-size: 11px; color: var(--text-muted); margin-top: 2px; }+  .spec-card.highlight { border-color: var(--led-color-border); background: linear-gradient(180deg, #DC143C0f, var(--surface)); }+  .spec-card.highlight .sc-value { color: var(--led-color); }++  .detail-table { width: 100%; border-collapse: collapse; }+  .detail-table tr { border-bottom: 1px solid var(--border-muted); }+  .detail-table tr:last-child { border-bottom: none; }+  .detail-table td { padding: 8px 0; }+  .detail-table td:first-child { color: var(--text-muted); font-size: 12px; width: 45%; }+  .detail-table td:last-child { font-family: 'JetBrains Mono', monospace; font-size: 13px; text-align: right; }++  .chart-wrap { background: var(--surface); border: 1px solid var(--border-muted); border-radius: 8px; padding: 16px; }+  .chart-wrap h3 { font-size: 13px; color: var(--text); margin-bottom: 12px; }+  .chart-wrap .chart-note { font-size: 11px; color: var(--text-dim); margin-top: 8px; }+  svg text { font-family: 'JetBrains Mono', monospace; }++  .pkg-drawing { display: flex; align-items: center; justify-content: center; padding: 20px; }++  .env-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }+  @media (max-width: 700px) { .env-grid { grid-template-columns: 1fr; } }+  .env-card { background: var(--surface); border: 1px solid var(--border-muted); border-radius: 8px; padding: 12px 14px; }+  .env-card .env-name { font-size: 12px; font-weight: 600; margin-bottom: 4px; }+  .env-card .env-spec { font-size: 11px; color: var(--text-muted); }+  .env-card .env-detail { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text); margin-top: 4px; }++  .vendor-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }+  @media (max-width: 700px) { .vendor-cards { grid-template-columns: 1fr; } }+  .vendor-card { background: var(--surface); border: 1px solid var(--border-muted); border-radius: 8px; padding: 14px; }+  .vendor-card .vc-name { font-size: 12px; font-weight: 600; margin-bottom: 6px; }+  .vendor-card .vc-name.mouser { color: var(--blue); }+  .vendor-card .vc-name.digikey { color: var(--red); }+  .vendor-card .vc-name.jlcpcb { color: var(--green); }+  .vendor-card .vc-price { font-size: 20px; font-weight: 700; font-family: 'Familjen Grotesk', sans-serif; }+  .vendor-card .vc-detail { font-size: 11px; color: var(--text-muted); margin-top: 4px; }+  .vendor-card .vc-link { display: inline-block; margin-top: 8px; font-size: 11px; color: var(--accent); }+  .vendor-card.recommended { border-color: var(--accent-border); }+  .vendor-card.recommended::before { content: 'RECOMMENDED'; display: block; font-size: 9px; letter-spacing: 1px; color: var(--accent); margin-bottom: 6px; font-weight: 600; }++  .copy-btn { background: none; border: 1px solid var(--border); color: var(--text-muted); border-radius: 4px; padding: 2px 6px; font-size: 13px; cursor: pointer; font-family: 'JetBrains Mono', monospace; vertical-align: middle; transition: border-color 0.15s, color 0.15s; }+  .copy-btn:hover { border-color: var(--accent); color: var(--accent); }++  .dl-btn { display: inline-block; margin-top: 10px; padding: 6px 14px; background: var(--accent-dim); border: 1px solid var(--accent-border); border-radius: 6px; color: var(--accent); font-size: 11px; font-weight: 500; font-family: 'Satoshi', sans-serif; text-decoration: none; transition: background 0.15s, border-color 0.15s; }+  .dl-btn:hover { background: rgba(0,184,176,0.2); border-color: var(--accent); text-decoration: none; color: var(--accent-bright); }++  .open-full-banner { display: flex; align-items: center; justify-content: center; padding: 12px 24px; background: linear-gradient(90deg, rgba(0,184,176,0.12), rgba(0,184,176,0.06)); border-bottom: 1px solid var(--accent-border); }+  .open-full-btn { display: inline-flex; align-items: center; gap: 8px; padding: 8px 20px; background: var(--accent); color: var(--bg); font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; border-radius: 6px; text-decoration: none; transition: background 0.15s; cursor: pointer; border: none; }+  .open-full-btn:hover { background: var(--accent-bright); text-decoration: none; color: var(--bg); }++  .source { text-align: center; color: var(--text-dim); font-size: 11px; padding: 24px 0 12px; border-top: 1px solid var(--border-muted); margin-top: 20px; }++  .color-swatch { border-radius: 8px; padding: 20px; text-align: center; position: relative; overflow: hidden; }+  .color-swatch .swatch-block { width: 100%; height: 80px; border-radius: 6px; margin-bottom: 12px; position: relative; }+  .color-swatch .swatch-label { font-size: 14px; font-weight: 600; font-family: 'Familjen Grotesk', sans-serif; }+  .color-swatch .swatch-detail { font-size: 11px; color: var(--text-muted); margin-top: 4px; }++  .calc-card { background: var(--surface); border: 1px solid var(--led-color-border); border-radius: 8px; padding: 20px; }+  .calc-card h3 { font-size: 14px; color: var(--led-color); margin-bottom: 16px; font-family: 'Familjen Grotesk', sans-serif; }+  .calc-inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }+  .calc-field label { display: block; font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }+  .calc-field input { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 8px 12px; font-family: 'JetBrains Mono', monospace; font-size: 14px; outline: none; transition: border-color 0.15s; }+  .calc-field input:focus { border-color: var(--led-color); }+  .calc-field .unit { font-size: 11px; color: var(--text-dim); margin-top: 2px; }+  .calc-results { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }+  .calc-result { background: var(--bg); border: 1px solid var(--border-muted); border-radius: 6px; padding: 10px 12px; }+  .calc-result .cr-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin-bottom: 2px; }+  .calc-result .cr-value { font-size: 18px; font-weight: 700; font-family: 'Familjen Grotesk', sans-serif; color: var(--led-color); }+  .calc-result .cr-detail { font-size: 11px; color: var(--text-muted); margin-top: 2px; }+  .calc-schematic { margin-top: 16px; text-align: center; }++  /* full-page seamless viewer (pulled in from the standalone README) */+  .viewer-card{overflow:hidden;background:#14171d;border-top:1px solid var(--border);border-bottom:1px solid var(--border);margin-bottom:18px;}   .viewer-card iframe{display:block;width:100%;height:600px;border:0;background:#14171d;}-  .note{color:var(--muted);font-size:13.5px;}-  blockquote{border-left:3px solid var(--accent);margin:1.1em 0;padding:.5em 1em;-    background:var(--panel);border-radius:0 8px 8px 0;color:var(--muted);font-size:13px;}-  table{border-collapse:collapse;width:100%;font-size:13.5px;margin:.7em 0;}-  th,td{border:1px solid var(--border);padding:6px 10px;text-align:left;vertical-align:top;}-  th{background:var(--panel);} tr:nth-child(even) td{background:#11151b;}-  code{background:var(--panel);padding:1px 5px;border-radius:4px;font:12.5px ui-monospace,monospace;}-  pre{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:12px;overflow:auto;}-  pre code{background:none;padding:0;}-  .btn{display:inline-block;background:var(--accent);color:#04231f;font-weight:700;-    padding:9px 18px;border-radius:8px;margin:.3em 0;}-  .btn:hover{text-decoration:none;filter:brightness(1.06);} </style>-<div class="wrap">-  <h1>IN-S42ATR</h1>-  <p class="sub"><strong>Inolux Corporation</strong> &middot; Red &middot; 0402 &middot; LED<br>-     Top View SMD LED &middot; 0402 (1.0 &times; 0.5&nbsp;mm) &middot; AlInGaP</p>--  <h2>Interactive 3D viewer</h2>-  <div class="viewer-card">-    <iframe srcdoc="<meta charset=&quot;utf-8&quot;>-<title>IN-S42ATR — Static &amp;amp; Pick-and-Place (Colby's viewer + additions)</title>+</head>+<body>++++<div class="header">+  <div class="header-top">+    <div class="brand">Adom</div>+    <div class="vsep"></div>+    <div class="crumb">Components &rsaquo; LEDs &rsaquo; 0402</div>+  </div>+  <h1>IN-S42ATR &mdash; <span class="val">Red</span></h1>+  <div class="subtitle">Inolux Corporation &middot; Top View SMD LED &middot; 0402 (1.0 &times; 0.5mm) &middot; AlInGaP</div>+  <div class="tags">+    <span class="tag pkg">0402</span>+    <span class="tag mfr">Inolux</span>+    <span class="tag series">IN-S42AT</span>+    <span class="tag rohs">RoHS</span>+    <span class="tag app">Red LED</span>+  </div>+</div>++<div class="viewer-card">+<iframe srcdoc="<meta charset=&quot;utf-8&quot;>+<title>IN-S42ATR — Timeline · Static · Pick-and-Place (Colby's viewer + additions)</title> <style>   :root{ --accent:#00b8b0; --panel:#161b22; --border:#30363d; }   html,body{ margin:0; height:100%; overflow:hidden; background:#14171d; color:#e6edf3;@@ -106,13 +227,74 @@   #hover-tip .rows{ margin-top:4px; }   #status{ position:fixed; top:52px; left:16px; z-index:10; color:#a9b6c2;     font:11px 'JetBrains Mono',ui-monospace,monospace; text-shadow:0 1px 3px #0009; }+  #status .manual{ color:var(--accent); }++  /* ---- Story caption card -------------------------------------------------+     A popup that explains whatever the reel is showing right now, then leaves.+     Bottom-left, clear of the ViewCube (top-right) and the button row (bottom-+     centre). Opacity/offset are driven per-frame from the timeline, so the fade+     is in lockstep with the geometry it describes — no CSS transitions. */+  #caption{ position:fixed; left:16px; bottom:60px; z-index:12; max-width:352px;+    background:linear-gradient(180deg, rgba(22,27,34,0.96), rgba(18,22,28,0.96));+    border:1px solid var(--border); border-left:3px solid var(--accent); border-radius:0 8px 8px 0;+    padding:11px 15px 12px; box-shadow:0 6px 22px #0009; opacity:0; pointer-events:none; }+  #caption .eyebrow{ font:10px/1 'JetBrains Mono',ui-monospace,monospace; letter-spacing:.14em;+    text-transform:uppercase; color:var(--accent); }+  #caption .head{ font-size:17px; font-weight:700; color:#f0f6fc; margin-top:5px; letter-spacing:-.01em; }+  #caption .rows{ margin-top:8px; display:flex; flex-direction:column; gap:3px; }+  #caption .kv{ display:flex; justify-content:space-between; gap:16px;+    font:11.5px/1.35 'JetBrains Mono',ui-monospace,monospace; }+  #caption .kv i{ color:#8b949e; font-style:normal; }+  #caption .kv b{ color:#e6edf3; font-weight:500; text-align:right; }+  #caption .kv.mdl b{ color:#9db4c8; }                 /* model-measured, not datasheet — visually distinct */+  #caption .note{ font-size:12px; line-height:1.45; color:#b6c2ce; margin-top:7px; }+  #caption .src{ font:10px/1.3 'JetBrains Mono',ui-monospace,monospace; color:#6e7781; margin-top:8px;+    padding-top:6px; border-top:1px solid rgba(255,255,255,.08); }++  /* ---- Story transport ----------------------------------------------------+     A hairline scrub bar across the very bottom that grows on hover, with a tick+     per chapter and a floating label. Click anywhere to seek. */+  #scrub{ position:fixed; left:0; right:0; bottom:0; height:4px; z-index:13; cursor:pointer;+    background:rgba(255,255,255,.07); transition:height .14s ease; }+  #scrub:hover, #scrub.hot{ height:9px; }+  #scrub-fill{ position:absolute; inset:0 auto 0 0; width:0; background:var(--accent); opacity:.85; }+  #scrub .tick{ position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,.35); }+  #scrub-label{ position:fixed; bottom:14px; z-index:13; transform:translateX(-50%);+    background:rgba(30,34,38,0.94); color:#e6edf3; font:11px 'JetBrains Mono',ui-monospace,monospace;+    padding:4px 9px; border-radius:4px; white-space:nowrap; display:none; pointer-events:none;+    box-shadow:0 2px 8px #0008; }+  #scrub-label.show{ display:block; }++  /* ---- Top transport bar --------------------------------------------------+     A full-width scrubber pinned to the very top. Click or drag anywhere on it+     (or on its knob) to move through the timeline. Grows on hover; carries a+     tick per chapter and a draggable knob at the playhead. */+  #topbar{ position:fixed; top:0; left:0; right:0; height:6px; z-index:16; cursor:pointer;+    background:rgba(255,255,255,.08); transition:height .12s ease; touch-action:none; }+  #topbar:hover, #topbar.hot{ height:12px; }+  #topbar-fill{ position:absolute; inset:0 auto 0 0; width:0; background:var(--accent); opacity:.9; }+  #topbar .tick{ position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,.4); }+  #topbar-knob{ position:absolute; top:50%; left:0; width:13px; height:13px; border-radius:50%;+    background:#fff; border:2px solid var(--accent); transform:translate(-50%,-50%);+    box-shadow:0 1px 5px #000a; pointer-events:none; }+  #topbar-time{ position:fixed; top:16px; z-index:16; transform:translateX(-50%);+    background:rgba(30,34,38,0.94); color:#e6edf3; font:11px 'JetBrains Mono',ui-monospace,monospace;+    padding:4px 9px; border-radius:4px; white-space:nowrap; display:none; pointer-events:none;+    box-shadow:0 2px 8px #0008; }+  #topbar-time.show{ display:block; }++  /* Timeline chrome only exists in Timeline mode. */+  body:not(.story) #scrub, body:not(.story) #scrub-label, body:not(.story) #topbar,+  body:not(.story) #topbar-time,+  body:not(.story) #caption, body:not(.story) #btn-play, body:not(.story) #btn-replay{ display:none; } </style>  <div id=&quot;app&quot;></div> <header>-  <h1>IN-S42ATR <span class=&quot;sub&quot;>· Colby's viewer + Static/Pick&amp;amp;Place additions · local</span></h1>+  <h1>IN-S42ATR <span class=&quot;sub&quot;>· Colby's viewer + Timeline/Static/Pick&amp;amp;Place additions · local</span></h1>   <div class=&quot;seg&quot;>-    <button id=&quot;mode-static&quot; class=&quot;on&quot;>Static</button>+    <button id=&quot;mode-story&quot; class=&quot;on&quot;>Timeline</button>+    <button id=&quot;mode-static&quot;>Static</button>     <button id=&quot;mode-pnp&quot;>Pick &amp;amp; Place</button>   </div> </header>@@ -124,11 +306,18 @@ </div> <div id=&quot;layer-menu&quot;><h2 id=&quot;layer-drag&quot;><span class=&quot;grip&quot;>⠿</span>Layers</h2><div id=&quot;layer-list&quot;></div></div> <div id=&quot;hover-tip&quot;></div>+<div id=&quot;caption&quot;><div class=&quot;eyebrow&quot;></div><div class=&quot;head&quot;></div><div class=&quot;rows&quot;></div><div class=&quot;note&quot;></div><div class=&quot;src&quot;></div></div> <div class=&quot;btn-row&quot;>+  <button class=&quot;btn on&quot; id=&quot;btn-play&quot;>⏸ Pause</button>+  <button class=&quot;btn&quot; id=&quot;btn-replay&quot;>↺ Replay</button>   <button class=&quot;btn on&quot; id=&quot;btn-ground&quot;>Ground</button>   <button class=&quot;btn&quot; id=&quot;btn-bottom&quot;>Bottom light</button>   <button class=&quot;btn&quot; id=&quot;btn-layers&quot;>Layers</button> </div>+<div id=&quot;topbar&quot;><div id=&quot;topbar-fill&quot;></div><div id=&quot;topbar-knob&quot;></div></div>+<div id=&quot;topbar-time&quot;></div>+<div id=&quot;scrub&quot;><div id=&quot;scrub-fill&quot;></div></div>+<div id=&quot;scrub-label&quot;></div>  <!-- Colby/Kyle's ACTUAL shipped viewer bundle (window.Adom3DViewer). Unmodified. --> <script>@@ -157,6 +346,45 @@   const B = V.BABYLON;                              // his Babylon core instance   try { +  // ---- config: captions, tooltips, chapters, and swappable models -------------+  // The Timeline text (captions), the hover tooltips, the chapter marks, and the tape/nozzle+  // model swaps all live in viewer-config.json, edited with ./viewer-cli. The hardcoded+  // defaults below (CAPS_DEFAULT / TIP_DEFAULT / CHAPTERS_DEFAULT) are the fallback when the+  // file is absent or a key is missing, so the viewer always works standalone. Merge rules:+  //   · captions / chapters : the config array, if present, REPLACES the default list.+  //   · tooltips : merged per id — config overrides title/sub/src (and rows, unless the+  //     tooltip's rows are computed from geometry); `enabled:false` drops it; new ids add.+  //   · assets : the tape/nozzle GLB swaps (was viewer-assets.json — still read as a fallback).+  let CFG = {};+  try { const r = await fetch('https://wiki.adom.inc/blob/component/in-s42atr/viewer-config.json', { cache:'no-store' }); if (r.ok) CFG = await r.json(); }+  catch(_) {}+  if (!CFG.assets) {                                // backward-compat: old viewer-assets.json+    try { const r = await fetch('https://wiki.adom.inc/blob/component/in-s42atr/viewer-assets.json', { cache:'no-store' }); if (r.ok) CFG.assets = await r.json(); }+    catch(_) {}+  }+  const _slug = s => String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');+  // Merge a config tooltip over a default entry. Rows: a computed default keeps its function+  // (measurement provenance is not editable); a static default takes config rows if given.+  function mergeTip(def){+    const c = (CFG.tooltips || {})[def.id];+    if (!c) return def;+    if (c.enabled === false) return null;+    const out = { ...def };+    if (c.title != null) out.title = c.title;+    if (c.sub   != null) out.sub   = c.sub;+    if (c.src   != null &amp;&amp; typeof def.src !== 'function') out.src = c.src;+    // config `rows` edits the DATASHEET rows only; the measured function is never overridden.+    if (Array.isArray(c.rows)) out.dsRows = c.rows;+    return out;+  }+  // Any config tooltips whose id isn't a default (user-added) — need a `match` regex + rows.+  function extraTips(defIds){+    return Object.entries(CFG.tooltips || {})+      .filter(([id,c]) => !defIds.has(id) &amp;&amp; c &amp;&amp; c.enabled !== false &amp;&amp; c.match)+      .map(([id,c]) => ({ id, re:new RegExp(c.match), title:c.title||id, sub:c.sub||'',+                          src:c.src||'', dsRows:Array.isArray(c.rows)?c.rows:[] }));+  }+   // 1) His viewer — his camera, SpotLight+ShadowGenerator, studio IBL, skybox, view cube, axes.   const viewer = V.init(document.getElementById('app'), { zUp: true, showViewCube: true, showGround: true }); @@ -207,7 +435,28 @@   if (led) led.rotationQuaternion = null;   // glTF sets a quaternion that would ignore rotation.z — use Euler   const ledLit  = scene.getNodeByName('LED_lit');   const fovCone = scene.getNodeByName('fov_cone');-  const ptLight = scene.lights.find(l => l.getClassName &amp;&amp; l.getClassName() === 'PointLight');+  // The composite ships the LED emitter as a POINT light — a full sphere, so it also throws red+  // onto the FR-4 below, which the opaque body would block in reality. Replace it with an+  // upward-facing SpotLight (≈ hemisphere): the die emits UP and out only, never down onto the+  // board. Harvest the SpotLight constructor from Colby's studio spot; keep the same position,+  // parent (so it follows the LED), colour, range and intensity so nothing else changes.+  let ptLight = scene.lights.find(l => l.getClassName &amp;&amp; l.getClassName() === 'PointLight');+  if (ptLight) {+    const studioSpot = scene.lights.find(l => l.getClassName &amp;&amp; l.getClassName() === 'SpotLight');+    const SpotCtor = studioSpot &amp;&amp; studioSpot.constructor;+    if (SpotCtor) {+      const up = new B.Vector3(0, 0, 1);                                  // z-up scene → LED top is +Z+      const spot = new SpotCtor('led_emitter_up', ptLight.position.clone(), up, Math.PI * 0.92, 1.2, scene);  // ~166° cone ≈ upward hemisphere+      spot.diffuse  = (ptLight.diffuse  || new B.Color3(1, 0.03, 0.015)).clone();+      spot.specular = spot.diffuse.clone();+      spot.range     = ptLight.range || 0.004;+      spot.intensity = ptLight.intensity;                                 // PT_I reads this below → unchanged strength+      if (ptLight.parent) spot.parent = ptLight.parent;                   // follow the LED like the original+      spot.shadowEnabled = false;+      ptLight.dispose();                                                  // retire the full sphere+      ptLight = spot;+    }+  }   const modelParent = (led &amp;&amp; led.parent) || null;          // share the model's coordinate space    // Bottom-fill light: the shipped bundle exposes no setBottomLight() and builds no bottomLight,@@ -362,11 +611,231 @@   });   const cover = B.MeshBuilder.CreateBox('cover_tape', { width:tLen, height:TW*0.6, depth:0.00003 }, scene);  // clear cover tape   cover.position = new B.Vector3(tCx, PKY + TBo*0.2, 0.00004); cover.material = coverMat; cover.parent = tape;++  // ---- self-contained GLB reader (no SceneLoader in the shipped bundle) -----+  // Reads a single-BIN-chunk .glb (or a .gltf with data: buffers), then builds each mesh+  // primitive with MeshBuilder + setVerticesData/setIndices — the same geometry path used+  // for the procedural tape above. glTF node transforms are NOT baked; the whole import is+  // placed by the manifest's scale/rotation/offset instead (predictable for CLI tuning).+  const _CT = { 5120:1, 5121:1, 5122:2, 5123:2, 5125:4, 5126:4 };+  const _NUM = { SCALAR:1, VEC2:2, VEC3:3, VEC4:4, MAT4:16 };+  async function loadGLB(url){+    const ab = await (await fetch(url, { cache:'no-store' })).arrayBuffer();+    const dv0 = new DataView(ab);+    let g = null, bin = null;+    if (dv0.getUint32(0, true) === 0x46546C67) {                 // 'glTF'+      let off = 12;+      while (off < ab.byteLength) {+        const len = dv0.getUint32(off, true), typ = dv0.getUint32(off+4, true); off += 8;+        const chunk = ab.slice(off, off+len); off += len;+        if (typ === 0x4E4F534A) g = JSON.parse(new TextDecoder().decode(chunk));      // JSON+        else if (typ === 0x004E4942) bin = chunk;                                     // BIN+      }+    } else { g = JSON.parse(new TextDecoder().decode(ab)); }+    const buffers = (g.buffers||[]).map(b => {+      if (!b.uri) return bin;+      if (/^data:/.test(b.uri)) { const s = atob(b.uri.split(',')[1]); const u = new Uint8Array(s.length);+        for (let i=0;i<s.length;i++) u[i]=s.charCodeAt(i); return u.buffer; }+      throw new Error('external buffer '+b.uri+' not supported — export a self-contained .glb');+    });+    const read = (ai, asIndex) => {+      const acc = g.accessors[ai], bv = g.bufferViews[acc.bufferView];+      const n = asIndex ? 1 : _NUM[acc.type], cs = _CT[acc.componentType];+      const base = (bv.byteOffset||0) + (acc.byteOffset||0), stride = bv.byteStride || cs*n;+      const dvb = new DataView(buffers[bv.buffer]); const out = new Array(acc.count*n);+      for (let i=0;i<acc.count;i++) for (let k=0;k<n;k++){ const o = base + i*stride + k*cs;+        out[i*n+k] = acc.componentType===5126 ? dvb.getFloat32(o,true)+                   : acc.componentType===5125 ? dvb.getUint32(o,true)+                   : acc.componentType===5123 ? dvb.getUint16(o,true)+                   : acc.componentType===5122 ? dvb.getInt16(o,true)+                   : acc.componentType===5120 ? dvb.getInt8(o) : dvb.getUint8(o); }+      return out;+    };+    const glMat = (mi, nm) => {+      const m = new B.StandardMaterial(nm, scene); let c = [0.80,0.80,0.83,1];+      if (mi!=null &amp;&amp; g.materials &amp;&amp; g.materials[mi]) {+        const pbr = g.materials[mi].pbrMetallicRoughness || {}; if (pbr.baseColorFactor) c = pbr.baseColorFactor; }+      m.diffuseColor = new B.Color3(c[0],c[1],c[2]);+      m.emissiveColor = new B.Color3(c[0]*0.25,c[1]*0.25,c[2]*0.25);+      m.specularColor = new B.Color3(0.10,0.10,0.10);+      if (c[3]!=null &amp;&amp; c[3]<1) { m.alpha = c[3]; m.backFaceCulling = false; }+      return m;+    };+    const root = makeGroup('custom_asset_root'); let built = 0;+    (g.meshes||[]).forEach((mesh, mIdx) => (mesh.primitives||[]).forEach((prim, pIdx) => {+      if (prim.attributes.POSITION == null) return;+      const pos = read(prim.attributes.POSITION);+      let idx = prim.indices!=null ? read(prim.indices, true) : Array.from({length:pos.length/3}, (_,i)=>i);+      let nor = prim.attributes.NORMAL!=null ? read(prim.attributes.NORMAL) : null;+      if (!nor) {                                                // flat normals if the model omits them+        nor = new Array(pos.length).fill(0);+        for (let t=0;t<idx.length;t+=3){ const a=idx[t]*3,b=idx[t+1]*3,c=idx[t+2]*3;+          const ux=pos[b]-pos[a],uy=pos[b+1]-pos[a+1],uz=pos[b+2]-pos[a+2];+          const vx=pos[c]-pos[a],vy=pos[c+1]-pos[a+1],vz=pos[c+2]-pos[a+2];+          const nx=uy*vz-uz*vy,ny=uz*vx-ux*vz,nz=ux*vy-uy*vx;+          [a,b,c].forEach(o=>{ nor[o]+=nx; nor[o+1]+=ny; nor[o+2]+=nz; }); }+      }+      const m = B.MeshBuilder.CreateGround('custom_'+mIdx+'_'+pIdx, { width:1, height:1, subdivisions:1 }, scene);+      m.setVerticesData('position', Array.from(pos));+      m.setVerticesData('normal', Array.from(nor));+      m.setVerticesData('uv', new Array((pos.length/3)*2).fill(0));+      m.setIndices(idx);+      if (m.refreshBoundingInfo) m.refreshBoundingInfo();+      m.material = glMat(prim.material, 'customMat_'+mIdx+'_'+pIdx); m.parent = root; built++;+    }));+    if (!built) throw new Error('no mesh primitives found in '+url);+    return root;+  }+  const applyXform = (node, spec) => {+    const s = spec.scale != null ? spec.scale : 0.001;           // default: model authored in mm+    node.scaling.set(s, s, s);+    const r = spec.rotDeg || [0,0,0]; node.rotationQuaternion = null;+    node.rotation.set(r[0]*Math.PI/180, r[1]*Math.PI/180, r[2]*Math.PI/180);+    const o = spec.offsetMM || [0,0,0]; node.position.set(o[0]*0.001, o[1]*0.001, o[2]*0.001);+  };+  // Swap a procedural node's geometry for an imported GLB, keeping the node (so existing+  // placement/animation still drives it). Returns true if a custom model replaced the default.+  const swapAsset = async (hostNode, spec, label) => {+    if (!spec || !spec.model) return false;+    const existing = hostNode.getChildMeshes ? hostNode.getChildMeshes().slice() : [];+    let grp;+    try { grp = await loadGLB('./' + spec.model); }+    catch (e) { statusEl.textContent = 'custom '+label+' failed: ' + e.message; console.warn(e); return false; }+    applyXform(grp, spec); grp.parent = hostNode;+    existing.forEach(m => m.setEnabled(false));                  // hide the procedural default+    return true;+  };+  window._assets = { active:{ tape:false, nozzle:false } };+  async function applyCustomAssets(tapeNode, nozzleNode){+    const manifest = CFG.assets;            // loaded up top (viewer-config.json, or legacy viewer-assets.json)+    if (!manifest) return;+    if (manifest.tape)   window._assets.active.tape   = await swapAsset(tapeNode,   manifest.tape,   'tape');+    if (manifest.nozzle) window._assets.active.nozzle = await swapAsset(nozzleNode, manifest.nozzle, 'nozzle');+  }++  // ---- CLI-swappable tape/reel + nozzle (viewer-assets.json) ----------------+  // The tape/reel and nozzle above are procedural and datasheet-exact — the DEFAULT. But a+  // user can drop in their own CAD via the `viewer-assets` CLI (writes viewer-assets.json ++  // copies the .glb next to this file). When the manifest names a model we parse the GLB+  // here (the shipped bundle exposes no SceneLoader, so we read the container ourselves — the+  // same MeshBuilder + setVerticesData path the tape above is built with) and swap it in:+  //   · tape:   procedural `tape` group hidden; custom meshes parented under it, so the reel+  //             sits in the same place the animation already reveals.+  //   · nozzle: procedural tip+shaft hidden; custom meshes parented under the `nozzle` node,+  //             so the EXISTING pick-and-place motion drives the new nozzle unchanged.+  // Each entry carries scale (scene-metres per model unit), rotation (deg) and offset (mm).+  await applyCustomAssets(tape, nozzle);+   registerShadows();   // now that ALL geometry exists (model + tape/reel + nozzle + pocket LEDs + cover), enable shadows on everything   if (fovCone) [fovCone].concat(fovCone.getChildMeshes ? fovCone.getChildMeshes() : []).forEach(m => { if ('receiveShadows' in m) m.receiveShadows = false; if (sg &amp;&amp; sg.removeShadowCaster) sg.removeShadowCaster(m); });   // FOV cone: no shadows (belt-and-suspenders vs the /fov/ exclusion) +  // ---- 3b) FOV cone: beam-like falloff + an apex pivot so it GROWS out of the LED ----+  // The GLB ships the cone as bare geometry: apex on the LED's top face (z = +0.45 mm,+  // the datasheet body height) opening to Ø4.289 mm at z = 1.45 mm — atan(2.1445/1.0) =+  // 65.0°, i.e. exactly the datasheet 2θ½ = 130° half-angle. We keep that geometry+  // untouched (it IS the datasheet number) and change only how it READS:+  //   · flat 25%-opaque red slab  ->  a beam that thins with distance, because the+  //     further light travels from the die the more it has spread out. The primitive+  //     carries no UVs, so derive them from position: V = 0 at the apex, 1 at the base,+  //     and drive an alpha-ramp opacityTexture off V.+  //   · scaling a node scales about its ORIGIN (the model origin, on the board), which+  //     would grow the cone from the wrong end. Re-parent it under a pivot sitting AT+  //     the apex so scaling the pivot 0 -> 1 pushes the beam out of the LED itself.+  // Emission originates from the LED's top surface. Measure it rather than hard-coding:+  // the composite scales the body z by 0.9, so its top lands at the datasheet 0.45 mm+  // height (verified live: LED top = 0.45 mm world). The cone apex sits exactly there —+  // the light &quot;starts from the LED&quot;, with no gap above it, in every mode.+  let FOV_EMIT = 0.00045;+  if (led) { let zt = -1e9; (led.getChildMeshes ? led.getChildMeshes() : []).forEach(m => {+      if (m.getTotalVertices &amp;&amp; m.getTotalVertices() > 0) { m.computeWorldMatrix(true);+        const z = m.getBoundingInfo().boundingBox.maximumWorld.z; if (z > zt) zt = z; } });+    if (zt > -1e8) FOV_EMIT = zt; }                // world = model-space here (model at origin, unrotated)+  const FOV_ALPHA  = 0.30;                       // &quot;70% transparent&quot;+  let FOV_CHECK = null;+  if (fovCone) {+    const coneMeshes = [fovCone].concat(fovCone.getChildMeshes ? fovCone.getChildMeshes() : [])+                                .filter(m => m.getTotalVertices &amp;&amp; m.getTotalVertices() > 0);+    // The shipped cone is a CAPPED solid with two rings of vertices. Capped, it reads as a+    // floating disc rather than a beam; two rings can only carry a straight-line falloff.+    // Rebuild it in place as an OPEN lateral surface with FOV_RINGS rings — same node, same+    // name, same datasheet geometry:+    //     base radius = FOV_LEN · tan(2θ½/2) = 1.0 mm · tan 65° = 2.1445 mm+    // which is the shipped cone's base radius to four decimals, so this is a re-derivation+    // of the datasheet number, not a new one. FOV_CHECK below asserts that at runtime.+    const FOV_2THETA = 130;                                   // datasheet 2θ½ (Inolux IN-S42AT p.4)+    const FOV_HALF   = (FOV_2THETA/2) * Math.PI/180;          // 65.0° half-angle+    const FOV_LEN    = 0.00100;                               // beam drawn 1.0 mm long, as the composite ships it+    const FOV_SEGS   = 96, FOV_RINGS = 26;+    const FOV_FLOOR  = 0.16, FOV_POW = 1.5;                   // how much beam is left at the rim, and the curve to it+    FOV_CHECK = FOV_LEN * Math.tan(FOV_HALF);                 // = 0.0021445 m — must equal the shipped cone's base radius+    coneMeshes.forEach(m => {+      const P = [], N = [], C = [], IDX = [];+      const ca = Math.cos(FOV_HALF), sa = Math.sin(FOV_HALF);+      for (let r=0; r<=FOV_RINGS; r++){+        const v  = r/FOV_RINGS;                               // 0 at the die, 1 at the rim+        // Apex at the mesh's LOCAL origin (0,0,0). We then position the node at the LED top+        // and uniformly scale it 0->1 to grow the beam, which scales about that local origin+        // — i.e. straight out of the die. No pivot node needed, so nothing can double the+        // offset (the old pivot + a bob that overwrote the node position put the apex 0.45 mm+        // too high in Static).+        const z  = FOV_LEN*v, rad = FOV_LEN*v*Math.tan(FOV_HALF);+        // Distance falloff baked into VERTEX ALPHA (not an opacity texture): a texture would+        // drag in DynamicTexture's invertY convention, and an RGB-luminance opacity map+        // REPLACES alpha rather than multiplying it, throwing away both material.alpha (the+        // 70% transparency) and mesh.visibility (the grow-in fade). Vertex alpha multiplies:+        //   final alpha = FOV_ALPHA · mesh.visibility · falloff(distance)+        const a = FOV_FLOOR + (1-FOV_FLOOR)*Math.pow(1-v, FOV_POW);+        for (let s=0; s<=FOV_SEGS; s++){+          const th = 2*Math.PI*s/FOV_SEGS, cx = Math.cos(th), cy = Math.sin(th);+          P.push(rad*cx, rad*cy, z);+          N.push(cx*ca, cy*ca, -sa);+          C.push(1, 1, 1, a);+        }+      }+      const W = FOV_SEGS + 1;+      for (let r=0; r<FOV_RINGS; r++) for (let s=0; s<FOV_SEGS; s++){+        const i0 = r*W+s, i1 = i0+1, i2 = i0+W, i3 = i2+1;+        IDX.push(i0, i2, i1,  i1, i2, i3);                    // ring 0 is degenerate (radius 0) — zero-area, harmless+      }+      m.setVerticesData('position', P, true);+      m.setVerticesData('normal', N, true);+      m.setVerticesData('color', C, true);+      m.setIndices(IDX);+      m.hasVertexAlpha = true;+      if (m.refreshBoundingInfo) m.refreshBoundingInfo();+      const fm = new B.StandardMaterial('fovBeamMat', scene);+      fm.diffuseColor  = new B.Color3(0,0,0);+      fm.emissiveColor = new B.Color3(1.55, 0.30, 0.24);                  // λD 631 nm red; over 1.0 to survive the scene's tone mapping+      fm.specularColor = new B.Color3(0,0,0);+      fm.disableLighting = true;                                          // a light field, not a lit surface+      fm.alpha = FOV_ALPHA;+      fm.backFaceCulling = false;                                         // near and far walls both draw -> denser at the silhouette, like a real beam+      fm.separateCullingPass = true;                                      // ...and in the right order+      m.material = fm;+      m.alphaIndex = 900;                                                 // draw after the solid part+    });+    // Apex now lives at the node's local origin; seat that origin on the LED top so the beam+    // emanates from the die. Both the Static bob and the Timeline reel set fovCone.position+    // to (ledX, ledY, ledZ + FOV_EMIT) and fovCone.scaling for the grow — see setConePose().+    fovCone.rotationQuaternion = null;+    fovCone.position = new B.Vector3(0, 0, FOV_EMIT);+    fovCone.scaling  = new B.Vector3(1, 1, 1);+  }+  // Single place that poses the cone: origin on the LED top (+ any body lift), uniform grow scale.+  const setConePose = (x, y, zBody, grow) => {+    if (!fovCone) return;+    fovCone.position.set(x, y, zBody + FOV_EMIT);+    const s = Math.max(grow, 1e-4); fovCone.scaling.set(s, s, s);+  };+   // 4) Motion — manual per-frame interpolation (no baked groups); positions in the model's space.   const LIFT=0.00135, NHI=0.0032, NLO=0.00047, CARRY=0.0027;+  // The LED's underside terminations rest ON the pads — not on the bare board. Placing the body at+  // z=0 let the 0.035 mm pads (1 oz Cu, verified) and the 0.113 mm solder crowns poke up THROUGH it+  // (that's the clipping, in every mode). Seat the body just above the pad top instead. Because the+  // solder crown (0.113 mm) is HIGHER than this standoff, the fillets then rise around the body ends+  // — the solder wets / &quot;melts into&quot; the terminations — rather than the body perching on the domes.+  const STAND = 0.000039;   // 35 µm pad (1 oz) + ~4 µm clearance (no z-fight), in metres   function lerpKeys(keys, t){     for (let i=0;i<keys.length-1;i++){ const a=keys[i], b=keys[i+1];       if (t>=a[0] &amp;&amp; t<=b[0]){ const u=(t-a[0])/(b[0]-a[0]); return [a[1]+(b[1]-a[1])*u, a[2]+(b[2]-a[2])*u, a[3]+(b[3]-a[3])*u]; } }@@ -377,9 +846,9 @@       if (t>=a[0] &amp;&amp; t<=b[0]){ const u=(t-a[0])/(b[0]-a[0]); return a[1]+(b[1]-a[1])*u; } }     return keys[keys.length-1][1];   }-  const bobKeys = [[0,0,0,0],[1.2,0,0,0],[3.0,0,0,LIFT],[4.2,0,0,LIFT],[6.0,0,0,0]];+  const bobKeys = [[0,0,0,STAND],[1.2,0,0,STAND],[3.0,0,0,STAND+LIFT],[4.2,0,0,STAND+LIFT],[6.0,0,0,STAND]];   // rests ON the pad, bobs up from there   const nozKeys = [[0,PKX,PKY,NHI],[1.0,PKX,PKY,NLO],[1.9,PKX,PKY,NHI],[2.7,PKX,PKY,NHI],[3.6,0,0,NHI],[4.4,0,0,NLO],[5.2,0,0,NHI],[6.8,PKX,PKY,NHI],[8.0,PKX,PKY,NHI]];-  const ledKeys = [[0,PKX,PKY,POCKET_LED_Z],[1.0,PKX,PKY,POCKET_LED_Z],[1.9,PKX,PKY,CARRY],[2.7,PKX,PKY,CARRY],[3.6,0,0,CARRY],[4.4,0,0,0],[6.8,0,0,0],[8.0,PKX,PKY,POCKET_LED_Z]];+  const ledKeys = [[0,PKX,PKY,POCKET_LED_Z],[1.0,PKX,PKY,POCKET_LED_Z],[1.9,PKX,PKY,CARRY],[2.7,PKX,PKY,CARRY],[3.6,0,0,CARRY],[4.4,0,0,STAND],[6.8,0,0,STAND],[8.0,PKX,PKY,POCKET_LED_Z]];   // places ON the pad (was 0 → clipped)   // LED lifts STRAIGHT UP out of the pocket (still at the in-tape angle, so it never clips the pocket walls),   // THEN the nozzle rotates it to the board orientation (0°) in the air, then carries it over and places it.   // In-tape angle = IN_TAPE_ROT (cathode toward the sprocket holes, per datasheet); board = 0° (cathode −X).@@ -388,10 +857,12 @@   scene.onBeforeRenderObservable.add(() => {     // keep the shadow map re-rendering so the cast shadow follows the animated LED     if (sg) { const sm = sg.getShadowMap(); if (sm &amp;&amp; sm.refreshRate !== 1) sm.refreshRate = 1; }+    if (mode === 'story') { storyFrame(); return; }        // §5d — the explainer reel drives everything itself     const t = (performance.now() - t0) / 1000;     if (mode === 'static') {       const p = lerpKeys(bobKeys, t % 6);-      [led, ledLit, fovCone].forEach(n => { if (n) n.position.set(p[0], p[1], p[2]); });+      [led, ledLit].forEach(n => { if (n) n.position.set(p[0], p[1], p[2]); });+      setConePose(p[0], p[1], p[2], 1);            // cone apex on the LED top (+bob), full size       if (led) led.rotation.z = 0;     } else {       const tt = t % 8;@@ -501,60 +972,72 @@   };    // ---- datasheet constants (Inolux IN-S42AT series, V1.1, 25 Apr 2017) --------  // Electricals/optics: p.4 table (IN-S42ATR row) — IF 20 mA, VF 2.1 typ / 2.6 max,-  // λD 631 nm, λP 645 nm, 2θ½ 130°, IV 90 mcd. Package: &quot;0402 0.45 mm&quot;, 1.0 × 0.5 × 0.45.-  // Tape/reel: p.9 packing figure (image — values transcribed, see SKILL note).-  const DS      = 'Inolux IN-S42AT V1.1 — p.4 electro-optical table';-  const DS_PKG  = 'Inolux IN-S42AT V1.1 — p.1 package dimensions';-  const DS_TAPE = 'Inolux IN-S42AT V1.1 — p.9 tape &amp; reel (figure, transcribed)';-  const MEAS    = 'measured from the loaded model geometry';-  const REEL    = 'Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 wide · 5000 pcs';+  // EVERY number a tooltip or caption shows is either datasheet-cited (DS*) or measured+  // off the model (MEAS) — no exceptions, nothing invented. Verified page-by-page against+  // the source PDF (C:\Users\arav\Desktop\BasicPartsDatasheets\IN-S42AT Series_V1.1.pdf):+  //   p.3  Electrical Characteristics table, IN-S42ATR row: IF 20 mA, VF 2.1 typ / 2.6 max,+  //        λD 631 nm, λP 645 nm, 2θ½ 130°, IV 90 mcd typ.+  //   p.2  Absolute Max: IF 25 mA, IFP 100 mA, VR 5 V, Pd 78 mW.+  //   p.8  Label decode: &quot;42A = 1.0 x 0.5 x 0.45mm&quot;, Top Mount, Clear lens.  p.1: 0402, AlInGaP.+  //   p.9  Packing Size table (READ OFF THE FIGURE, now verified): W 8.00, P1 4.00, E 1.75,+  //        F 3.50, Do 1.55, Po 4.00, P2 2.00, Ao 0.57, Bo 1.12, Ko 0.55, T 0.20; reel Ø178,+  //        hub Ø70, arbor Ø13.5, tape slot 9.0, width 12.0; 5000 pcs/reel.+  //   p.11 Reflow: tin-glue melt 178–192 °C.+  const DS      = 'Inolux IN-S42AT V1.1 · p.3 electro-optical table';+  const DS_MAX  = 'Inolux IN-S42AT V1.1 · p.2 absolute-max ratings';+  const DS_PKG  = 'Inolux IN-S42AT V1.1 · p.8 label + p.1 features';+  const DS_TAPE = 'Inolux IN-S42AT V1.1 · p.9 packing table (verified)';+  const DS_RFLW = 'Inolux IN-S42AT V1.1 · p.11 reflow profile';+  const MEAS    = 'measured from the model geometry';+  const REEL    = 'Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 slot · 12.0 wide · 5000 pcs';    // ---- THE TOOLTIP TABLE ----------------------------------------------------   // Keyed by mesh-name pattern; first match wins (walking the picked node up its   // parents). `rows` is either an array of [label, value] or a function returning one.   // Adding a new entry is a one-liner. Numbers are datasheet-sourced (src: DS*) or   // measured live off the mesh (src: MEAS) — never invented.-  const TIP_TABLE = [+  const TIP_DEFAULT = [     // ---------- optics -----------    { re:/^fov_cone/, title:'fov_cone', sub:'emission cone', src:DS,-      rows:()=>{ const r = [['viewing angle 2θ½','130°'], ['half-angle','±65°'], ['λD / λP','631 / 645 nm']];-        const m = measure('fov_cone');-        if (m){ const h = m.all.size[2], rad = Math.max(m.all.size[0], m.all.size[1])/2;-          r.push(['cone (model)', 'Ø'+f2(rad*2)+' mm @ '+f2(h)+' mm']);-          r.push(['half-angle (measured)', (Math.atan2(rad,h)*180/Math.PI).toFixed(1)+'°']); }-        return r; } },-    { re:/^LED_lit/, title:'LED_lit', sub:'emitter glow layer', src:DS,-      rows:()=>{ const r = [['λD (dominant)','631 nm — red'], ['IV','90 mcd @ IF 20 mA'],-                            ['VF','2.1 V typ · 2.6 V max'], ['2θ½','130°']];-        const m = measure('LED_lit');-        if (m) r.push(['emissive plane (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm']);-        return r; } },+    // Every entry = dsRows (the DATASHEET rows — the only editable rows, via viewer-cli) ++    // an optional `measured` function (rows computed live from the model geometry, never edited).+    // The tooltip shows dsRows first, then the measured rows. This keeps every stated spec+    // datasheet-cited and every measurement honest.+    { re:/^fov_cone/, title:'fov_cone', id:'fov-cone', sub:'emission cone', src:DS,+      dsRows:[['viewing angle 2θ½','130°'], ['half-angle','±65°'], ['λD / λP','631 / 645 nm']],+      measured:()=>{ const m = measure('fov_cone'); if (!m) return [];+        const h = m.all.size[2], rad = Math.max(m.all.size[0], m.all.size[1])/2;+        return [['cone (model)', 'Ø'+f2(rad*2)+' mm @ '+f2(h)+' mm'],+                ['half-angle (measured)', (Math.atan2(rad,h)*180/Math.PI).toFixed(1)+'°']]; } },+    { re:/^LED_lit/, title:'LED_lit', id:'led-lit', sub:'emitter glow layer', src:DS,+      dsRows:[['λD (dominant)','631 nm — red'], ['IV','90 mcd @ IF 20 mA'],+              ['VF','2.1 V typ · 2.6 V max'], ['2θ½','130°']],+      measured:()=>{ const m = measure('LED_lit'); return m ? [['emissive plane (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm']] : []; } },      // ---------- the part -----------    { re:/^IN-S42ATR/, title:'IN-S42ATR', sub:'Inolux 0402 red LED', src:DS+' + '+DS_PKG,-      rows:()=>{ const r = [['package','0402 · 1.0 × 0.5 × 0.45 mm'], ['λD / λP','631 / 645 nm'],-                            ['VF @ 20 mA','2.1 V typ · 2.6 V max'], ['IV','90 mcd'], ['2θ½','130°']];-        const m = measure('IN-S42ATR');-        if (m) r.push(['body (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' × '+f2(m.all.size[2])+' mm']);-        return r; } },+    { re:/^IN-S42ATR/, title:'IN-S42ATR', id:'in-s42atr', sub:'Inolux 0402 red LED', src:DS+' + '+DS_PKG,+      dsRows:[['package','0402 · 1.0 × 0.5 × 0.45 mm'], ['λD / λP','631 / 645 nm'],+              ['VF @ 20 mA','2.1 V typ · 2.6 V max'], ['IV','90 mcd'], ['2θ½','130°']],+      measured:()=>{ const m = measure('IN-S42ATR'); return m ? [['body (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' × '+f2(m.all.size[2])+' mm']] : []; } }, -    // ---------- board / footprint (all measured off the composite geometry) -----------    { re:/^fr4_board/, title:'fr4_board', sub:'FR-4 substrate', src:MEAS+' (1.6 mm = standard FR-4 stack)',-      rows:()=>{ const m = measure('fr4_board'); if (!m) return [['thickness','1.6 mm']];+    // ---------- board / footprint (rows all MEASURED off the composite geometry) ----------+    { re:/^fr4_board/, title:'fr4_board', id:'fr4-board', sub:'FR-4 substrate', src:MEAS+' (1.6 mm = standard FR-4 stack)',+      dsRows:[],+      measured:()=>{ const m = measure('fr4_board'); if (!m) return [['thickness','1.6 mm']];         return [['thickness', f2(m.all.size[2])+' mm'],                 ['board size', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm'],                 ['material','FR-4, 1.6 mm standard']]; } },-    { re:/^pad_top/, title:'pad_top', sub:'copper pads', src:MEAS+' (built from the IN-S42ATR footprint)',-      rows:()=>{ const m = measure('pad_top'); if (!m) return [];+    { re:/^pad_top/, title:'pad_top', id:'pad-top', sub:'copper pads', src:MEAS+' (built from the IN-S42ATR footprint)',+      dsRows:[],+      measured:()=>{ const m = measure('pad_top'); if (!m) return [];         const p = m.parts[0], r = [['pads', String(m.parts.length)],                                    ['pad size', f2(p.size[0])+' × '+f2(p.size[1])+' mm'],                                    ['copper thickness', f3(p.size[2])+' mm (≈1 oz)']];         if (m.parts.length === 2) { r.push(['gap between pads', f2(m.gap)+' mm']);           r.push(['pitch (centres)', f2(Math.abs(m.parts[1].center[0]-m.parts[0].center[0]))+' mm']); }         return r; } },-    { re:/^solder_top/, title:'solder_top', sub:'solder fillets', src:MEAS,-      rows:()=>{ const m = measure('solder_top'); if (!m) return [];+    { re:/^solder_top/, title:'solder_top', id:'solder-top', sub:'solder fillets', src:MEAS,+      dsRows:[],+      measured:()=>{ const m = measure('solder_top'); if (!m) return [];         const p = m.parts[0], d = Math.max(p.size[0], p.size[1]);         const pad = measure('pad_top');         const r = [['fillets', String(m.parts.length)],@@ -564,49 +1047,54 @@         if (pad) r.push(['above pad top', f3(p.max[2]-pad.parts[0].max[2])+' mm']);         if (m.parts.length === 2) r.push(['pitch (centres)', f2(Math.abs(m.parts[1].center[0]-m.parts[0].center[0]))+' mm']);         return r; } },-    { re:/^silk/, title:'silk', sub:'silkscreen cathode marker', src:MEAS,-      rows:()=>{ const m = measure('silk'); if (!m) return [];+    { re:/^silk/, title:'silk', id:'silk', sub:'silkscreen cathode marker', src:MEAS,+      dsRows:[],+      measured:()=>{ const m = measure('silk'); if (!m) return [];         return [['size', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm'],                 ['thickness', f3(m.all.size[2])+' mm'],                 ['marks','cathode (pin 1) side']]; } }, -    // ---------- carrier tape &amp; reel (datasheet p.9) -----------    { re:/^(pw\d|pf\d)/, title:'embossed pocket', sub:'carrier-tape cavity', src:DS_TAPE,-      rows:[['Ao (pocket W)','0.57 mm'], ['Bo (pocket L)','1.12 mm'], ['Ko (pocket depth)','0.55 mm'],-            ['P1 (pocket pitch)','4.00 mm'], ['F (pocket→sprocket row)','3.50 mm'],-            ['part seating','0.45 mm body in 0.55 deep → top 0.10 mm below tape face']] },-    { re:/^tape_led_/, title:'LED in pocket', sub:'IN-S42ATR, as reeled', src:DS_TAPE+' + '+DS,-      rows:[['part','IN-S42ATR · 0402 · 1.0 × 0.5 × 0.45 mm'], ['pocket Ao × Bo × Ko','0.57 × 1.12 × 0.55 mm'],-            ['orientation','cathode toward the sprocket holes'], ['P1 (pitch)','4.00 mm'], ['reel', REEL]] },-    { re:/^tape_top/, title:'tape_top', sub:'sprocket band', src:DS_TAPE,-      rows:[['Do (sprocket hole Ø)','1.55 mm'], ['Po (sprocket pitch)','4.00 mm'],-            ['E (hole centre→edge)','1.75 mm'], ['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'],-            ['reel', REEL]] },-    { re:/^tape_bot/, title:'tape_bot', sub:'carrier tape body', src:DS_TAPE,-      rows:[['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'],-            ['F (pocket→sprocket row)','3.50 mm'], ['P1 (pocket pitch)','4.00 mm'], ['reel', REEL]] },-    { re:/^tape_band/, title:'tape_band', sub:'carrier band between pockets', src:DS_TAPE,-      rows:[['P1 (pocket pitch)','4.00 mm'], ['P2 (pocket→hole offset)','2.00 mm'],-            ['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'], ['Bo (band height)','1.12 mm']] },-    { re:/^cover_tape/, title:'cover_tape', sub:'heat-seal cover film', src:DS_TAPE,-      rows:[['covers','8.00 mm wide carrier tape'], ['Ao × Bo × Ko','0.57 × 1.12 × 0.55 mm'],-            ['P1 (pocket pitch)','4.00 mm'], ['packing','5000 pcs per reel'], ['reel', REEL]] },-    { re:/^tape_reel$/, title:'tape &amp; reel', sub:'Inolux packing', src:DS_TAPE,-      rows:[['tape','W 8.00 · T 0.20 · P1 4.00 · P2 2.00'], ['sprockets','Do 1.55 · Po 4.00 · E 1.75'],-            ['pocket','Ao 0.57 × Bo 1.12 × Ko 0.55 mm'], ['row offset','F 3.50 mm'], ['reel', REEL]] },+    // ---------- carrier tape &amp; reel (all DATASHEET rows, p.9) ----------+    { re:/^(pw\d|pf\d)/, title:'embossed pocket', id:'embossed-pocket', sub:'carrier-tape cavity', src:DS_TAPE,+      dsRows:[['Ao (pocket W)','0.57 mm'], ['Bo (pocket L)','1.12 mm'], ['Ko (pocket depth)','0.55 mm'],+              ['P1 (pocket pitch)','4.00 mm'], ['F (pocket→sprocket row)','3.50 mm'],+              ['part seating','0.45 mm body in 0.55 deep → top 0.10 mm below tape face']] },+    { re:/^tape_led_/, title:'LED in pocket', id:'led-in-pocket', sub:'IN-S42ATR, as reeled', src:DS_TAPE+' + '+DS,+      dsRows:[['part','IN-S42ATR · 0402 · 1.0 × 0.5 × 0.45 mm'], ['pocket Ao × Bo × Ko','0.57 × 1.12 × 0.55 mm'],+              ['orientation','cathode toward the sprocket holes'], ['P1 (pitch)','4.00 mm'], ['reel', REEL]] },+    { re:/^tape_top/, title:'tape_top', id:'tape-top', sub:'sprocket band', src:DS_TAPE,+      dsRows:[['Do (sprocket hole Ø)','1.55 mm'], ['Po (sprocket pitch)','4.00 mm'],+              ['E (hole centre→edge)','1.75 mm'], ['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'],+              ['reel', REEL]] },+    { re:/^tape_bot/, title:'tape_bot', id:'tape-bot', sub:'carrier tape body', src:DS_TAPE,+      dsRows:[['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'],+              ['F (pocket→sprocket row)','3.50 mm'], ['P1 (pocket pitch)','4.00 mm'], ['reel', REEL]] },+    { re:/^tape_band/, title:'tape_band', id:'tape-band', sub:'carrier band between pockets', src:DS_TAPE,+      dsRows:[['P1 (pocket pitch)','4.00 mm'], ['P2 (pocket→hole offset)','2.00 mm'],+              ['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'], ['Bo (band height)','1.12 mm']] },+    { re:/^cover_tape/, title:'cover_tape', id:'cover-tape', sub:'heat-seal cover film', src:DS_TAPE,+      dsRows:[['covers','8.00 mm wide carrier tape'], ['Ao × Bo × Ko','0.57 × 1.12 × 0.55 mm'],+              ['P1 (pocket pitch)','4.00 mm'], ['packing','5000 pcs per reel'], ['reel', REEL]] },+    { re:/^tape_reel$/, title:'tape &amp; reel', id:'tape-reel', sub:'Inolux packing', src:DS_TAPE,+      dsRows:[['tape','W 8.00 · T 0.20 · P1 4.00 · P2 2.00'], ['sprockets','Do 1.55 · Po 4.00 · E 1.75'],+              ['pocket','Ao 0.57 × Bo 1.12 × Ko 0.55 mm'], ['row offset','F 3.50 mm'], ['reel', REEL]] },      // ---------- pick &amp; place -----------    { re:/^(pnp_nozzle|nozzle_)/, title:'pnp_nozzle', sub:'placement nozzle (ghost)',-      src:()=>NOZZLE_SPEC.src,-      rows:()=>{ const r = NOZZLE_SPEC.rows.slice();+    { re:/^(pnp_nozzle|nozzle_)/, title:'pnp_nozzle', id:'pnp-nozzle', sub:'placement nozzle (ghost)',+      src:()=>NOZZLE_SPEC.src, dsRows:[],+      measured:()=>{ const r = NOZZLE_SPEC.rows.slice();         if (NOZZLE_SPEC.tbd) r.unshift(['nozzle','spec TBD']);         const m = measure('pnp_nozzle');         if (m) r.push(['ghost geometry (model)', 'Ø'+f2(Math.max(m.all.size[0],m.all.size[1]))+' × '+f2(m.all.size[2])+' mm']);         return r; } },-    { re:/^pnp_ghosts$/, title:'pick &amp; place', sub:'ghost assembly', src:DS_TAPE,-      rows:[['scene','nozzle + carrier tape + reeled parts'], ['tape','8 mm, P1 4.00 mm'], ['reel', REEL]] },+    { re:/^pnp_ghosts$/, title:'pick &amp; place', id:'pick-place', sub:'ghost assembly', src:DS_TAPE,+      dsRows:[['scene','nozzle + carrier tape + reeled parts'], ['tape','8 mm, P1 4.00 mm'], ['reel', REEL]] },   ]; +  // Merge viewer-config.json over the defaults (see mergeTip / extraTips above).+  const _tipDefIds = new Set(TIP_DEFAULT.map(e => e.id));+  const TIP_TABLE = TIP_DEFAULT.map(mergeTip).filter(Boolean).concat(extraTips(_tipDefIds));+   // Resolve a picked mesh (or a bare name, for tests) to a tooltip entry: walk the node   // up its parents so glTF `_primitiveN` children and cloned in-tape LEDs resolve too.   function tipEntry(nodeOrName){@@ -625,8 +1113,12 @@     const nm = (typeof nodeOrName === 'string') ? nodeOrName : (nodeOrName &amp;&amp; nodeOrName.name) || '';     if (!hit) return nm ? { title:nm, sub:'', rows:[], src:'no spec mapped for this mesh' } : null;     const e = hit.entry;+    // datasheet rows (editable via viewer-cli) first, then the live measured rows (computed).+    const ds = e.dsRows || (Array.isArray(e.rows) ? e.rows : []);+    const meas = e.measured ? (e.measured(nodeOrName) || [])+               : (typeof e.rows === 'function' ? (e.rows(nodeOrName) || []) : []);     return { title:e.title, sub:e.sub || '', mesh:nm,-             rows:(typeof e.rows === 'function' ? e.rows(nodeOrName) : e.rows) || [],+             rows: ds.concat(meas),              src:(typeof e.src === 'function' ? e.src() : e.src) || '' };   } @@ -691,6 +1183,436 @@   // debug/verification hook: window._tip.data('solder_top') etc.   window._tip = { data:tipData, table:TIP_TABLE, measure, nozzle:NOZZLE_SPEC, show:showTip, hide:hideTip }; +  // ============================================================================+  // 5d) STORY — the explainer reel+  //+  // Replaces the old &quot;rise and fall&quot; bob with a narrated reel that actually+  // teaches the part: it lights up, shows you the beam it throws and tells you+  // what that cone means, lifts off the board to reveal the solder underneath,+  // sets back down onto it, then shows where the part came from (the carrier+  // tape) before returning to a still hero frame and holding.+  //+  // Everything the captions state is either a datasheet number or measured off+  // the loaded geometry — see BALL_* below, which read the real deposits rather+  // than quoting a figure.+  //+  // Camera: scripted keyframes + a slow drift so the frame is never dead. The+  // human always wins — any drag/zoom on the canvas hands the camera over for+  // CAM_YIELD_MS, and the reel keeps playing underneath; when the human stops+  // caring, the camera eases back onto its mark over CAM_BLEND_MS.+  // ============================================================================++  // ---- easing / interpolation ------------------------------------------------+  const clamp01 = v => (v < 0 ? 0 : v > 1 ? 1 : v);+  const smooth  = v => { v = clamp01(v); return v*v*(3-2*v); };                    // smoothstep+  const seg     = (t,a,b) => smooth((t-a)/(b-a));                                  // 0 -> 1 across [a,b]+  const win     = (t,a,b,f) => Math.min(smooth((t-a)/f), smooth((b-t)/f));         // fade in, hold, fade out+  const mix     = (a,b,u) => a + (b-a)*u;++  // ---- one fade path for every mesh in the scene -----------------------------+  // VISIBILITY ONLY. Babylon already routes a mesh through the transparent pass when+  // mesh.visibility < 1 (needAlphaBlendingForMesh), so a brief fade works even on an+  // OPAQUE material — and when visibility returns to 1 the mesh goes back to the opaque+  // pass, crisp and solid. The previous version also flipped material.transparencyMode,+  // which (a) left solid meshes in the transparent pass at full opacity → depth-sort+  // artifacts that read as &quot;the body is see-through&quot;, and (b) leaked across the in-tape+  // LED clones, which share the hero's glTF materials, flipping the HERO translucent at+  // random. Both are gone: we never touch material state here, and the clones get their+  // own opaque materials at build time (see cloneOpaque below).+  function meshesUnder(node){+    const ms = [];+    if (node.getTotalVertices &amp;&amp; node.getTotalVertices() > 0) ms.push(node);+    if (node.getChildMeshes) node.getChildMeshes().forEach(m => { if (m.getTotalVertices &amp;&amp; m.getTotalVertices() > 0) ms.push(m); });+    return ms;+  }+  function fadeNode(node, a){+    if (!node) return;+    a = clamp01(a);+    node.setEnabled(a > 0.004);+    meshesUnder(node).forEach(m => { m.visibility = a; });+  }+  // The LED body ships as four glTF ALPHABLEND materials at alpha 1 — solid, but parked in+  // the transparent pass, which is what caused the see-through look. Force them OPAQUE so+  // the body is unambiguously solid; alpha 1 means no colour change, just correct depth.+  const solidifyBody = () => { if (!led) return; meshesUnder(led).forEach(m => {+    const mat = m.material; if (mat &amp;&amp; 'transparencyMode' in mat) { mat.transparencyMode = 0; mat.alpha = 1; } }); };+  solidifyBody();++  // ---- measured facts (never typed in by hand) -------------------------------+  const solderNode = scene.getNodeByName('solder_top');+  const _ball = measure('solder_top'), _pad = measure('pad_top');+  const _bp   = _ball &amp;&amp; _ball.parts[0];+  const BALL_D_UM   = _bp ? Math.round(Math.max(_bp.size[0], _bp.size[1]) * 1000) : null;   // mm -> µm+  const BALL_H_UM   = _bp ? Math.round(_bp.size[2] * 1000) : null;+  const BALL_TOP    = _ball ? _ball.all.max[2] / 1000 : 0.000113;                           // mm -> m (world)+  const BALL_PROUD  = (_bp &amp;&amp; _pad) ? Math.round((_bp.max[2] - _pad.parts[0].max[2]) * 1000) : null;+  const PT_I        = ptLight ? ptLight.intensity : 0;+  // 5 mm off the board, per the storyboard. Note this is ~5× the part's longest dimension+  // and ~11× its height: to hold both the board and a part 5 mm above it, the frame has to+  // be ~6 mm tall, which leaves the 1 mm part at ~15% of frame. The lift-beat camera marks+  // below are DERIVED from this number, so changing it re-frames those shots automatically+  // (live: window._story.setLift(1.6)).+  let LIFT_M = 0.001;++  // ---- the beat sheet (seconds) ----------------------------------------------+  const SB = {+    hold:    { t: 0.0,  d: 1.5 },    // the part alone, dark: no glow, no cone, no solder+    zoom:    { t: 1.5,  d: 4.0 },    // slow pull back — 80% of frame -> 70%+    power:   { t: 6.5,  d: 1.1 },    // a second after the move settles, the die lights+    fovIn:   { t: 9.0,  d: 3.2 },    // the beam grows out of the LED+    fovHold: { t:12.2,  d: 3.3 },+    fovOut:  { t:15.5,  d: 2.1 },    // ...and retracts back into it+    lift:    { t:18.2,  d: 3.2 },    // body rises LIFT_M off the board+    ballIn:  { t:21.9,  d: 2.0 },    // solder deposits fade up on the pads+    lay:     { t:24.4,  d: 3.2 },    // body settles down ONTO the deposits; solder globs up under it+    seat:    { t:27.6,  d: 4.9 },    // hold — the part standing on its solder joints (they STAY, not fade)+    tapeIn:  { t:32.5,  d: 3.0 },    // carrier tape + nozzle arrive, camera opens up; solder clears off-frame+    pnp:     { t:35.5,  d: 7.6 },    // one full pick-and-place cycle (PNP_CYCLE of motion + a beat)+    tapeOut: { t:43.1,  d: 3.0 },    // tape leaves, camera sweeps back to the hero mark+    rest:    { t:46.1,  d:10.0 },    // 10 s of complete stillness, then loop+  };+  const end = k => SB[k].t + SB[k].d;+  const LEN = end('rest');+  const PNP_CYCLE = 6.8;             // the pick-place-release part of the 8 s loop; the tail is its reset, which we don't want here+  const T_HAND0 = SB.tapeIn.t + 0.2, T_HAND1 = SB.tapeIn.t + 0.9;   // hero LED dissolves off the board...+  const T_POCK0 = SB.tapeIn.t + 1.5, T_POCK1 = SB.tapeIn.t + 2.4;   // ...and re-forms in its tape pocket++  const CHAPTERS_DEFAULT = [+    { t: 0,           label:'The part'       },+    { t: SB.power.t,  label:'Powered on'     },+    { t: SB.fovIn.t,  label:'Viewing angle'  },+    { t: SB.lift.t,   label:'Lift off'       },+    { t: SB.ballIn.t, label:'Solder'         },+    { t: SB.tapeIn.t, label:'Tape &amp; reel'    },+    { t: SB.rest.t,   label:'Rest'           },+  ];++  // config chapters REPLACE the defaults when present. Each: {t,label}.+  const CHAPTERS = (Array.isArray(CFG.chapters) &amp;&amp; CFG.chapters.length)+    ? CFG.chapters.slice().sort((a,b)=>a.t-b.t) : CHAPTERS_DEFAULT;++  // ---- captions --------------------------------------------------------------+  // Data points, not prose. Each row is [label, value] and is EITHER datasheet-cited (the+  // caption's `src` names the page) or measured from the model — model rows carry mdl:true+  // and render in a distinct colour so a reader can always tell a spec from a measurement.+  // `note` is one optional clause of plain-language context for an engineer, never a story.+  const um = v => v == null ? '—' : v + ' µm';+  const CAPS_DEFAULT = [+    { t0:0.5,  t1:5.6,  eyebrow:'Part', head:'IN-S42ATR — Inolux 0402 red', src:DS_PKG,+      rows:[['package','0402 · 1.0 × 0.5 × 0.45 mm'], ['emitter','AlInGaP · clear lens'], ['λD','631 nm']] },+    { t0:6.7,  t1:8.9,  eyebrow:'Forward drive', head:'IF 20 mA', src:DS,+      rows:[['VF','2.1 V typ · 2.6 V max'], ['IV','90 mcd'], ['λD / λP','631 / 645 nm']] },+    { t0:9.2,  t1:17.5, eyebrow:'Viewing angle', head:'2θ½ = 130°', src:DS,+      rows:[['2θ½ (full)','130°'], ['half-angle','±65°'], ['λD','631 nm']],+      note:'Full-width half-power cone: on-axis intensity falls to 50 % at ±65°.' },+    { t0:18.3, t1:21.7, eyebrow:'Assembly', head:'Lifted '+(LIFT_M*1000).toFixed(1).replace(/\.0$/,'')+' mm to the joint', src:MEAS,+      rows:[['reveals','pads · cathode silk · solder'], ['pads','2 × (model)', true], ['lift','model view', true]] },+    { t0:22.0, t1:31.5, eyebrow:'Reflow', head:'Solder joints', src:DS_RFLW,+      rows:[['tin-glue melt','178–192 °C'],+            ['joint Ø', BALL_D_UM ? um(BALL_D_UM)+' (model)' : 'model', true],+            ['proud of pad', BALL_PROUD!=null ? um(BALL_PROUD)+' (model)' : 'model', true]],+      note:'One deposit per pad reflows and globs up; the body settles onto the joints.' },+    { t0:32.6, t1:43.0, eyebrow:'Tape &amp; reel', head:'8 mm carrier · 4 mm pitch', src:DS_TAPE,+      rows:[['tape W','8.00 mm'], ['pitch P1','4.00 mm'], ['pocket','0.57 × 1.12 × 0.55 mm'],+            ['reel','Ø178 · 5000 pcs']] },+  ];++  // config captions REPLACE the defaults when present (the CLI writes the full list, seeded+  // from these defaults). Each: {id,t0,t1,eyebrow,head,src,rows:[[k,v,mdl?]],note}.+  const CAPS = (Array.isArray(CFG.captions) &amp;&amp; CFG.captions.length)+    ? CFG.captions.map(c => ({ id:c.id, t0:c.t0, t1:c.t1, eyebrow:c.eyebrow, head:c.head,+        src:c.src||'', rows:(c.rows||[]).map(r => r[2] ? [r[0],r[1],true] : [r[0],r[1]]), note:c.note||'' }))+    : CAPS_DEFAULT.map((c,i) => ({ id:c.id || _slug(c.eyebrow) || ('cap'+i), ...c }));++  // ---- camera track ----------------------------------------------------------+  // Radii are for CAM_FOV; the LED's ~0.9 mm silhouette fills 80% of the frame at R80.+  //   visible height H = 2·r·tan(fov/2);  r = H / 0.4578 at fov 0.45.+  const CAM_FOV = 0.45;                                  // ~26° — telephoto, so a 1 mm part isn't lens-distorted+  const R80 = 0.00225, R70 = 0.00257, RFOV = 0.00720, RPNP = 0.02800;+  const A0 = 1.05, TAU = Math.PI*2;+  const VFRAC = 2 * Math.tan(CAM_FOV/2);                 // visible height = VFRAC · radius+  // The two lift-beat marks are derived from LIFT_M rather than typed, so the shot stays+  // composed if the lift changes: frame from the board (z=0) to the top of the raised part,+  // plus 8% headroom, centred between them.+  const liftMark = frac => { const span = frac*LIFT_M + 0.0006;      // board top -> part top at this point in the rise+                             return { r: span*1.08/VFRAC, tz: span*0.5 }; };+  // [ t, alpha, beta, radius, targetX, targetY, targetZ ]+  const buildCam = () => { const MK_TOP = liftMark(1.0), MK_MID = liftMark(0.71); return [+    [ 0.0,  A0,       0.98, R80,    0,      0, 0.00025],+    [ 1.5,  A0+0.03,  0.98, R80,    0,      0, 0.00025],+    [ 5.5,  1.40,     1.04, R70,    0,      0, 0.00025],   // the slow &quot;Hollywood&quot; pull-back lands here+    [ 7.6,  1.55,     1.06, R70,    0,      0, 0.00028],+    [ 9.0,  1.62,     1.16, 0.0032, 0,      0, 0.00030],+    // 2θ½ = 130° is a WIDE, shallow bowl: from a high angle you look down into it and it+    // reads as a floating disc. Drop the camera to ~11° above the board so the beam shows+    // its profile — which is also the more cinematic shot.+    [12.2,  1.95,     1.38, RFOV,   0,      0, 0.00082],   // opened up to hold the whole cone+    [15.5,  2.36,     1.40, RFOV,   0,      0, 0.00082],+    [17.6,  2.55,     1.16, 0.0042, 0,      0, 0.00045],+    [18.2,  2.60,     1.03, 0.0046, 0,      0, 0.00050],+    [21.4,  2.95,     1.12, MK_TOP.r, 0,    0, MK_TOP.tz],  // board + the raised part, as tight as both allow+    // A part lifted 5 mm and deposits 0.26 mm across cannot share a readable frame, so this+    // plays as three shots rather than one compromise: follow it up, push in on what's under+    // it (the part leaves frame overhead — deliberate), then pull back out to catch it landing.+    [22.3,  3.05,     1.28, 0.0060, 0,      0, 0.00060],   // push in to the pads+    [23.9,  3.16,     1.36, 0.0046, 0,      0, 0.00018],   // close + low: the deposits read as domes+    [24.9,  3.28,     1.30, 0.0052, 0,      0, 0.00025],+    [25.6,  3.38,     1.18, MK_MID.r, 0,    0, MK_MID.tz],  // back out to meet it on the way down+    [27.6,  3.62,     1.38, 0.0042, 0,      0, 0.00035],   // ride it down: the part standing on its solder+    [30.0,  3.86,     1.36, 0.0038, 0,      0, 0.00025],+    [32.0,  4.02,     1.25, 0.0060, 0,      0, 0.00025],+    [35.5,  4.30,     1.08, RPNP,   0.0080, 0, 0.00060],   // wide for the tape; target hands over to the part below+    [43.1,  5.20,     1.16, 0.0110, 0,      0, 0.00040],   // ...having crept in the whole way across+    [46.1,  A0+TAU,   0.98, R80,    0,      0, 0.00025],   // back on the opening mark, one full turn later+    [LEN,   A0+TAU,   0.98, R80,    0,      0, 0.00025],   // ...and dead still for the 10 s rest+  ]; };+  let CAM = buildCam();+  function camAt(t){+    let i = 0; while (i < CAM.length-2 &amp;&amp; t > CAM[i+1][0]) i++;+    const a = CAM[i], b = CAM[i+1], u = smooth((t - a[0]) / ((b[0]-a[0]) || 1));   // ease at every key -> nothing snaps+    return { alpha:mix(a[1],b[1],u), beta:mix(a[2],b[2],u), radius:mix(a[3],b[3],u),+             tx:mix(a[4],b[4],u), ty:mix(a[5],b[5],u), tz:mix(a[6],b[6],u) };+  }+  // Drift envelope: alive everywhere except the opening beat and the closing rest,+  // where the storyboard asks for stillness. Ramping it (rather than switching it)+  // keeps the hand-off invisible.+  const driftEnv = t => Math.min(seg(t, 1.5, 3.5), 1 - seg(t, SB.tapeOut.t, end('tapeOut')));++  // ---- transport -------------------------------------------------------------+  let storyT = 0, storyPlaying = true, storyLast = performance.now(), scrubDrag = false;+  const capEl   = document.getElementById('caption');+  const scrubEl = document.getElementById('scrub');+  const fillEl  = document.getElementById('scrub-fill');+  const slabEl  = document.getElementById('scrub-label');+  CHAPTERS.forEach(c => { if (c.t <= 0) return;+    const d = document.createElement('div'); d.className = 'tick'; d.style.left = (c.t/LEN*100)+'%'; scrubEl.appendChild(d); });+  const chapterAt = t => { let c = CHAPTERS[0]; CHAPTERS.forEach(k => { if (t >= k.t) c = k; }); return c; };+  const seekTo = x => { storyT = Math.max(0, Math.min(LEN - 0.001, x/window.innerWidth*LEN)); storyLast = performance.now(); };+  scrubEl.addEventListener('pointerdown', e => { e.stopPropagation(); scrubDrag = true; scrubEl.classList.add('hot');+    seekTo(e.clientX); try { scrubEl.setPointerCapture(e.pointerId); } catch(_) {} });+  scrubEl.addEventListener('pointermove', e => {+    const ct = e.clientX/window.innerWidth*LEN;+    slabEl.textContent = chapterAt(ct).label + '   ' + ct.toFixed(1) + 's';+    slabEl.style.left = e.clientX + 'px'; slabEl.classList.add('show');+    if (scrubDrag) seekTo(e.clientX);+  });+  const scrubEnd = e => { scrubDrag = false; scrubEl.classList.remove('hot');+    try { scrubEl.releasePointerCapture(e.pointerId); } catch(_) {} };+  scrubEl.addEventListener('pointerup', scrubEnd);+  scrubEl.addEventListener('pointercancel', scrubEnd);+  scrubEl.addEventListener('pointerleave', () => slabEl.classList.remove('show'));++  // Top transport bar — click/drag anywhere on it to scrub. Same seek surface as the bottom+  // hairline, but full-height and knobbed so it reads as a proper timeline you can grab.+  const topEl   = document.getElementById('topbar');+  const topFill = document.getElementById('topbar-fill');+  const topKnob = document.getElementById('topbar-knob');+  const topTime = document.getElementById('topbar-time');+  CHAPTERS.forEach(c => { if (c.t <= 0) return;+    const d = document.createElement('div'); d.className = 'tick'; d.style.left = (c.t/LEN*100)+'%'; topEl.appendChild(d); });+  topEl.addEventListener('pointerdown', e => { e.stopPropagation(); scrubDrag = true; topEl.classList.add('hot');+    seekTo(e.clientX); try { topEl.setPointerCapture(e.pointerId); } catch(_) {} });+  topEl.addEventListener('pointermove', e => {+    const ct = e.clientX/window.innerWidth*LEN;+    topTime.textContent = chapterAt(ct).label + '   ' + ct.toFixed(1) + 's';+    topTime.style.left = e.clientX + 'px'; topTime.classList.add('show');+    if (scrubDrag) seekTo(e.clientX);+  });+  const topEnd = e => { scrubDrag = false; topEl.classList.remove('hot');+    try { topEl.releasePointerCapture(e.pointerId); } catch(_) {} };+  topEl.addEventListener('pointerup', topEnd);+  topEl.addEventListener('pointercancel', topEnd);+  topEl.addEventListener('pointerleave', () => topTime.classList.remove('show'));++  // ---- the camera director ---------------------------------------------------+  // The human always wins. Any drag / wheel / pinch on the canvas parks the scripted+  // camera for CAM_YIELD_MS; the reel keeps playing regardless. When the human goes+  // quiet, ease back onto the mark over CAM_BLEND_MS instead of snapping.+  const CAM_YIELD_MS = 5000, CAM_BLEND_MS = 1400;+  let userUntil = 0, blendFrom = null, blendT0 = 0;+  const noteUser = () => { userUntil = performance.now() + CAM_YIELD_MS; blendFrom = null; };+  ['pointerdown','wheel','touchstart'].forEach(ev => canvasEl.addEventListener(ev, noteUser, { passive:true }));+  function driveCamera(t, now){+    const c = scene.activeCamera; if (!c) return 0;+    const left = userUntil - now;+    if (left > 0) { blendFrom = null; return left; }            // hands off — they're driving+    const k = camAt(t), env = driftEnv(t);+    // Gentle life on top of the marks. Frequencies are whole cycles over LEN, and the+    // envelope is 0 at both ends of the reel, so the loop point stays seamless.+    k.alpha  += env * 0.045 * Math.sin(t * (3*TAU/LEN));+    k.beta   += env * 0.022 * Math.sin(t * (2*TAU/LEN) + 1.1);+    k.radius *= 1 + env * 0.015 * Math.sin(t * (2*TAU/LEN) + 0.4);+    if (!blendFrom) { blendFrom = { a:c.alpha, b:c.beta, r:c.radius, x:c.target.x, y:c.target.y, z:c.target.z }; blendT0 = now; }+    const w = smooth((now - blendT0) / CAM_BLEND_MS);+    const d = ((k.alpha - blendFrom.a + Math.PI) % TAU + TAU) % TAU - Math.PI;   // shortest way round+    c.alpha  = blendFrom.a + d*w;+    c.beta   = mix(blendFrom.b, k.beta,   w);+    c.radius = mix(blendFrom.r, k.radius, w);+    c.target.copyFromFloats(mix(blendFrom.x,k.tx,w), mix(blendFrom.y,k.ty,w), mix(blendFrom.z,k.tz,w));+    return 0;+  }++  // ---- the reel itself -------------------------------------------------------+  // Height of the body above the board through the assembly beats. It rests ON the pads (STAND);+  // it lifts, then settles back DOWN to STAND — INTO the solder, whose crown is higher — so the+  // fillets wrap the terminations rather than the body sitting on top of the domes. It holds there+  // (a placed part sits on its joints) and only returns to STAND for the bare-part hero at the loop.+  function bodyZ(t){+    if (t <  SB.lift.t)    return STAND;+    if (t <  end('lift'))  return mix(STAND, LIFT_M, seg(t, SB.lift.t, end('lift')));+    if (t <  SB.lay.t)     return LIFT_M;+    if (t <  end('lay'))   return mix(LIFT_M, STAND, seg(t, SB.lay.t, end('lay')));       // settles onto the pads, into the solder+    if (t <  SB.tapeIn.t)  return STAND;                                                  // seated on its joints, held+    return STAND;                                                                         // tape chapter overrides this; hero rests on the pad+  }+  // Solder &quot;glob up&quot;: the deposits dome slightly as they reflow under the settling part+  // (z scaled about the board plane). Purely a melt cue — the fillet geometry itself is+  // the model's, measured, unchanged.+  function solderScaleZ(t){+    if (t < SB.ballIn.t) return 1;+    if (t < SB.lay.t)    return mix(1.0, 1.15, seg(t, SB.ballIn.t, SB.lay.t));   // build up as it melts+    return 1.15;                                                                  // stays globbed+  }+  function applyStory(t){+    // --- the part ---------------------------------------------------------+    let lx = 0, ly = 0, lz = bodyZ(t), lrot = 0, lvis = 1;+    const inTape = t >= T_HAND0 &amp;&amp; t < end('tapeOut');+    if (inTape) {+      lvis = t < T_HAND1 ? 1 - seg(t, T_HAND0, T_HAND1)+           : t < T_POCK0 ? 0+           : t < T_POCK1 ? seg(t, T_POCK0, T_POCK1) : 1;+      if (t >= T_HAND1) {                                   // now living in the tape's timeline+        const tt = Math.max(0, Math.min(PNP_CYCLE, t - SB.pnp.t));+        const p = lerpKeys(ledKeys, tt);+        lx = p[0]; ly = p[1]; lz = p[2]; lrot = lerp1(rotKeys, tt);+      }+    }+    // --- the glow ---------------------------------------------------------+    let lit = t >= SB.power.t ? seg(t, SB.power.t, end('power')) : 0;+    if (t >= SB.lift.t) lit *= 1 - seg(t, SB.lift.t, SB.lift.t + 1.0);   // powers down as it leaves the board+    // --- the beam ---------------------------------------------------------+    let coneS = 0;+    if (t >= SB.fovIn.t &amp;&amp; t < end('fovOut'))+      coneS = t < end('fovIn')  ? seg(t, SB.fovIn.t, end('fovIn'))+            : t < SB.fovOut.t   ? 1+            : 1 - seg(t, SB.fovOut.t, end('fovOut'));+    const coneV = Math.min(1, coneS * 2.5);                 // opacity leads the growth a hair so it never pops+    // --- the solder -------------------------------------------------------+    // Fades in on the pads, globs up, then STAYS as the joint through the whole board+    // section. It only clears during the tape chapter, off-frame while the camera has+    // moved to the reel — so it never appears to evaporate in front of you.+    let sol = 0;+    if (t >= SB.ballIn.t &amp;&amp; t < end('tapeIn'))+      sol = t < end('ballIn') ? seg(t, SB.ballIn.t, end('ballIn'))+          : t < SB.tapeIn.t   ? 1+          : 1 - seg(t, SB.tapeIn.t, end('tapeIn'));       // clears with the board scene, off-frame+    // --- the tape ---------------------------------------------------------+    let tap = 0;+    if (t >= SB.tapeIn.t &amp;&amp; t < end('tapeOut'))+      tap = t < end('tapeIn')  ? seg(t, SB.tapeIn.t, end('tapeIn'))+          : t < SB.tapeOut.t   ? 1+          : 1 - seg(t, SB.tapeOut.t, end('tapeOut'));++    if (led)      { led.position.set(lx, ly, lz); led.rotation.z = lrot; fadeNode(led, lvis); }+    if (ledLit)   { ledLit.position.set(lx, ly, lz); fadeNode(ledLit, lit); }+    if (ptLight)  { ptLight.setEnabled(lit > 0.01); ptLight.intensity = PT_I * lit; }+    setConePose(lx, ly, lz, coneS);                       // apex on the LED top, grows from the die+    if (fovCone)  fadeNode(fovCone, coneV);+    if (solderNode) { fadeNode(solderNode, sol); solderNode.scaling.z = solderScaleZ(t); }+    fadeNode(ghostRoot, tap);+    if (tap > 0.004) {+      const nt = Math.max(0, Math.min(PNP_CYCLE, t - SB.pnp.t));+      const np = lerpKeys(nozKeys, nt); nozzle.position.set(np[0], np[1], np[2]);+    }+    return { lx, ly, lz, lvis };+  }++  // --- caption ------------------------------------------------------------+  let capCur = -2;+  function updateCaption(t){+    let idx = -1, a = 0;+    for (let i=0;i<CAPS.length;i++) if (t >= CAPS[i].t0 &amp;&amp; t <= CAPS[i].t1) { idx = i; a = win(t, CAPS[i].t0, CAPS[i].t1, 0.55); break; }+    if (idx !== capCur) {+      capCur = idx;+      if (idx >= 0) { const c = CAPS[idx];+        capEl.querySelector('.eyebrow').textContent = c.eyebrow;+        capEl.querySelector('.head').textContent    = c.head;+        capEl.querySelector('.rows').innerHTML = (c.rows || []).map(r =>+          '<div class=&quot;kv'+(r[2]?' mdl':'')+'&quot;><i>'+esc(r[0])+'</i><b>'+esc(r[1])+'</b></div>').join('');+        const noteEl = capEl.querySelector('.note');+        noteEl.textContent = c.note || ''; noteEl.style.display = c.note ? 'block' : 'none';+        capEl.querySelector('.src').textContent     = c.src; }+    }+    capEl.style.opacity   = idx < 0 ? '0' : a.toFixed(3);+    capEl.style.transform = 'translateY(' + ((1 - (idx < 0 ? 0 : a)) * 10).toFixed(1) + 'px)';+  }++  // --- the per-frame entry point (called from the render observable) ------+  let uiLast = 0;+  function storyFrame(){+    const now = performance.now();+    let dt = (now - storyLast) / 1000; storyLast = now;+    if (dt > 0.25) dt = 0.25;                                  // backgrounded tab — don't jump the reel+    if (storyPlaying &amp;&amp; !scrubDrag) storyT = (storyT + dt) % LEN;+    const t = storyT;+    const st = applyStory(t);+    updateCaption(t);+    // During the pick-and-place beat the camera keeps the moving part centred, which+    // is what the storyboard asks for and what the scripted target can't do on its own.+    const trackW = Math.min(seg(t, SB.pnp.t, SB.pnp.t + 1.2), 1 - seg(t, SB.pnp.t + PNP_CYCLE - 0.6, SB.pnp.t + PNP_CYCLE));+    const manual = driveCamera(t, now);+    if (!manual &amp;&amp; trackW > 0.001) {+      const c = scene.activeCamera;+      c.target.copyFromFloats(mix(c.target.x, st.lx, trackW), mix(c.target.y, st.ly, trackW), mix(c.target.z, st.lz, trackW));+    }+    if (now - uiLast > 110) {+      uiLast = now;+      const pct = (t/LEN*100);+      fillEl.style.width = pct.toFixed(2) + '%';+      topFill.style.width = pct.toFixed(2) + '%';+      topKnob.style.left = pct.toFixed(2) + '%';+      statusEl.innerHTML = 'TIMELINE · ' + esc(chapterAt(t).label) ++        (manual > 0 ? ' · <span class=&quot;manual&quot;>camera: yours · ' + Math.ceil(manual/1000) + 's</span>' : '');+    }+  }++  const STORY_LOCKED = ['light','fov','body','solder'];        // the reel drives these — don't let the panel fight it+  function storyEnter(){+    storyT = 0; storyLast = performance.now(); storyPlaying = true;+    blendFrom = null; userUntil = 0; capCur = -2;+    STORY_LOCKED.forEach(k => lockLayer(k, true));+    const c = scene.activeCamera;+    c.fov = CAM_FOV; c.lowerRadiusLimit = 0.0015; c.upperRadiusLimit = 0.09; c.minZ = 0.00005; c.maxZ = 10;+    applyStory(0);+    playBtn.textContent = '⏸ Pause'; playBtn.classList.add('on');+  }+  function storyExit(){+    STORY_LOCKED.forEach(k => lockLayer(k, false));+    [led, ledLit, fovCone, solderNode, ghostRoot].forEach(n => fadeNode(n, 1));+    setConePose(0, 0, 0, 1);+    if (solderNode) solderNode.scaling.z = 1;+    if (led)     { led.position.set(0,0,0); led.rotation.z = 0; }+    if (ledLit)  ledLit.position.set(0,0,0);+    if (ptLight) ptLight.intensity = PT_I;+    capEl.style.opacity = '0';+  }+  const playBtn = document.getElementById('btn-play');+  playBtn.onclick = function(){ storyPlaying = !storyPlaying; storyLast = performance.now();+    this.textContent = storyPlaying ? '⏸ Pause' : '▶ Play'; this.classList.toggle('on', storyPlaying); };+  document.getElementById('btn-replay').onclick = () => { storyT = 0; storyLast = performance.now();+    storyPlaying = true; blendFrom = null; userUntil = 0;+    playBtn.textContent = '⏸ Pause'; playBtn.classList.add('on'); };+  // debug/authoring hook: window._story.seek(21.9) to jump straight to a beat+  window._story = { beats:SB, caps:CAPS, cam:CAM, chapters:CHAPTERS, len:()=>LEN,+                    seek:s => { storyT = Math.max(0, Math.min(LEN-0.001, s)); storyLast = performance.now(); },+                    at:()=>storyT, play:v => { storyPlaying = v!==false; storyLast = performance.now(); },+                    // try a different lift height without a reload: window._story.setLift(1.6)+                    setLift:mm => { LIFT_M = mm/1000; CAM = buildCam();+                                    // find the lift caption by id, not index — captions may be reordered via config+                                    const lc = CAPS.find(c => c.id === 'assembly'); if (lc) lc.head = 'Lifted ' + mm + ' mm to the joint';+                                    capCur = -2; return LIFT_M; },+                    measured:{ BALL_D_UM, BALL_H_UM, BALL_PROUD, BALL_TOP, FOV_CHECK } };+   // ============================================================================   // 5c) FLOATING, DRAGGABLE LAYERS PANEL   // Default position: directly under the #legend window (top-right), right edges@@ -749,21 +1671,35 @@   function applyFrame(mode){     try { viewer.frameModel(); } catch (e) {}     const c = scene.activeCamera;-    c.lowerRadiusLimit = 0.0015; c.upperRadiusLimit = 0.08; c.minZ = 0.00005; c.maxZ = 10;+    c.fov = CAM_FOV;   // the whole viewer runs on the ~26° telephoto the story is framed for;+                       // radii below are the old fov-0.8 ones scaled by 0.8456/0.4578 = 1.847+    c.lowerRadiusLimit = 0.0015; c.upperRadiusLimit = 0.09; c.minZ = 0.00005; c.maxZ = 10;     c.alpha = 1.05; c.beta = 1.0;-    if (mode === 'pnp') { c.target.copyFromFloats(0.003, 0, 0.0006); c.radius = 0.026; }-    else { c.target.copyFromFloats(0, 0, -0.0002); c.radius = 0.0065; }+    if (mode === 'pnp') { c.target.copyFromFloats(0.003, 0, 0.0006); c.radius = 0.048; }+    else { c.target.copyFromFloats(0, 0, -0.0002); c.radius = 0.0120; }     matchColby();   // frameModel may rebuild the ground/skybox — re-apply his ground fade + no-skybox   }    // 6) Mode switching — ghosts are a content root only in P&amp;P (so Static frames just the part).   let mode = 'static';   function setMode(m){+    const was = mode;     mode = m; t0 = performance.now();+    if (was === 'story' &amp;&amp; m !== 'story') storyExit();+    document.getElementById('mode-story').classList.toggle('on', m==='story');     document.getElementById('mode-static').classList.toggle('on', m==='static');     document.getElementById('mode-pnp').classList.toggle('on', m==='pnp');     document.getElementById('legend').classList.toggle('show', m==='pnp');-    if (m==='static'){+    document.body.classList.toggle('story', m==='story');+    if (m==='story'){+      // The reel owns every content node, so give it the whole scene: ghosts enabled+      // (it fades them itself), model visible, and the camera under its director.+      setNodeEnabled(ghostRoot, true);+      if (fovCone) setNodeEnabled(fovCone, true);+      if (solderNode) setNodeEnabled(solderNode, true);+      storyEnter();+      statusEl.textContent = 'TIMELINE · The part';+    } else if (m==='static'){       setNodeEnabled(ghostRoot, false); viewer.removeContentRoot(ghostRoot);       lockLayer('light',false); lockLayer('fov',false);       if (led) setNodeEnabled(led, true);@@ -782,6 +1718,7 @@   }    // 7) Buttons — Ground / Bottom light call HIS API; Layers is our menu.+  document.getElementById('mode-story').onclick  = () => setMode('story');   document.getElementById('mode-static').onclick = () => setMode('static');   document.getElementById('mode-pnp').onclick    = () => setMode('pnp');   document.getElementById('btn-ground').onclick = function(){ this.classList.toggle('on'); viewer.setGroundVisible(this.classList.contains('on')); matchColby(); };   // setGroundVisible rebuilds the ground opaque — re-apply his fade circle@@ -800,56 +1737,1126 @@     }   }, true); -  setMode('static');-  window._v = { viewer, scene, B, setMode };   // debug hook+  setMode('story');+  window._v = { viewer, scene, B, setMode, mode:()=>mode };   // debug hook++  // Frame grab for authoring/verification. The engine has no preserveDrawingBuffer, so+  // canvas.toDataURL() and headless screenshots both come back blank — read the pixels+  // straight out of the GL buffer immediately after an explicit render instead.+  window._shot = (scale) => {+    const eng = scene.getEngine(), gl = eng._gl;+    scene.render();+    const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;+    const px = new Uint8Array(w*h*4);+    gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);+    const cv = document.createElement('canvas'); cv.width = w; cv.height = h;+    const cx = cv.getContext('2d'), img = cx.createImageData(w, h);+    for (let y=0; y<h; y++) img.data.set(px.subarray((h-1-y)*w*4, (h-y)*w*4), y*w*4);   // GL origin is bottom-left+    cx.putImageData(img, 0, 0);+    const s = scale || 1;+    if (s === 1) return cv.toDataURL('image/png');+    const cv2 = document.createElement('canvas'); cv2.width = Math.round(w*s); cv2.height = Math.round(h*s);+    cv2.getContext('2d').drawImage(cv, 0, 0, cv2.width, cv2.height);+    return cv2.toDataURL('image/png');+  };   } catch (e) { statusEl.textContent = 'init error: ' + e.message; window._err = (e.stack || e.message); console.error(e); } })(); </script>-" title="IN-S42ATR interactive 3D viewer" allow="fullscreen"></iframe>+" title="IN-S42ATR interactive 3D viewer (Timeline)" allow="fullscreen"></iframe>+</div>++<div class="container">++  ++  <!-- Key Specs -->+  <div class="section">+    <div class="spec-cards">+      <div class="spec-card highlight">+        <div class="sc-label">Wavelength</div>+        <div class="sc-value">631nm</div>+        <div class="sc-detail">645nm peak</div>+      </div>+      <div class="spec-card">+        <div class="sc-label">Luminous Intensity</div>+        <div class="sc-value">90mcd</div>+        <div class="sc-detail">@ 20mA</div>+      </div>+      <div class="spec-card">+        <div class="sc-label">Forward Voltage</div>+        <div class="sc-value">2.1V</div>+        <div class="sc-detail">max 2.6V</div>+      </div>+      <div class="spec-card">+        <div class="sc-label">Forward Current</div>+        <div class="sc-value">25mA</div>+        <div class="sc-detail">20mA typical</div>+      </div>+      <div class="spec-card">+        <div class="sc-label">Viewing Angle</div>+        <div class="sc-value">130&deg;</div>+        <div class="sc-detail">2&theta;&frac12;</div>+      </div>+      <div class="spec-card">+        <div class="sc-label">Power Dissipation</div>+        <div class="sc-value">78mW</div>+        <div class="sc-detail">max rated</div>+      </div>+    </div>+  </div>++  <div class="grid-2">+    <!-- Left column -->+    <div>+      <!-- Electrical Characteristics -->+      <div class="section">+        <h2 class="section-title">Electrical Characteristics</h2>+        <div class="card">+          <table class="detail-table">+            <tr><td>Forward Voltage (typ)</td><td>2.1V @ 20mA</td></tr>+            <tr><td>Forward Voltage (max)</td><td>2.6V @ 20mA</td></tr>+            <tr><td>Reverse Voltage (max)</td><td>5V</td></tr>+            <tr><td>Forward Current (max)</td><td>25mA</td></tr>+            <tr><td>Peak Forward Current</td><td>100mA (1/10, 0.1ms)</td></tr>+            <tr><td>Power Dissipation</td><td>78mW</td></tr>+            <tr><td>Dominant Wavelength</td><td>631nm</td></tr>+            <tr><td>Peak Wavelength</td><td>645nm</td></tr>+            <tr><td>Luminous Intensity</td><td>90 mcd @ 20mA</td></tr>+            <tr><td>Viewing Angle (2&theta;&frac12;)</td><td>130&deg;</td></tr>+            <tr><td>Material</td><td>AlInGaP</td></tr>+            <tr><td>Lens Type</td><td>Clear</td></tr>+          </table>+        </div>+      </div>++      <!-- Operating Conditions -->+      <div class="section">+        <h2 class="section-title">Operating Conditions</h2>+        <div class="card">+          <table class="detail-table">+            <tr><td>Operating Temp</td><td>&minus;30&deg;C to +80&deg;C</td></tr>+            <tr><td>Storage Temp</td><td>&minus;40&deg;C to +85&deg;C</td></tr>+            <tr><td>ESD Sensitivity</td><td>AlInGaP &mdash; handle with care</td></tr>+            <tr><td>Polarity</td><td>Cathode marked on package</td></tr>+          </table>+          <div style="margin-top:10px;padding:8px 12px;background:rgba(248,81,73,0.08);border:1px solid rgba(248,81,73,0.2);border-radius:6px;font-size:11px;color:var(--red)">+            &#9888; ESD Sensitive Device &mdash; AlInGaP LEDs require proper ESD precautions during handling and assembly. Use grounded wrist straps and ESD-safe workstations.+          </div>+        </div>+      </div>+    </div>++    <!-- Right column -->+    <div>+      <!-- Package Drawing -->+      <div class="section">+        <h2 class="section-title">Package &amp; Dimensions</h2>+        <div class="card">+          <div class="pkg-drawing">+            <svg width="100%" height="200" viewBox="0 0 500 200">+              <!-- Top view -->+              <text x="120" y="14" fill="#8b949e" font-size="10" text-anchor="middle">Top View</text>+              <rect x="55" y="24" width="130" height="65" rx="2" fill="#1c2128" stroke="#30363d" stroke-width="1.5"/>+              <rect x="55" y="24" width="25" height="65" rx="1" fill="#DC143C26" stroke="#DC143C" stroke-width="1"/>+              <rect x="160" y="24" width="25" height="65" rx="1" fill="#DC143C26" stroke="#DC143C" stroke-width="1"/>+              <line x1="155" y1="28" x2="155" y2="85" stroke="#DC143C" stroke-width="1.5" stroke-dasharray="3,2"/>+              <rect x="92" y="38" width="36" height="37" rx="1" fill="#DC143C40" stroke="#DC143C80" stroke-width="0.8"/>+              <path d="M110,47 Q125,35 135,47" stroke="#DC143C99" stroke-width="0.6" fill="none"/>+              <text x="168" y="18" fill="#DC143C" font-size="9" text-anchor="middle">K (cathode)</text>+              <text x="68" y="18" fill="#8b949e" font-size="9" text-anchor="middle">A</text>++              <line x1="55" y1="102" x2="185" y2="102" stroke="#484f58" stroke-width="0.5"/>+              <line x1="55" y1="98" x2="55" y2="106" stroke="#484f58" stroke-width="0.5"/>+              <line x1="185" y1="98" x2="185" y2="106" stroke="#484f58" stroke-width="0.5"/>+              <text x="120" y="118" fill="#e6edf3" font-size="10" text-anchor="middle">1.0 &plusmn; 0.1</text>++              <line x1="195" y1="24" x2="195" y2="89" stroke="#484f58" stroke-width="0.5"/>+              <line x1="191" y1="24" x2="199" y2="24" stroke="#484f58" stroke-width="0.5"/>+              <line x1="191" y1="89" x2="199" y2="89" stroke="#484f58" stroke-width="0.5"/>+              <text x="215" y="60" fill="#e6edf3" font-size="10">0.5 &plusmn; 0.1</text>++              <!-- Side view -->+              <text x="380" y="14" fill="#8b949e" font-size="10" text-anchor="middle">Side View</text>+              <rect x="315" y="40" width="130" height="38" rx="2" fill="#1c2128" stroke="#30363d" stroke-width="1.5"/>+              <path d="M320,40 Q380,30 445,40" stroke="#DC143C80" stroke-width="1.5" fill="#DC143C14"/>+              <rect x="315" y="40" width="20" height="38" rx="1" fill="#DC143C26" stroke="#DC143C" stroke-width="1"/>+              <rect x="425" y="40" width="20" height="38" rx="1" fill="#DC143C26" stroke="#DC143C" stroke-width="1"/>+              <line x1="455" y1="30" x2="455" y2="78" stroke="#484f58" stroke-width="0.5"/>+              <line x1="451" y1="30" x2="459" y2="30" stroke="#484f58" stroke-width="0.5"/>+              <line x1="451" y1="78" x2="459" y2="78" stroke="#484f58" stroke-width="0.5"/>+              <text x="465" y="58" fill="#e6edf3" font-size="10">0.45 &plusmn; 0.1</text>++              <text x="120" y="145" fill="#8b949e" font-size="10" text-anchor="middle">0402 (1005 metric)</text>+              <text x="380" y="100" fill="#8b949e" font-size="10" text-anchor="middle">Clear lens &middot; AlInGaP</text>++              <rect x="55" y="158" width="390" height="28" rx="4" fill="#DC143C0f" stroke="#DC143C33" stroke-width="0.5"/>+              <text x="250" y="176" fill="#DC143C" font-size="10" text-anchor="middle">&#9888; Polarized &mdash; cathode mark on package indicates negative terminal</text>+            </svg>+          </div>+        </div>+      </div>++      <!-- Land Pattern -->+      <div class="section">+        <h2 class="section-title">Recommended Land Pattern</h2>+        <div class="card">+          <table class="detail-table">+            <tr><td>Pad Width</td><td>0.4 &plusmn; 0.1mm</td></tr>+            <tr><td>Pad Height</td><td>0.6 &plusmn; 0.1mm</td></tr>+            <tr><td>Pad Gap (edge-to-edge)</td><td>0.6 &plusmn; 0.1mm</td></tr>+            <tr><td>Polarity</td><td>Cathode marked on PCB</td></tr>+          </table>+        </div>+      </div>+    </div>+  </div>++  <!-- Color Swatch -->+  <div class="section">+    <h2 class="section-title">Emission Color</h2>+    <div class="card color-swatch">+      <div class="swatch-block" style="background: linear-gradient(135deg, #DC143C, #FF0000); box-shadow: 0 4px 24px #DC143C50; display:flex; align-items:center; justify-content:center;">+        <span style="font-size:28px;font-weight:700;font-family:'Familjen Grotesk',sans-serif;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,0.5)">Red</span>+      </div>+      <div class="swatch-label" style="color:#DC143C">631nm Dominant Wavelength</div>+      <div class="swatch-detail">Peak wavelength 645nm &middot; AlInGaP semiconductor &middot; CSS approximation: #DC143C</div>+    </div>+  </div>++  <!-- Spectral Distribution -->+  <div class="section">+    <h2 class="section-title">Spectral Distribution</h2>+    <div class="chart-wrap">+      <h3>Emission Spectrum &mdash; Relative Intensity vs. Wavelength</h3>+      <svg id="spectral-chart" width="100%" height="280" viewBox="0 0 700 280">+        <rect x="60" y="20" width="600" height="200" fill="#0d1117" rx="4"/>+        <line x1="60" y1="220" x2="660" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="20" x2="60" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="170" x2="660" y2="170" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="120" x2="660" y2="120" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="70" x2="660" y2="70" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="20" x2="660" y2="20" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="180" y1="20" x2="180" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="300" y1="20" x2="300" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="420" y1="20" x2="420" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="540" y1="20" x2="540" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>++        <path d="M60,220 L60,220 L369,219 L372,219 L375,218 L378,217 L381,216 L384,215 L387,213 L390,211 L393,208 L396,204 L399,199 L402,193 L405,186 L408,178 L411,169 L414,158 L417,146 L420,134 L423,120 L426,106 L429,92 L432,78 L435,64 L438,52 L441,41 L444,32 L447,25 L450,21 L453,20 L456,21 L459,25 L462,32 L465,41 L468,52 L471,64 L474,78 L477,92 L480,106 L483,120 L486,134 L489,146 L492,158 L495,169 L498,178 L501,186 L504,193 L507,199 L510,204 L513,208 L516,211 L519,213 L522,215 L525,216 L528,217 L531,218 L534,219 L537,219 L660,220 L660,220 Z" fill="url(#INS42ATRGrad)" opacity="0.3"/>+        <defs>+          <linearGradient id="INS42ATRGrad" x1="0" y1="0" x2="0" y2="1">+            <stop offset="0%" stop-color="#DC143C" stop-opacity="0.6"/>+            <stop offset="100%" stop-color="#DC143C" stop-opacity="0.05"/>+          </linearGradient>+        </defs>+        <path d="M60,220 L60,220 L369,219 L372,219 L375,218 L378,217 L381,216 L384,215 L387,213 L390,211 L393,208 L396,204 L399,199 L402,193 L405,186 L408,178 L411,169 L414,158 L417,146 L420,134 L423,120 L426,106 L429,92 L432,78 L435,64 L438,52 L441,41 L444,32 L447,25 L450,21 L453,20 L456,21 L459,25 L462,32 L465,41 L468,52 L471,64 L474,78 L477,92 L480,106 L483,120 L486,134 L489,146 L492,158 L495,169 L498,178 L501,186 L504,193 L507,199 L510,204 L513,208 L516,211 L519,213 L522,215 L525,216 L528,217 L531,218 L534,219 L537,219 L660,220 L660,220" stroke="#DC143C" stroke-width="2.5" fill="none"/>+++        <!-- Peak annotation -->+        <circle cx="453" cy="22" r="4" fill="#DC143C" opacity="0.9"/>+        <line x1="453" y1="22" x2="453" y2="220" stroke="#DC143C" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.4"/>+        <text x="453" y="238" fill="#DC143C" font-size="10" text-anchor="middle">631nm</text>++++        <text x="55" y="224" fill="#484f58" font-size="9" text-anchor="end">0%</text>+        <text x="55" y="174" fill="#484f58" font-size="9" text-anchor="end">25%</text>+        <text x="55" y="124" fill="#484f58" font-size="9" text-anchor="end">50%</text>+        <text x="55" y="74" fill="#484f58" font-size="9" text-anchor="end">75%</text>+        <text x="55" y="24" fill="#484f58" font-size="9" text-anchor="end">100%</text>+        <text x="12" y="124" fill="#484f58" font-size="9" text-anchor="middle" transform="rotate(-90,12,124)">Relative Intensity</text>++        <text x="60" y="255" fill="#484f58" font-size="9" text-anchor="middle">500</text>+        <text x="180" y="255" fill="#484f58" font-size="9" text-anchor="middle">540</text>+        <text x="300" y="255" fill="#484f58" font-size="9" text-anchor="middle">580</text>+        <text x="420" y="255" fill="#484f58" font-size="9" text-anchor="middle">620</text>+        <text x="540" y="255" fill="#484f58" font-size="9" text-anchor="middle">660</text>+        <text x="660" y="255" fill="#484f58" font-size="9" text-anchor="middle">700</text>+        <text x="360" y="272" fill="#484f58" font-size="9" text-anchor="middle">Wavelength (nm)</text>+      </svg>+      <div class="chart-note">Red emission centered at 631nm dominant wavelength (645nm peak). The narrow spectral width indicates a well-defined color output typical of AlInGaP LEDs.</div>+    </div>   </div>-  <p class="note">Interactive assembly view on the Adom 3D viewer with <strong>Static</strong> and-     <strong>Pick&nbsp;&amp;&nbsp;Place</strong> modes. In Pick&nbsp;&amp;&nbsp;Place the nozzle lifts an-     LED straight out of a datasheet-accurate carrier tape, rotates it to the board orientation, and seats-     it on the pads. Use <strong>Layers</strong> to toggle the board, pads, solder, silk, the LED body,-     <strong><code>LED_lit</code></strong> (red emitter on/off), and the <strong>FOV</strong> cone.</p>--  <h2>Characterization</h2>-  <p><a class="btn" href="/blob/component/in-s42atr/IN-S42ATR.html">View full characterization page &rarr;</a></p>-  <p class="note">Interactive specs, footprint viewer, symbol viewer, and design charts.</p>--  <blockquote><strong>Provenance &mdash; 3D assembly.</strong> <code>IN-S42ATR.glb</code> is a composite-    built from the page&#39;s own <code>IN-S42ATR.step</code> (via cadquery for the sunken side embossing)-    plus a generated FR4 board, copper pads, solder, and cathode silk mark. The LED body keeps its three-    original material regions (pale epoxy, gold terminations, green cathode dot); the top lens is tinted red-    with a toggleable emissive <code>LED_lit</code> layer. The insert animation is baked into the GLB.</blockquote>--  <h2>Downloads</h2>-  <table>-    <tr><th>File</th><th>Description</th></tr>-    <tr><td><a href="/blob/component/in-s42atr/IN-S42ATR.html">IN-S42ATR.html</a></td><td>Characterization Page</td></tr>-    <tr><td><a href="/blob/component/in-s42atr/IN-S42ATR.kicad_sym">IN-S42ATR.kicad_sym</a></td><td>KiCad Symbol</td></tr>-    <tr><td><a href="/blob/component/in-s42atr/IN-S42ATR.kicad_mod">IN-S42ATR.kicad_mod</a></td><td>KiCad Footprint</td></tr>-    <tr><td><a href="/blob/component/in-s42atr/IN-S42ATR.step">IN-S42ATR.step</a></td><td>3D STEP Model</td></tr>-  </table>--  <h2>Thumbnails (<code>ct/</code>)</h2>-  <p class="note">Standard chip-thumbnail bundle for this part &mdash; the full family of icons every-     downstream surface needs (index tiles, library cards, wiki heroes, <code>adom-symbol</code> centre-     overlay, print outlines) so no consumer has to re-invoke OCCT / KiCad rendering.</p>-  <pre><code>adom-chip-thumbnailer once IN-S42ATR                        # 3D chip icons-adom-symbol   render --file IN-S42ATR.kicad_sym --out ct/IN-S42ATR-symbol.svg-adom-footprint render --file IN-S42ATR.kicad_mod --out ct/IN-S42ATR-footprint.svg</code></pre>-  <table>-    <tr><th>Kind</th><th>Consumer</th></tr>-    <tr><td>Shaded 3D iso (6 orientations)</td><td>Card / index hero</td></tr>-    <tr><td>Transparent alpha-keyed icons</td><td>UI overlay, <code>adom-symbol</code> centre</td></tr>-    <tr><td>Vector outlines (11 styles &times; plain/-named/-named90)</td><td>Print, blueprint, neon UI, x-ray</td></tr>-    <tr><td>Symbol thumbnail (<a href="https://wiki.adom.inc/adom/adom-symbol">adom-symbol</a>)</td><td>Library card, tooltip</td></tr>-    <tr><td>Footprint thumbnail (<a href="https://wiki.adom.inc/adom/adom-footprint">adom-footprint</a>)</td><td>Library card, tooltip</td></tr>-    <tr><td>Manifest (<code>ct/IN-S42ATR-thumbs.json</code>)</td><td>Consumer index</td></tr>-  </table>-  <blockquote><strong>Provenance &mdash; <code>ct/</code> thumbnails.</strong> 3D icons by-    <code>adom-chip-thumbnailer once</code> (v0.6.0) driving <code>service-step2glb</code>&#39;s OCCT-    pipeline from <code>IN-S42ATR.step</code>. Symbol/footprint SVGs by <code>adom-symbol</code> /-    <code>adom-footprint</code>. Canonical Adom dark palette (<code>#0d1117</code> / <code>#00b8b0</code>).</blockquote>++  <!-- IV Curve -->+  <div class="section">+    <h2 class="section-title">Forward Current vs. Forward Voltage</h2>+    <div class="chart-wrap">+      <h3>IV Characteristic &mdash; the essential curve for circuit design</h3>+      <svg id="iv-chart" width="100%" height="280" viewBox="0 0 700 280">+        <rect x="60" y="20" width="600" height="200" fill="#0d1117" rx="4"/>+        <line x1="60" y1="220" x2="660" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="20" x2="60" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="170" x2="660" y2="170" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="120" x2="660" y2="120" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="70" x2="660" y2="70" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="20" x2="660" y2="20" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="180" y1="20" x2="180" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="300" y1="20" x2="300" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="420" y1="20" x2="420" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="540" y1="20" x2="540" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+++        <path d="M60,220 L168,220 L222,220 L254,219 L265,218 L276,216 L285,212 L294,200 L303,180 L312,140 L327,120 L342,100 L357,70 L372,20" stroke="#DC143C" stroke-width="2.5" fill="none"/>+        <path d="M60,220 L168,220 L222,220 L254,219 L265,218 L276,216 L285,212 L294,200 L303,180 L312,140 L327,120 L342,100 L357,70 L372,20 L372,220 L60,220 Z" fill="url(#INS42ATRGrad)" opacity="0.15"/>++        <!-- Operating point -->+        <circle cx="312" cy="140" r="5" fill="#DC143C" opacity="0.9"/>+        <line x1="312" y1="140" x2="312" y2="220" stroke="#DC143C" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.5"/>+        <line x1="60" y1="140" x2="312" y2="140" stroke="#DC143C" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.5"/>+        <rect x="317" y="126" width="108" height="28" rx="4" fill="#DC143C1e"/>+        <text x="371" y="139" fill="#DC143C" font-size="10" text-anchor="middle">Typ: 2.1V, 20mA</text>+        <text x="371" y="149" fill="#8b949e" font-size="8" text-anchor="middle">Operating point</text>++        <!-- Max Vf -->+        <line x1="372" y1="20" x2="372" y2="220" stroke="#f85149" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.5"/>+        <text x="372" y="248" fill="#f85149" font-size="9" text-anchor="middle">2.6V max</text>++        <!-- Max If -->+        <line x1="60" y1="120" x2="660" y2="120" stroke="#f85149" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.3"/>+        <text x="100" y="114" fill="#f85149" font-size="9">25mA max</text>++        <text x="55" y="224" fill="#484f58" font-size="9" text-anchor="end">0</text>+        <text x="55" y="174" fill="#484f58" font-size="9" text-anchor="end">12.5</text>+        <text x="55" y="124" fill="#484f58" font-size="9" text-anchor="end">25</text>+        <text x="55" y="74" fill="#484f58" font-size="9" text-anchor="end">37.5</text>+        <text x="55" y="24" fill="#484f58" font-size="9" text-anchor="end">50</text>+        <text x="12" y="124" fill="#484f58" font-size="9" text-anchor="middle" transform="rotate(-90,12,124)">Forward Current (mA)</text>++        <text x="60" y="255" fill="#484f58" font-size="9" text-anchor="middle">0.0</text>+        <text x="180" y="255" fill="#484f58" font-size="9" text-anchor="middle">1.0</text>+        <text x="300" y="255" fill="#484f58" font-size="9" text-anchor="middle">2.0</text>+        <text x="420" y="255" fill="#484f58" font-size="9" text-anchor="middle">3.0</text>+        <text x="540" y="255" fill="#484f58" font-size="9" text-anchor="middle">4.0</text>+        <text x="660" y="255" fill="#484f58" font-size="9" text-anchor="middle">5.0</text>++        <text x="360" y="272" fill="#484f58" font-size="9" text-anchor="middle">Forward Voltage (V)</text>+      </svg>+      <div class="chart-note">Characteristic exponential IV curve for an AlInGaP red LED. Near-zero current until threshold (~1.8V), then steep rise. Design point: 2.1V typical at 20mA. Never exceed 2.6V forward or 25mA continuous.</div>+    </div>+  </div>++  <!-- Current Derating -->+  <div class="section">+    <h2 class="section-title">Forward Current Derating</h2>+    <div class="chart-wrap">+      <h3>Maximum Forward Current vs. Ambient Temperature</h3>+      <svg id="derating-chart" width="100%" height="280" viewBox="0 0 700 280">+        <rect x="60" y="20" width="600" height="200" fill="#0d1117" rx="4"/>+        <line x1="60" y1="220" x2="660" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="20" x2="60" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="170" x2="660" y2="170" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="120" x2="660" y2="120" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="70" x2="660" y2="70" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="180" y1="20" x2="180" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="300" y1="20" x2="300" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="420" y1="20" x2="420" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="540" y1="20" x2="540" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>++        <path d="M60,20 L210,20" stroke="#DC143C" stroke-width="2.5" fill="none"/>+        <path d="M210,20 L540,187" stroke="#DC143C" stroke-width="2.5" fill="none"/>+        <path d="M540,187 L606,220" stroke="#DC143C" stroke-width="2.5" fill="none" stroke-dasharray="4,3"/>+        <path d="M60,20 L210,20 L540,187 L606,220 L60,220 Z" fill="url(#INS42ATRGrad)" opacity="0.12"/>++        <circle cx="210" cy="20" r="4" fill="#DC143C" opacity="0.9"/>+        <text x="210" y="40" fill="#DC143C" font-size="10" text-anchor="middle">30mA @ 25&deg;C</text>+        <circle cx="540" cy="187" r="4" fill="#DC143C" opacity="0.9"/>+        <text x="540" y="178" fill="#DC143C" font-size="10" text-anchor="middle">5mA @ 80&deg;C</text>++        <rect x="200" y="100" width="110" height="20" rx="4" fill="rgba(63,185,80,0.12)"/>+        <text x="255" y="114" fill="#3fb950" font-size="10" text-anchor="middle">Safe operating</text>++        <text x="55" y="224" fill="#484f58" font-size="9" text-anchor="end">0</text>+        <text x="55" y="174" fill="#484f58" font-size="9" text-anchor="end">7.5</text>+        <text x="55" y="124" fill="#484f58" font-size="9" text-anchor="end">15</text>+        <text x="55" y="74" fill="#484f58" font-size="9" text-anchor="end">22.5</text>+        <text x="55" y="24" fill="#484f58" font-size="9" text-anchor="end">30</text>+        <text x="12" y="124" fill="#484f58" font-size="9" text-anchor="middle" transform="rotate(-90,12,124)">Max If (mA)</text>++        <text x="60" y="255" fill="#484f58" font-size="9" text-anchor="middle">0</text>+        <text x="210" y="255" fill="#484f58" font-size="9" text-anchor="middle">25</text>+        <text x="360" y="255" fill="#484f58" font-size="9" text-anchor="middle">50</text>+        <text x="540" y="255" fill="#484f58" font-size="9" text-anchor="middle">80</text>+        <text x="660" y="255" fill="#484f58" font-size="9" text-anchor="middle">100</text>+        <text x="360" y="272" fill="#484f58" font-size="9" text-anchor="middle">Ambient Temperature (&deg;C)</text>+      </svg>+      <div class="chart-note">Maximum allowable forward current decreases linearly with temperature. At 80&deg;C ambient, derate to only 5mA. Design margin is critical in enclosed or high-temperature applications.</div>+    </div>+  </div>++  <!-- Luminous Intensity vs Current -->+  <div class="section">+    <h2 class="section-title">Luminous Intensity vs. Forward Current</h2>+    <div class="chart-wrap">+      <h3>Relative Brightness (normalized at 20mA)</h3>+      <svg id="brightness-current-chart" width="100%" height="280" viewBox="0 0 700 280">+        <rect x="60" y="20" width="600" height="200" fill="#0d1117" rx="4"/>+        <line x1="60" y1="220" x2="660" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="20" x2="60" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="170" x2="660" y2="170" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="120" x2="660" y2="120" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="70" x2="660" y2="70" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="180" y1="20" x2="180" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="300" y1="20" x2="300" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="420" y1="20" x2="420" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="540" y1="20" x2="540" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>++        <path d="M60,220 Q120,180 180,158 Q240,138 300,120 Q360,100 420,78 Q480,62 540,48 Q600,36 660,25"+              stroke="#DC143C" stroke-width="2.5" fill="none"/>++        <path d="M60,220 L660,20" stroke="#484f58" stroke-width="1" stroke-dasharray="5,4" opacity="0.4"/>+        <text x="600" y="50" fill="#484f58" font-size="9">Linear</text>++        <circle cx="300" cy="120" r="5" fill="#DC143C" opacity="0.9"/>+        <line x1="60" y1="120" x2="300" y2="120" stroke="#DC143C" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.4"/>+        <line x1="300" y1="120" x2="300" y2="220" stroke="#DC143C" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.4"/>+        <text x="310" y="114" fill="#DC143C" font-size="10">1.0 @ 20mA</text>++        <text x="55" y="224" fill="#484f58" font-size="9" text-anchor="end">0</text>+        <text x="55" y="174" fill="#484f58" font-size="9" text-anchor="end">0.5</text>+        <text x="55" y="124" fill="#484f58" font-size="9" text-anchor="end">1.0</text>+        <text x="55" y="74" fill="#484f58" font-size="9" text-anchor="end">1.5</text>+        <text x="55" y="24" fill="#484f58" font-size="9" text-anchor="end">2.0</text>+        <text x="12" y="124" fill="#484f58" font-size="9" text-anchor="middle" transform="rotate(-90,12,124)">Relative Brightness</text>++        <text x="60" y="255" fill="#484f58" font-size="9" text-anchor="middle">0</text>+        <text x="180" y="255" fill="#484f58" font-size="9" text-anchor="middle">10</text>+        <text x="300" y="255" fill="#484f58" font-size="9" text-anchor="middle">20</text>+        <text x="420" y="255" fill="#484f58" font-size="9" text-anchor="middle">30</text>+        <text x="540" y="255" fill="#484f58" font-size="9" text-anchor="middle">40</text>+        <text x="660" y="255" fill="#484f58" font-size="9" text-anchor="middle">50</text>+        <text x="360" y="272" fill="#484f58" font-size="9" text-anchor="middle">Forward Current (mA)</text>+      </svg>+      <div class="chart-note">Brightness increases sublinearly with current. Doubling the current from 20mA to 40mA yields only ~70% more light, not 100%. This means diminishing returns at higher drive currents &mdash; optimize for efficiency at or below 20mA.</div>+    </div>+  </div>++  <!-- Brightness vs Temperature -->+  <div class="section">+    <h2 class="section-title">Luminous Intensity vs. Temperature</h2>+    <div class="chart-wrap">+      <h3>Relative Brightness vs. Ambient Temperature (normalized at 25&deg;C)</h3>+      <svg id="brightness-temp-chart" width="100%" height="280" viewBox="0 0 700 280">+        <rect x="60" y="20" width="600" height="200" fill="#0d1117" rx="4"/>+        <line x1="60" y1="220" x2="660" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="20" x2="60" y2="220" stroke="#21262d" stroke-width="1"/>+        <line x1="60" y1="170" x2="660" y2="170" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="120" x2="660" y2="120" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="60" y1="70" x2="660" y2="70" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="180" y1="20" x2="180" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="300" y1="20" x2="300" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="420" y1="20" x2="420" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>+        <line x1="540" y1="20" x2="540" y2="220" stroke="#21262d" stroke-width="0.5" stroke-dasharray="4,4"/>++        <path d="M60,85 L120,89 L180,96 L240,103 L300,110 L390,120 L450,126 L510,133 L570,140 L630,150 L660,160" stroke="#DC143C" stroke-width="2.5" fill="none"/>++        <circle cx="390" cy="120" r="5" fill="#DC143C" opacity="0.9"/>+        <line x1="60" y1="120" x2="390" y2="120" stroke="#DC143C" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.4"/>+        <line x1="390" y1="120" x2="390" y2="220" stroke="#DC143C" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.4"/>+        <text x="400" y="114" fill="#DC143C" font-size="10">1.0 @ 25&deg;C</text>++        <text x="100" y="62" fill="#58a6ff" font-size="9">Brighter when cold</text>+        <text x="570" y="165" fill="#f85149" font-size="9">Dimmer when hot</text>++        <text x="55" y="224" fill="#484f58" font-size="9" text-anchor="end">0</text>+        <text x="55" y="174" fill="#484f58" font-size="9" text-anchor="end">0.5</text>+        <text x="55" y="124" fill="#484f58" font-size="9" text-anchor="end">1.0</text>+        <text x="55" y="74" fill="#484f58" font-size="9" text-anchor="end">1.5</text>+        <text x="55" y="24" fill="#484f58" font-size="9" text-anchor="end">2.0</text>+        <text x="12" y="124" fill="#484f58" font-size="9" text-anchor="middle" transform="rotate(-90,12,124)">Relative Brightness</text>++        <text x="60" y="255" fill="#484f58" font-size="9" text-anchor="middle">-30</text>+        <text x="180" y="255" fill="#484f58" font-size="9" text-anchor="middle">-10</text>+        <text x="300" y="255" fill="#484f58" font-size="9" text-anchor="middle">10</text>+        <text x="390" y="255" fill="#484f58" font-size="9" text-anchor="middle">25</text>+        <text x="540" y="255" fill="#484f58" font-size="9" text-anchor="middle">50</text>+        <text x="660" y="255" fill="#484f58" font-size="9" text-anchor="middle">70</text>+        <text x="360" y="272" fill="#484f58" font-size="9" text-anchor="middle">Ambient Temperature (&deg;C)</text>+      </svg>+      <div class="chart-note">AlInGaP LEDs lose brightness at elevated temperatures. At 70&deg;C, expect ~40% reduction in luminous intensity. Conversely, the LED is ~35% brighter at -30&deg;C. Account for this in designs with wide temperature swings.</div>+    </div>+  </div>++  <!-- Resistor Calculator -->+  <div class="section">+    <h2 class="section-title">LED Resistor Calculator</h2>+    <div class="calc-card">+      <h3>&#9889; Series Resistor Selection</h3>+      <div class="calc-inputs">+        <div class="calc-field">+          <label>Supply Voltage</label>+          <input type="number" id="calc-vsupply" value="3.3" step="0.1" min="0">+          <div class="unit">Volts</div>+        </div>+        <div class="calc-field">+          <label>Desired Current</label>+          <input type="number" id="calc-iled" value="20" step="1" min="0">+          <div class="unit">Milliamps</div>+        </div>+      </div>+      <div class="calc-results">+        <div class="calc-result">+          <div class="cr-label">Calculated Resistor</div>+          <div class="cr-value" id="calc-r-exact">60.0 &Omega;</div>+          <div class="cr-detail" id="calc-r-formula">R = (3.3V - 2.1V) / 20mA</div>+        </div>+        <div class="calc-result">+          <div class="cr-label">Nearest E24 Value</div>+          <div class="cr-value" id="calc-r-e24">62.0 &Omega;</div>+          <div class="cr-detail" id="calc-r-actual">Actual If = 19.4mA</div>+        </div>+        <div class="calc-result">+          <div class="cr-label">Resistor Power</div>+          <div class="cr-value" id="calc-p-resistor">23.2 mW</div>+          <div class="cr-detail" id="calc-p-r-detail">P = (V<sub>supply</sub> - V<sub>f</sub>) &times; I<sub>f</sub></div>+        </div>+        <div class="calc-result">+          <div class="cr-label">LED Power</div>+          <div class="cr-value" id="calc-p-led">40.6 mW</div>+          <div class="cr-detail" id="calc-p-led-detail">P = V<sub>f</sub> &times; I<sub>f</sub> (max 78mW)</div>+        </div>+      </div>+      <div class="calc-schematic">+        <svg width="400" height="80" viewBox="0 0 400 80" style="max-width:100%">+          <text x="20" y="38" fill="#e6edf3" font-size="11" font-family="JetBrains Mono,monospace" id="calc-svg-vsupply">3.3V</text>+          <line x1="50" y1="35" x2="90" y2="35" stroke="#e6edf3" stroke-width="1.5"/>+          <path d="M90,35 L100,25 L110,45 L120,25 L130,45 L140,25 L150,45 L160,35" stroke="#00b8b0" stroke-width="2" fill="none"/>+          <text x="125" y="60" fill="#00b8b0" font-size="10" text-anchor="middle" font-family="JetBrains Mono,monospace" id="calc-svg-r">62.0Ω</text>+          <line x1="160" y1="35" x2="210" y2="35" stroke="#e6edf3" stroke-width="1.5"/>+          <polygon points="210,22 210,48 240,35" fill="none" stroke="#DC143C" stroke-width="2"/>+          <line x1="240" y1="22" x2="240" y2="48" stroke="#DC143C" stroke-width="2"/>+          <line x1="230" y1="18" x2="238" y2="10" stroke="#DC143C" stroke-width="1"/>+          <line x1="235" y1="22" x2="243" y2="14" stroke="#DC143C" stroke-width="1"/>+          <polygon points="238,10 234,13 237,14" fill="#DC143C"/>+          <polygon points="243,14 239,17 242,18" fill="#DC143C"/>+          <text x="225" y="65" fill="#DC143C" font-size="10" text-anchor="middle" font-family="JetBrains Mono,monospace">LED</text>+          <line x1="240" y1="35" x2="300" y2="35" stroke="#e6edf3" stroke-width="1.5"/>+          <line x1="300" y1="28" x2="300" y2="42" stroke="#e6edf3" stroke-width="1.5"/>+          <line x1="290" y1="42" x2="310" y2="42" stroke="#e6edf3" stroke-width="1.5"/>+          <line x1="294" y1="47" x2="306" y2="47" stroke="#e6edf3" stroke-width="1.2"/>+          <line x1="297" y1="52" x2="303" y2="52" stroke="#e6edf3" stroke-width="0.8"/>+          <text x="320" y="42" fill="#8b949e" font-size="10" font-family="JetBrains Mono,monospace">GND</text>+          <text x="180" y="26" fill="#8b949e" font-size="9" text-anchor="middle" font-family="JetBrains Mono,monospace" id="calc-svg-if">20mA</text>+          <line x1="168" y1="30" x2="192" y2="30" stroke="#8b949e" stroke-width="0.5"/>+          <polygon points="192,30 187,27 187,33" fill="#8b949e"/>+        </svg>+      </div>+    </div>+  </div>++  <!-- Environmental -->+  <div class="section">+    <h2 class="section-title">Environmental &amp; Handling</h2>+    <div class="env-grid">+      <div class="env-card">+        <div class="env-name">ESD Sensitivity</div>+        <div class="env-spec">AlInGaP &mdash; handle with care</div>+        <div class="env-detail">Use grounded wrist straps &amp; ESD-safe tools</div>+      </div>+      <div class="env-card">+        <div class="env-name">Operating Temperature</div>+        <div class="env-spec">Continuous operation range</div>+        <div class="env-detail">&minus;30&deg;C to +80&deg;C</div>+      </div>+      <div class="env-card">+        <div class="env-name">Storage Temperature</div>+        <div class="env-spec">Unbiased storage</div>+        <div class="env-detail">&minus;40&deg;C to +85&deg;C</div>+      </div>+      <div class="env-card">+        <div class="env-name">Moisture Sensitivity</div>+        <div class="env-spec">MSL noted in packaging</div>+        <div class="env-detail">Follow J-STD-020 bake requirements</div>+      </div>+    </div>+  </div>++  <!-- Symbol, Footprint & 3D -->  <!-- Symbol, Footprint & 3D View -->+  <div class="section">+    <h2 class="section-title">Symbol &amp; Footprint</h2>+    <div class="grid-2" style="margin-bottom:12px">+      <!-- Interactive SymView -->+      <div class="card" style="padding:0;overflow:hidden">+        <div style="font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-dim);padding:12px 16px 0">Schematic Symbol</div>+        <img src="https://wiki.adom.inc/blob/component/in-s42atr/ct/IN-S42ATR-symbol.svg" alt="IN-S42ATR schematic symbol" style="width:100%;height:340px;object-fit:contain;background:#0d1117;display:block">+      </div>+      <!-- Interactive FpView -->+      <div class="card" style="padding:0;overflow:hidden">+        <div style="font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-dim);padding:12px 16px 0">Footprint (0402)</div>+        <img src="https://wiki.adom.inc/blob/component/in-s42atr/ct/IN-S42ATR-footprint.svg" alt="IN-S42ATR footprint (0402)" style="width:100%;height:340px;object-fit:contain;background:#0d1117;display:block">+      </div>+    </div>+    <!-- (Interactive 3D model is now the hero section at the top of the page.) -->+    <!-- Download / Export Bar -->+    <div class="card" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">+      <span style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px">Export:</span>+      <a class="dl-btn" href="https://wiki.adom.inc/blob/component/in-s42atr/IN-S42ATR.kicad_sym" download>KiCad Symbol (.kicad_sym)</a>+      <a class="dl-btn" href="https://wiki.adom.inc/blob/component/in-s42atr/IN-S42ATR.kicad_mod" download>KiCad Footprint (.kicad_mod)</a>+      <a class="dl-btn" href="https://wiki.adom.inc/blob/component/in-s42atr/IN-S42ATR.lbr" download>Fusion 360 (.lbr)</a>+      <a class="dl-btn" href="https://wiki.adom.inc/blob/component/in-s42atr/IN-S42ATR.step" download>3D Model (.step)</a>+    </div>+  </div>++  <!-- Pricing -->+  <div class="section">+    <h2 class="section-title">Pricing &amp; Availability</h2>+    <div class="vendor-cards">+      <div class="vendor-card recommended">+        <div class="vc-name mouser">Mouser Electronics</div>+        <div class="vc-price">&mdash;</div>+        <div class="vc-detail">Check availability</div>+        <a class="vc-link" href="https://www.mouser.com/Search/Refine?Keyword=IN-S42ATR" target="_blank">Search on Mouser &rarr;</a>+      </div>+      <div class="vendor-card">+        <div class="vc-name digikey">DigiKey</div>+        <div class="vc-price">&mdash;</div>+        <div class="vc-detail">Check availability</div>+        <a class="vc-link" href="https://www.digikey.com/en/products/result?keywords=IN-S42ATR" target="_blank">Search on DigiKey &rarr;</a>+      </div>+      <div class="vendor-card">+        <div class="vc-name jlcpcb">JLCPCB / LCSC</div>+        <div class="vc-price">&mdash;</div>+        <div class="vc-detail">Check availability</div>+      </div>+    </div>+  </div>++  <!-- Documents -->+  <div class="section">+    <h2 class="section-title">Documents</h2>+    <div class="card">+      <table class="detail-table">+        <tr><td>Datasheet</td><td><a href="http://www.inolux-corp.com/datasheet/SMDLED/Mono%20Color%20Top%20View/IN-S42AT%20Series_V1.1.pdf" target="_blank">IN-S42AT Series V1.1 (PDF) &rarr;</a></td></tr>+        <tr><td>Series</td><td>IN-S42AT</td></tr>+        <tr><td>Manufacturer</td><td>Inolux Corporation</td></tr>+      </table>+    </div>+  </div>++  <!-- Molecule -->+  <div class="section">+    <h2 class="section-title">Molecule &mdash; Single-Passive Board</h2>+    <div class="card" style="padding:16px"><div style="font-size:13px;color:var(--text-muted)">Interactive board viewer &mdash; <a href="https://wiki.adom.inc/aravk/in-s42atr" style="color:var(--accent)">open on the molecule page &rarr;</a></div></div>+  </div>++  <div class="source">+    IN-S42ATR characterization &middot; Data from Inolux IN-S42AT Series V1.1 datasheet + adom-parts-search &middot; 2026-06-03+  </div>+ </div>++<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>+<script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/pmrem/PMREMGenerator.js"></script>+<script>+(function() {+  // ==================== 3D Viewer — IN-S42ATR (0402 LED, red) ====================+  var canvas = document.getElementById('viewer3d');+  if (canvas && window.THREE) {+    var scene = new THREE.Scene();++    var renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true, logarithmicDepthBuffer: true });+    renderer.setSize(canvas.width, canvas.height);+    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));+    renderer.outputEncoding = THREE.sRGBEncoding;+    renderer.toneMapping = THREE.ACESFilmicToneMapping;+    renderer.toneMappingExposure = 1.2;+    renderer.physicallyCorrectLights = true;++    canvas.style.background = 'linear-gradient(180deg, #5a6b7e 0%, #2a3340 100%)';++    var camera = new THREE.PerspectiveCamera(35, canvas.width / canvas.height, 0.01, 1000);++    var pmremGen = new THREE.PMREMGenerator(renderer);+    pmremGen.compileEquirectangularShader();+    var envScene = new THREE.Scene();+    envScene.background = new THREE.Color(0x3a4550);+    var envLight1 = new THREE.DirectionalLight(0xffffff, 2);+    envLight1.position.set(1, 3, 2);+    envScene.add(envLight1);+    var envLight2 = new THREE.DirectionalLight(0x8899bb, 1);+    envLight2.position.set(-2, 1, -1);+    envScene.add(envLight2);+    var envRT = pmremGen.fromScene(envScene, 0.04);+    scene.environment = envRT.texture;++    var hemi = new THREE.HemisphereLight(0xe6edf3, 0xb3a699, 0.5);+    scene.add(hemi);+    var keyLight = new THREE.DirectionalLight(0xffffff, 1.5);+    keyLight.position.set(2, 5, 3);+    scene.add(keyLight);+    var fillLight = new THREE.DirectionalLight(0x8899aa, 0.5);+    fillLight.position.set(-3, 2, -2);+    scene.add(fillLight);+    var rimLight = new THREE.DirectionalLight(0xff3333, 0.4);+    rimLight.position.set(0, -1, 3);+    scene.add(rimLight);++    var S = 2.5;++    var bodyMat = new THREE.MeshStandardMaterial({+      color: 0x2a2018, metalness: 0.0, roughness: 0.4+    });+    var lensMat = new THREE.MeshStandardMaterial({+      color: 0xDC143C, metalness: 0.0, roughness: 0.15,+      transparent: true, opacity: 0.55,+      emissive: 0xFF0000, emissiveIntensity: 0.3+    });+    var termMat = new THREE.MeshStandardMaterial({+      color: 0xc8ccd1, metalness: 1.0, roughness: 0.25,+      polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1+    });+    var padMat = new THREE.MeshStandardMaterial({ color: 0xc9a04c, metalness: 1.0, roughness: 0.22 });+    var pcbMat = new THREE.MeshStandardMaterial({ color: 0x0a2a0a, metalness: 0.0, roughness: 0.6 });+    var cathodeMat = new THREE.MeshStandardMaterial({ color: 0xDC143C, emissive: 0xFF0000, emissiveIntensity: 0.5, metalness: 0.0, roughness: 0.5 });++    var bodyW = 1.0, bodyD = 0.5, bodyH = 0.30;+    var lensH = 0.15;+    var termW = 0.20;++    var innerW = bodyW - termW * 2 + 0.02;+    var body = new THREE.Mesh(new THREE.BoxGeometry(innerW*S, bodyH*S, bodyD*S), bodyMat);+    body.position.y = bodyH*S/2;+    scene.add(body);++    var lens = new THREE.Mesh(new THREE.BoxGeometry(bodyW*0.85*S, lensH*S, bodyD*0.85*S), lensMat);+    lens.position.y = (bodyH + lensH/2)*S;+    scene.add(lens);++    function makeTerminal(xSign) {+      var group = new THREE.Group();+      var front = new THREE.Mesh(new THREE.BoxGeometry(termW*S, bodyH*S, bodyD*S*0.99), termMat);+      front.position.set(xSign*(bodyW/2 - termW/2)*S, bodyH*S/2, 0);+      group.add(front);+      var bot = new THREE.Mesh(new THREE.BoxGeometry((termW+0.03)*S, 0.04*S, bodyD*S*0.99), termMat);+      bot.position.set(xSign*(bodyW/2 - termW/2)*S, 0.02*S, 0);+      group.add(bot);+      return group;+    }+    scene.add(makeTerminal(-1));+    scene.add(makeTerminal(1));++    var cathMark = new THREE.Mesh(new THREE.BoxGeometry(0.02*S, 0.02*S, bodyD*0.7*S), cathodeMat);+    cathMark.position.set(0.25*S, (bodyH + lensH + 0.02)*S, 0);+    scene.add(cathMark);++    var pcb = new THREE.Mesh(new THREE.BoxGeometry(10*S, 0.08*S, 8*S), pcbMat);+    pcb.position.y = -0.04*S;+    scene.add(pcb);++    var pad1 = new THREE.Mesh(new THREE.BoxGeometry(0.4*S, 0.025*S, 0.6*S), padMat);+    pad1.position.set(-0.3*S, 0.005*S, 0);+    scene.add(pad1);+    var pad2 = new THREE.Mesh(new THREE.BoxGeometry(0.4*S, 0.025*S, 0.6*S), padMat);+    pad2.position.set(0.3*S, 0.005*S, 0);+    scene.add(pad2);++    var gridMat2 = new THREE.LineBasicMaterial({ color: 0x0d3d0d, transparent: true, opacity: 0.3 });+    for (var i = -8; i <= 8; i++) {+      var pts1 = [new THREE.Vector3(i*0.4*S, 0.045*S, -4*S), new THREE.Vector3(i*0.4*S, 0.045*S, 4*S)];+      scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts1), gridMat2));+      var pts2 = [new THREE.Vector3(-4*S, 0.045*S, i*0.4*S), new THREE.Vector3(4*S, 0.045*S, i*0.4*S)];+      scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts2), gridMat2));+    }++    var isDragging = false, prevX = 0, prevY = 0;+    var theta = Math.PI/4, phi = Math.PI/5.5, radius = 5.0;+    var target = new THREE.Vector3(0, 0.4, 0);++    function updateCamera() {+      camera.position.x = radius * Math.sin(theta) * Math.cos(phi);+      camera.position.y = radius * Math.sin(phi) + target.y;+      camera.position.z = radius * Math.cos(theta) * Math.cos(phi);+      camera.lookAt(target);+    }+    updateCamera();++    canvas.addEventListener('mousedown', function(e) { isDragging = true; prevX = e.clientX; prevY = e.clientY; canvas.style.cursor = 'grabbing'; });+    window.addEventListener('mouseup', function() { isDragging = false; canvas.style.cursor = 'grab'; });+    canvas.addEventListener('mousemove', function(e) {+      if (!isDragging) return;+      theta -= (e.clientX - prevX) * 0.008;+      phi = Math.max(0.05, Math.min(Math.PI/2.2, phi + (e.clientY - prevY) * 0.008));+      prevX = e.clientX; prevY = e.clientY;+      updateCamera();+    });+    canvas.addEventListener('wheel', function(e) {+      e.preventDefault();+      radius = Math.max(2, Math.min(12, radius + e.deltaY * 0.004));+      updateCamera();+    }, { passive: false });++    function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); }+    animate();+  }++  // ==================== Chart Tooltip System ====================+  function setupChartTooltip(svgId, dataPoints, color, formatX, formatY) {+    var svg = document.getElementById(svgId);+    if (!svg) return;++    var ns = 'http://www.w3.org/2000/svg';++    var dot = document.createElementNS(ns, 'circle');+    dot.setAttribute('r', '5');+    dot.setAttribute('fill', color);+    dot.setAttribute('stroke', '#0d1117');+    dot.setAttribute('stroke-width', '2');+    dot.style.display = 'none';+    dot.style.pointerEvents = 'none';+    svg.appendChild(dot);++    var tipGroup = document.createElementNS(ns, 'g');+    tipGroup.style.display = 'none';+    tipGroup.style.pointerEvents = 'none';++    var tipBg = document.createElementNS(ns, 'rect');+    tipBg.setAttribute('rx', '4');+    tipBg.setAttribute('fill', '#161b22');+    tipBg.setAttribute('stroke', '#30363d');+    tipBg.setAttribute('stroke-width', '1');++    var tipLine1 = document.createElementNS(ns, 'text');+    tipLine1.setAttribute('font-family', 'JetBrains Mono, monospace');+    tipLine1.setAttribute('font-size', '10');+    tipLine1.setAttribute('fill', '#e6edf3');++    var tipLine2 = document.createElementNS(ns, 'text');+    tipLine2.setAttribute('font-family', 'JetBrains Mono, monospace');+    tipLine2.setAttribute('font-size', '10');+    tipLine2.setAttribute('fill', color);++    tipGroup.appendChild(tipBg);+    tipGroup.appendChild(tipLine1);+    tipGroup.appendChild(tipLine2);+    svg.appendChild(tipGroup);++    var vLine = document.createElementNS(ns, 'line');+    vLine.setAttribute('stroke', color);+    vLine.setAttribute('stroke-width', '0.5');+    vLine.setAttribute('stroke-dasharray', '3,3');+    vLine.setAttribute('opacity', '0.5');+    vLine.style.display = 'none';+    vLine.style.pointerEvents = 'none';+    svg.appendChild(vLine);++    function lerp(pts, svgX) {+      if (svgX <= pts[0].sx) return pts[0];+      if (svgX >= pts[pts.length-1].sx) return pts[pts.length-1];+      for (var i = 0; i < pts.length - 1; i++) {+        if (svgX >= pts[i].sx && svgX <= pts[i+1].sx) {+          var t = (svgX - pts[i].sx) / (pts[i+1].sx - pts[i].sx);+          return {+            sx: svgX,+            sy: pts[i].sy + t * (pts[i+1].sy - pts[i].sy),+            vx: pts[i].vx + t * (pts[i+1].vx - pts[i].vx),+            vy: pts[i].vy + t * (pts[i+1].vy - pts[i].vy),+          };+        }+      }+      return pts[0];+    }++    svg.addEventListener('mousemove', function(e) {+      var pt = svg.createSVGPoint();+      pt.x = e.clientX;+      pt.y = e.clientY;+      var svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());++      var minX = dataPoints[0].sx;+      var maxX = dataPoints[dataPoints.length-1].sx;+      if (svgPt.x < minX - 10 || svgPt.x > maxX + 10) {+        dot.style.display = 'none';+        tipGroup.style.display = 'none';+        vLine.style.display = 'none';+        return;+      }++      var p = lerp(dataPoints, svgPt.x);++      dot.setAttribute('cx', p.sx);+      dot.setAttribute('cy', p.sy);+      dot.style.display = '';++      vLine.setAttribute('x1', p.sx);+      vLine.setAttribute('y1', p.sy);+      vLine.setAttribute('x2', p.sx);+      vLine.setAttribute('y2', 220);+      vLine.style.display = '';++      var xLabel = formatX(p.vx);+      var yLabel = formatY(p.vy);+      tipLine1.textContent = xLabel;+      tipLine2.textContent = yLabel;++      var tipX = p.sx + 12;+      var tipY = p.sy - 28;+      var boxW = Math.max(xLabel.length, yLabel.length) * 7 + 16;+      var boxH = 32;++      var adjustedX = tipX + boxW > 660 ? p.sx - boxW - 12 : tipX;+      var adjustedY = tipY < 10 ? p.sy + 12 : tipY;++      tipBg.setAttribute('x', adjustedX);+      tipBg.setAttribute('y', adjustedY);+      tipBg.setAttribute('width', boxW);+      tipBg.setAttribute('height', boxH);++      tipLine1.setAttribute('x', adjustedX + 8);+      tipLine1.setAttribute('y', adjustedY + 13);+      tipLine2.setAttribute('x', adjustedX + 8);+      tipLine2.setAttribute('y', adjustedY + 26);++      tipGroup.style.display = '';+    });++    svg.addEventListener('mouseleave', function() {+      dot.style.display = 'none';+      tipGroup.style.display = 'none';+      vLine.style.display = 'none';+    });+  }++  // ==================== Spectral Chart ====================+  var spectralData = [+    {sx:60, sy:220, vx:500, vy:0.0},+    {sx:369, sy:219, vx:603, vy:0.4},+    {sx:372, sy:219, vx:604, vy:0.6},+    {sx:375, sy:218, vx:605, vy:0.9},+    {sx:378, sy:217, vx:606, vy:1.3},+    {sx:381, sy:216, vx:607, vy:1.8},+    {sx:384, sy:215, vx:608, vy:2.6},+    {sx:387, sy:213, vx:609, vy:3.5},+    {sx:390, sy:211, vx:610, vy:4.7},+    {sx:393, sy:208, vx:611, vy:6.2},+    {sx:396, sy:204, vx:612, vy:8.2},+    {sx:399, sy:199, vx:613, vy:10.6},+    {sx:402, sy:193, vx:614, vy:13.5},+    {sx:405, sy:186, vx:615, vy:17.0},+    {sx:408, sy:178, vx:616, vy:21.0},+    {sx:411, sy:169, vx:617, vy:25.7},+    {sx:414, sy:158, vx:618, vy:31.0},+    {sx:417, sy:146, vx:619, vy:36.9},+    {sx:420, sy:134, vx:620, vy:43.2},+    {sx:423, sy:120, vx:621, vy:50.0},+    {sx:426, sy:106, vx:622, vy:57.0},+    {sx:429, sy:92, vx:623, vy:64.2},+    {sx:432, sy:78, vx:624, vy:71.2},+    {sx:435, sy:64, vx:625, vy:77.9},+    {sx:438, sy:52, vx:626, vy:84.1},+    {sx:441, sy:41, vx:627, vy:89.5},+    {sx:444, sy:32, vx:628, vy:94.0},+    {sx:447, sy:25, vx:629, vy:97.3},+    {sx:450, sy:21, vx:630, vy:99.3},+    {sx:453, sy:20, vx:631, vy:100.0},+    {sx:456, sy:21, vx:632, vy:99.3},+    {sx:459, sy:25, vx:633, vy:97.3},+    {sx:462, sy:32, vx:634, vy:94.0},+    {sx:465, sy:41, vx:635, vy:89.5},+    {sx:468, sy:52, vx:636, vy:84.1},+    {sx:471, sy:64, vx:637, vy:77.9},+    {sx:474, sy:78, vx:638, vy:71.2},+    {sx:477, sy:92, vx:639, vy:64.2},+    {sx:480, sy:106, vx:640, vy:57.0},+    {sx:483, sy:120, vx:641, vy:50.0},+    {sx:486, sy:134, vx:642, vy:43.2},+    {sx:489, sy:146, vx:643, vy:36.9},+    {sx:492, sy:158, vx:644, vy:31.0},+    {sx:495, sy:169, vx:645, vy:25.7},+    {sx:498, sy:178, vx:646, vy:21.0},+    {sx:501, sy:186, vx:647, vy:17.0},+    {sx:504, sy:193, vx:648, vy:13.5},+    {sx:507, sy:199, vx:649, vy:10.6},+    {sx:510, sy:204, vx:650, vy:8.2},+    {sx:513, sy:208, vx:651, vy:6.2},+    {sx:516, sy:211, vx:652, vy:4.7},+    {sx:519, sy:213, vx:653, vy:3.5},+    {sx:522, sy:215, vx:654, vy:2.6},+    {sx:525, sy:216, vx:655, vy:1.8},+    {sx:528, sy:217, vx:656, vy:1.3},+    {sx:531, sy:218, vx:657, vy:0.9},+    {sx:534, sy:219, vx:658, vy:0.6},+    {sx:537, sy:219, vx:659, vy:0.4},+    {sx:660, sy:220, vx:700, vy:0.0},+  ];++  setupChartTooltip('spectral-chart', spectralData, '#DC143C',+    function(x) { return x.toFixed(0) + ' nm'; },+    function(y) { return 'Intensity: ' + y.toFixed(1) + '%'; }+  );++  // ==================== IV Chart ====================+  var ivData = [+    {sx:60, sy:220, vx:0.00, vy:0.0},+    {sx:168, sy:220, vx:0.90, vy:0.0},+    {sx:222, sy:220, vx:1.35, vy:0.1},+    {sx:254, sy:219, vx:1.62, vy:0.3},+    {sx:265, sy:218, vx:1.71, vy:0.5},+    {sx:276, sy:216, vx:1.80, vy:1.0},+    {sx:285, sy:212, vx:1.88, vy:2.0},+    {sx:294, sy:200, vx:1.95, vy:5.0},+    {sx:303, sy:180, vx:2.02, vy:10.0},+    {sx:312, sy:140, vx:2.10, vy:20.0},+    {sx:327, sy:120, vx:2.23, vy:25.0},+    {sx:342, sy:100, vx:2.35, vy:30.0},+    {sx:357, sy:70, vx:2.48, vy:37.5},+    {sx:372, sy:20, vx:2.60, vy:50.0},+  ];++  setupChartTooltip('iv-chart', ivData, '#DC143C',+    function(x) { return 'Vf = ' + x.toFixed(2) + ' V'; },+    function(y) { return 'If = ' + y.toFixed(1) + ' mA'; }+  );++  // ==================== Derating Chart ====================+  var deratingData = [+    {sx:60, sy:20, vx:0, vy:30.0},+    {sx:210, sy:20, vx:25, vy:30.0},+    {sx:240, sy:35, vx:30, vy:27.7},+    {sx:270, sy:50, vx:35, vy:25.5},+    {sx:300, sy:65, vx:40, vy:23.2},+    {sx:330, sy:81, vx:45, vy:20.9},+    {sx:360, sy:96, vx:50, vy:18.6},+    {sx:390, sy:111, vx:55, vy:16.4},+    {sx:420, sy:126, vx:60, vy:14.1},+    {sx:450, sy:141, vx:65, vy:11.8},+    {sx:480, sy:156, vx:70, vy:9.5},+    {sx:510, sy:172, vx:75, vy:7.3},+    {sx:540, sy:187, vx:80, vy:5.0},+    {sx:606, sy:220, vx:91, vy:0.0},+  ];++  setupChartTooltip('derating-chart', deratingData, '#DC143C',+    function(x) { return x.toFixed(0) + '\u00b0C'; },+    function(y) { return 'Max If = ' + y.toFixed(1) + ' mA'; }+  );++  // ==================== Brightness vs Current ====================+  var brightCurrentData = [+    {sx:60,  sy:220, vx:0,  vy:0},+    {sx:120, sy:168, vx:5,  vy:0.52},+    {sx:180, sy:148, vx:10, vy:0.72},+    {sx:240, sy:135, vx:15, vy:0.85},+    {sx:300, sy:120, vx:20, vy:1.0},+    {sx:360, sy:108, vx:25, vy:1.12},+    {sx:420, sy:95,  vx:30, vy:1.25},+    {sx:540, sy:69,  vx:40, vy:1.51},+    {sx:660, sy:40,  vx:50, vy:1.80},+  ];++  setupChartTooltip('brightness-current-chart', brightCurrentData, '#DC143C',+    function(x) { return 'If = ' + x.toFixed(0) + ' mA'; },+    function(y) { return 'Brightness: ' + y.toFixed(2) + 'x'; }+  );++  // ==================== Brightness vs Temperature ====================+  var brightTempData = [+    {sx:60, sy:70, vx:-30, vy:1.50},+    {sx:120, sy:79, vx:-20, vy:1.41},+    {sx:180, sy:88, vx:-10, vy:1.32},+    {sx:240, sy:97, vx:0, vy:1.23},+    {sx:300, sy:106, vx:10, vy:1.14},+    {sx:390, sy:120, vx:25, vy:1.00},+    {sx:450, sy:127, vx:35, vy:0.93},+    {sx:510, sy:133, vx:45, vy:0.87},+    {sx:570, sy:140, vx:55, vy:0.80},+    {sx:630, sy:147, vx:65, vy:0.73},+    {sx:660, sy:150, vx:70, vy:0.70},+  ];++  setupChartTooltip('brightness-temp-chart', brightTempData, '#DC143C',+    function(x) { return x.toFixed(0) + '\u00b0C'; },+    function(y) { return 'Brightness: ' + y.toFixed(2) + 'x'; }+  );++  // ==================== Resistor Calculator ====================+  var VF_TYP = 2.1;++  var E24 = [10,11,12,13,15,16,18,20,22,24,27,30,33,36,39,43,47,51,56,62,68,75,82,91];++  function nearestE24(val) {+    if (val <= 0) return 0;+    var decade = 1;+    while (val >= 100) { val /= 10; decade *= 10; }+    while (val < 10) { val *= 10; decade /= 10; }+    var best = E24[0], bestDist = Math.abs(val - E24[0]);+    for (var i = 1; i < E24.length; i++) {+      var d = Math.abs(val - E24[i]);+      if (d < bestDist) { bestDist = d; best = E24[i]; }+    }+    return best * decade;+  }++  function formatResistance(r) {+    if (r >= 1e6) return (r/1e6).toFixed(1) + ' M\u03a9';+    if (r >= 1e3) return (r/1e3).toFixed(0) + ' k\u03a9';+    if (r >= 100) return r.toFixed(0) + ' \u03a9';+    if (r >= 10) return r.toFixed(1) + ' \u03a9';+    return r.toFixed(2) + ' \u03a9';+  }++  function updateCalculator() {+    var vSupply = parseFloat(document.getElementById('calc-vsupply').value) || 0;+    var iLed = parseFloat(document.getElementById('calc-iled').value) || 0;+    var iLedA = iLed / 1000;++    var vDrop = vSupply - VF_TYP;+    var rExact = vDrop > 0 && iLedA > 0 ? vDrop / iLedA : 0;+    var rE24 = nearestE24(rExact);+    var iActual = rE24 > 0 ? vDrop / rE24 : 0;+    var pResistor = vDrop * iActual;+    var pLed = VF_TYP * iActual;++    document.getElementById('calc-r-exact').innerHTML = rExact > 0 ? formatResistance(rExact) : '&mdash;';+    document.getElementById('calc-r-formula').textContent = 'R = (' + vSupply.toFixed(1) + 'V - ' + VF_TYP + 'V) / ' + iLed.toFixed(0) + 'mA';+    document.getElementById('calc-r-e24').innerHTML = rE24 > 0 ? formatResistance(rE24) : '&mdash;';+    document.getElementById('calc-r-actual').textContent = iActual > 0 ? 'Actual If = ' + (iActual * 1000).toFixed(1) + 'mA' : '';+    document.getElementById('calc-p-resistor').textContent = pResistor > 0 ? (pResistor * 1000).toFixed(1) + ' mW' : '\u2014';+    document.getElementById('calc-p-led').textContent = pLed > 0 ? (pLed * 1000).toFixed(1) + ' mW' : '\u2014';++    var svgVs = document.getElementById('calc-svg-vsupply');+    if (svgVs) svgVs.textContent = vSupply.toFixed(1) + 'V';+    var svgR = document.getElementById('calc-svg-r');+    if (svgR) svgR.textContent = rE24 > 0 ? formatResistance(rE24) : '\u2014';+    var svgIf = document.getElementById('calc-svg-if');+    if (svgIf) svgIf.textContent = iActual > 0 ? (iActual * 1000).toFixed(1) + 'mA' : '';++    var pLedEl = document.getElementById('calc-p-led');+    if (pLed * 1000 > 78) {+      pLedEl.style.color = '#f85149';+    } else {+      pLedEl.style.color = '';+    }++    var rExactEl = document.getElementById('calc-r-exact');+    if (vDrop <= 0) {+      rExactEl.innerHTML = '<span style="color:#f85149">V<sub>supply</sub> too low</span>';+    }+  }++  document.getElementById('calc-vsupply').addEventListener('input', updateCalculator);+  document.getElementById('calc-iled').addEventListener('input', updateCalculator);++  updateCalculator();+})();+</script>+</div>+</body>+</html>++<script>+/* Seamless full-page: size the 3D viewer to the viewport (screen-proxied, since a sandboxed+   readme can't read the parent viewport), then grow the wiki readme-frame to the whole content+   via its postMessage protocol so there's no box-in-box scrollbar. */+(function () {+  var iframe = document.querySelector('.viewer-card iframe');+  function fit() {+    if (iframe) {+      var vp = (window.screen && screen.availHeight) ? screen.availHeight : 900;+      iframe.style.height = Math.max(560, Math.min(vp - 200, 1080)) + 'px';+    }+    parent.postMessage({ type: 'readme-height', height: document.documentElement.scrollHeight }, '*');+  }+  window.addEventListener('load', fit);+  window.addEventListener('resize', fit);+  setTimeout(fit, 400);+})();+</script>
screenshots/hero.png
⋯ 1 unchanged line ⋯
viewer-config.jsonadded+566
@@ -0,0 +1,566 @@+{+  "_comment": "Effective config for IN-S42ATR-viewer.html — the editable source of truth for the Timeline captions, the hover tooltips, the chapter marks, and the swappable tape/nozzle models. Edit with ./viewer-cli, NOT by hand. Tooltip `rows` are the DATASHEET rows (the only editable rows); tooltips with \"measured\": true also append rows computed live from the model geometry, which are never edited. So every stated spec stays datasheet-cited and every measurement stays honest.",+  "assets": {+    "tape": null,+    "nozzle": null+  },+  "captions": [+    {+      "id": "part",+      "t0": 0.5,+      "t1": 5.6,+      "eyebrow": "Part",+      "head": "IN-S42ATR — Inolux 0402 red",+      "src": "Inolux IN-S42AT V1.1 · p.8 label + p.1 features",+      "rows": [+        [+          "package",+          "0402 · 1.0 × 0.5 × 0.45 mm"+        ],+        [+          "emitter",+          "AlInGaP · clear lens"+        ],+        [+          "λD",+          "631 nm"+        ]+      ],+      "note": ""+    },+    {+      "id": "forward-drive",+      "t0": 6.7,+      "t1": 8.9,+      "eyebrow": "Forward drive",+      "head": "IF 20 mA",+      "src": "Inolux IN-S42AT V1.1 · p.3 electro-optical table",+      "rows": [+        [+          "VF",+          "2.1 V typ · 2.6 V max"+        ],+        [+          "IV",+          "90 mcd"+        ],+        [+          "λD / λP",+          "631 / 645 nm"+        ]+      ],+      "note": ""+    },+    {+      "id": "viewing-angle",+      "t0": 9.2,+      "t1": 17.5,+      "eyebrow": "LED FOV",+      "head": "2θ½ = 130°",+      "src": "Inolux IN-S42AT V1.1 · p.3 electro-optical table",+      "rows": [+        [+          "2θ½ (full)",+          "130°"+        ],+        [+          "half-angle",+          "±65°"+        ],+        [+          "λD",+          "631 nm"+        ]+      ],+      "note": "Full-width half-power cone: on-axis intensity falls to 50 % at ±65°."+    },+    {+      "id": "assembly",+      "t0": 18.3,+      "t1": 21.7,+      "eyebrow": "Assembly",+      "head": "Lifted 1 mm to the joint",+      "src": "measured from the model geometry",+      "rows": [+        [+          "reveals",+          "pads · cathode silk · solder"+        ],+        [+          "pads",+          "2 × (model)",+          true+        ],+        [+          "lift",+          "model view",+          true+        ]+      ],+      "note": ""+    },+    {+      "id": "reflow",+      "t0": 22,+      "t1": 31.5,+      "eyebrow": "Reflow",+      "head": "Solder joints",+      "src": "Inolux IN-S42AT V1.1 · p.11 reflow profile",+      "rows": [+        [+          "tin-glue melt",+          "178–192 °C"+        ],+        [+          "joint Ø",+          "260 µm (model)",+          true+        ],+        [+          "proud of pad",+          "78 µm (model)",+          true+        ]+      ],+      "note": "One deposit per pad reflows and globs up; the body settles onto the joints."+    },+    {+      "id": "tape-reel",+      "t0": 32.6,+      "t1": 43,+      "eyebrow": "Tape & reel",+      "head": "8 mm carrier · 4 mm pitch",+      "src": "Inolux IN-S42AT V1.1 · p.9 packing table (verified)",+      "rows": [+        [+          "tape W",+          "8.00 mm"+        ],+        [+          "pitch P1",+          "4.00 mm"+        ],+        [+          "pocket",+          "0.57 × 1.12 × 0.55 mm"+        ],+        [+          "reel",+          "Ø178 · 5000 pcs"+        ]+      ],+      "note": ""+    }+  ],+  "chapters": [+    {+      "t": 0,+      "label": "The part"+    },+    {+      "t": 6.5,+      "label": "Powered on"+    },+    {+      "t": 9,+      "label": "LED FOV"+    },+    {+      "t": 18.2,+      "label": "Lift off"+    },+    {+      "t": 21.9,+      "label": "Solder"+    },+    {+      "t": 32.5,+      "label": "Tape & reel"+    },+    {+      "t": 46.1,+      "label": "Rest"+    }+  ],+  "tooltips": {+    "fov-cone": {+      "title": "fov_cone",+      "match": "^fov_cone",+      "sub": "emission cone",+      "src": "Inolux IN-S42AT V1.1 · p.3 electro-optical table",+      "srcComputed": false,+      "rows": [+        [+          "viewing angle 2θ½",+          "130°"+        ],+        [+          "half-angle",+          "±65°"+        ],+        [+          "λD / λP",+          "631 / 645 nm"+        ]+      ],+      "measured": true,+      "enabled": true+    },+    "led-lit": {+      "title": "LED_lit",+      "match": "^LED_lit",+      "sub": "emitter glow layer",+      "src": "Inolux IN-S42AT V1.1 · p.3 electro-optical table",+      "srcComputed": false,+      "rows": [+        [+          "λD (dominant)",+          "631 nm — red"+        ],+        [+          "IV",+          "90 mcd @ IF 20 mA"+        ],+        [+          "VF",+          "2.1 V typ · 2.6 V max"+        ],+        [+          "2θ½",+          "130°"+        ]+      ],+      "measured": true,+      "enabled": true+    },+    "in-s42atr": {+      "title": "IN-S42ATR",+      "match": "^IN-S42ATR",+      "sub": "Inolux 0402 red LED",+      "src": "Inolux IN-S42AT V1.1 · p.3 electro-optical table + Inolux IN-S42AT V1.1 · p.8 label + p.1 features",+      "srcComputed": false,+      "rows": [+        [+          "package",+          "0402 · 1.0 × 0.5 × 0.45 mm"+        ],+        [+          "λD / λP",+          "631 / 645 nm"+        ],+        [+          "VF @ 20 mA",+          "2.1 V typ · 2.6 V max"+        ],+        [+          "IV",+          "90 mcd"+        ],+        [+          "2θ½",+          "130°"+        ]+      ],+      "measured": true,+      "enabled": true+    },+    "fr4-board": {+      "title": "fr4_board",+      "match": "^fr4_board",+      "sub": "FR-4 substrate",+      "src": "measured from the model geometry (1.6 mm = standard FR-4 stack)",+      "srcComputed": false,+      "rows": [],+      "measured": true,+      "enabled": true+    },+    "pad-top": {+      "title": "pad_top",+      "match": "^pad_top",+      "sub": "copper pads",+      "src": "measured from the model geometry (built from the IN-S42ATR footprint)",+      "srcComputed": false,+      "rows": [],+      "measured": true,+      "enabled": true+    },+    "solder-top": {+      "title": "solder_top",+      "match": "^solder_top",+      "sub": "solder fillets",+      "src": "measured from the model geometry",+      "srcComputed": false,+      "rows": [],+      "measured": true,+      "enabled": true+    },+    "silk": {+      "title": "silk",+      "match": "^silk",+      "sub": "silkscreen cathode marker",+      "src": "measured from the model geometry",+      "srcComputed": false,+      "rows": [],+      "measured": true,+      "enabled": true+    },+    "embossed-pocket": {+      "title": "embossed pocket",+      "match": "^(pw\\d|pf\\d)",+      "sub": "carrier-tape cavity",+      "src": "Inolux IN-S42AT V1.1 · p.9 packing table (verified)",+      "srcComputed": false,+      "rows": [+        [+          "Ao (pocket W)",+          "0.57 mm"+        ],+        [+          "Bo (pocket L)",+          "1.12 mm"+        ],+        [+          "Ko (pocket depth)",+          "0.55 mm"+        ],+        [+          "P1 (pocket pitch)",+          "4.00 mm"+        ],+        [+          "F (pocket→sprocket row)",+          "3.50 mm"+        ],+        [+          "part seating",+          "0.45 mm body in 0.55 deep → top 0.10 mm below tape face"+        ]+      ],+      "measured": false,+      "enabled": true+    },+    "led-in-pocket": {+      "title": "LED in pocket",+      "match": "^tape_led_",+      "sub": "IN-S42ATR, as reeled",+      "src": "Inolux IN-S42AT V1.1 · p.9 packing table (verified) + Inolux IN-S42AT V1.1 · p.3 electro-optical table",+      "srcComputed": false,+      "rows": [+        [+          "part",+          "IN-S42ATR · 0402 · 1.0 × 0.5 × 0.45 mm"+        ],+        [+          "pocket Ao × Bo × Ko",+          "0.57 × 1.12 × 0.55 mm"+        ],+        [+          "orientation",+          "cathode toward the sprocket holes"+        ],+        [+          "P1 (pitch)",+          "4.00 mm"+        ],+        [+          "reel",+          "Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 slot · 12.0 wide · 5000 pcs"+        ]+      ],+      "measured": false,+      "enabled": true+    },+    "tape-top": {+      "title": "tape_top",+      "match": "^tape_top",+      "sub": "sprocket band",+      "src": "Inolux IN-S42AT V1.1 · p.9 packing table (verified)",+      "srcComputed": false,+      "rows": [+        [+          "Do (sprocket hole Ø)",+          "1.55 mm"+        ],+        [+          "Po (sprocket pitch)",+          "4.00 mm"+        ],+        [+          "E (hole centre→edge)",+          "1.75 mm"+        ],+        [+          "W (tape width)",+          "8.00 mm"+        ],+        [+          "T (tape thickness)",+          "0.20 mm"+        ],+        [+          "reel",+          "Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 slot · 12.0 wide · 5000 pcs"+        ]+      ],+      "measured": false,+      "enabled": true+    },+    "tape-bot": {+      "title": "tape_bot",+      "match": "^tape_bot",+      "sub": "carrier tape body",+      "src": "Inolux IN-S42AT V1.1 · p.9 packing table (verified)",+      "srcComputed": false,+      "rows": [+        [+          "W (tape width)",+          "8.00 mm"+        ],+        [+          "T (tape thickness)",+          "0.20 mm"+        ],+        [+          "F (pocket→sprocket row)",+          "3.50 mm"+        ],+        [+          "P1 (pocket pitch)",+          "4.00 mm"+        ],+        [+          "reel",+          "Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 slot · 12.0 wide · 5000 pcs"+        ]+      ],+      "measured": false,+      "enabled": true+    },+    "tape-band": {+      "title": "tape_band",+      "match": "^tape_band",+      "sub": "carrier band between pockets",+      "src": "Inolux IN-S42AT V1.1 · p.9 packing table (verified)",+      "srcComputed": false,+      "rows": [+        [+          "P1 (pocket pitch)",+          "4.00 mm"+        ],+        [+          "P2 (pocket→hole offset)",+          "2.00 mm"+        ],+        [+          "W (tape width)",+          "8.00 mm"+        ],+        [+          "T (tape thickness)",+          "0.20 mm"+        ],+        [+          "Bo (band height)",+          "1.12 mm"+        ]+      ],+      "measured": false,+      "enabled": true+    },+    "cover-tape": {+      "title": "cover_tape",+      "match": "^cover_tape",+      "sub": "heat-seal cover film",+      "src": "Inolux IN-S42AT V1.1 · p.9 packing table (verified)",+      "srcComputed": false,+      "rows": [+        [+          "covers",+          "8.00 mm wide carrier tape"+        ],+        [+          "Ao × Bo × Ko",+          "0.57 × 1.12 × 0.55 mm"+        ],+        [+          "P1 (pocket pitch)",+          "4.00 mm"+        ],+        [+          "packing",+          "5000 pcs per reel"+        ],+        [+          "reel",+          "Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 slot · 12.0 wide · 5000 pcs"+        ]+      ],+      "measured": false,+      "enabled": true+    },+    "tape-reel": {+      "title": "tape & reel",+      "match": "^tape_reel$",+      "sub": "Inolux packing",+      "src": "Inolux IN-S42AT V1.1 · p.9 packing table (verified)",+      "srcComputed": false,+      "rows": [+        [+          "tape",+          "W 8.00 · T 0.20 · P1 4.00 · P2 2.00"+        ],+        [+          "sprockets",+          "Do 1.55 · Po 4.00 · E 1.75"+        ],+        [+          "pocket",+          "Ao 0.57 × Bo 1.12 × Ko 0.55 mm"+        ],+        [+          "row offset",+          "F 3.50 mm"+        ],+        [+          "reel",+          "Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 slot · 12.0 wide · 5000 pcs"+        ]+      ],+      "measured": false,+      "enabled": true+    },+    "pnp-nozzle": {+      "title": "pnp_nozzle",+      "match": "^(pnp_nozzle|nozzle_)",+      "sub": "placement nozzle (ghost)",+      "src": "",+      "srcComputed": true,+      "rows": [],+      "measured": true,+      "enabled": true+    },+    "pick-place": {+      "title": "pick & place",+      "match": "^pnp_ghosts$",+      "sub": "ghost assembly",+      "src": "Inolux IN-S42AT V1.1 · p.9 packing table (verified)",+      "srcComputed": false,+      "rows": [+        [+          "scene",+          "nozzle + carrier tape + reeled parts"+        ],+        [+          "tape",+          "8 mm, P1 4.00 mm"+        ],+        [+          "reel",+          "Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 slot · 12.0 wide · 5000 pcs"+        ]+      ],+      "measured": false,+      "enabled": true+    }+  }+}\ No newline at end of file