12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Orbital lab</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e6edf3' stroke-width='1.7'%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3Cellipse cx='12' cy='12' rx='10' ry='4.5'/%3E%3Cellipse cx='12' cy='12' rx='10' ry='4.5' transform='rotate(60 12 12)'/%3E%3C/svg%3E">
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<style>
  @font-face { font-family:'Familjen Grotesk'; font-weight:600; font-display:swap;
    src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'); }
  @font-face { font-family:'Satoshi'; font-weight:400; font-display:swap;
    src:url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2'); }
  @font-face { font-family:'Satoshi'; font-weight:500; font-display:swap;
    src:url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2'); }

  :root{
    --bg:#0d1117; --surface:#161b22; --elevated:#1c2128; --overlay:#21262d;
    --border:#30363d; --text:#e6edf3; --text-2:#8b949e; --text-3:#484f58;
    --accent:#00b8b0; --danger:#f85149; --accent-bright:#00e6dc; --purple:#8c6bf7; --blue:#58a6ff;
    --r-card:8px; --r-pill:999px;
  }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{height:100%;overflow:hidden}
  body{background:var(--bg);color:var(--text);font-family:'Satoshi',sans-serif;display:flex}

  #panel{
    width:330px; flex-shrink:0; height:100%;
    background:var(--surface); border-right:1px solid var(--border);
    display:flex; flex-direction:column;
  }
  .head{padding:14px 16px 12px; border-bottom:1px solid var(--border)}
  .head h1{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:15px}
  .head p{font-size:11px;color:var(--text-2);margin-top:3px;line-height:1.5}

  .actions{display:flex;gap:6px;padding:10px 16px;border-bottom:1px solid var(--border);flex-wrap:wrap}
  button{
    font-family:'Satoshi',sans-serif;font-weight:500;font-size:11px;
    padding:6px 11px;border-radius:var(--r-pill);cursor:pointer;
    background:var(--elevated);border:1px solid var(--border);color:var(--text);
    transition:background .15s ease,border-color .15s ease,transform .05s ease;
  }
  button:hover{background:var(--overlay);border-color:var(--text-3)}
  /* Mousedown has to be unmistakable: a click that produced nothing visible
     reads as a broken button. */
  button:active{
    transform:scale(.94);background:rgba(0,184,176,.28);
    border-color:var(--accent);color:var(--accent-bright);
  }
  button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  button:disabled{opacity:.4;cursor:not-allowed}
  button:disabled:active{transform:none;background:var(--elevated)}
  button.on{background:rgba(0,184,176,.15);border-color:var(--accent);color:var(--accent-bright)}

  .sliders{flex:1;overflow-y:auto;padding:6px 16px 18px}
  .shell{margin-top:12px}
  .shell-h{
    font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:10px;
    letter-spacing:.4px;color:var(--text-3);padding:6px 0 4px;
    display:flex;justify-content:space-between;align-items:baseline;
  }
  .shell-h em{font-style:normal;font-family:'JetBrains Mono','DM Mono',monospace;font-size:9px}

  .row{display:grid;grid-template-columns:74px 1fr 42px;align-items:center;gap:8px;padding:3px 0}
  .row label{
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:10px;color:var(--text-2);
    white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
  }
  .row output{
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:10px;color:var(--text);
    text-align:right;
  }
  .row.hot label{color:var(--accent)}

  input[type=range]{
    -webkit-appearance:none;appearance:none;width:100%;height:3px;border-radius:var(--r-pill);
    background:var(--border);outline:none;cursor:pointer;
  }
  input[type=range]::-webkit-slider-thumb{
    -webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;
    background:var(--accent);cursor:pointer;border:0;
  }
  input[type=range]::-moz-range-thumb{
    width:12px;height:12px;border-radius:50%;background:var(--accent);cursor:pointer;border:0;
  }
  /* Red pip marking the value a control would take if it did not exist: the
     natural, co-timed default. Lets you see whether you are early or late. */
  /* The wander table: one row per harmonic, so you can see exactly what the
     next loop is aiming at and switch off any that misbehave. */
  /* The handle is your GAIN: how much of that state is allowed in. The pip is
     the LIVE coefficient, gain x envelope x cos(E t), which is what the number
     beside it reads. They are different quantities, so the handle must stay
     where you put it while the pip does the moving. */
  /* Specificity has to beat `.mark i`, the red timing pip, or this inherits a
     1px red bar instead of a round teal dot. */
  .mark i.livepip{
    position:absolute;top:50%;margin-top:-3.5px;width:7px;height:7px;border-radius:50%;
    background:var(--accent-bright);box-shadow:0 0 7px var(--accent);
    pointer-events:none;opacity:.95;
  }
  .mark i.livepip::after{content:none;display:none}
  .mark i.livepip.neg{background:var(--blue);box-shadow:0 0 7px var(--blue)}
  .mark i.livepip.zero{opacity:.12;box-shadow:none;background:var(--text-3)}
  .wlist{margin-top:6px;border:1px solid var(--border);border-radius:var(--r-card);overflow:hidden}
  .wrow{
    display:grid;grid-template-columns:16px 74px 1fr 38px;align-items:center;gap:6px;
    padding:3px 8px;font-family:'JetBrains Mono','DM Mono',monospace;font-size:9.5px;
    color:var(--text-3);border-bottom:1px solid rgba(48,54,61,.5);
  }
  .wrow:last-child{border-bottom:0}
  .wrow.off{opacity:.32}
  .wrow.hot{color:var(--text-2)}
  .wrow input[type=checkbox]{
    appearance:none;width:11px;height:11px;border-radius:3px;margin:0;cursor:pointer;
    border:1px solid var(--text-3);background:transparent;
  }
  .wrow input[type=checkbox]:checked{background:var(--accent);border-color:var(--accent)}
  .wbar{position:relative;height:3px;border-radius:2px;background:rgba(48,54,61,.7)}
  .wbar i{position:absolute;top:0;bottom:0;border-radius:2px;background:var(--accent);
          left:50%;transition:none}
  .wbar i.neg{background:var(--blue)}
  .wrow b{color:var(--text-2);font-weight:400;text-align:right}
  .whead{
    display:grid;grid-template-columns:16px 74px 1fr 38px;gap:6px;padding:4px 8px;
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:9px;letter-spacing:.06em;
    text-transform:uppercase;color:var(--text-3);background:var(--surface);
  }
  /* Tooltips per the Adom UI skill: ONE body-appended fixed element, never a
     ::after (panel overflow and transforms clip those). Viewport-clamped on
     both axes, caret keeps pointing at the trigger after the clamp. */
  #tt{
    position:fixed;z-index:99999;max-width:280px;padding:8px 11px;
    background:var(--surface);border:1px solid var(--border);border-radius:8px;
    box-shadow:0 8px 24px rgba(0,0,0,.5);
    font-family:'Satoshi',sans-serif;font-size:11.5px;line-height:1.55;
    color:var(--text);text-transform:none;letter-spacing:0;
    opacity:0;transform:translateY(4px);pointer-events:none;
    transition:opacity .18s ease,transform .18s ease;
  }
  #tt.show{opacity:1;transform:translateY(0)}
  #tt b{color:var(--accent-bright)}
  #tt .caret{
    position:absolute;width:9px;height:9px;background:var(--surface);
    border:1px solid var(--border);border-top:0;border-left:0;
    transform:rotate(45deg);bottom:-5px;
  }
  #tt.above .caret{transform:rotate(225deg);bottom:auto;top:-5px}
  select{
    font-family:'Satoshi',sans-serif;font-weight:500;font-size:11px;color:var(--text);
    background:var(--elevated);border:1px solid var(--border);border-radius:var(--r-pill);
    padding:6px 10px;cursor:pointer;outline:none;max-width:180px;
  }
  select:hover{background:var(--overlay);border-color:var(--text-3)}
  .chk{display:flex;align-items:center;gap:8px;padding:6px 0;font-size:11px;color:var(--text-2);cursor:pointer}
  .chk input{
    appearance:none;width:12px;height:12px;border-radius:3px;flex-shrink:0;margin:0;
    border:1px solid var(--text-3);background:transparent;cursor:pointer;
  }
  .chk input:checked{background:var(--accent);border-color:var(--accent)}
  .adv{margin-top:12px;border-top:1px solid var(--border);padding-top:4px}
  .adv > summary{
    list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:10px;letter-spacing:.08em;
    text-transform:uppercase;color:var(--text-3);padding:7px 0;
  }
  .adv > summary::-webkit-details-marker{display:none}
  .adv > summary:hover{color:var(--text-2)}
  .adv > summary em{font-style:normal;text-transform:none;letter-spacing:0;font-size:10px}
  .adv > summary::before{content:'+ ';color:var(--accent)}
  .adv[open] > summary::before{content:'- ';color:var(--accent)}
  .mark{position:relative;display:block;width:100%}
  .mark i{
    position:absolute;top:50%;margin-top:-6px;width:1px;height:12px;
    background:var(--danger);pointer-events:none;
  }
  .mark i::after{
    content:'';position:absolute;left:-2px;top:-4px;width:5px;height:5px;
    border-radius:50%;background:var(--danger);
  }

  .tabs{display:flex;border-bottom:1px solid var(--border)}
  .tabs .mode{
    flex:1;border:0;border-radius:0;background:transparent;color:var(--text-2);
    padding:11px 0;font-size:12px;border-bottom:2px solid transparent;
  }
  .tabs .mode:hover{background:var(--elevated);border-color:transparent}
  .tabs .mode.on{color:var(--text);border-bottom-color:var(--accent);background:transparent}
  .acts{display:flex;gap:6px;flex-wrap:wrap;padding:8px 0 4px}
  .result{
    margin:12px 0 4px;padding:13px 14px;border-radius:var(--r-card);
    background:linear-gradient(150deg, rgba(0,184,176,.10), rgba(140,107,247,.10));
    border:1px solid rgba(0,184,176,.45);
  }
  .result-k{
    font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:9px;
    letter-spacing:.7px;color:var(--text-2);text-transform:none;
  }
  .result-eq{
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:16px;color:var(--accent-bright);
    margin:7px 0 10px;
  }
  .result-eq sub{font-size:.7em}
  .result-c{display:flex;gap:6px;margin-bottom:9px}
  .result-c div{
    flex:1;background:var(--bg);border:1px solid var(--border);border-radius:6px;
    padding:6px 4px;text-align:center;
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:9px;color:var(--text-3);
  }
  .result-c b{display:block;font-size:12px;color:var(--text);font-weight:500;margin-top:2px}
  .result-n{font-size:10.5px;line-height:1.6;color:var(--text-2)}
  .result-n b{color:var(--accent)}
  .copy{width:100%;margin-top:10px;font-size:10px}
  /* Mode LED: is the surface the untouched wavefunction, or are we bending
     it toward the brandmark? Purple, not teal, so it never reads as part of
     the artwork, and not green or red since those are reserved. */
  #stageWrap.cam-live{
    box-shadow:
      inset 0 0 0 1px var(--accent),
      inset 0 0 26px rgba(0,184,176,.16),
      0 0 18px rgba(0,184,176,.35);
    transition:box-shadow .18s ease;
  }
  .stage-hint{
    position:absolute;left:50%;bottom:44px;transform:translateX(-50%);
    padding:6px 13px;border-radius:var(--r-pill);
    background:rgba(13,17,23,.78);border:1px solid var(--border);
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:10px;letter-spacing:.05em;
    color:var(--text-2);pointer-events:none;opacity:0;transition:opacity .2s ease;
  }
  #stageWrap:hover .stage-hint{opacity:1}
  #stageWrap.cam-live .stage-hint{opacity:0}
  #stageWrap.cam-live:hover .stage-hint.live{opacity:1;color:var(--accent)}
  .led-stack{position:absolute;left:18px;top:44px;display:flex;flex-direction:column;gap:6px;align-items:flex-start}
  .led-badge{
    display:flex;align-items:center;gap:9px;
    padding:7px 13px 7px 11px;border-radius:var(--r-pill);
    background:rgba(13,17,23,.72);border:1px solid var(--border);
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:10px;
    color:var(--text-2);letter-spacing:.4px;
    transition:border-color .25s ease,color .25s ease;
  }
  /* Two lights, two layers. Teal = the morph, which is still real physics.
     Purple = the clip, which is not. They are independent on purpose: the
     morph lights first and stays lit under the clip. */
  .led-badge.lit-morph{border-color:var(--accent);color:var(--accent)}
  .led-badge.lit-clip{border-color:var(--purple);color:var(--purple)}
  .led-badge{min-width:132px;justify-content:flex-start}
  .led-badge .led-pct{margin-left:auto}
  .led.conv{background:var(--accent);box-shadow:0 0 9px var(--accent),0 0 18px rgba(0,184,176,.55)}
  .led{
    width:9px;height:9px;border-radius:50%;flex-shrink:0;
    background:#243040;box-shadow:none;
    transition:background .2s ease,box-shadow .2s ease;
  }
  .led.on{background:var(--purple);box-shadow:0 0 9px var(--purple),0 0 18px rgba(140,107,247,.55)}
  .led.part{animation:led-pulse .9s ease-in-out infinite}
  @keyframes led-pulse{0%,100%{opacity:.45}50%{opacity:1}}
  .led-pct{color:var(--text-3)}

  .fit{
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:10px;color:var(--text-2);
    line-height:1.7;padding:8px 10px;margin-top:6px;
    background:var(--bg);border:1px solid var(--border);border-radius:var(--r-card);
  }
  .fit b{color:var(--accent)}
  #ghost{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}

  .toast-wrap{
    position:absolute;left:50%;bottom:22px;transform:translateX(-50%);
    display:flex;flex-direction:column-reverse;align-items:center;gap:8px;
    pointer-events:none;z-index:40;
  }
  .toast{
    display:flex;align-items:center;gap:10px;
    padding:10px 16px;border-radius:var(--r-pill);
    background:var(--overlay);border:1px solid var(--accent);
    box-shadow:0 14px 40px rgba(0,0,0,.6);
    font-family:'Satoshi',sans-serif;font-size:13px;color:var(--text);
    opacity:0;transform:translateY(10px) scale(.97);
    transition:opacity .18s ease, transform .22s cubic-bezier(.34,1.4,.64,1);
    pointer-events:auto;
  }
  .toast.show{opacity:1;transform:translateY(0) scale(1)}
  .toast .t-sub{color:var(--text-2);font-size:12px}
  .t-spin{
    width:14px;height:14px;border-radius:50%;flex-shrink:0;
    border:2px solid rgba(0,184,176,.25);border-top-color:var(--accent);
    animation:t-spin .7s linear infinite;
  }
  @keyframes t-spin{to{transform:rotate(360deg)}}
  #stageWrap{flex:1;position:relative}
  #stage{position:absolute;inset:0;width:100%;height:100%;display:block;outline:none}
  .hud{
    position:absolute;right:16px;bottom:14px;text-align:right;
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:10px;color:var(--text-3);line-height:1.6;
  }
  .hud b{color:var(--accent);font-weight:500}
  .formula{
    position:absolute;left:16px;top:14px;
    font-family:'JetBrains Mono','DM Mono',monospace;font-size:11px;color:var(--text-2);
  }
  .formula b{color:var(--accent)}
</style>
</head>
<body>

<div id="panel">
  <div class="tabs">
    <button id="mExplore" title="All 16 real spherical harmonics for l=0 to 3, as live gain sliders over an evolving superposition." class="mode on">Explore</button>
    <button id="mLogo" title="Align the orbital against the real logo vectors and measure how well it actually fits."    class="mode">Logo cheat</button>
    <button id="mFinal" title="The earlier cut, kept as a scratchpad: wander mode, camera grab and timing experiments live here."   class="mode">Scratch</button>
    <button id="mLoop" title="The final cut: a clean teal intro, then play, dinner call, cheat, sit, and back out, with a fresh roll every pass." class="mode">Final</button>
  </div>
  <div class="head">
    <h1>Orbital lab</h1>
    <p id="intro">Every real spherical harmonic through <b>l = 3</b>. The superposition keeps
       evolving; each slider is how much that state is allowed to join in. Numbers
       on the right are the live coefficient right now.</p>
  </div>
  <div class="actions" id="exploreActions">
    <button id="bAnim" title="Run the time evolution. Each coefficient advances by cos(E_n t) with the real hydrogen energies, so the shape morphs continuously. Off freezes it and the gains you set become the state exactly." class="on">Evolve</button>
    <button id="bRand" title="Zero all 16 gains, then give 2 to 5 randomly chosen harmonics a gain between 0.35 and 1.00. A random superposition, for finding shapes by accident.">Randomise</button>
    <button id="bClear" title="Set all 16 gains to zero. Psi becomes zero everywhere, so there is nothing left to draw. This is an empty canvas, not a reset to defaults.">Clear</button>
    <button id="bLogo" title="Zero everything except p_z, set to 1.00. The same SHAPE as the logo orbital, but pointing along z. The logo is this shape rotated onto the diagonal by mixing p_x and p_y equally.">2p (logo)</button>
    <button id="bShell1" title="Zero everything, then give each of the three l=1 harmonics (p_y, p_z, p_x) a random gain from 0.50 to 1.00. A random p-shell state.">p only</button>
    <button id="bShell2" title="Zero everything, then give each of the five l=2 harmonics (the d orbitals) a random gain from 0.50 to 1.00.">d only</button>
    <button id="bShell3" title="Zero everything, then give each of the seven l=3 harmonics (the f orbitals) a random gain from 0.50 to 1.00. The busiest shapes here.">f only</button>
  </div>
  <div class="sliders" id="sliders"></div>

  <div class="sliders" id="final" style="display:none">
    <div class="shell">
      <div class="shell-h"><span>final adom schrodinger anim</span><em>loops</em></div>
      <p style="font-size:11px;color:var(--text-2);line-height:1.6;margin:6px 0 12px">
        The superposition evolves, converges onto the one state that is the Adom
        logomark, holds square on for a beat, then lets go and carries on.
      </p>
      <div class="acts">
        <button id="fCam" class="on" title="Drift the camera while the physics runs. Nothing to do with the equation, it is framing.">Camera motion</button>
        <button id="fSpin" class="on" title="Rotate the object itself. Also not the equation: a real superposition of real harmonics does not rotate, it breathes in place.">Spin</button>
        <button id="fFree" title="Drag to orbit while the physics runs. Control is taken back as the morph starts, because the logomark only reads from one angle.">Free rotate</button>
        <button id="fGuide" title="Draw the dashed logo vectors over the animation so you can check the match while it runs.">Alignment guides</button>
        <button id="fRestart" title="Rewind every clock to zero: the timeline, the time evolution and the spin.">Restart</button>
        <button id="fDefaults" title="Put every timing control back to the tuned values">Defaults</button>
      </div>
      <div class="row" style="margin-top:10px">
        <label title="How long the mark is held before the physics resumes. 0 means no pause at all.">hold</label>
        <span class="mark"><input type="range" id="fHold" min="0" max="3" step="0.1" value="1"><i class="livepip" id="lpHold"></i></span><output id="oHold">1.0s</output>
      </div>
      <div class="row">
        <label title="Peak opacity the flat teal logomark fades to over the 3D at the logo moment. 0% never shows it, 100% fully covers the shading.">logo opacity</label>
        <span class="mark"><input type="range" id="fFill" min="0" max="1" step="0.01" value="0.50"><i class="livepip" id="lpFill"></i></span><output id="oFill2">50%</output>
      </div>
      <div class="shell-h" style="margin-top:14px"><span>wander</span><em>a new state every loop</em></div>
      <p style="font-size:10.5px;color:var(--text-2);line-height:1.6;margin:2px 0 8px">
        Instead of one fixed animation, roll a fresh random state every time the
        loop leaves the epoch, ease out to it, then ease back so the logo lands
        again. Never repeats. Untick any harmonic whose shape makes the steering
        or sculpting misbehave and it is left out of every future roll.
      </p>
      <div class="acts">
        <button id="fWander" title="Roll a new random state each loop instead of replaying one fixed animation.">Wander</button>
        <button id="fRoll" title="Throw away the pending state and roll another one right now.">Roll now</button>
        <button id="fWanderAll" title="Include every harmonic in the roll.">All</button>
        <button id="fWanderNoF" title="Exclude the seven f harmonics, the busiest shapes and the most likely to fight the clip.">No f</button>
        <button id="fWanderP" title="Only the three p harmonics, so the wander stays close to the logo's own shell.">p only</button>
      </div>
      <div class="row">
        <label title="Seconds between one epoch and the next: out to the random state and back again">excursion</label>
        <span class="mark"><input type="range" id="fExc" min="1" max="20" step="0.1" value="3"><i class="livepip" id="lpExc"></i></span>
        <output id="oExc">3.0s</output>
      </div>
      <div class="row">
        <label title="How far each roll may stray from the logo state. At 0% every roll IS the logo, so the loop never leaves it. At 100% rolls are pure random with no logo left in them. Past 100% it overshoots: the logo's share goes negative and rolls are pushed actively AWAY from the mark, the wild end. The dot shows where the state is on this scale right now.">stray</label>
        <span class="mark"><input type="range" id="fAmt" min="0" max="2" step="0.01" value="0.75"><i class="livepip" id="lpAmt"></i></span>
        <output id="oAmt">75%</output>
      </div>
      <div class="fit" id="wanderInfo" style="margin:6px 0 2px">wander</div>
      <div class="whead"><span></span><span>state</span><span>next target</span><span>live</span></div>
      <div class="wlist" id="wanderList"></div>

      <details class="adv">
        <summary>Timing controls<em>tuned already, open only to play</em></summary>
      <div class="shell-h" style="margin-top:12px"><span>steering</span><em>the teal light</em></div>
      <p style="font-size:10.5px;color:var(--text-2);line-height:1.6;margin:2px 0 8px">
        Steering is the teal <b>STEERING</b> light: pulling the superposition
        onto the one state that IS the logomark. Its percentage is how far
        along that pull is. These two say how many seconds the pull takes,
        going in and letting go. Nothing physical picks them, they are pacing.
      </p>
      <div class="row">
        <label title="Seconds the teal STEERING light takes to go 0 to 100%: the pull from the wandering state onto the logo state.">steer in</label>
        <span class="mark"><input type="range" id="fMorphIn" min="0.8" max="10" step="0.1" value="2.6"><i class="livepip" id="lpMorphIn"></i></span>
        <output id="oMorphIn">2.6s</output>
      </div>
      <div class="row">
        <label title="Seconds the teal STEERING light takes to fall 100% to 0: letting the state go free again.">steer out</label>
        <span class="mark"><input type="range" id="fMorphOut" min="0.8" max="10" step="0.1" value="2.6"><i class="livepip" id="lpMorphOut"></i></span>
        <output id="oMorphOut">2.6s</output>
      </div>

      <div class="shell-h" style="margin-top:12px"><span>sculpting</span><em>the purple light</em></div>
      <p style="font-size:10.5px;color:var(--text-2);line-height:1.6;margin:2px 0 8px">
        Sculpting is the purple <b>SCULPTING</b> light: cutting each point to
        the exact logo outline. It is always fully on through the epoch.
        These two say how many seconds <b>before</b> the epoch it fades up and
        how many <b>after</b> it fades away. Steering always finishes before
        sculpting starts, so the knife only ever touches a finished 2p. The
        <span style="color:var(--danger)">red pip</span> marks the old
        behaviour, sculpting the whole steer, which is what caused the
        stretch artefacts.
      </p>
      <div class="row">
        <label title="Seconds before the epoch that the purple SCULPTING light begins fading up.">lead in</label>
        <span class="mark"><input type="range" id="fArtOn" min="0" max="5" step="0.05" value="1.0"><i class="livepip" id="lpArtOn"></i><i id="pipOn"></i></span>
        <output id="oArtOn">1.00s</output>
      </div>
      <div class="row">
        <label title="Seconds after the epoch that the purple SCULPTING light finishes fading away.">tail out</label>
        <span class="mark"><input type="range" id="fArtOff" min="0" max="5" step="0.05" value="1.0"><i class="livepip" id="lpArtOff"></i><i id="pipOff"></i></span>
        <output id="oArtOff">1.00s</output>
      </div>
      <div class="row">
        <label title="Free rotate only: seconds before the epoch that the camera is taken from you, and how long after it that you get it back. Between grabs the camera is entirely yours, even mid-morph.">cam grab</label>
        <span class="mark"><input type="range" id="fGrab" min="0.3" max="5" step="0.1" value="1.5"><i id="pipGrab"></i><i class="livepip" id="lpGrab"></i></span>
        <output id="oGrab">1.5s</output>
      </div>
      <div class="fit" id="artWindow" style="margin:6px 0 2px">window</div>
      <div class="row" style="margin-top:8px">
        <label title="Slow the clock while steering runs, slowest exactly at the epoch, so the approach is not rushed.">ease</label>
        <span class="mark"><input type="range" id="fEase" min="0" max="0.85" step="0.01" value="0"><i class="livepip" id="lpEase"></i></span>
        <output id="oEase">off</output>
      </div>
      <div class="fit" id="easeWindow" style="margin:6px 0 2px">ease</div>
      </details>
      <div class="fit" id="phase" style="margin-top:8px">phase</div>
    </div>
  </div>

  <div class="sliders" id="loop" style="display:none">
    <div class="shell">
      <div class="shell-h"><span>the directed loop</span><em>intro, then forever</em></div>
      <p style="font-size:11px;color:var(--text-2);line-height:1.6;margin:6px 0 10px">
        Opens clean: the teal mark fades in alone, the 3D arrives underneath it
        exactly on the epoch, the mark lets go and the loop runs. Play, dinner
        call, cheat, sit, back out to play, and a fresh roll every pass so no
        two loops are ever the same.
      </p>
      <div class="acts">
        <button id="lRestart" title="Replay everything from the teal intro.">Restart intro</button>
        <button id="lDefaults" title="Put every control on this tab back to the tuned defaults.">Defaults</button>
      </div>
      <div class="acts" style="margin-top:0">
        <select id="l_snaps" title="Saved snapshots of every control on this tab. Pick one and it applies instantly."></select>
        <button id="lSnapSave" title="Save every control on this tab, exactly as set right now, as a new named snapshot. Survives reloads.">Save</button>
        <button id="lSnapDel" title="Delete the snapshot currently selected in the pulldown.">Del</button>
      </div>
      <div class="row">
        <label title="pt, the playground timeline. The tab 1 animation exactly: Evolve running over this loop's rolled random state, free and alive, for this many seconds before mom calls.">pt play</label>
        <span class="mark"><input type="range" id="l_pt" min="2" max="30" step="0.5" value="5"><i class="livepip" id="lp4pt"></i></span>
        <output id="o_pt">5.0s</output>
      </div>
      <div class="row">
        <label title="dt, the dinner timeline. Mom calls: every coefficient steers from wherever play left it onto the known epoch numbers, p_x and p_y at 0.7071 and everything else 0, arriving exactly as these seconds end. The way back out to play afterwards uses this same length.">dt dinner</label>
        <span class="mark"><input type="range" id="l_dt" min="0.5" max="8" step="0.1" value="0.7"><i class="livepip" id="lp4dt"></i></span>
        <output id="o_dt">0.7s</output>
      </div>
      <div class="row">
        <label title="ct, the cheat timeline. The tab 2 trick, the quadrant clip and edge correction that force the exact logo outline, ramped 0 to 100% and reaching exactly 100% at the epoch. The harsh edges live here, so experiment with WHEN it runs. The un-cheat on the way out mirrors this length.">ct cheat</label>
        <span class="mark"><input type="range" id="l_ct" min="0.2" max="5" step="0.1" value="0.2"><i class="livepip" id="lp4ct"></i></span>
        <output id="o_ct">0.2s</output>
      </div>
      <label class="chk" title="Checked: ct OVERLAYS the tail of dt, carving while the steer is still finishing underneath, busier and the cycle stays shorter. Unchecked: ct becomes its OWN act after dt, carving a finished, still 2p into the mark, calmer, and adds ct to each side of the cycle.">
        <input type="checkbox" id="l_ctMode" checked><span>ct is an overlay on dt</span>
      </label>
      <div class="row">
        <label title="st, the sit timeline. Arrived. The shape pins to the mark and does nothing for this long, dead still. The fresh random roll for the NEXT playground happens the moment we arrive, invisibly, so the exit is always brand new.">st sit</label>
        <span class="mark"><input type="range" id="l_st" min="0" max="5" step="0.1" value="1"><i class="livepip" id="lp4st"></i></span>
        <output id="o_st">1.0s</output>
      </div>
      <label class="chk" title="Fade the flat teal logomark over the 3D during the sit: in across the first half of the sit, out across the second half.">
        <input type="checkbox" id="l_logoOn" checked><span>logo fade during the sit</span>
      </label>
      <div class="row">
        <label title="Peak opacity the logo fade reaches, halfway through the sit. 0% never shows it, 100% fully covers the shading.">logo peak</label>
        <span class="mark"><input type="range" id="l_logoPeak" min="0" max="1" step="0.01" value="1"><i class="livepip" id="lp4peak"></i></span>
        <output id="o_logoPeak">100%</output>
      </div>
      <div class="row">
        <label title="Start the logo fade this many seconds BEFORE the sit begins, and let the fade-out run the same amount past its end. Negative means earlier. With sit at 0.0s this slider alone still creates a logo moment around the epoch instant.">logo lead</label>
        <span class="mark"><input type="range" id="l_logoLead" min="-3" max="0" step="0.1" value="-0.6"><i class="livepip" id="lp4llead"></i></span>
        <output id="o_logoLead">-0.6s</output>
      </div>
      <div class="row">
        <label title="The dark negative lobe fades to the positive teal this many seconds before the epoch, and fades back the same amount after it, so the mark reads as one colour by the time the logo lands. Negative means earlier. At 0.0s the colour snaps exactly on the epoch.">teal lead</label>
        <span class="mark"><input type="range" id="l_tealLead" min="-3" max="0" step="0.1" value="-0.2"><i class="livepip" id="lp4tlead"></i></span>
        <output id="o_tealLead">-0.2s</output>
      </div>
      <label class="chk" title="Draw the dashed logo vectors over the 3D as it steers in, for checking the match against the real mark. A debug aid: off is the clean look. Never shown during the intro.">
        <input type="checkbox" id="l_guides"><span>dashed logo guides</span>
      </label>
      <div class="fit" id="loopPhase" style="margin-top:10px">loop</div>
    </div>
  </div>

  <div class="sliders" id="cheat" style="display:none">

    <div class="result">
      <div class="result-k">The Adom logomark is</div>
      <div class="result-eq">&psi; = (2p<sub>x</sub> + 2p<sub>y</sub>) / &radic;2</div>
      <div class="result-c">
        <div><span>c</span><sub>p<sub>x</sub></sub><b>0.707</b></div>
        <div><span>c</span><sub>p<sub>y</sub></sub><b>0.707</b></div>
        <div><span>c</span><sub>p<sub>z</sub></sub><b>0.000</b></div>
      </div>
      <div class="result-n">
        A single 2p eigenstate of hydrogen, its axis in the view plane on the
        diagonal. Measured against the real vectors:
        <b>99.9%</b> of the mark covered, <b>0.6%</b> spill,
        <b>99.3%</b> IoU.<br>
        Untouched, the same state already scores <b>79.2%</b>. The licence is
        a quadrant clip plus a per-direction edge correction.
      </div>
      <button id="bCopy" title="Copy the equation and the fit numbers to the clipboard as plain text." class="copy">Copy the state</button>
    </div>

    <div class="shell">
      <div class="shell-h"><span>the cheat</span><em>physics &rarr; brandmark</em></div>
      <div class="row"><label title="Lock the camera square-on so the eye never catches the curved profile">face on</label>
        <input type="range" id="cFace" min="0" max="1" step="0.01" value="1"><output id="oFace">1.00</output></div>
      <div class="row"><label title="Clip each lobe into a 90 degree wedge: the flat radial edge the leaves have">flatten</label>
        <input type="range" id="cFlat" min="0" max="1" step="0.01" value="0"><output id="oFlat">0.00</output></div>
      <div class="row"><label title="In-plane rotation. 135 is the measured best fit to the mark">rotate</label>
        <input type="range" id="cRot" min="0" max="360" step="1" value="135"><output id="oRot">135</output></div>
      <div class="row"><label title="Round the outer edge toward the mark's fuller leaf">plump</label>
        <input type="range" id="cPlump" min="0" max="1" step="0.01" value="0"><output id="oPlump">0.00</output></div>
      <div class="row"><label title="Pull the leaves apart or together across the node">gap</label>
        <input type="range" id="cGap" min="-0.3" max="0.5" step="0.01" value="0"><output id="oGap">0.00</output></div>
      <div class="row"><label title="Round the corner where the flat edge meets the outer curve">corner</label>
        <input type="range" id="cCorner" min="0" max="0.2" step="0.005" value="0"><output id="oCorner">0.00</output></div>
      <div class="row"><label title="Pull the outer boundary onto the mark: in where it overshoots, out where it falls short">edge</label>
        <input type="range" id="cEdge" min="0" max="1" step="0.01" value="0"><output id="oEdge">0.00</output></div>
      <div class="row"><label title="Overall size, to fill the mark rather than sit inside it">size</label>
        <input type="range" id="cSize" min="0.6" max="2.2" step="0.01" value="1"><output id="oSize">1.00</output></div>
    </div>

    <div class="shell">
      <div class="shell-h"><span>reference</span><em>the real vectors</em></div>
      <div class="row"><label title="The dashed reference outline">outline</label>
        <input type="range" id="cGhost" min="0" max="1" step="0.01" value="0.45"><output id="oGhost">0.45</output></div>
      <div class="row"><label title="Manual version of the flat teal overlay, for alignment work on THIS tab. The Final anim tab drives its own copy with the logo opacity slider.">overlay fill</label>
        <input type="range" id="cFill" min="0" max="1" step="0.01" value="0"><output id="oFill">0.00</output></div>
      <div class="acts">
        <button id="bFill25" title="Lay the teal logomark over the 3D at 25% opacity.">25%</button>
        <button id="bFill40" title="Lay the teal logomark over the 3D at 40% opacity.">40%</button>
        <button id="bFill50" title="Lay the teal logomark over the 3D at 50% opacity.">50%</button>
        <button id="bFill65" title="Lay the teal logomark over the 3D at 65% opacity.">65%</button>
        <button id="bFill80" title="Lay the teal logomark over the 3D at 80% opacity.">80%</button>
      </div>
      <div class="acts">
        <button id="bView" title="Re-engage the square-on camera lock after you have orbited away from it.">Square on</button>
        <button id="bMeasure" title="Read the actual pixels back off the GPU and compute the overlap against the logo. Numbers, not eyeballing.">Measure fit</button>
        <button id="bPure" title="Drop every cheat back to zero. What is left is the honest 2p orbital with nothing done to it.">Pure physics</button>
        <button id="bMark" title="Apply the full cheat recipe: quadrant clip, edge correction, 45 degree rotation, 1.15 scale. This is the tuned 99.3% fit.">Full brandmark</button>
      </div>
      <div id="fit" class="fit">drag a slider, then measure</div>
    </div>
  </div>
</div>

<div id="stageWrap">
  <canvas id="stage"></canvas>
  <canvas id="ghost"></canvas>
  <div class="toast-wrap" id="toasts"></div>
  <div class="stage-hint">click to zoom and orbit</div>
  <div class="stage-hint live">interactive &middot; click outside to release</div>
  <div class="led-stack" id="ledBadge" style="display:none">
    <div class="led-badge" id="ledMorphBadge" title="Manipulating WHICH state: the coefficients are pulled toward the 2p target. The points are still computed from the equation, so the surface is a real orbital, just not the one free evolution would have reached.">
      <span class="led" id="ledMorph"></span>
      <span id="ledMorphLabel">STEERING</span>
      <span class="led-pct" id="ledMorphPct">0%</span>
    </div>
    <div class="led-badge" id="ledClipBadge" title="Manipulating THE POINTS: each one is cut against the quadrant planes and pushed to the exact logo radius. The surface is no longer any orbital.">
      <span class="led" id="ledClip"></span>
      <span id="ledClipLabel">SCULPTING</span>
      <span class="led-pct" id="ledClipPct">0%</span>
    </div>
    <div class="led-badge" id="ledCamBadge" title="Whether the pointer can move the camera right now. It is taken away as the morph starts, because the logomark only reads head on.">
      <span class="led" id="ledCam"></span>
      <span id="ledCamLabel">CAMERA</span>
      <span class="led-pct" id="ledCamPct">scripted</span>
    </div>
  </div>
  <div class="formula">&psi;(&theta;,&phi;) = &Sigma; c<sub>lm</sub> &middot; Y<sub>lm</sub>(&theta;,&phi;)</div>
  <div class="hud" id="hud"></div>
</div>

<script>
/* =====================================================================
   Every real spherical harmonic up to l=3, exposed as a weight you can
   drag. The surface is r(th,ph) = |SUM c_lm Y_lm(th,ph)|, displaced in
   the vertex shader, so adding harmonics costs uniforms rather than
   geometry: still one mesh, one draw call, nothing re-uploaded.

   l=0 is the s shell (a sphere), l=1 the p orbitals (the Adom mark is
   one of these), l=2 the d orbitals from the brand animation, and l=3
   the f orbitals, which are the ones worth auditioning if the loading
   screen's randomness should reach further than the source clip did.
   ===================================================================== */

const HARMONICS = [
  { l:0, m: 0, name:'s',            key:'Y00'  },
  { l:1, m:-1, name:'p_y',          key:'Y1m1' },
  { l:1, m: 0, name:'p_z',          key:'Y10'  },
  { l:1, m:+1, name:'p_x',          key:'Y11'  },
  { l:2, m:-2, name:'d_xy',         key:'Y2m2' },
  { l:2, m:-1, name:'d_yz',         key:'Y2m1' },
  { l:2, m: 0, name:'d_z2',         key:'Y20'  },
  { l:2, m:+1, name:'d_xz',         key:'Y21'  },
  { l:2, m:+2, name:'d_x2-y2',      key:'Y22'  },
  { l:3, m:-3, name:'f_y(3x2-y2)',  key:'Y3m3' },
  { l:3, m:-2, name:'f_xyz',        key:'Y3m2' },
  { l:3, m:-1, name:'f_yz2',        key:'Y3m1' },
  { l:3, m: 0, name:'f_z3',         key:'Y30'  },
  { l:3, m:+1, name:'f_xz2',        key:'Y31'  },
  { l:3, m:+2, name:'f_z(x2-y2)',   key:'Y32'  },
  { l:3, m:+3, name:'f_x(x2-3y2)',  key:'Y33'  }
];
const N = HARMONICS.length;

// n for each l, used only for the time evolution's energy
const nOf = l => l + 1 + (l === 0 ? 0 : 0);   // s->1, p->2, d->3, f->4
const E = HARMONICS.map(h => -13.6 / Math.pow(h.l + 1, 2));

const canvas = document.getElementById('stage');
const engine = new BABYLON.Engine(canvas, true, { stencil:true });
const scene  = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color4(0.051,0.067,0.09,1);

const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI/2, Math.PI/2.4, 4.4,
                                           BABYLON.Vector3.Zero(), scene);
camera.attachControl(canvas, true);
camera.zoomToMouseLocation = true;
/* Click-to-interact: embedded in a scrolling page, a permanently-attached
   camera hijacks the wheel and page scrolling dies over the viewer. So the
   camera is DETACHED until the viewer is clicked, and released again the
   moment anything outside it is clicked. camLive gates every attach the
   drive paths make too. */
let camLive = false;
function setCamLive(on){
  if (camLive === on) return;
  camLive = on;
  const w = document.getElementById('stageWrap');
  if (w) w.classList.toggle('cam-live', on);
  /* noPreventDefault=false while armed: Babylon EATS wheel and pointer
     events, so the page cannot scroll underneath the zoom. Released, the
     camera is detached and the page gets everything back. */
  if (on) { camera.attachControl(canvas, false); canvas.focus(); }
  else    { camera.detachControl(); canvas.blur(); }
}
camera.detachControl();
canvas.tabIndex = 0;                       // focusable, so arming truly takes focus
canvas.addEventListener('pointerdown', () => setCamLive(true));
document.addEventListener('pointerdown', e => {
  if (camLive && e.target.closest && !e.target.closest('#stageWrap')) setCamLive(false);
}, true);
camera.lowerRadiusLimit = 2.0;
camera.upperRadiusLimit = 14;
camera.wheelDeltaPercentage = 0.012;
new BABYLON.HemisphericLight('h', new BABYLON.Vector3(0.3,1,-0.4), scene).intensity = 0.8;

BABYLON.Effect.ShadersStore['labVertexShader'] = `
precision highp float;
attribute vec3 position;
uniform mat4 worldViewProjection;
uniform float c[16];
uniform float uScale;
varying float vPsi;
varying vec3 vN;

float psiOf(vec3 d){
  float x=d.x, y=d.y, z=d.z;
  float s = 0.0;
  s += c[0]  * 0.2820948;
  s += c[1]  * 0.4886025 * y;
  s += c[2]  * 0.4886025 * z;
  s += c[3]  * 0.4886025 * x;
  s += c[4]  * 1.0925484 * x * y;
  s += c[5]  * 1.0925484 * y * z;
  s += c[6]  * 0.3153916 * (3.0*z*z - 1.0);
  s += c[7]  * 1.0925484 * x * z;
  s += c[8]  * 0.5462742 * (x*x - y*y);
  s += c[9]  * 0.5900436 * y * (3.0*x*x - y*y);
  s += c[10] * 2.8906114 * x * y * z;
  s += c[11] * 0.4570458 * y * (5.0*z*z - 1.0);
  s += c[12] * 0.3731763 * z * (5.0*z*z - 3.0);
  s += c[13] * 0.4570458 * x * (5.0*z*z - 1.0);
  s += c[14] * 1.4453057 * z * (x*x - y*y);
  s += c[15] * 0.5900436 * x * (x*x - 3.0*y*y);
  return s;
}

uniform float uFlat, uRot, uPlump, uGap, uEdge, uCorner;
uniform float uLogoR[128];        // the mark's measured edge, 0..90 degrees

/* The morph target for ANY direction.

   Honest note on what works and what does not.

   Sliding only the RADIUS toward the mark's edge leaves out-of-quadrant
   vertices with a target of zero, so they pile at the node: that was the bead
   and the creases.

   Moving the DIRECTION too seemed like the fix, but both variants failed for
   the same underlying reason: this sphere's parameterisation covers the full
   180-degree lobe, and the mark's leaf is 90 degrees. Clamping stacks half the
   vertices on the boundary planes (shredding). Remapping 2:1 spreads them, but
   then every vertex including those at the node gets pushed out to the leaf
   edge, so the nodal pinch vanishes and the two lobes fuse into a tube.

   What actually measures best is the simplest thing: keep the physics surface
   exactly as the wavefunction gives it, and cut it to the mark's quadrants.
   That scored 95.9% IoU at 0.8% spill and renders clean, because it never
   moves a vertex anywhere the surface was not already. It IS a clip, and that
   is the honest description of the licence in the mark. */
vec3 shapedPos(vec3 dir){
  float pp = psiOf(dir);
  vec3 physics = dir * abs(pp);
  if (uFlat <= 0.0001) return physics;

  float PI4  = 0.7853981634;
  float axis = radians(uRot);
  vec2  e1   = vec2(cos(axis - PI4), sin(axis - PI4));
  vec2  e2   = vec2(cos(axis + PI4), sin(axis + PI4));
  float s    = (pp >= 0.0) ? 1.0 : -1.0;
  float lobe = (pp >= 0.0) ? axis : axis + 3.14159265;

  /* A hard half-plane cut leaves a sharp corner where the flat edge meets the
     outer curve; the mark has a real radius there. min(d,0) rounded is
     0.5*(d - sqrt(d*d + rc*rc)), which eases into the cut over a band of
     width rc instead of hitting it as a crease. */
  vec2  q  = physics.xy;
  float rc = uCorner;
  float d1 = dot(q, e1) * s;
  float p1 = (rc > 1e-5) ? 0.5 * (d1 - sqrt(d1*d1 + rc*rc)) : min(d1, 0.0);
  q -= e1 * (p1 * s);
  float d2 = dot(q, e2) * s;
  float p2 = (rc > 1e-5) ? 0.5 * (d2 - sqrt(d2*d2 + rc*rc)) : min(d2, 0.0);
  q -= e2 * (p2 * s);

  vec3 clipped = vec3(mix(physics.xy, q, uFlat), physics.z);

  /* OUTER BOUNDARY correction.
     The clip fixes the inner corner but leaves the outer edge as the plain
     circle the physics gives: it overshoots the mark in some directions and
     falls short in others. So for each direction, compare the orbital's own
     in-plane radius against the mark's measured edge at that same angle and
     scale by the ratio. Points outside the mark are pulled in, points inside
     are pushed out, and the silhouette lands exactly on the vectors.

     This scales the in-plane component only, so the solid stays coherent
     out of plane rather than collapsing. */
  if (uEdge > 0.0001) {
    vec2  cq = clipped.xy;
    float cl = length(cq);
    if (cl > 1e-5) {
      float cang = atan(cq.y, cq.x);
      float cda  = atan(sin(cang - lobe), cos(cang - lobe));
      if (abs(cda) <= PI4 + 0.001) {
        float u = (clamp(cda, -PI4, PI4) + PI4) / (2.0 * PI4) * 127.0;
        int   i = int(floor(u));
        float f = u - float(i);
        int   j = min(i + 1, 127);
        float rEdge = mix(uLogoR[i], uLogoR[j], f);

        // the orbital's own equatorial radius in this same direction
        vec3  eqd  = vec3(cos(cang), sin(cang), 0.0);
        float rPsi = abs(psiOf(eqd));
        if (rPsi > 1e-5) {
          float k = rEdge / rPsi;
          clipped.xy = cq * mix(1.0, k, uEdge);
        }
      }
    }
  }
  return clipped;
}

void main(void){
  vec3 d = normalize(position);
  float p = psiOf(d);
  vPsi = p;

  vec3 pos = shapedPos(d) * uScale;
  pos.xy += normalize(vec2(cos(radians(uRot)), sin(radians(uRot))))
            * ((p >= 0.0) ? 1.0 : -1.0) * uGap * uScale * uFlat;

  /* Normals are differenced from the SHAPED radius, not the raw wavefunction.
     Using the raw psi here left the lighting describing a surface that no
     longer existed once the cheat moved it, which showed up as a dark crater
     across the transformed lobe. */
  vec3 t1 = normalize(abs(d.z) < 0.9 ? cross(d, vec3(0.,0.,1.)) : cross(d, vec3(1.,0.,0.)));
  vec3 t2 = cross(d, t1);
  float hh = 0.03;
  vec3 a = normalize(d + t1*hh), b = normalize(d + t2*hh);
  vec3 pa = shapedPos(a) * uScale;
  vec3 pb = shapedPos(b) * uScale;
  vec3 nn = cross(pa - pos, pb - pos);
  vN = normalize(dot(nn, d) < 0.0 ? -nn : nn);

  gl_Position = worldViewProjection * vec4(pos, 1.0);
}`;

BABYLON.Effect.ShadersStore['labFragmentShader'] = `
precision highp float;
varying float vPsi;
varying vec3 vN;
uniform vec3 cPos, cNeg;
void main(void){
  vec3 base = vPsi >= 0.0 ? cPos : cNeg;
  vec3 n = normalize(vN);
  float lam = 0.42 + 0.58*max(0.0, dot(n, normalize(vec3(-0.4,0.85,-0.55))));
  float rim = pow(1.0 - abs(n.z), 2.5)*0.32;
  gl_FragColor = vec4(base*lam + vec3(0.0,0.72,0.68)*rim, 1.0);
}`;

const mat = new BABYLON.ShaderMaterial('lab', scene, 'lab', {
  attributes:['position'], uniforms:['worldViewProjection','c','uScale','cPos','cNeg','uFlat','uRot','uPlump','uGap','uEdge','uCorner','uLogoR']
});
mat.setFloats('uLogoR', [0.29682, 0.31691, 0.33085, 0.34219, 0.3519, 0.36055, 0.36839, 0.37555, 0.38216, 0.3883, 0.39398, 0.39936, 0.40453, 0.40917, 0.41376, 0.41798, 0.422, 0.42584, 0.4294, 0.43293, 0.43608, 0.43927, 0.44211, 0.445, 0.44757, 0.45018, 0.45253, 0.45491, 0.45702, 0.45917, 0.46107, 0.46303, 0.46475, 0.46655, 0.46809, 0.46969, 0.4711, 0.47252, 0.47382, 0.47507, 0.47627, 0.47735, 0.47847, 0.47938, 0.48038, 0.48119, 0.48205, 0.48281, 0.4835, 0.48421, 0.48477, 0.48541, 0.48586, 0.48634, 0.48677, 0.48712, 0.48754, 0.48774, 0.48802, 0.4882, 0.48834, 0.48853, 0.48852, 0.48859, 0.48859, 0.48852, 0.48853, 0.48834, 0.4882, 0.48802, 0.48774, 0.48754, 0.48712, 0.48677, 0.48634, 0.48586, 0.48541, 0.48477, 0.48421, 0.4835, 0.48281, 0.48205, 0.48119, 0.48038, 0.47938, 0.47847, 0.47735, 0.47627, 0.47507, 0.47382, 0.47252, 0.4711, 0.46969, 0.46807, 0.46651, 0.46473, 0.46303, 0.46107, 0.45918, 0.45702, 0.45491, 0.45253, 0.45018, 0.44757, 0.445, 0.44211, 0.43927, 0.43608, 0.43293, 0.4294, 0.42584, 0.422, 0.41798, 0.41376, 0.40917, 0.40453, 0.3993, 0.39391, 0.38829, 0.38214, 0.37552, 0.36839, 0.36056, 0.3519, 0.34209, 0.33073, 0.31683, 0.2966]);
const C_POS = BABYLON.Color3.FromHexString('#19c8bd');
const C_NEG = BABYLON.Color3.FromHexString('#123a63');
mat.setColor3('cPos', C_POS);
mat.setColor3('cNeg', C_NEG);
mat.backFaceCulling = false;

const mesh = BABYLON.MeshBuilder.CreateSphere('orb', { diameter:2, segments:128 }, scene);
mesh.material = mat;

/* ---------- the weights ---------- */
/* The sliders are GAINS on the running evolution, not a frozen pose. The
   superposition keeps evolving the whole time; each slider says how much
   that state is allowed to participate. Dial f up and the f orbitals join
   the dance; dial one to zero and it drops out. The animation never stops. */
const gain = new Array(N).fill(0);
[1,2,3].forEach(i => gain[i] = 1);                 // p shell on
[4,5,6,7,8].forEach(i => gain[i] = 0.7);           // d shell on, slightly under
let animate = true, t = 0, tEvo = 0;

const sliders = [];
const wrap = document.getElementById('sliders');
[0,1,2,3].forEach(l => {
  const group = HARMONICS.map((h,i)=>({h,i})).filter(o=>o.h.l===l);
  const sec = document.createElement('div'); sec.className='shell';
  sec.innerHTML = `<div class="shell-h"><span>${['s shell','p shell','d shell','f shell'][l]}</span>
                   <em>l = ${l} &middot; ${group.length} state${group.length>1?'s':''}</em></div>`;
  group.forEach(({h,i}) => {
    const row = document.createElement('div'); row.className='row';
    row.innerHTML = `<label title="l=${h.l}, m=${h.m>=0?'+':''}${h.m}">${h.name}</label>
                     <span class="mark"><input type="range" min="0" max="1" step="0.01" value="${gain[i]}"><i class="livepip"></i></span>
                     <output>0.00</output>`;
    const inp = row.querySelector('input'), out = row.querySelector('output');
    const pip = row.querySelector('.livepip');
    inp.addEventListener('input', () => {
      gain[i] = parseFloat(inp.value);          // steer, never stop
      row.classList.toggle('hot', Math.abs(gain[i]) > 0.02);
    });
    inp.value = gain[i];
    row.classList.toggle('hot', Math.abs(gain[i])>0.02);
    sliders.push({inp,out,row,pip});
    sec.appendChild(row);
  });
  wrap.appendChild(sec);
});

function push(vals){                      // set GAINS; evolution keeps running
  vals.forEach((v,i)=>{
    gain[i]=v;
    sliders[i].inp.value=v;
    sliders[i].row.classList.toggle('hot', Math.abs(v)>0.02);
  });
}
const zeros = () => new Array(N).fill(0);
document.getElementById('bAnim').addEventListener('click', e => {
  animate = !animate; e.target.classList.toggle('on', animate);
});
document.getElementById('bRand').addEventListener('click', () => {
  const v = zeros();
  const k = 2 + Math.floor(Math.random()*4);
  for (let j=0;j<k;j++) v[(Math.random()*N)|0] = 0.35 + Math.random()*0.65;
  push(v);
});
document.getElementById('bClear').addEventListener('click', () => push(zeros()));
document.getElementById('bLogo').addEventListener('click', () => {
  const v=zeros(); v[2]=1; push(v);
});
const shellOnly = l => () => {
  const v=zeros();
  HARMONICS.forEach((h,i)=>{ if(h.l===l) v[i]=0.5+Math.random()*0.5; });
  push(v);
};
document.getElementById('bShell1').addEventListener('click', shellOnly(1));
document.getElementById('bShell2').addEventListener('click', shellOnly(2));
document.getElementById('bShell3').addEventListener('click', shellOnly(3));

const hud=document.getElementById('hud');
scene.registerBeforeRender(() => {
 try {
  const dt = engine.getDeltaTime()/1000; t += dt;

  if (finalMode) {
    tEvo += dt;
    driveFinal(dt);
    if ((t*3|0)!==(window.__h|0)){
      window.__h=t*3;
      hud.innerHTML = `${mesh.getTotalVertices().toLocaleString()} verts &middot; `+
        `${(mesh.getTotalIndices()/3).toLocaleString()} tris<br>`+
        `<b>${Math.round(engine.getFps())}</b> fps &middot; 1 draw call<br>`+
        /* Staleness must be visible at a glance: this is the file's own
           Last-Modified, so an old build can never masquerade as current. */
        `build <b>${document.lastModified.slice(-8)}</b>`;
      paintWanderLive();
    }
    return;
  }

  if (loopMode) {
    tEvo += dt;
    driveLoop(dt);
    if ((t*3|0)!==(window.__h|0)){
      window.__h=t*3;
      hud.innerHTML = `${mesh.getTotalVertices().toLocaleString()} verts &middot; `+
        `${(mesh.getTotalIndices()/3).toLocaleString()} tris<br>`+
        `<b>${Math.round(engine.getFps())}</b> fps &middot; 1 draw call<br>`+
        `build <b>${document.lastModified.slice(-8)}</b>`;
    }
    return;
  }

  /* Live coefficients: each state's own time evolution, scaled by the gain
     the user dialled in. Freezing (Evolve off) holds the phases still so a
     shape can be studied, but the gains behave identically either way. */
  if (animate) tEvo += dt;
  /* When frozen, the gains ARE the state: use them directly. Previously the
     per-harmonic envelope and phase kept multiplying in even with the
     evolution stopped, so p_x and p_y at an equal 0.71 still came out
     unequal and the axis refused to sit on the diagonal. That is why the
     lobes never lined up with the mark no matter what was tuned. */
  let c;
  if (animate) {
    tEvo += 0;
    c = HARMONICS.map((h,i) => {
      const env = 0.55 + 0.45*Math.cos(tEvo*(0.13 + i*0.017) + i*1.7);
      return gain[i] * env * Math.cos(E[i]*tEvo*0.5);
    });
  } else {
    c = gain.slice();
  }
  const nrm = Math.hypot(...c) || 1;
  c = c.map(v=>v/nrm);
  // report the LIVE contribution next to each slider without moving it
  c.forEach((v,i)=> {
    const s = sliders[i];
    s.out.textContent = v.toFixed(2);
    /* Same inset maths as the timing pips: the thumb is 12px, so the reachable
       track is inset 6px at each end. */
    const f = Math.max(0, Math.min(1, Math.abs(v)));
    s.pip.style.left = `calc(6px + ${f*100}% - ${f} * 12px)`;
    s.pip.classList.toggle('neg', v < -0.005);
    s.pip.classList.toggle('zero', Math.abs(v) < 0.005);
  });

  mat.setFloats('c', c);
  mat.setFloat('uScale', 2.5 * (logoMode ? cheat.size : 1));
  mat.setFloat('uFlat',  logoMode ? cheat.flat  : 0);
  mat.setFloat('uRot',   cheat.rot);
  mat.setFloat('uPlump', logoMode ? cheat.plump : 0);
  mat.setFloat('uGap',   logoMode ? cheat.gap   : 0);
  mat.setFloat('uEdge',  logoMode ? cheat.edge  : 0);
  mat.setFloat('uCorner',logoMode ? cheat.corner: 0);

  syncLogoLines();

  if (logoMode) {
    /* Square-on is the presentation view: it is what makes the mark read.
       But holding it every frame made the view impossible to DRAG, because
       the lock fought the pointer. So a drag releases it for inspection, and
       "Square on" re-engages and eases back. `face on` still sets how hard
       it returns. */
    if (!viewFree) {
      const f = cheat.face;
      camera.alpha = camera.alpha + (-Math.PI/2 - camera.alpha) * f * 0.18;
      camera.beta  = camera.beta  + ( Math.PI/2 - camera.beta ) * f * 0.18;
      camera.radius = camera.radius + (4.4 - camera.radius) * f * 0.12;
    }
    mesh.rotation.y = 0;
  } else {
    mesh.rotation.y = t*0.24;
  }

  if ((t*3|0)!==(window.__h|0)){
    window.__h=t*3;
    const live = HARMONICS.filter((h,i)=>Math.abs(c[i])>0.02).length;
    hud.innerHTML = `${mesh.getTotalVertices().toLocaleString()} verts &middot; `+
      `${(mesh.getTotalIndices()/3).toLocaleString()} tris<br>`+
      `<b>${live}</b> / ${N} harmonics active &middot; <b>${Math.round(engine.getFps())}</b> fps<br>`+
      `1 draw call &middot; 0 bytes uploaded per frame`;
  }
 } catch (err) {
   /* A throw inside a beforeRender observer silently aborts the whole
      dispatch, so the symptom is "nothing animates" with no console error at
      all. Surface it once instead of hunting it. */
   if (!window.__loopErr) {
     window.__loopErr = String((err && err.message) || err);
     console.error('[lab] render loop threw:', err);
   }
 }
});

/* =====================================================================
   LOGO CHEAT MODE
   The brandmark is a 2p orbital (measured: the real isosurface at 135
   degrees contains 96.5% of the mark), but not exactly one. The designers
   squared off the inner edges where the physics has a smooth pinch. These
   controls dial that licence in, so you can see precisely how much cheating
   the mark costs, and the real vectors are ghosted on top to judge against.
   ===================================================================== */
const cheat = { face:1, flat:0, rot:45, plump:0, gap:0, edge:0, corner:0, size:1, ghost:0.45, fill:0 };
let logoMode = false, logoPaths = null;

/* Free the view the moment the user grabs it, so inspection is possible, and
   show that the presentation view is no longer being held. */
let viewFree = false;
/* Declared here, far above the boot block: setMode() clears this timer, and
   the boot call setMode('final') ran before the old declaration executed,
   which was a dead-zone throw that killed the whole script mid-boot. */
let restoreTimer = null, toldAboutSquareOn = false;
/* Final mode has its own camera contract (freeCam / camHeld), and drawGhost
   refuses to draw while viewFree is set, so tripping it from tab 3 made the
   logo fill vanish after any click or wheel until a timer restored it. */
canvas.addEventListener('pointerdown', () => {
  if (logoMode && !finalMode) { viewFree = true; document.getElementById('bView').classList.add('on'); }
});
canvas.addEventListener('wheel', () => {
  if (logoMode && !finalMode) { viewFree = true; document.getElementById('bView').classList.add('on'); }
}, { passive:true });

fetch('assets/logomark.json').then(r=>r.json()).then(d=>{
  logoPaths=d; buildLogoLines(); drawGhost();
}).catch(()=>{});

/* The reference outline lives IN THE SCENE, not painted flat over it, so it
   orbits with the geometry. That is the point: to debug a 3D morph you have to
   be able to swing round and see where the surface leaves the mark's plane. A
   screen-space overlay can only ever check the one square-on view. */
let logoLines = null;
/* The 3D dashed vectors. ONE place decides their visibility, called from
   every drive path: they used to be updated only on explore and tab 2
   frames, so tabs 3 and 4 inherited whatever state was left behind, which
   is how ghost rings ended up floating over the loop with guides off.
   sw overrides the world scale so the vectors track a DRIVEN uScale. */
function syncLogoLines(sw){
  if (!logoLines) return;
  const ls = (0.4886 * (sw || 2.5 * cheat.size)) / 1.138;
  logoLines.forEach(l => {
    l.isVisible = (logoMode || finalMode || loopMode) && cheat.ghost > 0.01;
    l.scaling.setAll(ls);
    l.alpha = cheat.ghost;
  });
}
function buildLogoLines(){
  if (!logoPaths || logoLines) return;
  logoLines = [];
  logoPaths.forEach(pts => {
    const closed = pts.concat([pts[0]]);
    const l = BABYLON.MeshBuilder.CreateDashedLines('logoline', {
      points: closed.map(([x,y]) => new BABYLON.Vector3(x, y, 0)),
      dashSize: 3, gapSize: 2, dashNb: 240
    }, scene);
    l.color = BABYLON.Color3.FromHexString('#8c6bf7');
    l.isPickable = false;
    logoLines.push(l);
  });
}

const ghostCv = document.getElementById('ghost');
function drawGhost(){
  const w = ghostCv.clientWidth, ht = ghostCv.clientHeight;
  ghostCv.width = w; ghostCv.height = ht;
  const g = ghostCv.getContext('2d');
  g.clearRect(0,0,w,ht);
  // Once the view is free the flat overlay would lie, so it yields to the
  // 3D outline and stops drawing.
  if (!logoMode || !logoPaths || (viewFree && !finalMode && !loopMode)) return;
  if (cheat.ghost <= 0.01 && cheat.fill <= 0.005) return;
  /* The mark is drawn at the same on-screen size the orbital occupies. Two
     rules make that true EVERYWHERE, not just at the calibration pose:
     height only, because Babylon's FOV is vertical-fixed, so the 3D's
     projected size follows the stage height and never its width (min(w,h)
     broke on square and portrait stages, the wiki iframe among them); and
     4.4/radius, so wheel zoom scales the overlay exactly as it scales the
     object. At the tab 2 calibration pose (radius 4.4) this reduces to the
     measured 0.34 exactly. */
  /* min(w,h), matching fitFraming(): whichever axis limits the 3D also
     limits the overlay, so the two stay congruent at every window shape. */
  const s = Math.min(w, ht) * 0.34 * (4.4 / Math.max(0.1, camera.radius));
  g.save(); g.translate(w/2, ht/2);

  /* The teal fill is the logo moment: laid over the 3D at a partial alpha it
     flattens the shading just enough to read as the flat brandmark while the
     geometry still shows through underneath. Full opacity would just be the
     SVG and would throw away the object; zero leaves the 3D shading reading
     as an object rather than a mark. The right value is a judgement call,
     which is why it is a slider. */
  if (cheat.fill > 0.005) {
    g.globalAlpha = cheat.fill;
    g.fillStyle = '#00b8b0';
    logoPaths.forEach(pts=>{
      g.beginPath();
      pts.forEach(([x,y],i)=>{ const X=x*s, Y=-y*s; i?g.lineTo(X,Y):g.moveTo(X,Y); });
      g.closePath(); g.fill();
    });
  }

  g.globalAlpha = cheat.ghost;
  g.strokeStyle = '#8c6bf7'; g.lineWidth = 2; g.setLineDash([6,4]);
  logoPaths.forEach(pts=>{
    g.beginPath();
    pts.forEach(([x,y],i)=>{ const X=x*s, Y=-y*s; i?g.lineTo(X,Y):g.moveTo(X,Y); });
    g.closePath(); g.stroke();
  });
  g.restore();
}
window.addEventListener('resize', drawGhost);

/* Units, everywhere a value has one. Percent for every 0..1 opacity or
   strength, degrees for rotation, x for the size multiplier. The two overlay
   sliders also stop sharing a name: tab 3 owns "logo opacity" (the fade at
   the logo moment), tab 2's manual one is "overlay fill" for alignment work. */
function fmtCheat(k, v){
  if (k === 'rot')  return v.toFixed(0) + '\u00b0';
  if (k === 'size') return v.toFixed(2) + 'x';
  return Math.round(v * 100) + '%';
}
function bindCheat(id,outId,key,fmt){
  const el=document.getElementById(id), out=document.getElementById(outId);
  el.addEventListener('input',()=>{
    cheat[key]=parseFloat(el.value);
    out.textContent = fmt? fmt(cheat[key]) : fmtCheat(key, cheat[key]);
    // Anything the overlay canvas draws has to repaint here. Listing only
    // 'ghost' meant dragging the fill slider updated the value but never
    // redrew, so it did nothing, while the preset buttons worked because they
    // route through setCheat() which repaints.
    if(key==='ghost' || key==='fill') drawGhost();
    if(key==='rot')   orientP();      // rotation is a change of STATE, not just the clip
  });
}
bindCheat('cFace','oFace','face');
bindCheat('cFlat','oFlat','flat');
bindCheat('cRot','oRot','rot');
bindCheat('cPlump','oPlump','plump');
bindCheat('cGap','oGap','gap');
bindCheat('cCorner','oCorner','corner');
bindCheat('cEdge','oEdge','edge');
bindCheat('cSize','oSize','size');
bindCheat('cGhost','oGhost','ghost');
bindCheat('cFill','oFill','fill');

function setCheat(o){
  Object.assign(cheat,o);
  const map={face:['cFace','oFace'],flat:['cFlat','oFlat'],rot:['cRot','oRot'],
             plump:['cPlump','oPlump'],gap:['cGap','oGap'],edge:['cEdge','oEdge'],
             corner:['cCorner','oCorner'],
             size:['cSize','oSize'],
             ghost:['cGhost','oGhost'],fill:['cFill','oFill']};
  Object.entries(o).forEach(([k,v])=>{
    const [i,ou]=map[k]; document.getElementById(i).value=v;
    document.getElementById(ou).textContent = fmtCheat(k, v);
  });
  orientP(); drawGhost();
}
setCheat({...cheat});   // sync every output to the formatted units once at load
document.getElementById('bView').addEventListener('click',()=>{
  viewFree = false;                                  // re-engage the lock
  clearTimeout(restoreTimer);
  document.getElementById('bView').classList.remove('on');
});
document.getElementById('bPure').addEventListener('click',()=>
  setCheat({flat:0,plump:0,gap:0,edge:0,corner:0,size:1}));
/* Measured, not eyeballed. Sweeping every parameter against the pixel fit:
   aligned 2p alone gives 79.2% IoU; adding only the quadrant clip takes it to
   95.9% at 0.8% spill. plump and gap both make it WORSE (they push coverage to
   100% but spill to 17%+), so the honest optimum leaves them at zero. The
   entire licence in the Adom mark is one clip. */
document.getElementById('bMark').addEventListener('click',()=>
  setCheat({face:1,flat:1,rot:45,plump:0,gap:0,edge:1,corner:0,size:1.15}));

/* ---- mode switching ---- */
/* A p orbital pointing along an arbitrary direction is not "p_z rotated",
   it is a SUPERPOSITION of the three p states:
       p along (a,b,c)  =  a*p_x + b*p_y + c*p_z
   The mark's axis lies in the view plane on the diagonal, so the state that
   matches it is (p_x + p_y)/sqrt(2). Steering `rotate` therefore has to
   re-weight the p shell, not just spin the clip; rotating only the clip left
   the physics vertical while the mark sat diagonal, which pinned spill near
   50% no matter what else was tuned. */
function orientP(){
  if(!logoMode) return;
  const a = radiansOf(cheat.rot);
  gain[1] = Math.sin(a);      // p_y
  gain[2] = 0;                // p_z: out of the view plane
  gain[3] = Math.cos(a);      // p_x
  [1,2,3].forEach(i=>{
    sliders[i].inp.value = Math.abs(gain[i]).toFixed(2);   // slider is magnitude; sign is the state
    sliders[i].row.classList.toggle('hot', Math.abs(gain[i])>0.02);
  });
}
const radiansOf = d => d*Math.PI/180;

/* Each tab is an independent workspace: leaving a tab saves its variables,
   returning restores them. And because dev reloads are constant, the whole
   bundle also persists to localStorage so a reload keeps every knob where you
   left it. (In the wiki sandbox localStorage throws, so there this quietly
   degrades to per-session state.) */
const TAB_STATE = { explore:null, logo:null, final:null };
let currentTab = 'explore', __savedTab = null;
const snapCam = () => ({a:camera.alpha,b:camera.beta,r:camera.radius,
                        tx:camera.target.x,ty:camera.target.y,tz:camera.target.z});
const restCam = s => { if(!s) return; camera.alpha=s.a; camera.beta=s.b; camera.radius=s.r;
                       camera.target.set(s.tx,s.ty,s.tz); };
function saveTab(){
  if (currentTab === 'explore')   TAB_STATE.explore = { gain:[...gain], animate, cam:snapCam() };
  else if (currentTab === 'logo') TAB_STATE.logo    = { cheat:{...cheat}, viewFree, gain:[...gain], cam:snapCam() };
  else if (currentTab === 'loop') TAB_STATE.loop    = { loopT, cam:snapCam() };
  else                            TAB_STATE.final   = { finalT, cam:snapCam() };
  persistLab();
}

const LAB_KEY = 'orbital-lab-state-v1';
let lsOK = true; try { localStorage.getItem(LAB_KEY); } catch(e){ lsOK = false; }
function persistLab(){
  if (!lsOK) return;
  try {
    localStorage.setItem(LAB_KEY, JSON.stringify({
      tabs: TAB_STATE, currentTab,
      final: { FINAL: {...FINAL}, artLeadIn, artTailOut, timeEase, camGrab, finalFill,
               wanderMode, wanderAmt, excursionT, wanderOn, spinOn, camMotion, freeCam, finalGuides },
      loop: { pt:l_pt, dt:l_dt, ct:l_ct, ov:l_ctOverlay, st:l_st,
              lo:l_logoOn, lp:l_logoPeak, ll:l_logoLead, tl:l_tealLead, gd:l_guides },
      gain: [...gain], animate
    }));
  } catch(e){}
}
let persistTimer = null;
function schedulePersist(){ clearTimeout(persistTimer); persistTimer = setTimeout(persistLab, 400); }
function restoreLab(){
  if (!lsOK) return;
  let s = null; try { s = JSON.parse(localStorage.getItem(LAB_KEY) || 'null'); } catch(e){}
  if (!s) return;
  Object.assign(TAB_STATE, s.tabs || {});
  __savedTab = s.currentTab || null;
  const f = s.final;
  if (f) {
    Object.assign(FINAL, f.FINAL || {});
    if (f.artLeadIn  !== undefined) artLeadIn  = f.artLeadIn;
    if (f.artTailOut !== undefined) artTailOut = f.artTailOut;
    if (f.timeEase   !== undefined) timeEase   = f.timeEase;
    if (f.camGrab    !== undefined) camGrab    = f.camGrab;
    if (f.finalFill  !== undefined) finalFill  = f.finalFill;
    wanderMode = !!f.wanderMode;
    if (f.wanderAmt  !== undefined) wanderAmt  = f.wanderAmt;
    if (f.excursionT !== undefined) excursionT = f.excursionT;
    if (Array.isArray(f.wanderOn) && f.wanderOn.length === N) wanderOn = f.wanderOn;
    spinOn = f.spinOn !== false; camMotion = f.camMotion !== false;
    freeCam = !!f.freeCam; finalGuides = !!f.finalGuides;
    document.getElementById('fCam').disabled = freeCam;
    if (freeCam) camMotion = false;
  }
  const lo4 = s.loop;
  if (lo4) {
    [['l_pt',lo4.pt],['l_dt',lo4.dt],['l_ct',lo4.ct],['l_st',lo4.st],['l_logoPeak',lo4.lp],
     ['l_logoLead', lo4.ll !== undefined ? -Math.abs(lo4.ll) : undefined],
     ['l_tealLead', lo4.tl !== undefined ? -Math.abs(lo4.tl) : undefined]].forEach(([id,v])=>{
      const el = document.getElementById(id);
      if (el && v !== undefined) { el.value = v; el.dispatchEvent(new Event('input')); }
    });
    const cm = document.getElementById('l_ctMode'), lg = document.getElementById('l_logoOn');
    if (cm && lo4.ov !== undefined) { cm.checked = !!lo4.ov; cm.dispatchEvent(new Event('change')); }
    if (lg && lo4.lo !== undefined) { lg.checked = !!lo4.lo; lg.dispatchEvent(new Event('change')); }
    const gd4 = document.getElementById('l_guides');
    if (gd4 && lo4.gd !== undefined) { gd4.checked = !!lo4.gd; gd4.dispatchEvent(new Event('change')); }
  }
  if (Array.isArray(s.gain) && s.gain.length === N) push(s.gain);
  if (typeof s.animate === 'boolean') animate = s.animate;
  /* Push the restored numbers back into the tab 3 controls: setting value and
     dispatching input runs each handler, so outputs, pips and readouts all
     agree without duplicating their logic here. */
  [['fMorphIn',FINAL.morphIn],['fMorphOut',FINAL.morphOut],['fHold',FINAL.settle],
   ['fFill',finalFill],['fArtOn',artLeadIn],['fArtOff',artTailOut],['fEase',timeEase],
   ['fGrab',camGrab],['fExc',excursionT],['fAmt',wanderAmt]].forEach(([id,v])=>{
    const el = document.getElementById(id);
    if (el && v !== undefined) { el.value = v; el.dispatchEvent(new Event('input')); }
  });
}

function setMode(which){
  // which: false = explore, true = logo cheat, 'final' = the finished anim
  const isFinal = (which === 'final');
  const isLoop  = (which === 'loop');
  const logo = (which === true);
  saveTab();                          // the outgoing tab keeps its variables
  if (!logo) { cancelDemo(); clearTimeout(restoreTimer); }

  finalMode = isFinal;
  loopMode  = isLoop;
  logoMode  = logo || isFinal || isLoop;   // final and loop both use the cheat pipeline

  document.getElementById('mExplore').classList.toggle('on', which === false);
  document.getElementById('mLogo').classList.toggle('on', logo);
  document.getElementById('mFinal').classList.toggle('on', isFinal);
  document.getElementById('mLoop').classList.toggle('on', isLoop);
  document.getElementById('sliders').style.display = (which === false) ? 'block' : 'none';

  /* The Evolve / Randomise / shell buttons drive the EXPLORE state only. On the
     logo tab they would stomp the state being aligned, and on the final tab the
     animation overwrites them every frame, so they read as broken. Show them
     only where they do something. */
  document.getElementById('exploreActions').style.display = (which === false) ? 'flex' : 'none';
  const intro = document.getElementById('intro');
  if (intro) intro.innerHTML =
    (which === false)
      ? 'Every real spherical harmonic through <b>l = 3</b>. The superposition keeps '
        + 'evolving; each slider is how much that state is allowed to join in. Numbers '
        + 'on the right are the live coefficient right now.'
      : isLoop
        ? 'The final cut. A clean teal open, then forever: <b>pt</b> play, '
          + '<b>dt</b> dinner call, <b>ct</b> cheat, <b>st</b> sit as the mark, '
          + 'then back out, with a fresh roll every pass. Hover any control, '
          + 'every one explains itself.'
      : isFinal
        ? 'The scratchpad: the earlier cut, kept for experiments. Wander, cam grab '
          + 'and the timing panel live here; the polished version is the Final tab. '
          + 'Two lights still tell the truth: <b>steering</b> changes which state '
          + 'we are in, <b>sculpting</b> moves the points themselves.'
        : 'Aligning the brandmark against the real wavefunction. Dial the cheat, '
          + 'compare against the vectors, and <b>Measure fit</b> for the actual numbers.';
  document.getElementById('cheat').style.display   = logo ? 'block' : 'none';
  document.getElementById('final').style.display   = isFinal ? 'block' : 'none';
  document.getElementById('loop').style.display    = isLoop ? 'block' : 'none';

  const lb = document.getElementById('ledBadge');
  if (lb) lb.style.display = (isFinal || isLoop) ? 'flex' : 'none';

  if (isFinal) {
    if (!TAB_STATE.final) finalT = 0;
    animate = false;                  // the final loop drives the state itself
    viewFree = false;
    cheat.ghost = 0; cheat.fill = 0;
  } else if (!isLoop) {
    mat.setColor3('cNeg', C_NEG);     // tabs 1 and 2 always show both phases honestly
    mesh.setEnabled(true);            // in case the loop intro had it hidden
  }
  if (isLoop) {
    animate = false; viewFree = false;
    cheat.ghost = 0; cheat.fill = 0;
    if (!TAB_STATE.loop) startIntro();
  }
  if (logo && !isFinal && !TAB_STATE.logo){
    push(zeros());
    animate=false; document.getElementById('bAnim').classList.remove('on');
    orientP();
    viewFree=false; document.getElementById('bView').classList.remove('on');
    if (!isFinal) runGuidedDemo();
  }
  /* The arriving tab gets its own variables back. */
  const key = isLoop ? 'loop' : isFinal ? 'final' : logo ? 'logo' : 'explore';
  const saved = TAB_STATE[key];
  if (saved) {
    if (key === 'explore')   { push(saved.gain); animate = saved.animate; restCam(saved.cam); }
    else if (key === 'logo') { setCheat({...saved.cheat}); viewFree = saved.viewFree;
                               push(saved.gain); restCam(saved.cam); }
    else if (key === 'loop') { loopT = saved.loopT; inIntro = false; restCam(saved.cam); }
    else                     { finalT = saved.finalT; restCam(saved.cam); }
  }
  if (isFinal && wanderMode && !excursion) rollExcursion();
  currentTab = key;
  if (typeof syncToggles === 'function') syncToggles();
  drawGhost();
}
document.getElementById('mExplore').addEventListener('click',()=>setMode(false));
document.getElementById('mLogo').addEventListener('click',()=>setMode(true));

/* ---------- Guided demo ----------
   Someone landing on this tab has no way to know that "Full brandmark" is the
   payoff, so run it for them: pause three seconds on the untouched physics so
   they see the starting point, announce what is about to happen, then apply
   the cheat and measure it. Cancelled the moment they touch a control of
   their own, because a demo that fights the user is worse than no demo. */
const toastWrap = document.getElementById('toasts');
function toast(msg, sub, opts){
  opts = opts || {};
  const el = document.createElement('div');
  el.className = 'toast';
  el.innerHTML = (opts.spinner ? '<div class="t-spin"></div>' : '') +
    `<span>${msg}${sub ? `<br><span class="t-sub">${sub}</span>` : ''}</span>`;
  toastWrap.appendChild(el);
  while (toastWrap.children.length > 2) toastWrap.firstChild.remove();
  void el.offsetWidth; el.classList.add('show');
  let timer = setTimeout(dismiss, opts.duration || 4200);
  function dismiss(){ el.classList.remove('show'); setTimeout(()=>el.remove(), 260); }
  el.addEventListener('mouseenter', ()=>clearTimeout(timer));       // pause on hover
  el.addEventListener('mouseleave', ()=>{ timer = setTimeout(dismiss, 1200); });
  return { dismiss };
}

/* Every button reports what it just did. Wired at the DOCUMENT level so it
   fires AFTER the button's own handler has run, which lets a toggle name the
   state it landed in rather than the one it left. Only trusted events toast,
   so the guided demo's synthetic clicks stay quiet. */
const SAY = {
  mExplore: () => ['Explore', 'All 16 real spherical harmonics, l=0 to 3, as live gains'],
  mLogo:    () => ['Logo cheat', 'Align the orbital to the real logo vectors and measure the fit'],
  mFinal:   () => ['Scratch', 'The earlier cut, kept as a scratchpad for experiments'],

  bAnim:    () => animate
              ? ['Evolve on',  'Coefficients advancing by cos(E_n t), the real time-dependent phase']
              : ['Evolve off', 'Frozen. The gains you set are now the state exactly'],
  bRand:    () => ['Randomised', `${gain.filter(v=>Math.abs(v)>0.02).length} of 16 harmonics given a random gain`],
  bClear:   () => ['Cleared', 'All 16 gains to zero, so psi is zero and there is nothing to draw'],
  bLogo:    () => ['Set to p_z', 'The logo shape, but along z. The logo mixes p_x and p_y to swing it onto the diagonal'],
  bShell1:  () => ['p shell only', 'The three l=1 harmonics given random gains, everything else zeroed'],
  bShell2:  () => ['d shell only', 'The five l=2 harmonics given random gains, everything else zeroed'],
  bShell3:  () => ['f shell only', 'The seven l=3 harmonics given random gains, everything else zeroed'],

  bMark:    () => ['Full brandmark cheat', 'Quadrant clip, edge correction, 45 degree rotation, 1.15 scale'],
  bPure:    () => ['Cheats off', 'The honest 2p orbital, nothing done to it'],
  bMeasure: () => ['Measuring fit', 'Reading pixels back off the GPU to compute the real overlap'],
  bView:    () => ['Square on', 'Camera lock re-engaged'],
  bCopy:    () => ['Copied', 'The equation and fit numbers are on the clipboard'],

  fCam:     () => camMotion ? ['Camera motion on', 'The viewpoint drifts. Framing, not the equation']
                            : ['Camera motion off', 'Viewpoint held still'],
  fSpin:    () => spinOn ? ['Spin on', 'The object rotates. My choice, not the equation: a real superposition does not spin']
                         : ['Spin off', 'The true picture: the surface breathing in place'],
  fFree:    () => freeCam ? ['Free rotate on', 'Drag to orbit. The camera is only taken near the epoch; the cam grab slider says when']
                          : ['Free rotate off', 'The scripted camera drift is back'],
  fGuide:   () => finalGuides ? ['Alignment guides on', 'Dashed logo vectors drawn over the animation']
                              : ['Alignment guides off', 'Guides hidden'],
  fRestart: () => ['Restarted', 'Every clock back to zero: timeline, evolution and spin'],
  fWander:  () => wanderMode
              ? ['Wander on', `A fresh random state every loop, ${wanderOn.filter(Boolean).length} of 16 harmonics in the pool`]
              : ['Wander off', 'Back to one fixed animation, identical every loop'],
  fRoll:    () => ['Rolled a new state', `${excursion ? excursion.filter(v=>Math.abs(v)>0.02).length : 0} harmonics active in the next excursion`],
  fWanderAll:() => ['All 16 in the pool', 'Every harmonic can appear in a roll'],
  fWanderNoF:() => ['f shell excluded', 'The nine remaining harmonics stay in the pool'],
  fWanderP: () => ['p shell only', 'Rolls stay in the shell the logo itself lives in, so they never stray far'],
  lSnapSave:() => lsOK ? ['Snapshot saved', (loadSnaps().slice(-1)[0]||{}).name || 'stored']
                       : ['No storage on this surface', 'The wiki sandbox cannot save. Factory snapshots still apply; tune and save on the lab page.'],
  lSnapDel: () => ['Snapshot deleted', `${allSnaps().length} left · factory ones (*) ship with the page`],
  mLoop:    () => ['Final', 'The final cut: teal intro, then play, dinner, cheat, sit, and back out, fresh roll every pass'],
  lRestart: () => ['Intro restarted', 'From the teal open'],
  lDefaults:() => ['Loop defaults restored', 'john fav: pt 5s, dt 0.7s, ct 0.2s overlay, 1s sit, logo 100% on a 0.6s lead, teal lead 0.2s'],
  fDefaults:() => ['Defaults restored', 'Morph 2.6s each way, lead in and tail out 0.6s, ease 0.35, hold 1.0s, fill 50%'],
};
document.addEventListener('click', e => {
  const b = e.target.closest && e.target.closest('button');
  if (!b || b.disabled || !e.isTrusted) return;
  let msg, sub;
  const entry = SAY[b.id];
  if (entry) { const r = entry(); msg = r[0]; sub = r[1]; }
  else { msg = b.textContent.trim(); sub = b.title || ''; }   // fill pills and anything unlisted
  toast(msg, sub, { duration: 2600 });
});

let demoTimers = [];
function cancelDemo(){ demoTimers.forEach(clearTimeout); demoTimers = []; }

function runGuidedDemo(){
  cancelDemo();
  demoTimers.push(setTimeout(() => {
    const t = toast('Running Full Brandmark',
                    'Morphing the 2p orbital onto the real logo vectors',
                    { spinner:true, duration:3200 });
    demoTimers.push(setTimeout(() => {
      document.getElementById('bMark').click();
      demoTimers.push(setTimeout(() => {
        document.getElementById('bMeasure').click();
        demoTimers.push(setTimeout(() => {
          const fit = document.getElementById('fit').innerText.split('\n')[0];
          toast('That is the Adom logomark', fit + ' against the real vectors',
                { duration:5200 });
        }, 700));
      }, 900));
    }, 700));
  }, 3000));
}

// any deliberate input cancels the demo
['cFace','cFlat','cRot','cPlump','cGap','cCorner','cEdge','cSize','cGhost']
  .forEach(id => {
    const el = document.getElementById(id);
    if (el) el.addEventListener('input', cancelDemo);
  });
['bMark','bPure','bMeasure','bView'].forEach(id => {
  const el = document.getElementById(id);
  if (el) el.addEventListener('click', cancelDemo);
});
canvas.addEventListener('pointerdown', cancelDemo);

// quick presets for judging the logo-moment fade
[['bFill25',0.25],['bFill40',0.40],['bFill50',0.50],['bFill65',0.65],['bFill80',0.80]]
  .forEach(([id,v]) => {
    const b = document.getElementById(id);
    if (b) b.addEventListener('click', () => { cancelDemo(); setCheat({ fill:v }); });
  });

/* =====================================================================
   FINAL ADOM SCHRODINGER ANIM
   One loop, four beats:
     evolve   the superposition wanders the whole harmonic family
     converge it collapses onto (p_x + p_y)/sqrt2, the cheat ramps in, and
              the camera eases to square on
     hold     dead still, teal fill up, the mark reads
     release  fill and cheat drop away, the physics resumes
   Camera motion is a toggle because the drift sells the 3D but makes the
   logo beat land differently; with it off, the convergence is purely the
   shape changing.
   ===================================================================== */
/* The timeline, as separate beats rather than one blended number.

   The first version drove the state, the cheat, the camera AND the logo fill
   off a single k, so everything happened at once over 2.2s and it read as a
   cut. Worse, the fill was already at 65% the moment the hold began, so there
   was no fade in or out at all: the beat had no shape.

   Each stage below owns one job:
     evolve    the Explore animation, untouched
     morphIn   points slide from the evolving state onto the brandmark
     settle    fully arrived, dead still, no logo yet
     fadeIn    the teal svg comes up over the geometry
     logoHold  the mark, held
     fadeOut   the svg leaves
     morphOut  points slide back to the physics
   then it loops. */
const FINAL = {
  evolve:   7.0,
  morphIn:  2.6,
  settle:   1.0,     // the pause you asked for, before any logo appears
  fadeIn:   0.5,
  logoHold: 0.8,
  fadeOut:  0.5,
  morphOut: 2.6
};
const FINAL_ORDER = ['evolve','morphIn','settle','fadeIn','logoHold','fadeOut','morphOut'];
const smooth = x => { const c = Math.max(0, Math.min(1, x)); return c*c*(3-2*c); };
let finalMode = false, camMotion = true, finalT = 0, finalFill = 0.50;
/* Spin state. Kept as a WRAPPED angle plus a latched target, for the reason
   spelled out at the assignment below. */
let spin = 0, spinTarget = 0, spinLatched = false, spinOn = true;
/* Free-rotate state. userA/B/R hold whatever pose the pointer left the camera
   in, so the handover has something to ease FROM. */
let freeCam = false, camHeld = false;
let userA = -Math.PI/2, userB = Math.PI/2, userR = 4.4;
let finalGuides = false;   // dashed vectors during the anim, for checking the match
/* The artistic window. The clip and edge correction are built for a 2p shape,
   so applying them while the state is still a multi-harmonic blob stretches it:
   that is the artefact. artOn holds the cheat back until the state has actually
   arrived, artOff drops it before the state leaves again. Independent, because
   the two directions do not need the same threshold. */
let artLeadIn = 1.0, artTailOut = 1.0, timeEase = 0;   // seconds around the epoch
let camGrab = 1.5;   // seconds before the epoch the camera is taken, and after it that it returns

/* WANDER. The epoch is the fixed anchor; everything between one epoch and the
   next is disposable, so it may as well be different every time. Each loop
   rolls a fresh random state, eases out to it on the way from the logo, and
   eases back on the way to the next one. With sixteen harmonics in play the
   sequence never repeats. Any harmonic can be excluded from the roll, because
   some shapes fight the clip and you need to be able to see which. */
let wanderMode = false, wanderAmt = 0.75, excursionT = 3.0;
let wanderOn = HARMONICS.map(() => true);
let excursion = null, lastBeat = '', liveC = null, wanderRows = null;
const LOGO_BASE = (() => { const v = new Array(N).fill(0); v[1] = 0.7071; v[3] = 0.7071; return v; })();

function buildWanderList(){
  const host = document.getElementById('wanderList');
  if (!host) return;
  host.innerHTML = '';
  wanderRows = HARMONICS.map((hm,i) => {
    const row = document.createElement('div');
    row.className = 'wrow' + (wanderOn[i] ? '' : ' off');
    row.innerHTML = `<input type="checkbox"${wanderOn[i] ? ' checked' : ''}>` +
                    `<span>${hm.name}</span><span class="wbar"><i></i></span><b>0.00</b>`;
    row.title = `Keep ${hm.name} in the dice pool. Unticked it can never be drawn again until re-ticked. The bar is the next roll's target for it (blue means negative phase), the number is its live coefficient right now.`;
    const cb = row.querySelector('input');
    cb.addEventListener('change', () => {
      wanderOn[i] = cb.checked;
      row.classList.toggle('off', !cb.checked);
      describeWander();
      toast(`${hm.name} ${cb.checked ? 'back in the pool' : 'excluded from the roll'}`,
            cb.checked ? 'It can appear in future random states'
                       : 'Every future roll leaves this harmonic at zero',
            { duration: 2600 });
    });
    host.appendChild(row);
    return { row, bar: row.querySelector('.wbar i'), val: row.querySelector('b'), cb };
  });
}

function paintWander(){
  if (!wanderRows) return;
  wanderRows.forEach((r,i) => {
    const v = excursion ? excursion[i] : 0;
    const w = Math.min(50, Math.abs(v) * 50);
    r.bar.style.width = w + '%';
    r.bar.style.left  = v >= 0 ? '50%' : (50 - w) + '%';
    r.bar.classList.toggle('neg', v < 0);
    r.row.classList.toggle('hot', Math.abs(v) > 0.02);
  });
  describeWander();
}

function paintWanderLive(){
  if (!wanderRows || !liveC) return;
  wanderRows.forEach((r,i) => { r.val.textContent = liveC[i].toFixed(2); });
}

function describeWander(){
  const el = document.getElementById('wanderInfo');
  if (!el) return;
  const n = wanderOn.filter(Boolean).length;
  if (!wanderMode) {
    el.innerHTML = `<span style="color:var(--text-3)">off: one fixed animation, ` +
                   `identical every loop</span>`;
    return;
  }
  el.innerHTML =
    `logo lands every <b>${finalCycle().toFixed(1)}s</b> &middot; ` +
    `<b>${n}</b> of 16 harmonics in the pool` +
    `<br><span style="color:var(--text-3)">` +
    (n === 0 ? 'nothing left to roll, the loop will sit on the logo'
     : n < 4 ? 'small pool, states will start to feel familiar'
     : 'a fresh state rolled every time the loop leaves the epoch') + `</span>`;
}

function syncMorphInputs(){
  const set = (id,out,v) => {
    const a = document.getElementById(id), b = document.getElementById(out);
    if (a) a.value = v;
    if (b) b.textContent = v.toFixed(1) + 's';
  };
  set('fMorphIn','oMorphIn',FINAL.morphIn);
  set('fMorphOut','oMorphOut',FINAL.morphOut);
  placePips(); describeEase(); describeWindow(); describeWander();
}

/* Wander retimes the loop: there is no long free-running beat any more, just
   out to the rolled state and straight back, so `excursion` IS the gap between
   one logo and the next. */
function applyExcursionTiming(){
  FINAL.evolve = 0;
  FINAL.morphIn = FINAL.morphOut = Math.max(0.8, excursionT / 2);
  syncMorphInputs();
}

function rollExcursion(){
  const pool = HARMONICS.map((hm,i) => i).filter(i => wanderOn[i]);
  const v = new Array(N).fill(0);
  if (pool.length) {
    const k = Math.min(pool.length, 2 + Math.floor(Math.random()*4));
    const picked = pool.slice();
    for (let j = 0; j < k; j++) {
      const idx = picked.splice((Math.random()*picked.length)|0, 1)[0];
      v[idx] = (Math.random() < 0.5 ? -1 : 1) * (0.35 + Math.random()*0.65);
    }
  }
  /* Blended against the logo's own 2p, so `amount` is literally how far the
     loop is allowed to stray from the mark rather than an abstract knob. */
  const mix = v.map((x,i) => LOGO_BASE[i]*(1-wanderAmt) + x*wanderAmt);
  const n = Math.hypot(...mix) || 1;
  excursion = mix.map(x => x/n);
  paintWander();
}

function finalCycle(){ return FINAL_ORDER.reduce((a,k)=>a+FINAL[k],0); }

/* The EPOCH: the instant the shape has fully reached the logomark, and the
   instant it starts leaving again. Everything artistic is expressed relative
   to these two marks, because that is how the animation reads when you watch
   it: "a beat before the logo" and "a beat after". */
function epochStart(){ return FINAL.evolve + FINAL.morphIn; }
function epochEnd(){ return epochStart() + FINAL.settle + FINAL.fadeIn + FINAL.logoHold + FINAL.fadeOut; }

function finalPhase(u){
  let acc = 0;
  for (const name of FINAL_ORDER) {
    if (u < acc + FINAL[name]) return { name, local:(u-acc)/FINAL[name] };
    acc += FINAL[name];
  }
  return { name:'morphOut', local:1 };
}

/* Tab 3 live pips: the same visual language as the Explore sliders. The
   handle is the SETTING; the dot is where that quantity actually is right
   now, in the slider's own units. */
let tab3Pips = null;
function paintTab3Pips(vals){
  if (!tab3Pips) tab3Pips = [
    'fHold','fFill','fMorphIn','fMorphOut','fArtOn','fArtOff','fEase','fExc','fAmt','fGrab'
  ].map(iid => {
    const inp = document.getElementById(iid);
    const pip = document.getElementById('lp' + iid.slice(1));
    return inp && pip ? { key:iid, pip, lo:parseFloat(inp.min), hi:parseFloat(inp.max) } : null;
  }).filter(Boolean);
  tab3Pips.forEach(s => {
    const f = Math.max(0, Math.min(1, ((vals[s.key] || 0) - s.lo) / (s.hi - s.lo)));
    s.pip.style.left = `calc(6px + ${f*100}% - ${f} * 12px)`;
    s.pip.classList.toggle('zero', f <= 0.001);
  });
}

function driveFinal(dt){
  /* Time easing: the clock slows as the morph approaches the brandmark and
     again as it leaves, so the interesting part is not rushed while the long
     evolve beat still plays at full speed. Applied to the CLOCK, so every
     downstream value inherits it. */
  {
    const probe = finalPhase(finalT);
    let speed = 1;
    if (probe.name === 'morphIn')  speed = 1 - timeEase * smooth(probe.local);
    if (probe.name === 'morphOut') speed = 1 - timeEase * (1 - smooth(probe.local));
    finalT = (finalT + dt * Math.max(0.08, speed)) % finalCycle();
  }
  const { name, local } = finalPhase(finalT);

  /* SCULPTING first, because it outranks everything. (It was also being read
     one line before a declaration further down, a dead-zone throw the render
     loop's catch swallowed silently, which froze this whole tab.)
     The windows are clamped to the morph beats: sculpting must never land on
     a state steering has not finished preparing, so a lead in longer than the
     morph is capped rather than allowed to chew on raw physics. */
  const eS = epochStart(), eE = epochEnd();
  const effLead = Math.max(0, Math.min(artLeadIn,  FINAL.morphIn  - 0.2));
  const effTail = Math.max(0, Math.min(artTailOut, FINAL.morphOut - 0.2));
  let cheatK;
  if (finalT >= eS && finalT <= eE) cheatK = 1;
  else if (finalT < eS) cheatK = effLead <= 0.001 ? 0 : smooth(1 - (eS - finalT) / effLead);
  else                  cheatK = effTail <= 0.001 ? 0 : smooth(1 - (finalT - eE) / effTail);
  cheatK = Math.max(0, Math.min(1, cheatK));

  /* The camera has its OWN window now, decoupled from the morph. It used to
     ride k, which took the camera at the first instant of the morph and gave
     it back at the very last, an eternity on a long excursion. camGrab is the
     only thing that decides it: taken that many seconds before the epoch,
     eased home to arrive exactly on it, returned that many seconds after.
     Capped so it always hands back before the loop wraps. */
  const effGrab = Math.min(Math.max(0.3, camGrab), Math.max(0.3, (finalCycle() - eE) * 0.95));
  let camK;
  if (finalT >= eS && finalT <= eE) camK = 1;
  else if (finalT < eS) camK = (eS - finalT) >= effGrab ? 0 : smooth(1 - (eS - finalT) / effGrab);
  else                  camK = (finalT - eE) >= effGrab ? 0 : smooth(1 - (finalT - eE) / effGrab);

  /* STEERING yields to sculpting: it finishes EARLY, completing exactly as the
     clip begins, so the whole lead in window is spent holding the finished 2p
     still while the camera, target and spin ease home for the epoch. Coming
     out it stays parked until the clip has fully released, then unmorphs over
     what is left of the beat. Both joins are continuous, so no pops. */
  let k = 0, fill = 0;
  switch (name) {
    case 'evolve':   k = 0; fill = 0; break;
    case 'morphIn': {
      const el = local * FINAL.morphIn;
      k = smooth(Math.min(1, el / Math.max(0.2, FINAL.morphIn - effLead)));
      fill = 0; break;
    }
    case 'settle':   k = 1; fill = 0;                 break;
    case 'fadeIn':   k = 1; fill = smooth(local);     break;
    case 'logoHold': k = 1; fill = 1;                 break;
    case 'fadeOut':  k = 1; fill = 1 - smooth(local); break;
    case 'morphOut': {
      const el = local * FINAL.morphOut;
      k = 1 - smooth(Math.max(0, (el - effTail) / Math.max(0.2, FINAL.morphOut - effTail)));
      fill = 0; break;
    }
  }

  /* A new random state is rolled the moment the loop LEAVES the epoch, so the
     whole outward journey and the whole way back aim at the same fresh target
     and the return lands cleanly on the mark. */
  if (name !== lastBeat) {
    if (wanderMode && (name === 'morphOut' || !excursion)) rollExcursion();
    lastBeat = name;
  }

  /* The state away from the epoch. The rolled excursion is a GAIN SET, not a
     frozen pose: each rolled harmonic keeps the live Explore-style evolution
     (envelope times the real energy phase), so the away leg shimmers exactly
     like tab 1 no matter how long the excursion is. It still lands perfectly,
     because k hands the blend fully to LOGO_BASE at the epoch. */
  const liveEvo = gains => HARMONICS.map((hm,i) => {
    const env = 0.55 + 0.45*Math.cos(tEvo*(0.13 + i*0.017) + i*1.7);
    return gains[i] * env * Math.cos(E_LEVELS[i]*tEvo*0.5);
  });
  const evo = liveEvo(wanderMode && excursion ? excursion : gainFinalBase);
  const c = evo.map((v,i) => v*(1-k) + LOGO_BASE[i]*k);
  const nrm = Math.hypot(...c) || 1;
  const cN = c.map(v=>v/nrm);
  mat.setFloats('c', cN);
  liveC = cN;

  mat.setFloat('uScale', 2.5 * (0.9 + 0.25*k));
  mat.setFloat('uFlat',  cheatK);
  mat.setFloat('uEdge',  cheatK);
  mat.setFloat('uRot',   45);
  mat.setFloat('uPlump', 0);
  mat.setFloat('uGap',   0);
  mat.setFloat('uCorner',0);
  /* Sculpting also unifies the phase colours. The negative lobe fades to the
     positive teal exactly as the clip lands and back out as it releases, so
     by the time the flat logo fades over the 3D the whole mark already reads
     as ONE colour. Driven by cheatK, so it needs no timing of its own. */
  mat.setColor3('cNeg', BABYLON.Color3.Lerp(C_NEG, C_POS, cheatK));

  // Camera: drifting while the physics runs, square on for the mark.
  /* Camera, two regimes.
     Scripted (default): a drift I choose, blended to square-on by k.
     Free rotate: the pointer owns the camera while the physics runs, but the
     logomark only reads from ONE angle, so the camera has to be taken back
     before the epoch. Input is detached the instant the morph starts and
     whatever pose the user left is eased to the epoch pose by k, arriving
     exactly as the mark does. The alpha delta is wrapped to the shortest way
     round: a user who has orbited a few times would otherwise get the same
     runaway sweep the object spin used to have. */
  const EPOCH_A = -Math.PI/2, EPOCH_B = Math.PI/2, EPOCH_R = 4.4;
  if (freeCam) {
    if (camK <= 0.001) {
      if (camHeld) { if (camLive) camera.attachControl(canvas, false); camHeld = false; }
      userA = camera.alpha; userB = camera.beta; userR = camera.radius;
    } else {
      if (!camHeld) { camera.detachControl(); camHeld = true; }
      const raw = EPOCH_A - userA;
      const dA  = Math.atan2(Math.sin(raw), Math.cos(raw));   // shortest way round
      camera.alpha  = userA + dA * camK;
      camera.beta   = userB + (EPOCH_B - userB) * camK;
      camera.radius = userR + (EPOCH_R - userR) * camK;
    }
  } else {
    if (camHeld) { if (camLive) camera.attachControl(canvas, false); camHeld = false; }
    const driftA = -Math.PI/2 + (camMotion ? Math.sin(tEvo*0.19)*0.55 : 0);
    const driftB =  Math.PI/2 + (camMotion ? Math.sin(tEvo*0.13)*0.30 : 0);
    camera.alpha = driftA*(1-k) + EPOCH_A*k;
    camera.beta  = driftB*(1-k) + EPOCH_B*k;
    camera.radius += (EPOCH_R - camera.radius) * 0.06;
  }

  /* At the exact epoch moment (k=1 and the logo is visible), the pose must be
     canonical: dead centre, head on, everything right. Once we leave the epoch,
     release control back to the user/animator. */
  const AT_EPOCH = k > 0.99 && cheatK > 0.99;
  if (AT_EPOCH) {
    camera.target.set(0,0,0);
    spin = 0; spinTarget = 0; spinLatched = false;
  } else {
    const pull = freeCam ? (camK <= 0.001 ? 0 : camK) : Math.min(1, 0.05 + k * 0.95);
    camera.target.scaleInPlace(1 - pull);
  }
  /* This used to be `tEvo*0.10*(1-k)`: an absolute, ever-growing angle scaled
     by (1-k). So every morph had to sweep the WHOLE accumulated angle back to
     zero inside the morph beat, and the sweep rate grew with uptime. After
     twenty minutes that was ~120 radians crammed into 2.6s, roughly seven
     revolutions a second, and it kept getting worse the longer it ran.
     Now the angle is integrated at a gated rate and kept wrapped, and the
     moment the morph starts it is eased to the NEAREST WHOLE TURN (visually
     identical to zero, so the mark still lands square on) which is never more
     than half a turn away. Bounded no matter how long the page is open. */
  const TAU = Math.PI * 2;
  if (k <= 0.001) { spin = (spin + dt * 0.10) % TAU; spinLatched = false; }
  else if (!spinLatched) { spinTarget = Math.round(spin / TAU) * TAU; spinLatched = true; }
  mesh.rotation.y = spinOn ? spin + (spinTarget - spin) * k : 0;

  cheat.fill  = finalFill * fill;
  /* The dashed vectors are an alignment aid, so they fade in with the morph
     rather than sitting over the free-running physics where they mean nothing.
     Off by default: the finished animation should not show its workings. */
  cheat.ghost = finalGuides ? 0.9 * k : 0;
  syncLogoLines(2.5 * (0.9 + 0.25 * k));
  drawGhost();

  /* TWO lights, because there are TWO layers and conflating them is exactly
     what made the timing controls unreadable. MORPH is the coefficient blend
     from the evolving state onto the brandmark: every frame of it is still a
     genuine solution. CLIP is the geometric cut that forces the exact outline:
     that one is not physics. MORPH lights first and stays lit underneath. */
  const setLed = (badgeId, ledId, pctId, v) => {
    const badge = document.getElementById(badgeId), el = document.getElementById(ledId),
          pct   = document.getElementById(pctId);
    if (!badge || !el || !pct) return;
    const lit = v > 0.001, full = v > 0.995;
    const clip = badgeId === 'ledClipBadge';
    badge.classList.toggle(clip ? 'lit-clip' : 'lit-morph', lit);
    el.classList.toggle(clip ? 'on' : 'conv', lit);
    el.classList.toggle('part', lit && !full);
    pct.textContent = `${(v*100).toFixed(0)}%`;
  };
  const badge = document.getElementById('ledBadge');
  if (badge) badge.style.display = 'flex';
  setLed('ledMorphBadge', 'ledMorph', 'ledMorphPct', k);
  setLed('ledClipBadge',  'ledClip',  'ledClipPct',  cheatK);
  {
    /* Three states, because "can I drag right now" has three honest answers. */
    const cb = document.getElementById('ledCamBadge'),
          cl = document.getElementById('ledCam'),
          cp = document.getElementById('ledCamPct');
    if (cb && cl && cp) {
      const yours = freeCam && !camHeld;
      const taken = freeCam && camHeld;
      cb.classList.toggle('lit-morph', yours);
      cb.classList.toggle('lit-clip',  taken);
      cl.classList.toggle('conv', yours);
      cl.classList.toggle('on',   taken);
      cl.classList.toggle('part', taken);
      cp.textContent = yours ? 'yours' : taken ? 'taken' : 'scripted';
    }
  }

  paintTab3Pips({
    fHold:    name === 'settle' ? local * FINAL.settle
            : (name === 'fadeIn' || name === 'logoHold' || name === 'fadeOut') ? FINAL.settle : 0,
    fFill:    cheat.fill,
    fMorphIn: name === 'morphIn' ? local * FINAL.morphIn
            : (name === 'settle' || name === 'fadeIn' || name === 'logoHold' || name === 'fadeOut') ? FINAL.morphIn : 0,
    fMorphOut:name === 'morphOut' ? local * FINAL.morphOut : 0,
    fArtOn:   finalT < eS ? Math.max(0, effLead - (eS - finalT)) : (finalT <= eE ? effLead : 0),
    fArtOff:  finalT > eE ? Math.max(0, effTail - (finalT - eE)) : (finalT >= eS ? effTail : 0),
    fEase:    name === 'morphIn' ? timeEase * smooth(local)
            : name === 'morphOut' ? timeEase * (1 - smooth(local)) : 0,
    fExc:     wanderMode ? (name === 'morphOut' ? local * FINAL.morphOut
            : name === 'morphIn' ? FINAL.morphOut + local * FINAL.morphIn : 0) : 0,
    fAmt:     wanderMode ? (1 - k) * wanderAmt : 0,
    fGrab:    finalT < eS ? Math.max(0, effGrab - (eS - finalT))
            : finalT <= eE ? effGrab : Math.max(0, effGrab - (finalT - eE)),
  });

  const ph = document.getElementById('phase');
  if (ph) ph.innerHTML =
    `<b>${name}</b>  ${finalT.toFixed(1)}s / ${finalCycle().toFixed(1)}s` +
    `<br>steer <b>${(k*100).toFixed(0)}%</b>  &middot;  sculpt <b>${(cheatK*100).toFixed(0)}%</b>` +
    `  &middot;  logo <b>${(cheat.fill*100).toFixed(0)}%</b>` +
    (freeCam
      ? `<br><span style="color:${camHeld ? 'var(--purple)' : 'var(--accent)'}">` +
        (camHeld ? `camera taken back, easing to the epoch pose`
                 : `camera is yours, drag to orbit`) + `</span>`
      : '');
}

/* ===================== TAB 4: THE DIRECTED LOOP =====================
   John's cut, in his vocabulary. One cycle:
     pt (playground) -> dt (dinner call) -> [ct cheat] -> st (sit) -> mirror out
   ct either OVERLAYS dt's tail or is its own act after dt (his checkbox).
   The whole thing opens with the teal intro: mark fades in alone, the 3D
   arrives underneath exactly on the epoch, mark lets go, loop runs. */
let loopMode=false, loopT=0, introT=0, inIntro=true, loopRolled=false;
let l_pt=5, l_dt=0.7, l_ct=0.2, l_ctOverlay=true, l_st=1;   // john fav ships as the default
let l_logoOn=true, l_logoPeak=1, l_logoLead=0.6, l_tealLead=0.2, l_guides=false;  // leads kept positive
let loopGains=null;

function rollLoopGains(){                    // tab 1's Randomise, verbatim
  const v=zeros(); const n=2+Math.floor(Math.random()*4);
  for(let j=0;j<n;j++) v[(Math.random()*N)|0]=0.35+Math.random()*0.65;
  loopGains=v;
}
const loopEpochStart = () => l_pt + l_dt + (l_ctOverlay ? 0 : l_ct);
const loopEpochEnd   = () => loopEpochStart() + l_st;
const loopCycle      = () => loopEpochEnd() + (l_ctOverlay ? 0 : l_ct) + l_dt;

const INTRO = { dark:0.9, logoIn:1.4, hold:0.5, reveal:1.2 };
function startIntro(){ inIntro=true; introT=0; loopT=0; if(!loopGains) rollLoopGains(); }

let loop4Pips=null;
function paintLoopPips(vals){
  if (!loop4Pips) loop4Pips = [
    ['l_pt','lp4pt'],['l_dt','lp4dt'],['l_ct','lp4ct'],['l_st','lp4st'],
    ['l_logoPeak','lp4peak'],['l_logoLead','lp4llead'],['l_tealLead','lp4tlead']
  ].map(([iid,pid])=>{
    const inp=document.getElementById(iid), pip=document.getElementById(pid);
    return inp&&pip?{key:iid,pip,lo:parseFloat(inp.min),hi:parseFloat(inp.max)}:null;
  }).filter(Boolean);
  loop4Pips.forEach(s=>{
    const f=Math.max(0,Math.min(1,((vals[s.key]||0)-s.lo)/(s.hi-s.lo)));
    s.pip.style.left=`calc(6px + ${f*100}% - ${f} * 12px)`;
    s.pip.classList.toggle('zero',f<=0.001);
  });
}

function driveLoop(dt){
  const liveEvo4 = g => HARMONICS.map((hm,i)=>{
    const env=0.55+0.45*Math.cos(tEvo*(0.13+i*0.017)+i*1.7);
    return g[i]*env*Math.cos(E_LEVELS[i]*tEvo*0.5);
  });
  const EA=-Math.PI/2, EB=Math.PI/2, ER=4.4;
  let k=0, cheatK=0, fill=0, tealK=0, phaseName='', pinned=false;

  if (inIntro){
    introT+=dt;
    const t=introT, e1=INTRO.dark, e2=e1+INTRO.logoIn, e3=e2+INTRO.hold, e4=e3+INTRO.reveal;
    const seg=(a,b,x)=>Math.max(0,Math.min(1,(x-a)/Math.max(0.001,b-a)));
    k=1; cheatK=1; tealK=1; pinned=true; phaseName='intro';
    /* The mark fades in ALONE, at full teal. The 3D is enabled only once the
       mark is solid, so it arrives already congruent underneath it, and the
       reveal is the mark letting go of its own 3D self. */
    mesh.setEnabled(t>=e2+0.01);
    fill = t<e1?0 : t<e2? smooth(seg(e1,e2,t)) : t<e3? 1 : 1-smooth(seg(e3,e4,t));
    camera.alpha=EA; camera.beta=EB; camera.radius=ER; camera.target.set(0,0,0);
    if (t>=e4){ inIntro=false; mesh.setEnabled(true); loopT=loopEpochEnd(); rollLoopGains(); }
  } else {
    mesh.setEnabled(true);
    loopT=(loopT+dt)%loopCycle();
    const eS=loopEpochStart(), eE=loopEpochEnd(), t=loopT;
    const ctw=l_ctOverlay? Math.min(l_ct,l_dt) : l_ct;
    if (t<l_pt){ k=0; phaseName='pt play'; }
    else if (t<l_pt+l_dt){ k=smooth((t-l_pt)/l_dt); phaseName='dt dinner'; }
    else if (t<eS){ k=1; phaseName='ct cheat'; }
    else if (t<eE){ k=1; phaseName='st sit'; pinned=true; }
    else if (!l_ctOverlay && t<eE+l_ct){ k=1; phaseName='ct out'; }
    else { const o=l_ctOverlay? t-eE : t-eE-l_ct; k=1-smooth(Math.min(1,o/l_dt)); phaseName='dt out'; }
    cheatK = t<eS ? (t<=eS-ctw?0:smooth(1-(eS-t)/ctw))
           : t<=eE ? 1
           : (t>=eE+ctw?0:smooth(1-(t-eE)/ctw));
    /* The fresh roll happens the moment we ARRIVE, invisibly: the display is
       pinned to the mark, so the new state is only met on the way out. */
    if (t>=eS && !loopRolled){ rollLoopGains(); loopRolled=true; }
    if (t<eS) loopRolled=false;
    const L=l_logoLead, mid=(eS+eE)/2, a=mid-Math.max(0.001,(eE-eS)/2+L), b=mid+Math.max(0.001,(eE-eS)/2+L);
    fill = (!l_logoOn||l_logoPeak<=0||t<=a||t>=b||(b-a)<0.02) ? 0
         : (t<mid? smooth((t-a)/(mid-a)) : smooth((b-t)/(b-mid))) * l_logoPeak;
    const TL=l_tealLead;
    tealK = t<eS ? (TL<=0.01?0:(t<=eS-TL?0:smooth(1-(eS-t)/TL)))
          : t<=eE ? 1
          : (TL<=0.01?0:(t>=eE+TL?0:smooth(1-(t-eE)/TL)));
    const dA=EA+(camMotion?Math.sin(tEvo*0.19)*0.55:0), dB=EB+(camMotion?Math.sin(tEvo*0.13)*0.30:0);
    camera.alpha=dA*(1-k)+EA*k; camera.beta=dB*(1-k)+EB*k;
    camera.radius+=(ER-camera.radius)*0.06;
    camera.target.scaleInPlace(1-Math.min(1,0.05+k*0.95));
  }
  mesh.rotation.y=0;
  mat.setColor3('cNeg', BABYLON.Color3.Lerp(C_NEG, C_POS, Math.max(0,Math.min(1,tealK))));

  const gains=loopGains||gainFinalBase;
  const c = pinned ? LOGO_BASE.slice()
                   : liveEvo4(gains).map((v,i)=>v*(1-k)+LOGO_BASE[i]*k);
  const nrm=Math.hypot(...c)||1; const cN=c.map(v=>v/nrm);
  mat.setFloats('c',cN); liveC=cN;
  mat.setFloat('uScale',2.5*(0.9+0.25*k));
  mat.setFloat('uFlat',cheatK); mat.setFloat('uEdge',cheatK);
  mat.setFloat('uRot',45); mat.setFloat('uPlump',0); mat.setFloat('uGap',0); mat.setFloat('uCorner',0);
  cheat.fill=fill; cheat.ghost=(l_guides && !inIntro) ? 0.9*k : 0;
  syncLogoLines(2.5*(0.9+0.25*k));
  drawGhost();

  const led=(bid,lid,pid,v,clip)=>{
    const bd=document.getElementById(bid), el=document.getElementById(lid), pc=document.getElementById(pid);
    if(!bd||!el||!pc) return;
    const lit=v>0.001, full=v>0.995;
    bd.classList.toggle(clip?'lit-clip':'lit-morph',lit);
    el.classList.toggle(clip?'on':'conv',lit);
    el.classList.toggle('part',lit&&!full);
    pc.textContent=`${(v*100).toFixed(0)}%`;
  };
  const lb=document.getElementById('ledBadge'); if(lb) lb.style.display='flex';
  led('ledMorphBadge','ledMorph','ledMorphPct',k,false);
  led('ledClipBadge','ledClip','ledClipPct',cheatK,true);
  const cb=document.getElementById('ledCamBadge');
  if (cb){ cb.classList.remove('lit-morph','lit-clip');
    document.getElementById('ledCam').classList.remove('on','conv','part');
    document.getElementById('ledCamPct').textContent='scripted'; }

  paintLoopPips({
    l_pt: inIntro?0:Math.min(loopT,l_pt),
    l_dt: k*l_dt,
    l_ct: cheatK*(l_ctOverlay?Math.min(l_ct,l_dt):l_ct),
    l_st: inIntro?0:Math.max(0,Math.min(l_st,loopT-loopEpochStart())),
    l_logoPeak: fill,
    l_logoLead: l_logoLead>0.01? -(1-Math.min(1,fill/Math.max(0.01,l_logoPeak)))*l_logoLead : 0,
    l_tealLead: l_tealLead>0.01? -(1-tealK)*l_tealLead : 0,
  });

  const ph=document.getElementById('loopPhase');
  if (ph) ph.innerHTML = inIntro
    ? `<b>intro</b>  ${introT.toFixed(1)}s &middot; the mark first, the physics underneath`
    : `<b>${phaseName}</b>  ${loopT.toFixed(1)}s / ${loopCycle().toFixed(1)}s` +
      `<br>steer <b>${(k*100).toFixed(0)}%</b> &middot; cheat <b>${(cheatK*100).toFixed(0)}%</b>` +
      ` &middot; teal <b>${(tealK*100).toFixed(0)}%</b> &middot; logo <b>${(fill*100).toFixed(0)}%</b>` +
      `<br><span style="color:var(--text-3)">${l_ctOverlay?'ct overlays the dt tail':'ct is its own act, cycle grows by 2 ct'}</span>`;
}

/* ---- tab 4 controls ---- */
const LOOP_DEFAULTS={l_pt:5,l_dt:0.7,l_ct:0.2,l_st:1,l_logoPeak:1,l_logoLead:-0.6,l_tealLead:-0.2};
[['l_pt','o_pt',v=>{l_pt=v;return v.toFixed(1)+'s';}],
 ['l_dt','o_dt',v=>{l_dt=v;return v.toFixed(1)+'s';}],
 ['l_ct','o_ct',v=>{l_ct=v;return v.toFixed(1)+'s';}],
 ['l_st','o_st',v=>{l_st=v;return v.toFixed(1)+'s';}],
 ['l_logoPeak','o_logoPeak',v=>{l_logoPeak=v;return Math.round(v*100)+'%';}],
 ['l_logoLead','o_logoLead',v=>{l_logoLead=Math.abs(v);return v.toFixed(1)+'s';}],
 ['l_tealLead','o_tealLead',v=>{l_tealLead=Math.abs(v);return v.toFixed(1)+'s';}]
].forEach(([id,out,fn])=>{
  document.getElementById(id).addEventListener('input',e=>{
    document.getElementById(out).textContent=fn(parseFloat(e.target.value));
  });
});
document.getElementById('l_ctMode').addEventListener('change',e=>{ l_ctOverlay=e.target.checked; });
document.getElementById('l_logoOn').addEventListener('change',e=>{ l_logoOn=e.target.checked; });
document.getElementById('lRestart').addEventListener('click',startIntro);
document.getElementById('lDefaults').addEventListener('click',()=>{
  for(const [id,v] of Object.entries(LOOP_DEFAULTS)){
    const el=document.getElementById(id); if(!el) continue;
    el.value=v; el.dispatchEvent(new Event('input'));
  }
  const cm=document.getElementById('l_ctMode'); cm.checked=true; cm.dispatchEvent(new Event('change'));
  const lo=document.getElementById('l_logoOn'); lo.checked=true; lo.dispatchEvent(new Event('change'));
  const gd=document.getElementById('l_guides'); gd.checked=false; gd.dispatchEvent(new Event('change'));
});
document.getElementById('l_guides').addEventListener('change',e=>{ l_guides=e.target.checked; });

/* ---- named snapshots: freeze a set of numbers you like, recall from the
   pulldown. Stored separately from the auto-persist, so clearing one never
   touches the other. ---- */
const SNAP_KEY='orbital-lab-snapshots-v1';
/* Factory snapshots ship IN the page, so they exist on every surface,
   including the wiki sandbox where there is no storage at all. Saved user
   snapshots live per browser origin and appear after these. */
const FACTORY_SNAPS = [
  { name: 'john fav · pt5 dt0.7 ct0.2 st1', factory: true,   // the shipped default
    vals: { pt:5, dt:0.7, ct:0.2, ov:true, st:1, lo:true, lp:1, ll:0.6, tl:0.2, gd:false } },
  { name: 'john 1 · pt4.5 dt3 ct0.3 st0', factory: true,
    vals: { pt:4.5, dt:3, ct:0.3, ov:true, st:0, lo:true, lp:1, ll:1, tl:1.5, gd:false } },
  { name: 'snap 2 · pt5 dt1 ct0.2 st0', factory: true,
    vals: { pt:5, dt:1, ct:0.2, ov:true, st:0, lo:true, lp:1, ll:0.3, tl:1.5, gd:false } },
  { name: 'snap 3 · pt5 dt0.7 ct0.2 st0', factory: true,
    vals: { pt:5, dt:0.7, ct:0.2, ov:true, st:0, lo:true, lp:0.8, ll:0.5, tl:1.5, gd:false } }
];
function allSnaps(){
  const stored = loadSnaps().filter(s => !FACTORY_SNAPS.some(f => f.name === s.name));
  return FACTORY_SNAPS.concat(stored);
}
function snapVals(){ return { pt:l_pt, dt:l_dt, ct:l_ct, ov:l_ctOverlay, st:l_st,
  lo:l_logoOn, lp:l_logoPeak, ll:l_logoLead, tl:l_tealLead, gd:l_guides }; }
function loadSnaps(){ try{ return JSON.parse(localStorage.getItem(SNAP_KEY)||'[]'); }catch(e){ return []; } }
function saveSnaps(a){ try{ localStorage.setItem(SNAP_KEY, JSON.stringify(a)); }catch(e){} }
function applySnap(v){
  [['l_pt',v.pt],['l_dt',v.dt],['l_ct',v.ct],['l_st',v.st],['l_logoPeak',v.lp],
   ['l_logoLead', -Math.abs(v.ll||0)],['l_tealLead', -Math.abs(v.tl||0)]].forEach(([id,x])=>{
    const el=document.getElementById(id);
    if (el && x!==undefined){ el.value=x; el.dispatchEvent(new Event('input')); }
  });
  const cm=document.getElementById('l_ctMode'); cm.checked=v.ov!==false; cm.dispatchEvent(new Event('change'));
  const lg=document.getElementById('l_logoOn'); lg.checked=v.lo!==false; lg.dispatchEvent(new Event('change'));
  const gd=document.getElementById('l_guides'); gd.checked=!!v.gd; gd.dispatchEvent(new Event('change'));
}
function refreshSnapList(sel){
  const el=document.getElementById('l_snaps'); const snaps=allSnaps();
  const head = lsOK ? 'snapshots (' + snaps.length + ')'
                    : 'snapshots · no storage on this surface';
  el.innerHTML = '<option value="">' + head + '</option>' +
    snaps.map((s,i)=>`<option value="${i}"${i===sel?' selected':''}>${s.name}${s.factory?' *':''}</option>`).join('');
}
document.getElementById('lSnapSave').addEventListener('click',()=>{
  if (!lsOK) return;                       // the SAY toast explains why
  const stored=loadSnaps();
  const name=`snap ${allSnaps().length+1} · pt${l_pt} dt${l_dt} ct${l_ct}${l_ctOverlay?'':' seq'} st${l_st}`;
  stored.push({ name, vals:snapVals() });
  saveSnaps(stored); refreshSnapList(allSnaps().length-1);
});
document.getElementById('lSnapDel').addEventListener('click',()=>{
  const el=document.getElementById('l_snaps'); const i=parseInt(el.value,10);
  const snaps=allSnaps(); if (isNaN(i)||!snaps[i]) return;
  if (snaps[i].factory) return;            // ships with the page, cannot be deleted
  const stored=loadSnaps();
  const j=stored.findIndex(s=>s.name===snaps[i].name);
  if (j>=0){ stored.splice(j,1); saveSnaps(stored); }
  refreshSnapList();
});
document.getElementById('l_snaps').addEventListener('change',e=>{
  const i=parseInt(e.target.value,10); const snaps=allSnaps();
  if (isNaN(i)||!snaps[i]) return;
  applySnap(snaps[i].vals);
  toast('Snapshot applied', snaps[i].name, { duration:2400 });
});
refreshSnapList();

// Matches the Explore tab's defaults, so the "normal animation" here is the
// one that already looks right rather than a busier variant.
const gainFinalBase = new Array(16).fill(0);
[1,2,3].forEach(i=>gainFinalBase[i]=1);
[4,5,6,7,8].forEach(i=>gainFinalBase[i]=0.7);
const E_LEVELS = E;

document.getElementById('fCam').addEventListener('click', e => {
  camMotion = !camMotion; e.target.classList.toggle('on', camMotion);
});
document.getElementById('fFree').addEventListener('click', e => {
  freeCam = !freeCam; e.target.classList.toggle('on', freeCam);
  // The scripted drift would fight the pointer, so the two swap over.
  camMotion = !freeCam;
  document.getElementById('fCam').classList.toggle('on', camMotion);
  document.getElementById('fCam').disabled = freeCam;
  if (!freeCam && camHeld) { if (camLive) camera.attachControl(canvas, false); camHeld = false; }
});
document.getElementById('fSpin').addEventListener('click', e => {
  spinOn = !spinOn; e.target.classList.toggle('on', spinOn);
});
document.getElementById('fGuide').addEventListener('click', e => {
  finalGuides = !finalGuides; e.target.classList.toggle('on', finalGuides);
});
/* Restart rewinds EVERY clock the loop reads, not just the timeline cursor.
   It used to set finalT alone, but the shape comes from tEvo and the framing
   from spin, so the picture did not change and the button looked dead. */
document.getElementById('fRestart').addEventListener('click', () => {
  finalT = 0;
  tEvo   = 0;
  spin   = 0; spinTarget = 0; spinLatched = false;
  driveFinal(0);
});
document.getElementById('fHold').addEventListener('input', e => {
  FINAL.settle = parseFloat(e.target.value);
  document.getElementById('oHold').textContent = FINAL.settle.toFixed(1) + 's';
});
document.getElementById('fFill').addEventListener('input', e => {
  finalFill = parseFloat(e.target.value);
  document.getElementById('oFill2').textContent = Math.round(finalFill*100) + '%';
});
/* The red pip sits at the value each artistic slider would take if it did not
   exist. With no slider the clip was simply tied to the morph, so the natural
   value IS the morph duration: it moves when you retime the morph. */
/* The tuned values. The artistic lead in / tail out sit well behind the pip on
   purpose: at 0.6s into a 2.6s morph the state is already 87% of the way to a
   pure 2p, so the clip only ever lands on a shape it was built for. That is
   what keeps the stretch artefacts out. */
const FINAL_DEFAULTS = {
  fMorphIn:2.6, fMorphOut:2.6, fArtOn:0.6, fArtOff:0.6,
  fEase:0.35, fHold:1.0, fFill:0.50, fGrab:1.5
};
function applyFinalDefaults(){
  for (const [id,v] of Object.entries(FINAL_DEFAULTS)) {
    const el = document.getElementById(id);
    if (!el) continue;
    el.value = v;
    el.dispatchEvent(new Event('input'));
  }
}

function placePips(){
  const put = (pipId, inputId, seconds) => {
    const pip = document.getElementById(pipId), inp = document.getElementById(inputId);
    if (!pip || !inp) return;
    const lo = parseFloat(inp.min), hi = parseFloat(inp.max);
    const pct = Math.max(0, Math.min(1, (seconds - lo) / (hi - lo))) * 100;
    // The thumb is 12px wide, so the track it can reach is inset 6px each end.
    pip.style.left = `calc(6px + ${pct}% - ${pct/100} * 12px)`;
    pip.title = `no-slider default: ${seconds.toFixed(1)}s, co-timed with the morph`;
  };
  put('pipOn',  'fArtOn',  FINAL.morphIn);
  put('pipOff', 'fArtOff', FINAL.morphOut);
  put('pipGrab','fGrab',   FINAL.morphIn);   // the old behavior: taken for the whole morph
}

function describeEase(){
  const el = document.getElementById('easeWindow');
  if (!el) return;
  if (timeEase < 0.02) {
    el.innerHTML = `clock runs at full speed throughout` +
      `<br><span style="color:var(--text-3)">steering takes its ` +
      `${FINAL.morphIn.toFixed(1)}s and ${FINAL.morphOut.toFixed(1)}s as written</span>`;
    return;
  }
  const slowest = Math.max(0.08, 1 - timeEase);
  const inEff  = FINAL.morphIn  / ((1 + slowest) / 2);
  const outEff = FINAL.morphOut / ((1 + slowest) / 2);
  el.innerHTML =
    `eases while steering runs, slowest <b>${slowest.toFixed(2)}x</b> at the epoch` +
    `<br><span style="color:var(--text-3)">steer in plays over about ` +
    `${inEff.toFixed(1)}s, steer out about ${outEff.toFixed(1)}s &middot; ` +
    `the free wander and the hold are untouched</span>`;
}

function describeWindow(){
  const el = document.getElementById('artWindow');
  if (!el) return;
  const eS = epochStart(), eE = epochEnd();
  const total = artLeadIn + (eE - eS) + artTailOut;
  el.innerHTML =
    `sculpting from <b>epoch &minus; ${artLeadIn.toFixed(2)}s</b> ` +
    `to <b>epoch &plus; ${(eE-eS+artTailOut).toFixed(2)}s</b>` +
    `<br><span style="color:var(--text-3)">` +
    `${total.toFixed(1)}s of ${finalCycle().toFixed(1)}s total &middot; ` +
    (artLeadIn > FINAL.morphIn - 0.2 || artTailOut > FINAL.morphOut - 0.2
       ? 'capped at the steer length: sculpting is never allowed to touch raw physics'
     : artLeadIn < 0.2 && artTailOut < 0.2 ? 'snaps on and off at the epoch'
     : 'steering finishes early, then sculpting fades up over this window') + `</span>`;
}
document.getElementById('fArtOn').addEventListener('input', e => {
  artLeadIn = parseFloat(e.target.value);
  document.getElementById('oArtOn').textContent = artLeadIn.toFixed(2) + 's';
  describeWindow();
});
document.getElementById('fArtOff').addEventListener('input', e => {
  artTailOut = parseFloat(e.target.value);
  document.getElementById('oArtOff').textContent = artTailOut.toFixed(2) + 's';
  describeWindow();
});
document.getElementById('fDefaults').addEventListener('click', applyFinalDefaults);
document.getElementById('fWander').addEventListener('click', e => {
  wanderMode = !wanderMode;
  e.target.classList.toggle('on', wanderMode);
  if (wanderMode) { applyExcursionTiming(); rollExcursion(); }
  else { FINAL.evolve = 7.0; FINAL.morphIn = FINAL.morphOut = 2.6; excursion = null; syncMorphInputs(); }
  describeWander();
});
document.getElementById('fRoll').addEventListener('click', rollExcursion);
[['fWanderAll', () => true],
 ['fWanderNoF', (i) => HARMONICS[i].l !== 3],
 ['fWanderP',   (i) => HARMONICS[i].l === 1]].forEach(([id, pick]) => {
  document.getElementById(id).addEventListener('click', () => {
    wanderOn = HARMONICS.map((hm,i) => pick(i));
    if (wanderRows) wanderRows.forEach((r,i) => {
      r.cb.checked = wanderOn[i]; r.row.classList.toggle('off', !wanderOn[i]);
    });
    describeWander();
  });
});
document.getElementById('fGrab').addEventListener('input', e => {
  camGrab = parseFloat(e.target.value);
  document.getElementById('oGrab').textContent = camGrab.toFixed(1) + 's';
});
document.getElementById('fExc').addEventListener('input', e => {
  excursionT = parseFloat(e.target.value);
  document.getElementById('oExc').textContent = excursionT.toFixed(1) + 's';
  if (wanderMode) applyExcursionTiming();
  describeWander();
});
document.getElementById('fAmt').addEventListener('input', e => {
  wanderAmt = parseFloat(e.target.value);
  document.getElementById('oAmt').textContent = Math.round(wanderAmt*100) + '%';
});
document.getElementById('fMorphIn').addEventListener('input', e => {
  FINAL.morphIn = parseFloat(e.target.value);
  document.getElementById('oMorphIn').textContent = FINAL.morphIn.toFixed(1) + 's';
  placePips(); describeWindow(); describeEase();
});
document.getElementById('fMorphOut').addEventListener('input', e => {
  FINAL.morphOut = parseFloat(e.target.value);
  document.getElementById('oMorphOut').textContent = FINAL.morphOut.toFixed(1) + 's';
  placePips(); describeWindow(); describeEase();
});
document.getElementById('fEase').addEventListener('input', e => {
  timeEase = parseFloat(e.target.value);
  describeEase();
  document.getElementById('oEase').textContent =
    timeEase < 0.02 ? 'off' : timeEase.toFixed(2) + 'x';
});
applyFinalDefaults();
restoreLab();
buildWanderList();
if (wanderMode) { applyExcursionTiming(); rollExcursion(); }
describeWindow(); describeEase(); placePips(); describeWander();
/* Toggle buttons must never disagree with the state they claim to show. The
   state changes down many paths (tab switches flip animate, Free rotate flips
   Camera motion, the guided demo drives things), so instead of chasing every
   path, all of them resync a few times a second. Seven class toggles: free. */
const TOGGLES = [
  ['bAnim',  () => animate],   ['bView', () => viewFree],
  ['fCam',   () => camMotion], ['fSpin', () => spinOn],
  ['fFree',  () => freeCam],   ['fGuide',() => finalGuides],
  ['fWander',() => wanderMode],
];
function syncToggles(){
  for (const [id, get] of TOGGLES) {
    const b = document.getElementById(id);
    if (b) b.classList.toggle('on', !!get());
  }
}
syncToggles();
setInterval(syncToggles, 400);
/* Any control change persists (debounced); so does every tab switch. */
['input','change'].forEach(t => document.addEventListener(t, schedulePersist, true));
document.addEventListener('click', e => {
  if (e.isTrusted && e.target.closest && e.target.closest('button')) schedulePersist();
}, true);
/* Always boot into the Loop tab: it is the tab under active development. The
   other tabs keep their saved state and are one click away. */
setMode('loop');
document.getElementById('mFinal').addEventListener('click', () => setMode('final'));
document.getElementById('mLoop').addEventListener('click', () => setMode('loop'));

/* ---------- Getting back to the money shot ----------
   Once the brandmark match is on screen, rotating away breaks it: the fit only
   reads at all from square on, because the licence is a flat cut and any other
   angle shows the cut faces. So the first time someone spins it we say how to
   get back, and if they simply stop for five seconds we put it back ourselves.
   Recovery should not be something you have to know about. */
const RESTORE_AFTER = 5000;

function scheduleRestore(){
  if (!logoMode || cheat.flat < 0.5) return;      // only when the match is set up
  clearTimeout(restoreTimer);

  if (!toldAboutSquareOn) {
    toldAboutSquareOn = true;
    toast('Rotated off the match',
          'Click Square on to snap back, or just stop and it returns itself',
          { duration:5000 });
  }
  restoreTimer = setTimeout(() => {
    if (!viewFree) return;
    viewFree = false;
    document.getElementById('bView').classList.remove('on');
    toast('Back to the brandmark view', 'square on', { duration:2600 });
  }, RESTORE_AFTER);
}

// every interaction with the view restarts the idle countdown
canvas.addEventListener('pointerdown', scheduleRestore);
canvas.addEventListener('pointermove', e => { if (viewFree && e.buttons) scheduleRestore(); });
canvas.addEventListener('pointerup',   scheduleRestore);
canvas.addEventListener('wheel',       scheduleRestore, { passive:true });

/* ---- measure the actual fit ----
   Reads the rendered pixels back and compares the silhouette against the
   real logo vectors. An honest number beats squinting at the overlay. */
document.getElementById('bCopy').addEventListener('click', async (e) => {
  const txt =
`The Adom logomark is a hydrogen 2p orbital.

    psi = (2p_x + 2p_y) / sqrt(2)

    c_px = 0.707
    c_py = 0.707
    c_pz = 0.000

Axis in the view plane, on the diagonal (45 deg).
Measured against the real logo vectors:
    99.9% of the mark covered
     0.6% spill
    99.3% IoU

Untouched, the same state already scores 79.2% IoU.
The licence is a quadrant clip plus a per-direction edge correction.`;
  try { await navigator.clipboard.writeText(txt); e.target.textContent='Copied'; }
  catch { e.target.textContent='Copy failed'; }
  setTimeout(()=>{ e.target.textContent='Copy the state'; }, 1600);
});

document.getElementById('bMeasure').addEventListener('click', async () => {
  const fit=document.getElementById('fit');
  fit.innerHTML='measuring...';
  await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
  const w=canvas.width, ht=canvas.height;
  const gl=engine._gl;
  const px=new Uint8Array(w*ht*4);
  gl.readPixels(0,0,w,ht,gl.RGBA,gl.UNSIGNED_BYTE,px);

  // orbital silhouette = anything not the clear colour
  const bg=[13,17,23];
  const orb=new Uint8Array(w*ht);
  for(let i=0;i<w*ht;i++){
    const r=px[i*4],g2=px[i*4+1],b=px[i*4+2];
    orb[i]=(Math.abs(r-bg[0])+Math.abs(g2-bg[1])+Math.abs(b-bg[2])>28)?1:0;
  }
  // logo mask at the same scale/placement as the ghost
  const cv=document.createElement('canvas'); cv.width=w; cv.height=ht;
  const c2=cv.getContext('2d');
  const s=Math.min(w,ht)*0.34;
  c2.fillStyle='#fff'; c2.translate(w/2,ht/2);
  logoPaths.forEach(pts=>{
    c2.beginPath();
    pts.forEach(([x,y],i)=>{const X=x*s,Y=-y*s; i?c2.lineTo(X,Y):c2.moveTo(X,Y);});
    c2.closePath(); c2.fill();
  });
  const ld=c2.getImageData(0,0,w,ht).data;

  let inter=0,uni=0,lo=0,ob=0;
  for(let y=0;y<ht;y++){
    for(let x=0;x<w;x++){
      const i=y*w+x;
      const li=((ht-1-y)*w+x)*4;          // readPixels is bottom-up
      const L=ld[li+3]>127?1:0, O=orb[i];
      if(L&&O)inter++; if(L||O)uni++; if(L)lo++; if(O)ob++;
    }
  }
  fit.innerHTML =
    `IoU <b>${(inter/Math.max(1,uni)*100).toFixed(1)}%</b><br>`+
    `mark covered <b>${(inter/Math.max(1,lo)*100).toFixed(1)}%</b><br>`+
    `spill outside mark <b>${((ob-inter)/Math.max(1,ob)*100).toFixed(1)}%</b>`;
});

/* ---- tooltip engine (Adom UI skill, section 3) ---- */
const tt = document.createElement('div');
tt.id = 'tt'; tt.innerHTML = '<span class="body"></span><span class="caret"></span>';
document.body.appendChild(tt);
{
  const body = tt.querySelector('.body'), caret = tt.querySelector('.caret');
  let mx = 0, my = 0, timer = null, shown = false;
  // native title attrs become data-tt so the browser tooltip never doubles up
  const migrate = el => { if (el.title){ el.dataset.tt = el.title; el.removeAttribute('title'); } };
  document.querySelectorAll('[title]').forEach(migrate);
  const ttOf = t => {
    let el = t.closest ? t.closest('[data-tt],[title]') : null;
    if (el) { migrate(el); return el.dataset.tt ? el : null; }
    // sliders and outputs inherit their row label's explanation
    const row = t.closest ? t.closest('.row') : null;
    const lab = row && row.querySelector('label');
    if (lab) { migrate(lab); if (lab.dataset.tt) return lab; }
    return null;
  };
  const show = text => {
    body.textContent = text;
    tt.classList.add('show'); shown = true;
    // measure only AFTER content is set and laid out, then clamp BOTH axes
    const M = 8, w = tt.offsetWidth, hh = tt.offsetHeight;
    const vw = innerWidth, vh = innerHeight;
    let x = mx + 14, y = my + 14, above = false;
    if (y + hh > vh - M) { y = my - 14 - hh; above = true; }      // flip, never clip
    if (x + w > vw - M) x = vw - w - M;
    x = Math.max(M, Math.min(x, vw - w - M));
    y = Math.max(M, Math.min(y, vh - hh - M));
    tt.style.left = x + 'px'; tt.style.top = y + 'px';
    tt.classList.toggle('above', above);
    caret.style.left = Math.max(10, Math.min(mx - x, w - 10)) - 4 + 'px';
  };
  const hide = () => { clearTimeout(timer); timer = null; if (shown){ tt.classList.remove('show'); shown = false; } };
  document.addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; });
  document.addEventListener('mouseover', e => {
    const el = ttOf(e.target);
    hide();
    if (!el) return;
    timer = setTimeout(() => show(el.dataset.tt), 600);   // fly-bys stay quiet
  });
  document.addEventListener('mouseout', e => { if (!e.relatedTarget || !ttOf(e.relatedTarget)) hide(); });
  ['pointerdown','wheel'].forEach(t => document.addEventListener(t, hide, true));
  document.addEventListener('scroll', hide, true);
  document.addEventListener('keydown', e => { if (e.key === 'Escape') hide(); });
  // touch has no hover: a 500ms still press reveals the same explanation
  let pressTimer = null;
  document.addEventListener('pointerdown', e => {
    if (e.pointerType !== 'touch') return;
    const el = ttOf(e.target);
    if (!el) return;
    mx = e.clientX; my = e.clientY;
    pressTimer = setTimeout(() => show(el.dataset.tt), 500);
  }, true);
  ['pointerup','pointermove','pointercancel'].forEach(t =>
    document.addEventListener(t, e => { if (e.pointerType === 'touch') clearTimeout(pressTimer); }, true));
  window.__tt = { show, hide, ttOf };   // driven by the ralph-test
}
engine.runRenderLoop(()=>scene.render());
/* The object should always occupy roughly the same fraction of the canvas,
   about 80% at the mark, however the window is shaped. Babylon's FOV is
   fixed on ONE axis; pick that axis to be the LIMITING one, so the animation
   shrinks with the window instead of clipping when the stage goes narrow. */
function fitFraming(){
  const wr = document.getElementById('stageWrap');
  if (!wr) return;
  camera.fovMode = (wr.clientWidth >= wr.clientHeight)
    ? BABYLON.Camera.FOVMODE_VERTICAL_FIXED
    : BABYLON.Camera.FOVMODE_HORIZONTAL_FIXED;
}
fitFraming();
window.addEventListener('resize', ()=>{ fitFraming(); engine.resize(); drawGhost(); });
/* A window resize is not the only way the stage changes size: the panel, the
   wiki iframe and a devtools split all resize it without firing `resize`.
   Observing the stage itself covers every case. */
if (window.ResizeObserver) {
  new ResizeObserver(() => { fitFraming(); engine.resize(); drawGhost(); })
    .observe(document.getElementById('stageWrap'));
}
window.__lab = { push, zeros, HARMONICS, setAnim:v=>{animate=v;} };
</script>
</body>
</html>