app
Adom Chipsmith
Public Made by Adomby adom
STEP-native chip validator — 5-source cross-validation, OCCT-generated copyright-free 3D models with embedded signal annotations, datasheet-to-STEP pipeline.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>STEP Viewer</title>
<base href="./" />
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="adom.css" />
</head>
<body>
<script>
// SANITY: prove the page reached the parser. If you don't see "page loaded"
// in /console, the proxy or webview is the culprit, not the boot script.
fetch('console', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({level: 'log', text: 'page loaded ua=' + navigator.userAgent.slice(0, 80) + ' href=' + location.href})
}).catch(()=>{});
</script>
<!-- Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.
Single row: brand (icon + name + subtitle) | subject (file + meta) | actions.
Brand block hovers a popover with version / build / engine / wiki link.
Subject-meta hides on narrow viewports; subject-name then carries the
stats tooltip. Total height: 44 px. -->
<header>
<div class="brand" data-tooltip-target="app-info" tabindex="0">
<svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M3 7l9-4 9 4-9 4-9-4zm0 5l9 4 9-4M3 17l9 4 9-4M16 18.5v3M16 21.5h-1.5"/>
</svg>
<span class="app-name">adom-step</span>
<span class="sep">·</span>
<span class="app-subtitle">STEP file viewer</span>
</div>
<div class="vsep" aria-hidden="true"></div>
<div class="subject" data-tooltip-target="subject-info">
<span class="subject-name" id="topSource">No model loaded</span>
<span class="state-pill" id="topProvenance" data-tooltip="Where the loaded STEP came from. KiCad library = fetched from service-kicad. Local file = your file passed via `adom-step view`. Drop = a STEP file you dropped onto the canvas.">unknown</span>
<span class="subject-meta" id="topMeta"></span>
</div>
<!-- App-info popover (hover the brand block) -->
<div class="hover-popover" id="app-info" role="dialog" aria-label="App information">
<div class="popover-title">adom-step</div>
<div class="popover-row"><span class="k">about</span>
<span class="v">Fusion 360-style STEP file viewer with measure tool, components browser, and AI-driven inspection.</span></div>
<div class="popover-row"><span class="k">version</span><span class="v">v0.1.0</span></div>
<div class="popover-row"><span class="k">engine</span><span class="v">Adom 3D Viewer · Babylon.js 9.5</span></div>
<div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>
<div class="popover-actions">
<a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-step" target="_blank" rel="noopener">Open wiki ↗</a>
<a class="popover-link" href="https://github.com/adom-inc/adom-step" target="_blank" rel="noopener">GitHub ↗</a>
</div>
</div>
<!-- Subject-meta tooltip (hover the subject — surfaces meta when row is narrow) -->
<div class="hover-popover compact" id="subject-info" role="dialog">
<div class="popover-row"><span class="k">file</span><span class="v" id="tipFile">—</span></div>
<div class="popover-row"><span class="k">source</span><span class="v" id="tipSource">—</span></div>
<div class="popover-row"><span class="k">nodes</span><span class="v" id="tipNodes">—</span></div>
<div class="popover-row"><span class="k">size</span><span class="v" id="tipSize">—</span></div>
</div>
</header>
<div class="app">
<!-- ===== OUTLINE (left-side floating panel — Fusion 360 Browser style) ===== -->
<aside class="sidebar" id="outlinePanel">
<div class="sb-header">
<div class="sb-title-row">
<div class="sb-title"><span>Outline</span> <span class="count" id="compCount">—</span></div>
<button class="sb-collapse" id="outlineCollapse" aria-label="Collapse outline" data-tooltip="Collapse the outline (O) — slides the panel away so the 3D view fills the canvas. Click the tab at the left edge to expand.">❮</button>
</div>
<input class="sb-search" id="sbSearch" type="text" placeholder="Search outline" data-tooltip="Filter the assembly tree. Matching nodes stay opaque, non-matches dim. Ancestors of matches auto-expand." />
<div class="sb-filters" id="sbFilters">
<button class="sb-chip active" data-filter-type="all" data-tooltip="Show every part type (default).">All</button>
</div>
<div class="sb-source" id="sbSource">No model loaded</div>
</div>
<div class="sb-tree" id="sbTree"></div>
</aside>
<!-- Expand tab — only shown when the outline is collapsed. Fusion-style
slim vertical strip at the canvas's left edge with a chevron pointing
inward. Click expands the outline back. -->
<button class="outline-expander" id="outlineExpander" aria-label="Expand outline" data-tooltip="Expand the outline (O).">❯</button>
<!-- ===== CANVAS + HUD ===== -->
<div class="canvas-wrap">
<!-- Container for the canonical Babylon9 viewer's Svelte-mounted canvas.
Adom3DViewerBabylon9.init() takes a CONTAINER element (not a canvas)
and creates its own <canvas> inside, so we give it a plain div. -->
<div id="renderContainer" tabindex="0"></div>
<!-- Top-left toolbar — icon-only (Home / Front / Top / Iso are handled
by the canonical viewer's ViewCube top-right). Tooltips carry the
label + behaviour, so the canvas stays clean. Per
gallia/skills/human-ui-patterns/playbooks/icons-and-displays.md
§4b — every glyph is a custom-drawn monochrome SVG (currentColor)
at viewBox 0 0 24 24, stroke 1.6. NEVER Unicode emoji. -->
<div class="toolbar">
<button class="tb-btn icon measure" id="tbMeasure" aria-label="Measure" data-tooltip="Measure (M) — Fusion 360-style smart pick. Shows mm + mils.">
<!-- Calipers — two jaws + beam, the canonical EE measure tool. -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9 v3 a2 2 0 0 0 2 2 h12 a2 2 0 0 0 2 -2 v-3"/><path d="M4 9 h16"/><path d="M7 14 v3 M11 14 v4 M15 14 v3"/></svg>
</button>
<span class="tb-sep"></span>
<button class="tb-btn icon" id="tbGround" aria-label="Ground plane" data-tooltip="Ground plane + cast shadows. Off by default — flip on when you want a contact shadow under the part.">
<!-- Ground line + diagonal hatching beneath. -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 14 h18"/><path d="M6 14 l-2 4 M10 14 l-2 4 M14 14 l-2 4 M18 14 l-2 4 M22 14 l-2 4"/></svg>
</button>
<button class="tb-btn icon" id="tbBottomLight" aria-label="Bottom light" data-tooltip="Bottom-fill light (B) — illuminate the underside of the part. Useful for inspecting BGA balls / QFN pads / through-hole leg bottoms.">
<!-- Sun with rays pointing UP (light source below, rays going up
into the chip). -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="17" r="3"/><path d="M12 12 v-2 M12 22 v0 M7 17 h-2 M19 17 h2 M8.5 13.5 l-1.5 -1.5 M15.5 13.5 l1.5 -1.5"/></svg>
</button>
<button class="tb-btn icon active" id="tbAxes" aria-label="World axes" data-tooltip="World-origin axes (X red / Y green / Z blue). 3d-viewer-design §8a.">
<!-- 3-axis triad — Z up, X back-left, Y back-right (canonical
pseudo-3D origin marker). -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 14 v-9"/><path d="M12 14 l-7 4"/><path d="M12 14 l7 4"/><circle cx="12" cy="14" r="1.4" fill="currentColor"/></svg>
</button>
<button class="tb-btn icon" id="tbMeshAxes" aria-label="Local axes" data-tooltip="Per-mesh local-origin axes. 3d-viewer-design §8b — useful for spotting Y-up vs Z-up bugs.">
<!-- Triad inside a small mesh-body box (LOCAL frame = bound to
a single mesh, not the world). -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="13" width="8" height="8" rx="1"/><path d="M7 17 v-9 M7 17 l8 4 M7 17 l8 -4"/></svg>
</button>
<span class="tb-sep"></span>
<button class="tb-btn icon" id="tbInspect" aria-label="Babylon Inspector" data-tooltip="Babylon Inspector — open the materials / scene-graph / debug panel.">
<!-- Magnifier with a small data dot inside — chosen over the
generic gear because chipfit / chiplinter readers know
"inspect" reads as a magnifier. -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="5.5"/><path d="M14.5 14.5 L20 20"/><circle cx="10.5" cy="10.5" r="1.4" fill="currentColor"/></svg>
</button>
</div>
<!-- View presets handled by the canonical viewer's ViewCube (top-right
of canvas). No app-level Front/Top/Iso/Home buttons needed. -->
<!-- Drop zone overlay -->
<div class="dropzone">
<div class="dz-card">
Drop a .step / .stp file
<div class="dz-sub">Converted via service-step2glb · cached on disk by content hash</div>
</div>
</div>
<!-- Measure HUD -->
<div class="measure-hud" id="measureHud">
<div class="mh-head">
<span class="mh-title">Measure</span>
<span class="mh-close" id="measureClose" data-tooltip="Close (Esc)">✕</span>
</div>
<div class="mh-filters">
<button class="mb-filter active" data-filter="smart" data-tooltip="Smart pick — snaps to the nearest vertex, then edge, then face, in priority order. Fusion 360 default.">Smart</button>
<button class="mb-filter" data-filter="vertex" data-tooltip="Vertex — snaps your click to the closest mesh vertex within ~1.5 mm.">Vertex</button>
<button class="mb-filter" data-filter="edge" data-tooltip="Edge — snaps to the midpoint of the nearest edge of the picked face.">Edge</button>
<button class="mb-filter" data-filter="face" data-tooltip="Face — uses the raw click point on whatever face you picked.">Face</button>
<button class="mb-filter" data-filter="body" data-tooltip="Body — snaps to the bounding-box center of the whole picked mesh.">Body</button>
</div>
<div class="mh-snap" id="measureSnap" data-tooltip="Live indicator of what the next click will snap to. Smart mode tries edges first, then faces, then vertexes.">
<span class="mh-snap-label">Snap:</span>
<span class="mh-snap-kind" id="measureSnapKind">—</span>
</div>
<div class="mh-body" id="measureBody">
<div class="mh-hint">Click two points on the model.</div>
</div>
</div>
<!-- Section feature was removed (May 2026): user wanted a Fusion 360-grade
section (visible plane gizmo + draggable + cap fill) which is a much
bigger refactor. Rather than ship a half-baked clip-plane HUD, the
feature is gone entirely until that proper implementation arrives. -->
<!-- Inspect HUD (amber, follows cursor) -->
<div class="inspect-card" id="inspectCard">
<div class="ic-head">
<div class="ic-name" id="icName">—</div>
<div class="ic-path" id="icPath"></div>
</div>
<div class="ic-body" id="icBody"></div>
<div class="ic-foot">Click to pin · Esc to dismiss</div>
</div>
<!-- Splash while the model loads -->
<div class="splash" id="splash">
<div class="spinner"></div>
<div id="splashMsg">Loading 3D viewer…</div>
</div>
<div class="toast" id="toast"></div>
<!-- Right-click context menu for tree rows AND for the 3D canvas. -->
<div class="ctx-menu" id="ctxMenu">
<div class="ctx-item" data-act="reveal" data-tooltip="Find this item in the Outline panel and flash it. Useful when you've hidden something and need to find its row to restore visibility.">Reveal in Outline</div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-act="isolate" data-tooltip="Hide everything except this item. Click again to un-isolate. Ctrl+Z to undo.">Isolate</div>
<div class="ctx-item" data-act="hide-others" data-tooltip="Hide every other top-level item. Ctrl+Z to undo.">Hide Others</div>
<div class="ctx-item" data-act="show" data-tooltip="Show this subtree. Ctrl+Z to undo.">Show</div>
<div class="ctx-item" data-act="hide" data-tooltip="Hide this subtree. The row will flash in the outline so you can find it again to restore. Ctrl+Z also undoes.">Hide</div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-act="zoom" data-tooltip="Frame the camera tight on this item.">Zoom To</div>
<div class="ctx-item" data-act="copy-name" data-tooltip="Copy the item's name to clipboard.">Copy Name</div>
<div class="ctx-item" data-act="copy-json" data-tooltip="Copy the item's full metadata as a JSON blob — id, kind, depth, mesh count, bbox, role tags, overlay group (if any). Paste-able into bug reports.">Copy as JSON</div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-act="properties" data-tooltip="Pin the inspect card showing this item's bbox, material, and geometry stats.">Properties</div>
</div>
</div>
</div>
<!-- =====================================================================
The canonical Adom Babylon9 viewer is loaded as an ES module.
It exposes window.Adom3DViewerBabylon9 + window.BABYLON.
===================================================================== -->
<script type="module" src="viewer/adom-3d-viewer-babylon9.esm.js"></script>
<script>
'use strict';
// =====================================================================
// 0. Tooltip system — body-appended position:fixed div per
// human-ui-patterns §1d. Replaces title= attributes (which §1a bans).
// 500ms reveal delay (§1b), cursor-anchored (§1d), suppresses
// ancestor tooltips when inner element has its own (§1f), never
// ALL CAPS (§1e — handled in CSS via text-transform: none).
// =====================================================================
(function setupTooltips() {
const tip = document.createElement('div');
tip.id = 'adom-tooltip';
tip.style.cssText = [
'position: fixed',
'z-index: 99999', /* literal — above any --z-tooltip token */
'pointer-events: none',
'background: rgba(32, 36, 43, 0.97)',
'color: #e6e8eb',
'border: 1px solid #00b8b1', /* brand teal */
'border-radius: 4px',
'padding: 6px 9px',
'font-size: 11.5px',
'line-height: 1.35',
'max-width: min(320px, calc(100vw - 40px))',
'box-shadow: 0 6px 18px rgba(0,0,0,0.5)',
'opacity: 0',
'transition: opacity 120ms ease-out',
'text-transform: none', /* §1e — never inherit uppercase */
'letter-spacing: 0',
'font-family: inherit',
'white-space: pre-line', /* honor \n in data-tooltip strings as line breaks */
].join(';');
document.body.appendChild(tip);
let showTimer = 0;
let lastTrigger = null;
function position(x, y) {
const off = 14;
const rect = tip.getBoundingClientRect();
const W = window.innerWidth, H = window.innerHeight;
let left = x + off, top = y + off;
if (left + rect.width > W - 8) left = x - rect.width - off;
if (top + rect.height > H - 8) top = y - rect.height - off;
if (left < 8) left = 8;
if (top < 8) top = 8;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
}
function hide() {
clearTimeout(showTimer);
showTimer = 0;
tip.style.opacity = '0';
lastTrigger = null;
}
document.addEventListener('mousemove', (e) => {
const el = e.target.closest && e.target.closest('[data-tooltip]');
if (!el) { hide(); return; }
const text = el.getAttribute('data-tooltip');
if (!text) { hide(); return; }
if (el !== lastTrigger) {
lastTrigger = el;
tip.textContent = text;
tip.style.opacity = '0';
clearTimeout(showTimer);
showTimer = setTimeout(() => {
position(e.clientX, e.clientY);
tip.style.opacity = '1';
}, 500);
} else if (tip.style.opacity === '1') {
position(e.clientX, e.clientY);
}
});
document.addEventListener('mouseleave', hide);
document.addEventListener('mousedown', hide);
window.addEventListener('blur', hide);
})();
// =====================================================================
// 1. Console forwarder — every log goes to the server's ring buffer so
// `adom-step console -f` can tail it. (Pattern from adom-tsci.)
// =====================================================================
(function setupConsoleForwarder() {
const origLog = console.log.bind(console);
const origWarn = console.warn.bind(console);
const origErr = console.error.bind(console);
function send(level, args) {
const text = args.map(a => {
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch (e) { return String(a); }
}).join(' ');
fetch('console', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level, text }),
keepalive: true,
}).catch(() => {});
}
console.log = (...a) => { send('log', a); origLog(...a); };
console.warn = (...a) => { send('warn', a); origWarn(...a); };
console.error = (...a) => { send('error', a); origErr(...a); };
window.addEventListener('error', (e) => { send('error', [e.message, e.filename, e.lineno]); });
window.addEventListener('unhandledrejection', (e) => { send('error', ['unhandled rejection:', String(e.reason)]); });
})();
// =====================================================================
// 2. Boot — wait for the viewer module, init the scene, kick off the
// initial model load, register all UI behaviors.
// =====================================================================
function setSourceLabel(source, sourceType) {
const txt = source || 'No model loaded';
document.getElementById('sbSource').textContent = txt;
// The header subject-name shows the basename only — the full source path
// moves into the subject-info popover.
const basename = txt.includes('/') ? txt.split('/').pop() : txt;
document.getElementById('topSource').textContent = basename;
const tipFile = document.getElementById('tipFile');
const tipSource = document.getElementById('tipSource');
if (tipFile) tipFile.textContent = basename;
if (tipSource) tipSource.textContent = txt;
const pill = document.getElementById('topProvenance');
const t = sourceType || 'unknown';
pill.dataset.sourceType = t;
// Friendly label per type.
const labels = {
'kicad-library': 'KiCad library',
'local-file': 'Local file',
'upload': 'Drop',
'unknown': 'unknown',
};
pill.textContent = labels[t] || t;
}
// Update the header subject-meta + tooltip rows with current scene stats.
// Called after a model finishes loading.
function setSubjectMeta(stats) {
const meta = document.getElementById('topMeta');
const tipNodes = document.getElementById('tipNodes');
const tipSize = document.getElementById('tipSize');
if (!stats) {
if (meta) meta.innerHTML = '';
if (tipNodes) tipNodes.textContent = '—';
if (tipSize) tipSize.textContent = '—';
return;
}
const parts = [];
if (stats.nodes != null) parts.push(`${stats.nodes} nodes`);
if (stats.size) parts.push(stats.size);
if (meta) {
meta.innerHTML = parts.map((p, i) => i === 0 ? `<span class="sep">·</span><span>${p}</span>` : `<span class="sep">·</span><span>${p}</span>`).join('');
}
if (tipNodes) tipNodes.textContent = stats.nodes != null ? String(stats.nodes) : '—';
if (tipSize) tipSize.textContent = stats.size || '—';
}
// Interactive hover-popover. SHOW_DELAY = 500ms (human-ui-patterns §1b
// — intentional hovers get the popover, fly-bys don't); HIDE_GRACE =
// 220ms so the user can cross the gap into the popover and click the
// wiki link without dismissing it.
(function attachHeaderPopovers(){
if (typeof document === 'undefined') return;
const SHOW_DELAY = 500;
const HIDE_GRACE = 220;
const wire = () => {
document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {
if (trigger.__popoverWired) return;
trigger.__popoverWired = true;
const target = document.getElementById(trigger.dataset.tooltipTarget);
if (!target) return;
let showTimer = null, hideTimer = null;
const show = () => {
clearTimeout(hideTimer);
if (target.classList.contains('show')) return;
clearTimeout(showTimer);
showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);
};
const queueHide = () => {
clearTimeout(showTimer);
clearTimeout(hideTimer);
hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);
};
trigger.addEventListener('mouseenter', show);
trigger.addEventListener('mouseleave', queueHide);
trigger.addEventListener('focus', show);
trigger.addEventListener('blur', queueHide);
target.addEventListener('mouseenter', show);
target.addEventListener('mouseleave', queueHide);
trigger.addEventListener('click', e => {
clearTimeout(showTimer);
if (target.classList.contains('show')) target.classList.remove('show');
else target.classList.add('show');
e.stopPropagation();
});
});
document.addEventListener('click', e => {
document.querySelectorAll('header .hover-popover.show').forEach(p => {
if (!p.contains(e.target)) {
const trigger = document.querySelector(`[data-tooltip-target="${p.id}"]`);
if (!trigger || !trigger.contains(e.target)) p.classList.remove('show');
}
});
}, true);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
else wire();
})();
function showToast(msg, kind) {
const t = document.getElementById('toast');
t.className = 'toast open' + (kind ? ' ' + kind : '');
t.textContent = msg;
clearTimeout(t._timer);
t._timer = setTimeout(() => { t.classList.remove('open'); }, 3500);
}
function setSplash(msg) {
const s = document.getElementById('splash');
if (msg === null) { s.style.display = 'none'; return; }
s.style.display = 'flex';
document.getElementById('splashMsg').textContent = msg;
}
async function waitForViewer() {
const t0 = performance.now();
while (!window.Adom3DViewerBabylon9 || !window.BABYLON) {
if (performance.now() - t0 > 15000) {
throw new Error('Adom3DViewerBabylon9 module never loaded after 15s');
}
await new Promise(r => setTimeout(r, 50));
}
}
let viewer = null; // ViewerInstance from Adom3DViewerBabylon9.init()
let scene = null;
let BABYLON = null;
let modelRoots = []; // top-level TransformNodes from loaded GLBs
let assemblyTree = []; // flat list of {id, name, depth, parent, children, meshes, visible}
let nodeById = new Map();
let materialsCache = []; // from /state.materials
let visibilityHistory = []; // undo stack of {prev, next, label}
let visibilityRedo = [];
async function boot() {
setSplash('Loading 3D viewer…');
await waitForViewer();
BABYLON = window.BABYLON;
const container = document.getElementById('renderContainer');
// App-level override of 3d-viewer-design §4d: skill says ground + shadow
// default ON for PCB viewers. For a single-component STEP viewer the
// ground dominates the iso frame at default zoom and washes the §3b
// gradient, so we ship it OFF and let the user toggle it on via the
// Ground button when they want a contact shadow. The toggle still works.
//
// NOTE: Adom3DViewerBabylon9.init() takes a CONTAINER element. Passing
// a <canvas> would nest a 0×0 canvas inside it that nobody can see.
viewer = window.Adom3DViewerBabylon9.init(container, {
zUp: true,
showViewCube: true,
showGround: false,
initialViewMode: 'perspective',
});
scene = viewer.getScene();
// The actual rendering canvas is now a child of #renderContainer; we
// keep a handle to it for sizing + DOM-overlay measurements.
const canvas = container.querySelector('canvas');
applyGradientBackground(scene);
function resizeCanvas() {
const w = container.clientWidth;
const h = container.clientHeight;
if (canvas) {
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
}
if (viewer.getEngine) {
const eng = viewer.getEngine();
if (eng && eng.resize) eng.resize();
}
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// Try to load the initial model from /state.
await loadInitialModel();
// Wire all UI.
setupSidebar();
setupContextMenu();
setupToolbar();
// View buttons removed — ViewCube handles all view presets.
setupDropzone();
setupMeasureTool();
setupInspectTool();
// (Section tool removed.)
setupKeyboard();
setupEvalPolling();
setupCommandPolling();
setSplash(null);
showToast('Ready', 'ok');
}
function applyGradientBackground(scene) {
// Build the gradient texture: 4×512 vertical strip, top → bottom.
const W = 4, H = 512;
const tex = new BABYLON.DynamicTexture('bgGradient', { width: W, height: H }, scene, false);
const ctx = tex.getContext();
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#5a6b7e'); // §3b top — medium steel blue-gray
grad.addColorStop(1, '#2a3340'); // §3b bottom — darker steel
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
tex.update();
// Render-behind layer (drawn before the scene meshes).
const layer = new BABYLON.Layer('bgGradientLayer', null, scene, true /* isBackground */);
layer.texture = tex;
// Hide the canonical viewer's BackgroundHelper + BackgroundSkybox so the
// gradient is visible. The env texture (scene.environmentTexture) still
// lights materials via PBR — we're only suppressing the *visible* skybox.
// Re-applies on every frame because the canonical viewer recreates these
// meshes inside frameModel().
scene.onBeforeRenderObservable.add(() => {
for (const m of scene.meshes) {
if (m.name === 'BackgroundHelper' || m.name === 'BackgroundSkybox') {
if (m.isEnabled()) m.setEnabled(false);
}
}
});
}
async function loadInitialModel() {
setSplash('Loading model…');
let state;
try {
state = await fetch('state').then(r => r.json());
} catch (e) {
setSplash('Failed to fetch /state — is the server up?');
return;
}
setSourceLabel(state.source, state.source_type);
materialsCache = state.materials || [];
if (state.has_model) {
await loadModelUrl('model.glb?cb=' + Date.now());
} else {
setSplash('Drop a .step / .stp file onto the canvas');
}
}
async function loadModelUrl(url) {
setSplash('Loading model…');
// Clear previous content
if (modelRoots.length) {
viewer.clearScene();
modelRoots = [];
assemblyTree = [];
nodeById = new Map();
}
// Snapshot the scene before load so we can diff in the new nodes only.
// This is the reliable way to identify the user's model — any name-based
// filter falls over the moment a STEP file legitimately names a part
// "arrow" or "cylinder" (e.g. mechanical hardware libraries).
const beforeIds = new Set();
for (const n of scene.transformNodes) beforeIds.add(n.uniqueId);
for (const m of scene.meshes) beforeIds.add(m.uniqueId);
try {
await viewer.loadModel(url);
} catch (e) {
console.error('loadModel failed:', e);
setSplash('Failed to load model — see console');
showToast('Load failed: ' + e.message, 'err');
return;
}
// Babylon's GLB loader inserts a synthetic `__root__` whose children are
// the GLB's true top-level nodes. Find every fresh `__root__` (not in the
// before-snapshot) and lift its direct children as our model roots.
// This is reliable across viewer-internal nodes (axis helpers, indicator
// sphere, viewcube) that may also be (re)created during loadModel.
modelRoots = [];
const allNodes = scene.transformNodes.concat(scene.meshes);
for (const r of allNodes) {
if (r.name !== '__root__') continue;
if (beforeIds.has(r.uniqueId)) continue;
for (const c of allNodes) {
if (c.parent === r && !beforeIds.has(c.uniqueId)) modelRoots.push(c);
}
}
// Fallback: if no __root__ was created (some loaders skip it), fall back
// to "any new node with no new ancestor" — same diff approach as before.
if (!modelRoots.length) {
const newNodes = allNodes.filter(n => !beforeIds.has(n.uniqueId));
const newIds = new Set(newNodes.map(n => n.uniqueId));
modelRoots = newNodes.filter(n => {
let p = n.parent;
while (p) {
if (newIds.has(p.uniqueId)) return false;
p = p.parent;
}
return true;
});
}
rebuildAssemblyTree();
renderTree();
postComponentsToServer();
setSplash(null);
}
// =====================================================================
// 3. Assembly tree — walk scene.transformNodes recursively.
// =====================================================================
function rebuildAssemblyTree() {
assemblyTree = [];
nodeById = new Map();
function friendlyName(node) {
if (node.name && node.name !== '__root__' && !node.name.startsWith('node_')) return node.name;
if (node.metadata && node.metadata.gltf && node.metadata.gltf.extras && node.metadata.gltf.extras.name) {
return node.metadata.gltf.extras.name;
}
const meshes = node.getChildMeshes ? node.getChildMeshes(true) : [];
if (meshes.length && meshes[0].name) return meshes[0].name;
return 'Component_' + node.uniqueId;
}
function isMeshWithGeometry(n) {
return n && n.getTotalVertices && n.getTotalVertices() > 0;
}
function walk(node, depth, parentEntry) {
const id = node.uniqueId;
const directMeshes = (node.getChildMeshes ? node.getChildMeshes(true) : []).filter(m => m.parent === node);
const allDescendantMeshes = (node.getChildMeshes ? node.getChildMeshes(false) : []);
// If the node ITSELF is a Mesh with geometry, include it. Babylon 9's
// Mesh extends TransformNode, so leaf primitives walked by
// getChildTransformNodes are nodes whose own geometry needs to count.
if (isMeshWithGeometry(node) && !allDescendantMeshes.includes(node)) {
allDescendantMeshes.push(node);
}
const entry = {
id,
node,
name: friendlyName(node),
depth,
parentId: parentEntry ? parentEntry.id : null,
children: [],
directMeshes,
allMeshes: allDescendantMeshes,
visible: true,
expanded: depth < 1, // root expanded by default
};
nodeById.set(id, entry);
assemblyTree.push(entry);
if (parentEntry) parentEntry.children.push(entry);
const childTNs = (node.getChildTransformNodes ? node.getChildTransformNodes(true) : []);
for (const c of childTNs) {
if (c.parent !== node) continue;
walk(c, depth + 1, entry);
}
}
for (const root of modelRoots) {
walk(root, 0, null);
}
// Post-process: many GLBs (especially STEPs from kicad) ship as one
// top-level node with hundreds of material-split leaf primitives. That
// floods the tree with noise. Replace any "many leaves under one parent"
// with material-grouped synthetic entries (Body / Pins / Contacts / ...).
// Real assemblies with named sub-parts are left alone.
collapseLeafPrimitives();
}
function inferMaterialType(name, baseColor) {
const n = (name || '').toLowerCase();
if (baseColor && baseColor.length >= 3) {
const [r, g, b] = baseColor;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
// Gold / copper / brass: red > green > blue, fairly saturated
if (r > 0.55 && g > 0.35 && b < 0.45 && r > b + 0.15) return { type: 'contact', label: 'Contacts' };
// Silver / chrome / tin: near-equal RGB and bright
if (Math.abs(r - g) < 0.12 && Math.abs(g - b) < 0.12 && lum > 0.5) return { type: 'pin', label: 'Pins' };
// Bright red marker dot
if (r > 0.55 && g < 0.3 && b < 0.3) return { type: 'marker', label: 'Markers' };
// Dark plastic body
if (lum < 0.22) return { type: 'body', label: 'Body' };
}
if (/pin|lead|leg|contact/.test(n)) return { type: 'pin', label: 'Pins' };
if (/body|case|plastic|epoxy|mold/.test(n)) return { type: 'body', label: 'Body' };
if (/gold|copper|brass|pad/.test(n)) return { type: 'contact', label: 'Contacts' };
if (/mark|dot|text|silk/.test(n)) return { type: 'marker', label: 'Markers' };
return { type: 'other', label: 'Other' };
}
let synthIdCounter = -1;
function meshCentroid(m) {
if (!m.getBoundingInfo) return null;
m.computeWorldMatrix(true);
const bb = m.getBoundingInfo().boundingBox;
return {
x: (bb.minimumWorld.x + bb.maximumWorld.x) * 0.5,
y: (bb.minimumWorld.y + bb.maximumWorld.y) * 0.5,
z: (bb.minimumWorld.z + bb.maximumWorld.z) * 0.5,
};
}
// Cluster primitives into individual pins. KiCad gull-wing leads have a
// body-side junction at one Z and a bent foot at a lower Z, with the foot
// shifted outward — so XY-distance clustering sees them as 2 separate pins
// per real pin. Correct approach: classify each primitive by the side of
// the body it lives on, then 1D-cluster along the pitch axis (perpendicular
// to the side normal). Returns clusters with { id, meshes, centroid, side }.
function clusterPinsByCentroid(meshes) {
if (!meshes || meshes.length < 2) return [];
const items = meshes.map((m, i) => ({ idx: i, mesh: m, c: meshCentroid(m) })).filter(x => x.c);
if (!items.length) return [];
// Body bounds from centroid spread (good enough since pins surround the body).
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const it of items) {
if (it.c.x < minX) minX = it.c.x; if (it.c.x > maxX) maxX = it.c.x;
if (it.c.y < minY) minY = it.c.y; if (it.c.y > maxY) maxY = it.c.y;
}
const bodyCx = (minX + maxX) * 0.5;
const bodyCy = (minY + maxY) * 0.5;
// Classify side of each primitive by which axis-distance from center is larger.
// Front/back pins live on -Y/+Y face — pitch axis is X.
// Left/right pins live on -X/+X face — pitch axis is Y.
const bySide = { front: [], back: [], left: [], right: [] };
for (const it of items) {
const dx = it.c.x - bodyCx, dy = it.c.y - bodyCy;
let side;
if (Math.abs(dx) > Math.abs(dy)) side = dx > 0 ? 'right' : 'left';
else side = dy > 0 ? 'back' : 'front';
bySide[side].push(it);
}
// For each side, cluster along the pitch axis only.
function clusterAlongAxis(list, axis /* 'x' or 'y' */) {
if (!list.length) return [];
// Estimate pitch: sort by axis, find smallest gap that's "real" (not within-pin).
const sorted = list.slice().sort((a, b) => a.c[axis] - b.c[axis]);
// First clue at pitch: median gap between adjacent CENTROIDS that differ enough.
const gaps = [];
for (let i = 1; i < sorted.length; i++) {
const g = sorted[i].c[axis] - sorted[i - 1].c[axis];
if (g > 0) gaps.push(g);
}
gaps.sort((a, b) => a - b);
// Pitch ≈ median of upper half of gaps (within-pin gaps cluster near zero;
// between-pin gaps cluster at the real pitch).
const upper = gaps.slice(Math.floor(gaps.length / 2));
const pitchEst = upper.length ? upper[Math.floor(upper.length / 2)] : 0;
const eps = pitchEst > 0 ? pitchEst * 0.4 : 0.0003; // 40% of pitch
// 1D clustering: walk sorted list, start a new cluster when gap > eps.
const clusters = [];
let cur = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const g = sorted[i].c[axis] - sorted[i - 1].c[axis];
if (g > eps) { clusters.push(cur); cur = []; }
cur.push(sorted[i]);
}
if (cur.length) clusters.push(cur);
return clusters;
}
const allClusters = [];
function addClusters(list, axis, sideName) {
const cs = clusterAlongAxis(list, axis);
for (const list2 of cs) {
const meshes2 = list2.map(it => it.mesh);
let cx = 0, cy = 0, cz = 0;
for (const it of list2) { cx += it.c.x; cy += it.c.y; cz += it.c.z; }
cx /= list2.length; cy /= list2.length; cz /= list2.length;
allClusters.push({ meshes: meshes2, centroid: { x: cx, y: cy, z: cz }, side: sideName });
}
}
addClusters(bySide.front, 'x', 'front');
addClusters(bySide.right, 'y', 'right');
addClusters(bySide.back, 'x', 'back');
addClusters(bySide.left, 'y', 'left');
// Order pins counterclockwise starting at front-left (pin1 conventional location).
const sideOrder = { front: 0, right: 1, back: 2, left: 3 };
allClusters.sort((a, b) => {
if (a.side !== b.side) return sideOrder[a.side] - sideOrder[b.side];
if (a.side === 'front') return a.centroid.x - b.centroid.x;
if (a.side === 'right') return a.centroid.y - b.centroid.y;
if (a.side === 'back') return b.centroid.x - a.centroid.x;
return b.centroid.y - a.centroid.y;
});
allClusters.forEach((c, i) => { c.id = i + 1; });
return allClusters;
}
let pinClusters = [];
// 2D grid clustering for LGA / BGA / module contacts arranged on a flat
// face (NOT side-mounted leads). Each contact becomes one cluster. Uses
// union-find with eps ≈ pad_pitch/2 estimated from bbox extent and
// primitive count. Reused by the strobe / isolatePin / pins-list APIs so
// the same UX applies to leaded chips and grid-pad chips alike.
function clusterContactsByGrid(meshes) {
if (!meshes || meshes.length < 2) return [];
const items = meshes.map((m, i) => ({ idx: i, mesh: m, c: meshCentroid(m) })).filter(x => x.c);
if (!items.length) return [];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const it of items) {
if (it.c.x < minX) minX = it.c.x; if (it.c.x > maxX) maxX = it.c.x;
if (it.c.y < minY) minY = it.c.y; if (it.c.y > maxY) maxY = it.c.y;
}
const bodyCx = (minX + maxX) * 0.5;
const bodyCy = (minY + maxY) * 0.5;
// Pitch estimation in 3D using BIMODAL SPLIT of nearest-neighbor
// distances. Within-pad NNs cluster at the small end; between-pad NNs
// at the larger end. Find the largest ratio-gap between consecutive
// sorted NN values — that's the natural split. Set eps just above the
// small group's max, just below the large group's min.
const nnDists = [];
for (let i = 0; i < items.length; i++) {
let best = Infinity;
for (let j = 0; j < items.length; j++) {
if (i === j) continue;
const dx = items[i].c.x - items[j].c.x;
const dy = items[i].c.y - items[j].c.y;
const dz = items[i].c.z - items[j].c.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 < best) best = d2;
}
if (best < Infinity && best > 1e-18) nnDists.push(Math.sqrt(best));
}
nnDists.sort((a, b) => a - b);
let eps;
if (nnDists.length < 2) {
eps = (nnDists[0] || 0.001) * 0.5;
} else {
// Find the largest RATIO gap. A 1.5x or larger jump between adjacent
// sorted NN distances is a strong signal of "within-pad ends here,
// between-pad starts here".
let bestRatio = 1, splitIdx = -1;
for (let i = 1; i < nnDists.length; i++) {
if (nnDists[i - 1] < 1e-12) continue;
const ratio = nnDists[i] / nnDists[i - 1];
if (ratio > bestRatio) { bestRatio = ratio; splitIdx = i; }
}
if (bestRatio >= 1.5 && splitIdx > 0) {
// eps = midpoint between the last within-pad NN and the first
// between-pad NN, on a log scale (geometric mean).
eps = Math.sqrt(nnDists[splitIdx - 1] * nnDists[splitIdx]);
} else {
// No clear gap — fall back to 80th percentile × 0.5 (safer than 60).
const p = nnDists[Math.floor(nnDists.length * 0.8)];
eps = p * 0.5;
}
}
eps = Math.max(eps, 0.00005);
const eps2 = eps * eps;
const cellSize = eps * 2;
const buckets = new Map();
function key(x, y, z) {
return Math.floor(x / cellSize) + ',' + Math.floor(y / cellSize) + ',' + Math.floor(z / cellSize);
}
items.forEach((it, i) => {
const k = key(it.c.x, it.c.y, it.c.z);
if (!buckets.has(k)) buckets.set(k, []);
buckets.get(k).push(i);
});
const parent = items.map((_, i) => i);
function find(i) { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }
function union(a, b) { a = find(a); b = find(b); if (a !== b) parent[a] = b; }
for (let i = 0; i < items.length; i++) {
const a = items[i];
const cx = Math.floor(a.c.x / cellSize),
cy = Math.floor(a.c.y / cellSize),
cz = Math.floor(a.c.z / cellSize);
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
for (let dz = -1; dz <= 1; dz++) {
const k = (cx + dx) + ',' + (cy + dy) + ',' + (cz + dz);
const list = buckets.get(k);
if (!list) continue;
for (const j of list) {
if (j <= i) continue;
const b = items[j];
const ddx = a.c.x - b.c.x, ddy = a.c.y - b.c.y, ddz = a.c.z - b.c.z;
if (ddx * ddx + ddy * ddy + ddz * ddz <= eps2) union(i, j);
}
}
}
const groups = new Map();
for (let i = 0; i < items.length; i++) {
const r = find(i);
if (!groups.has(r)) groups.set(r, []);
groups.get(r).push(items[i]);
}
const clusters = [];
for (const list of groups.values()) {
const meshes = list.map(it => it.mesh);
let cx = 0, cy = 0, cz = 0;
for (const it of list) { cx += it.c.x; cy += it.c.y; cz += it.c.z; }
cx /= list.length; cy /= list.length; cz /= list.length;
// Side classification for grid contacts: 'bottom' is conventional
// for LGA/BGA, but if the centroids span both top and bottom Z layers
// we tag each cluster with whichever face it's nearest to.
clusters.push({ meshes, centroid: { x: cx, y: cy, z: cz }, side: 'bottom' });
}
// Row-major order from front-left → back-right (similar to common
// BGA pad numbering A1 at top-left going right then down).
clusters.sort((a, b) => {
const dy = a.centroid.y - b.centroid.y;
if (Math.abs(dy) > 0.00001) return dy;
return a.centroid.x - b.centroid.x;
});
clusters.forEach((c, i) => { c.id = i + 1; });
return clusters;
}
// =====================================================================
// Geometry-based THT pin discovery — Mode B.
//
// Color-based clustering (Mode A) misses pins on STEPs where the
// manufacturer modeled the pin legs in the same material as the body
// (e.g. WAGO terminal blocks: 50 silver "spring contact" meshes ≠ the
// 5 actual through-hole solder legs, which sit in the gray body
// material). Mode B walks ALL meshes and finds tall-thin sub-meshes
// poking BELOW the part's seating plane. Material color is irrelevant.
//
// Pin candidate filter (units = scene meters):
// • Mesh's bbox-min Z is within 0.8 mm of the model's bottom plane
// (bottommost mesh in the scene).
// • Cross-section ≤ 1.5 mm: max(width_x, depth_y) ≤ 0.0015.
// • Height ≥ 0.8 mm: bbox_z extent ≥ 0.0008.
// • Aspect ratio: height / max(w, d) ≥ 1.2 (taller than wide).
//
// Sub-meshes of the same pin (chamfer + shaft + tip) get merged when
// their XY centroids are within 1.0 mm — typical sub-mesh jitter for
// a single THT lead is 0.2-0.3 mm; 1.0 mm is well below 2.0 mm pitch
// (the smallest standard THT pitch found on connectors).
// =====================================================================
function discoverThruHolePins() {
// Skip viewer's helper meshes (axis arrows, viewcube faces, gizmos).
const allMeshes = scene.meshes.filter(m =>
m.geometry && m.getTotalVertices() > 0 && m.material &&
!/Axis|sphere|Sky|grid|axis|viewcube|gizmo/i.test(m.material.name || '') &&
!/__viewer__/i.test(m.name || '')
);
if (allMeshes.length < 3) return [];
// Find the part's seating plane = lowest Z of any real mesh.
let modelMinZ = Infinity;
for (const m of allMeshes) {
const z = m.getBoundingInfo().boundingBox.minimumWorld.z;
if (z < modelMinZ) modelMinZ = z;
}
// Filter to pin-shaped meshes near the seating plane.
const PIN_FOOT_MAX = 0.0015; // 1.5 mm cross-section ceiling
const PIN_HEIGHT_MIN = 0.0008; // 0.8 mm tall minimum
const PIN_ASPECT_MIN = 1.2;
const SEAT_TOLERANCE = 0.0008; // 0.8 mm — bottom of pin must be near bottom of part
const candidates = [];
for (const m of allMeshes) {
const bb = m.getBoundingInfo().boundingBox;
const bottomZ = bb.minimumWorld.z;
if (bottomZ > modelMinZ + SEAT_TOLERANCE) continue;
const w = bb.maximumWorld.x - bb.minimumWorld.x;
const d = bb.maximumWorld.y - bb.minimumWorld.y;
const h = bb.maximumWorld.z - bb.minimumWorld.z;
const foot = Math.max(w, d);
if (foot > PIN_FOOT_MAX) continue;
if (h < PIN_HEIGHT_MIN) continue;
if (h / Math.max(foot, 1e-6) < PIN_ASPECT_MIN) continue;
candidates.push({
mesh: m,
c: { x: bb.centerWorld.x, y: bb.centerWorld.y, z: bb.centerWorld.z },
});
}
if (candidates.length < 2) return [];
// Union-find merge by XY proximity. eps = 1.0 mm.
const eps = 0.0010;
const eps2 = eps * eps;
const parent = candidates.map((_, i) => i);
function find(i) { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }
for (let i = 0; i < candidates.length; i++) {
for (let j = i + 1; j < candidates.length; j++) {
const ddx = candidates[i].c.x - candidates[j].c.x;
const ddy = candidates[i].c.y - candidates[j].c.y;
if (ddx * ddx + ddy * ddy <= eps2) {
const a = find(i), b = find(j);
if (a !== b) parent[a] = b;
}
}
}
const groups = new Map();
for (let i = 0; i < candidates.length; i++) {
const r = find(i);
if (!groups.has(r)) groups.set(r, []);
groups.get(r).push(candidates[i]);
}
// Build clusters. The "side" tag is used downstream to render side
// badges in the outline; for THT inline parts we tag every cluster
// 'bottom' (matches Mode B's grid behavior) — the per-side semantics
// of leaded chips don't apply.
const clusters = [];
for (const list of groups.values()) {
let cx = 0, cy = 0, cz = 0;
for (const it of list) { cx += it.c.x; cy += it.c.y; cz += it.c.z; }
cx /= list.length; cy /= list.length; cz /= list.length;
clusters.push({
meshes: list.map(it => it.mesh),
centroid: { x: cx, y: cy, z: cz },
side: 'bottom',
});
}
// Number along the dominant pitch axis (X if X-spread > Y-spread).
// For a 1×N inline connector, pin 1 conventionally sits at the lowest
// index along the long axis.
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const c of clusters) {
if (c.centroid.x < minX) minX = c.centroid.x; if (c.centroid.x > maxX) maxX = c.centroid.x;
if (c.centroid.y < minY) minY = c.centroid.y; if (c.centroid.y > maxY) maxY = c.centroid.y;
}
const xSpread = maxX - minX, ySpread = maxY - minY;
if (xSpread >= ySpread) {
clusters.sort((a, b) => a.centroid.x - b.centroid.x);
} else {
clusters.sort((a, b) => a.centroid.y - b.centroid.y);
}
clusters.forEach((c, i) => { c.id = i + 1; });
return clusters;
}
function collapseLeafPrimitives() {
const before = assemblyTree.slice();
const newTree = [];
const newById = new Map();
function visit(entry, parent) {
const cloned = Object.assign({}, entry, { children: [] });
newById.set(cloned.id, cloned);
newTree.push(cloned);
if (parent) parent.children.push(cloned);
const leafChildren = entry.children.filter(c => c.children.length === 0);
const nonLeafChildren = entry.children.filter(c => c.children.length > 0);
// Group leaves by material if the count is "noisy" (>= 6 leaves).
if (leafChildren.length >= 6 && !nonLeafChildren.length) {
const groups = new Map();
for (const c of leafChildren) {
const mesh = c.allMeshes[0];
const mat = mesh && mesh.material;
const matName = mat ? (mat.name || 'unnamed') : 'no-material';
let color = null;
if (mat) {
const c3 = mat.albedoColor || mat.diffuseColor;
if (c3) color = [c3.r, c3.g, c3.b];
}
if (!color) {
const m = materialsCache.find(x => x.name === matName);
if (m && m.baseColor) color = m.baseColor;
}
const inf = inferMaterialType(matName, color);
const key = matName + '|' + inf.type;
if (!groups.has(key)) {
groups.set(key, {
id: synthIdCounter--,
node: null,
isMaterialGroup: true,
name: inf.label,
matName,
type: inf.type,
baseColor: color,
depth: cloned.depth + 1,
parentId: cloned.id,
children: [],
directMeshes: [],
allMeshes: [],
visible: true,
expanded: false,
primitiveCount: 0,
});
}
const g = groups.get(key);
g.allMeshes.push(...c.allMeshes);
g.directMeshes.push(...c.directMeshes);
g.primitiveCount += 1;
}
// Order groups: Body, Pins, Contacts, Markers, Other (visual hierarchy)
const order = { body: 0, pin: 1, contact: 2, marker: 3, other: 4 };
const sorted = Array.from(groups.values()).sort((a, b) => (order[a.type] - order[b.type]) || a.matName.localeCompare(b.matName));
for (const g of sorted) {
newById.set(g.id, g);
newTree.push(g);
cloned.children.push(g);
}
} else {
for (const c of entry.children) visit(c, cloned);
}
}
const roots = before.filter(e => e.depth === 0);
for (const r of roots) visit(r, null);
assemblyTree = newTree;
nodeById = newById;
// After grouping, cluster the electrical contacts (leads OR pads) into
// individual pin clusters. Two paths:
// (a) Leaded chips (QFP/QFN/SOIC/DIP/SOT): per-side 1D clustering on
// the Pins material group. Pin pitch comes from gap analysis.
// (b) Grid chips (LGA/BGA/module): 2D union-find on the Contacts
// material group. Pad pitch ≈ bbox / sqrt(N).
// Whichever produces clusters becomes the strobe / isolatePin / per-pin
// outline source. Both share the same `pinClusters` array so downstream
// code (CLI, JS API, sidebar entries) doesn't care about the source.
pinClusters = [];
let clusterHostGroup = null;
const pinsGroup = assemblyTree.find(e => e.isMaterialGroup && e.type === 'pin');
const contactsGroup = assemblyTree.find(e => e.isMaterialGroup && e.type === 'contact');
if (pinsGroup && pinsGroup.allMeshes.length > 0) {
pinClusters = clusterPinsByCentroid(pinsGroup.allMeshes);
clusterHostGroup = pinsGroup;
}
if (!pinClusters.length && contactsGroup && contactsGroup.allMeshes.length >= 4) {
// No leaded pins detected — try grid clustering on the Contacts group.
pinClusters = clusterContactsByGrid(contactsGroup.allMeshes);
clusterHostGroup = contactsGroup;
}
// Mode B fallback: geometry-based THT pin discovery. Runs when material-
// based clustering came up short (≤ 4 clusters) — that's the threshold
// where we'd rather trust geometry. WAGO 2601-3105 is the canonical
// case: 50 silver "spring" meshes group into 4 sites, but the 5 actual
// THT solder legs are gray and only Mode B sees them. Mode B wins ONLY
// if it finds strictly more clusters than Mode A — never regress a
// good Mode A result on a leaded SMD.
if (pinClusters.length <= 4) {
const geom = discoverThruHolePins();
if (geom.length > pinClusters.length) {
pinClusters = geom;
// Host the geometry-discovered pins under the existing Pins group
// if there is one; otherwise host them under the body group so the
// outline still has a parent row to expand into.
const bodyGroup = assemblyTree.find(e => e.isMaterialGroup && e.type === 'body');
clusterHostGroup = pinsGroup || contactsGroup || bodyGroup || clusterHostGroup;
}
}
if (clusterHostGroup) {
// Re-number so pin 1 is the IPC-convention pin near the marker. The
// marker sits at a corner of the package; pin 1 is the FIRST pin you
// encounter going counterclockwise FROM that corner — which means
// pin 1 is on the side that's CCW-adjacent to the marker corner, not
// on the side perpendicular to it. Going CCW from each corner:
// back-left → LEFT side, back-most pin
// front-left → FRONT side, left-most pin
// front-right → RIGHT side, front-most pin
// back-right → BACK side, right-most pin
// Then number CCW from there.
const markerCentroid = findPin1MarkerCentroid();
if (markerCentroid && pinClusters.length > 1) {
// Body center estimated from the cluster centroids' bounds.
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const c of pinClusters) {
if (c.centroid.x < minX) minX = c.centroid.x; if (c.centroid.x > maxX) maxX = c.centroid.x;
if (c.centroid.y < minY) minY = c.centroid.y; if (c.centroid.y > maxY) maxY = c.centroid.y;
}
const bodyCx = (minX + maxX) * 0.5, bodyCy = (minY + maxY) * 0.5;
const dx = markerCentroid.x - bodyCx;
const dy = markerCentroid.y - bodyCy;
let pin1Side;
if (dx < 0 && dy > 0) pin1Side = 'left'; // back-left corner → LEFT side
else if (dx < 0 && dy < 0) pin1Side = 'front'; // front-left corner → FRONT side
else if (dx > 0 && dy < 0) pin1Side = 'right'; // front-right corner → RIGHT side
else pin1Side = 'back'; // back-right corner → BACK side
// Find the cluster on pin1Side closest to the marker corner.
let bestIdx = -1;
let bestD = Infinity;
for (let i = 0; i < pinClusters.length; i++) {
if (pinClusters[i].side !== pin1Side) continue;
const c = pinClusters[i].centroid;
const ddx = c.x - markerCentroid.x;
const ddy = c.y - markerCentroid.y;
const d2 = ddx * ddx + ddy * ddy;
if (d2 < bestD) { bestD = d2; bestIdx = i; }
}
// Fallback: if no cluster on pin1Side (shouldn't happen with QFP), use overall nearest.
if (bestIdx < 0) {
bestIdx = 0; bestD = Infinity;
for (let i = 0; i < pinClusters.length; i++) {
const c = pinClusters[i].centroid;
const ddx = c.x - markerCentroid.x, ddy = c.y - markerCentroid.y;
const d2 = ddx * ddx + ddy * ddy;
if (d2 < bestD) { bestD = d2; bestIdx = i; }
}
}
pinClusters = pinClusters.slice(bestIdx).concat(pinClusters.slice(0, bestIdx));
pinClusters.forEach((c, i) => { c.id = i + 1; });
}
// Inject each detected pin as a child entry of the Pins material
// group so the user can see, isolate, and toggle visibility per-pin
// from the components outline. Per-pin centroid (mm) is in the row
// tooltip — feeds footprint matching workflows.
if (pinClusters.length) {
// Naming: leaded chips show "Pin N", grid-pad chips show "Contact N"
// so the row label matches the source material group's category.
const isContactSource = clusterHostGroup.type === 'contact';
const nameKind = isContactSource ? 'Contact' : 'Pin';
clusterHostGroup.children = []; // replace any leaf children
for (const c of pinClusters) {
const pinEntry = {
id: synthIdCounter--,
node: null,
isPinEntry: true,
pinId: c.id,
pinSide: c.side,
pinKind: nameKind, // 'Pin' or 'Contact'
pinCentroid: c.centroid,
name: nameKind + ' ' + c.id,
parentId: clusterHostGroup.id,
depth: clusterHostGroup.depth + 1,
children: [],
directMeshes: c.meshes,
allMeshes: c.meshes,
visible: true,
expanded: false,
};
nodeById.set(pinEntry.id, pinEntry);
assemblyTree.push(pinEntry);
clusterHostGroup.children.push(pinEntry);
}
clusterHostGroup.primitiveCount = pinClusters.length;
}
}
}
function findPin1MarkerCentroid() {
// Prefer a 'marker'-typed group; fall back to a small 'other' group
// (often the pin-1 dot misclassified by color heuristic — KiCad's brown
// marker tone doesn't match the bright-red threshold).
const candidates = assemblyTree.filter(e =>
e.isMaterialGroup && (e.type === 'marker' || (e.type === 'other' && e.primitiveCount <= 3))
);
if (!candidates.length) return null;
// Use the one with the fewest primitives (most likely to be the actual
// marker dot, not random small features).
candidates.sort((a, b) => a.primitiveCount - b.primitiveCount);
const meshes = candidates[0].allMeshes;
if (!meshes.length) return null;
let cx = 0, cy = 0, cz = 0, n = 0;
for (const m of meshes) {
const c = meshCentroid(m);
if (!c) continue;
cx += c.x; cy += c.y; cz += c.z; n += 1;
}
if (!n) return null;
return { x: cx / n, y: cy / n, z: cz / n };
}
function postComponentsToServer() {
const flat = assemblyTree.map(e => ({
id: e.id,
name: e.name,
depth: e.depth,
parentId: e.parentId,
visible: e.visible,
childCount: e.children.length,
meshCount: e.allMeshes.length,
}));
fetch('api/components', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flat),
}).catch(() => {});
}
// =====================================================================
// 4. Sidebar render — depth-padded rows with caret + eye + isolate.
// =====================================================================
let activeTypeFilter = 'all'; // 'all' | 'body' | 'pin' | 'contact' | 'marker' | 'other'
function renderFilterChips() {
const wrap = document.getElementById('sbFilters');
wrap.innerHTML = '';
const counts = { body: 0, pin: 0, contact: 0, marker: 0, other: 0 };
for (const e of assemblyTree) {
if (e.isMaterialGroup) counts[e.type] = (counts[e.type] || 0) + (e.primitiveCount || 1);
}
const total = Object.values(counts).reduce((a, b) => a + b, 0);
const chips = [
{ type: 'all', label: 'All', count: total, tip: 'Show every part type (default).' },
{ type: 'body', label: 'Body', count: counts.body || 0, tip: 'Body / case / plastic — dark molded compound.' },
{ type: 'pin', label: 'Pins', count: counts.pin || 0, tip: 'Leads / legs — silver or tin metallic surfaces.' },
{ type: 'contact', label: 'Contacts', count: counts.contact || 0, tip: 'Gold / copper pads — solderable contact metal.' },
{ type: 'marker', label: 'Markers', count: counts.marker || 0, tip: 'Pin-1 dots, marking text, silkscreen ID.' },
{ type: 'other', label: 'Other', count: counts.other || 0, tip: 'Materials that didn\'t match a known category.' },
];
for (const c of chips) {
if (c.type !== 'all' && c.count === 0) continue;
const btn = document.createElement('button');
btn.className = 'sb-chip' + (activeTypeFilter === c.type ? ' active' : '');
btn.dataset.filterType = c.type;
btn.setAttribute('data-tooltip', c.tip);
btn.innerHTML = c.label + ' <span class="count">' + c.count + '</span>';
btn.onclick = () => { activeTypeFilter = c.type; renderTree(); };
wrap.appendChild(btn);
}
}
function passesTypeFilter(entry) {
if (activeTypeFilter === 'all') return true;
// A material group matches if its type matches.
if (entry.isMaterialGroup) return entry.type === activeTypeFilter;
// A non-group node passes if any of its descendants match.
let p = entry;
const queue = [entry];
while (queue.length) {
const cur = queue.shift();
if (cur.isMaterialGroup && cur.type === activeTypeFilter) return true;
queue.push(...cur.children);
}
return false;
}
function renderTree() {
renderFilterChips();
const treeEl = document.getElementById('sbTree');
treeEl.innerHTML = '';
document.getElementById('compCount').textContent = assemblyTree.length + ' nodes';
// Mirror node count into the canonical header subject-meta + tooltip.
setSubjectMeta({ nodes: assemblyTree.length, size: window.__lastModelSizeStr || null });
function appendRow(entry) {
if (!isAncestorChainExpanded(entry)) return; // hidden under collapsed parent
if (!passesTypeFilter(entry)) return; // hidden by chip filter
const row = document.createElement('div');
row.className = 'tree-row';
if (!entry.visible) row.classList.add('hidden-vis');
if (entry.isMaterialGroup) row.classList.add('material-group');
row.dataset.id = entry.id;
row.style.paddingLeft = (4 + entry.depth * 14) + 'px';
const caret = document.createElement('span');
caret.className = 'caret';
if (entry.children.length) {
caret.classList.add('has-children');
if (entry.expanded) caret.classList.add('open');
caret.textContent = '❯'; // VS-Code-style chevron ❯ — CSS rotates 90° when .open
caret.setAttribute('data-tooltip',
entry.expanded
? 'Collapse — hide this node\'s ' + entry.children.length + ' children in the tree.'
: 'Expand — show this node\'s ' + entry.children.length + ' children in the tree.');
caret.onclick = (e) => { e.stopPropagation(); entry.expanded = !entry.expanded; renderTree(); };
} else {
// Leaf row — no caret glyph (CSS visibility:hidden), just an empty
// slot so the alignment matches the parent rows. Not clickable.
caret.classList.add('leaf');
caret.textContent = '';
}
row.appendChild(caret);
const eye = document.createElement('span');
eye.className = 'eye';
const triState = computeTriState(entry);
if (triState === 'mixed') eye.classList.add('mixed');
eye.textContent = triState === 'visible' ? '●' : (triState === 'hidden' ? '○' : '◐');
const eyeTip = triState === 'visible'
? 'Visible — click to hide this node and its descendants.'
: (triState === 'hidden'
? 'Hidden — click to show this node and its descendants.'
: 'Mixed visibility — some descendants hidden. Click to show all.');
eye.setAttribute('data-tooltip', eyeTip);
eye.onclick = (e) => {
e.stopPropagation();
const willHide = triState !== 'hidden';
pushVisibilityHistory(() => setSubtreeVisibility(entry, !willHide));
};
row.appendChild(eye);
const iso = document.createElement('span');
iso.className = 'iso';
iso.textContent = '◇';
iso.setAttribute('data-tooltip',
'Isolate — hide every other top-level subtree, leave only this node visible. Camera stays where it is — double-click the row or use right-click → Zoom To if you want to re-frame.');
iso.onclick = (e) => { e.stopPropagation(); pushVisibilityHistory(() => isolateEntry(entry, { frame: false })); };
row.appendChild(iso);
if (entry.isMaterialGroup && entry.baseColor) {
const swatch = document.createElement('span');
swatch.className = 'mat-swatch';
const [r, g, b] = entry.baseColor;
const css = `rgb(${Math.round(r*255)},${Math.round(g*255)},${Math.round(b*255)})`;
swatch.style.background = css;
swatch.setAttribute('data-tooltip',
'Material color: ' + css + ' (from the GLB material baseColor). All ' +
(entry.primitiveCount || entry.children.length) + ' primitives in this ' +
entry.type + ' group share this material.');
row.appendChild(swatch);
}
const label = document.createElement('span');
label.className = 'label';
label.textContent = entry.name;
row.appendChild(label);
// Per-row status badges. Derived from entry.kind + entry.isOverlay
// + future entry.locked / entry.reDetected flags. SVG monochrome
// icons (no Unicode emoji) per human-ui-patterns icon rules.
const badges = computeRowBadges(entry);
if (badges.length) {
const wrap = document.createElement('span');
wrap.className = 'tree-badges';
for (const b of badges) {
const el = document.createElement('span');
el.className = 'tree-badge';
el.dataset.kind = b.kind;
el.setAttribute('data-tooltip', b.tip);
el.innerHTML = TREE_BADGE_SVG[b.kind] || '';
wrap.appendChild(el);
}
row.appendChild(wrap);
}
// Always set a row-level tooltip with the FULL name + metadata so
// that ellipsis-truncated rows reveal their full label on hover.
// Pin/material-group rows extend this with the existing detail.
row.setAttribute('data-tooltip', buildRowTooltip(entry));
if (entry.isPinEntry) {
// Side badge ("front" / "right" / "back" / "left").
const side = document.createElement('span');
side.className = 'pin-side';
side.dataset.side = entry.pinSide;
side.textContent = entry.pinSide;
row.appendChild(side);
} else if (entry.isMaterialGroup) {
const cnt = document.createElement('span');
cnt.className = 'child-count';
// If pin clustering produced entries under THIS group (Pins or
// Contacts), show the cluster count + the kind name. Otherwise show
// the raw primitive count from material grouping.
const hostsClusters = (entry.type === 'pin' || entry.type === 'contact') && pinClusters.length;
const num = hostsClusters ? pinClusters.length : entry.primitiveCount;
const kind = entry.type === 'contact' ? 'contact' : 'pin';
const unit = hostsClusters
? (num === 1 ? ' ' + kind : ' ' + kind + 's')
: (num === 1 ? ' part' : ' parts');
cnt.textContent = num + unit;
// Tooltip explaining what the number means. Specific so a user who
// says "what does 30 mean?" gets the answer on hover.
cnt.setAttribute('data-tooltip', hostsClusters
? num + ' individual ' + kind + 's detected by clustering this material’s mesh primitives by spatial position. Each is selectable + isolatable in the outline below.'
: num + ' mesh primitives in this material group. Primitives are how the GLB exporter splits geometry by material/face — they’re not semantic objects.');
row.appendChild(cnt);
} else if (entry.children.length) {
const cnt = document.createElement('span');
cnt.className = 'child-count';
cnt.textContent = entry.children.length;
cnt.setAttribute('data-tooltip',
entry.children.length + ' direct child entr' +
(entry.children.length === 1 ? 'y' : 'ies') +
' below this node. Expand the caret to see them.');
row.appendChild(cnt);
}
// Mirror the 3D-viewport gentle highlight when the user hovers this
// row. The sidebar hover targets THIS exact entry (no second-level
// demotion) so a Pin N row highlights just that pin, not the whole
// Pins group.
row.onmouseenter = () => setHoverHighlight(entry);
row.onmouseleave = () => setHoverHighlight(null);
row.ondblclick = () => frameOnEntry(entry);
row.oncontextmenu = (e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, entry);
};
treeEl.appendChild(row);
for (const c of entry.children) appendRow(c);
}
for (const e of assemblyTree) {
if (e.depth === 0) appendRow(e);
}
applySearchFilter();
}
// Per-row status badge map. Each kind maps to a 12px monochrome SVG
// using stroke-currentColor so the per-kind tint comes from CSS. Kept
// as inline SVGs (not Unicode emoji) per gallia human-ui-patterns
// icons-and-displays.md §4b — emoji renders inconsistently across
// platforms, never aligns to icon stroke weight, and Apple-specific
// emoji on Linux looks misplaced.
const TREE_BADGE_SVG = {
// Pin-1 star — 5-pointed outline. Marks the user-placed pin-1
// indicator (entries with kind containing 'pin1').
pin1:
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M8 1.8 9.85 5.55 14 6.16l-3 2.92L11.7 13.2 8 11.24 4.3 13.2 5 9.08 2 6.16 6.15 5.55 8 1.8z"/>' +
'</svg>',
// Synthesized — 4-pointed sparkle. Marks chipsmith-added overlays
// (footprint pads, faux PCB, vias, solder-jet domes, user-placed
// pin-1 — anything outside the original GLB hierarchy).
synth:
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M8 1.5 V5.5 M8 10.5 V14.5 M1.5 8 H5.5 M10.5 8 H14.5"/>' +
'<path d="M3.5 3.5 L5.5 5.5 M10.5 10.5 L12.5 12.5 M3.5 12.5 L5.5 10.5 M10.5 5.5 L12.5 3.5" stroke-opacity="0.55"/>' +
'</svg>',
// Locked — padlock. Set when the entry came from a baked
// .chipsmith.step (post-sign-off, not editable until re-opened).
locked:
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<rect x="3" y="7" width="10" height="7" rx="1.2"/>' +
'<path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>' +
'</svg>',
// Re-detected after Y-up auto-rotate. Sub-tree gets this badge when
// detection ran a second time post-rotate. (v1.1 — flag wired but
// detection currently disabled.)
redet:
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M3 8a5 5 0 0 1 8.5-3.5L13 6"/>' +
'<path d="M13 3v3h-3"/>' +
'<path d="M13 8a5 5 0 0 1-8.5 3.5L3 10"/>' +
'<path d="M3 13v-3h3"/>' +
'</svg>',
};
// Decide which badges (if any) a tree row should carry. Single source
// of truth; called from appendRow. The chipsmith overlay system tags
// every overlay entry with kind=`overlay-*`, isOverlay=true; we map
// those to the synth + (for pin-1) star icons. Locked + re-detected
// flags come later from sign-off / detection state — wired here so
// they light up automatically once the upstream state lands.
function computeRowBadges(entry) {
const out = [];
const kind = entry.kind || '';
const k = kind.toLowerCase();
if (k.includes('pin1')) {
out.push({
kind: 'pin1',
tip: 'Pin-1 indicator. Marks the chip-side polarity reference — every downstream Adom tool reads this entry to align footprints, silkscreen, and BOM rows. Survives sign-off bake.',
});
}
if (entry.isOverlay && !entry.isMaterialGroup) {
out.push({
kind: 'synth',
tip: 'Chipsmith-synthesized overlay (not part of the manufacturer STEP). Toggleable, removable, and baked into the .chipsmith.step at sign-off so the synthesized geometry survives every future load.',
});
}
if (entry.locked) {
out.push({
kind: 'locked',
tip: 'Locked — loaded from a baked .chipsmith.step. Re-open the chip for editing to clear locks.',
});
}
if (entry.reDetected) {
out.push({
kind: 'redet',
tip: 'Re-detected after Y-up auto-rotate. The original orientation was wrong; chipsmith rotated 90° about X and re-classified this feature.',
});
}
return out;
}
// Build the hover-tooltip string for a tree row. Always includes the
// FULL entry name (so ellipsis-truncated rows reveal what they are on
// hover) plus a second line of structured metadata: depth, kind,
// mesh + child counts, overlay group, and pin-specific centroid info
// when applicable. Per the human-ui-patterns skill: tooltip on every
// interactive element, 600 ms reveal delay, never ALL CAPS — handled
// by the existing tooltip CSS.
function buildRowTooltip(entry) {
// Overlay rows can carry a rich tooltip set by the overlay plugin
// (chipsmith injects provenance explanations here so the user
// understands where each footprint source came from).
if (entry.overlayTooltip) return entry.overlayTooltip;
const lines = [];
// Line 1: the full name. Use the entry.name verbatim — that's what
// got truncated visually and what the user wants to see.
lines.push(entry.name || '<unnamed>');
// Line 2: structured metadata. Compose only the fields that apply
// so the tooltip stays compact.
const meta = [];
if (typeof entry.depth === 'number') meta.push('depth ' + entry.depth);
if (entry.kind && !entry.isOverlay) meta.push(entry.kind);
if (entry.isOverlay) meta.push('overlay');
if (entry.overlayGroup) meta.push('group "' + entry.overlayGroup + '"');
const meshN = (entry.allMeshes || []).length;
if (meshN) meta.push(meshN + ' mesh' + (meshN === 1 ? '' : 'es'));
const childN = (entry.children || []).length;
if (childN) meta.push(childN + ' child' + (childN === 1 ? '' : 'ren'));
if (entry.isMaterialGroup) meta.push('material group');
if (meta.length) lines.push(meta.join(' · '));
// Pin entries: append the centroid + side line that the legacy
// tooltip exposed. Same data; just one line down.
if (entry.isPinEntry && entry.pinCentroid) {
const c = entry.pinCentroid;
const SCENE_TO_MM = 1000;
lines.push('pin ' + entry.pinId + ' · ' + entry.pinSide +
' side · centroid ' +
(c.x * SCENE_TO_MM).toFixed(3) + ', ' +
(c.y * SCENE_TO_MM).toFixed(3) + ', ' +
(c.z * SCENE_TO_MM).toFixed(3) + ' mm');
}
// Material-group entries: explain what the count number means so a
// hover reveals "30 parts" → "30 mesh primitives". Same wording as
// the inline child-count tooltip, lifted to the row.
if (entry.isMaterialGroup) {
const num = entry.primitiveCount || 0;
lines.push(num + ' mesh primitives — how the GLB exporter split this material\'s geometry by face.');
}
return lines.join('\n');
}
function isAncestorChainExpanded(entry) {
let p = entry.parentId == null ? null : nodeById.get(entry.parentId);
while (p) {
if (!p.expanded) return false;
p = p.parentId == null ? null : nodeById.get(p.parentId);
}
return true;
}
function computeTriState(entry) {
if (!entry.children.length) return entry.visible ? 'visible' : 'hidden';
const states = entry.children.map(computeTriState);
if (states.every(s => s === 'visible') && entry.visible) return 'visible';
if (states.every(s => s === 'hidden') && !entry.visible) return 'hidden';
return 'mixed';
}
function setSubtreeVisibility(entry, visible) {
function walk(e) {
e.visible = visible;
for (const m of e.allMeshes) m.isVisible = visible;
// Synthetic material-group entries have no Babylon node — skip setEnabled.
if (e.node && e.node.setEnabled) e.node.setEnabled(visible);
for (const c of e.children) walk(c);
}
walk(entry);
renderTree();
postVisibilityToServer();
}
// Tracks which entry is currently isolated (null = nothing isolated). When
// the user clicks the isolate icon a second time on the same entry, we
// un-isolate by restoring everything that was visible at the time of the
// FIRST click. Both the isolate AND the un-isolate go through
// pushVisibilityHistory so Ctrl+Z works on both.
let isolatedEntryId = null;
function isolateEntry(entry, opts) {
// Toggle: second click on the same entry un-isolates (restores all).
if (isolatedEntryId === entry.id) {
showAllEntries();
isolatedEntryId = null;
return;
}
// ISOLATE: show only the meshes that belong to entry's subtree, hide
// everything else. Works at any depth (Pin 1 / Pins group / chip top
// level all behave correctly). Done at the per-mesh level (using
// mesh.isVisible) instead of per-entry setEnabled so we don't get
// tangled in parent-child setEnabled propagation.
const visibleMeshIds = new Set();
function collect(e) {
for (const m of e.allMeshes) if (m && m.uniqueId != null) visibleMeshIds.add(m.uniqueId);
for (const c of e.children) collect(c);
}
collect(entry);
// Pass 1: per-mesh visibility from the visible set. Walks BOTH
// the assemblyTree entries AND all scene meshes (in case the GLB
// loader added meshes that aren't tracked in any tree entry).
for (const e of assemblyTree) {
for (const m of e.allMeshes) {
if (!m || m.uniqueId == null) continue;
m.isVisible = visibleMeshIds.has(m.uniqueId);
}
}
const scene = viewer?.getScene?.();
if (scene) {
for (const m of scene.meshes) {
if (!m || m.uniqueId == null) continue;
if (['skyBox','ground','shadowGround'].includes(m.name)) continue;
if ((m.name || '').startsWith('viewCube')) continue;
if (!visibleMeshIds.has(m.uniqueId)) m.isVisible = false;
}
}
// Pass 2: derive entry.visible (true if any of its meshes are visible).
for (const e of assemblyTree) {
let any = false;
for (const m of e.allMeshes) {
if (m && m.isVisible) { any = true; break; }
}
e.visible = any;
}
// Re-enable the host TransformNodes along the ancestor chain (in case
// a previous setEnabled(false) is still hanging on them) so the picked
// mesh gets actually rendered. Babylon's setEnabled cascades, so we
// walk up from the entry and re-enable each.
let cur = entry;
while (cur) {
if (cur.node && cur.node.setEnabled) cur.node.setEnabled(true);
cur = cur.parentId == null ? null : nodeById.get(cur.parentId);
}
renderTree();
postVisibilityToServer();
isolatedEntryId = entry.id;
if (opts && opts.frame) frameOnEntry(entry);
}
function showAllEntries() {
for (const e of assemblyTree) {
if (e.depth === 0) setSubtreeVisibility(e, true);
}
const scene = viewer?.getScene?.();
if (scene) {
for (const m of scene.meshes) {
if (m && m.uniqueId != null) m.isVisible = true;
}
}
}
function postVisibilityToServer() {
const map = {};
for (const e of assemblyTree) map[e.name] = e.visible;
fetch('api/visibility', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(map),
}).catch(() => {});
}
let highlightLayer = null;
function getHighlight() {
if (!highlightLayer) {
highlightLayer = new BABYLON.HighlightLayer('hover-hl', scene, { mainTextureRatio: 1 });
highlightLayer.innerGlow = false;
}
return highlightLayer;
}
function highlightEntry(entry, on) {
const hl = getHighlight();
for (const m of entry.allMeshes) {
if (!m.material) continue;
if (on) hl.addMesh(m, BABYLON.Color3.FromHexString('#4fc3f7'));
else hl.removeMesh(m);
}
}
function frameOnEntry(entry) {
if (!entry.allMeshes.length) return;
// Compute combined bbox.
const bb = combinedBoundingBox(entry.allMeshes);
if (!bb) return;
const cam = scene.activeCamera;
if (!cam) return;
const center = bb.center;
const radius = bb.radius * 1.3;
if (cam.target) cam.target.copyFrom(center);
if (typeof cam.radius === 'number') cam.radius = Math.max(radius, 0.05);
// Use built-in zoomOn if available.
if (cam.zoomOn) {
try { cam.zoomOn(entry.allMeshes, true); } catch (e) { /* fall back to target/radius */ }
}
}
function combinedBoundingBox(meshes) {
let minV = null, maxV = null;
for (const m of meshes) {
if (!m.getBoundingInfo) continue;
m.computeWorldMatrix(true);
const bi = m.getBoundingInfo();
const lo = bi.boundingBox.minimumWorld;
const hi = bi.boundingBox.maximumWorld;
if (!minV) { minV = lo.clone(); maxV = hi.clone(); }
else {
minV.x = Math.min(minV.x, lo.x); minV.y = Math.min(minV.y, lo.y); minV.z = Math.min(minV.z, lo.z);
maxV.x = Math.max(maxV.x, hi.x); maxV.y = Math.max(maxV.y, hi.y); maxV.z = Math.max(maxV.z, hi.z);
}
}
if (!minV) return null;
const center = minV.add(maxV).scale(0.5);
const radius = maxV.subtract(minV).length() * 0.5;
return { min: minV, max: maxV, center, radius };
}
// =====================================================================
// 5. Search — dim non-matching rows + auto-expand ancestors of matches.
// =====================================================================
function applySearchFilter() {
const q = (document.getElementById('sbSearch').value || '').trim().toLowerCase();
const rows = document.querySelectorAll('.tree-row');
if (!q) { rows.forEach(r => r.classList.remove('dimmed')); return; }
const matchIds = new Set();
for (const e of assemblyTree) {
if (e.name.toLowerCase().includes(q)) {
matchIds.add(e.id);
// walk up
let p = e.parentId == null ? null : nodeById.get(e.parentId);
while (p) {
matchIds.add(p.id);
p.expanded = true;
p = p.parentId == null ? null : nodeById.get(p.parentId);
}
}
}
rows.forEach(r => {
const id = parseInt(r.dataset.id, 10);
if (matchIds.has(id)) r.classList.remove('dimmed');
else r.classList.add('dimmed');
});
}
function setupSidebar() {
const search = document.getElementById('sbSearch');
let timer = null;
search.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => { renderTree(); }, 80);
});
// Outline collapse — Fusion 360 Browser-style. The outline floats over the
// canvas with a translucent bg; collapsing slides it off-screen and reveals
// a slim vertical expand-tab at the canvas's left edge. State persists in
// localStorage so a user who likes the canvas-fills-viewport feel keeps it
// across reloads. Keyboard: 'o' toggles.
const panel = document.getElementById('outlinePanel');
const expander = document.getElementById('outlineExpander');
const STORAGE_KEY = 'adom-step.outline.collapsed';
function setCollapsed(collapsed) {
panel.classList.toggle('collapsed', collapsed);
document.body.classList.toggle('outline-collapsed', collapsed);
try { localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0'); } catch (_) {}
}
// Restore last state on load — default expanded for first-time users.
try {
if (localStorage.getItem(STORAGE_KEY) === '1') setCollapsed(true);
} catch (_) {}
document.getElementById('outlineCollapse').onclick = () => setCollapsed(true);
expander.onclick = () => setCollapsed(false);
// Keyboard shortcut — 'o' toggles. Don't intercept while a text input is
// focused (search box, inspect HUD inputs, measure HUD).
document.addEventListener('keydown', (e) => {
if (e.key !== 'o' && e.key !== 'O') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
setCollapsed(!panel.classList.contains('collapsed'));
});
}
// =====================================================================
// 6. Visibility undo / redo
// =====================================================================
function snapshotVisibility() {
const snap = {};
for (const e of assemblyTree) snap[e.id] = e.visible;
return snap;
}
function applyVisibilitySnapshot(snap) {
for (const e of assemblyTree) {
if (snap[e.id] !== undefined && snap[e.id] !== e.visible) {
e.visible = snap[e.id];
for (const m of e.allMeshes) m.isVisible = snap[e.id];
if (e.node && e.node.setEnabled) e.node.setEnabled(snap[e.id]);
}
}
renderTree();
postVisibilityToServer();
}
function pushVisibilityHistory(mutate) {
const before = snapshotVisibility();
mutate();
const after = snapshotVisibility();
visibilityHistory.push({ before, after });
if (visibilityHistory.length > 50) visibilityHistory.shift();
visibilityRedo = [];
}
function undoVisibility() {
const last = visibilityHistory.pop();
if (!last) return;
applyVisibilitySnapshot(last.before);
visibilityRedo.push(last);
}
function redoVisibility() {
const last = visibilityRedo.pop();
if (!last) return;
applyVisibilitySnapshot(last.after);
visibilityHistory.push(last);
}
// =====================================================================
// 7. Context menu
// =====================================================================
let ctxEntry = null;
function openContextMenu(x, y, entry) {
ctxEntry = entry;
const m = document.getElementById('ctxMenu');
// Sync downstream-app extras (chipsmith adds "Move to decorative
// branch" here). Extras are reset every open so toggle-state labels
// stay current with the entry under the cursor.
syncContextMenuExtras(entry);
m.classList.add('open');
m.style.left = x + 'px';
m.style.top = y + 'px';
}
// Downstream apps register extra context-menu items via
// window.adomStepCtxMenuExtras = [{
// id: 'mark-decorative', // unique within the menu
// label: (entry) => 'Move to decorative branch on bake',
// tooltip: (entry) => '…',
// visibleFor: (entry) => true, // optional filter
// onClick: (entry) => { … },
// }, …]
// Items are appended (with a separator) at the bottom of the menu the
// first time the menu opens; on subsequent opens, only labels +
// visibility are updated. The host shell never has to know what the
// downstream app's verbs mean.
function syncContextMenuExtras(entry) {
const m = document.getElementById('ctxMenu');
const extras = (window.adomStepCtxMenuExtras || []);
// Drop previous extras so we don't accumulate duplicates.
m.querySelectorAll('.ctx-extra, .ctx-extra-sep').forEach(el => el.remove());
if (!extras.length) return;
const sep = document.createElement('div');
sep.className = 'ctx-sep ctx-extra-sep';
m.appendChild(sep);
for (const ex of extras) {
if (typeof ex.visibleFor === 'function' && !ex.visibleFor(entry)) continue;
const it = document.createElement('div');
it.className = 'ctx-item ctx-extra';
it.dataset.act = 'extra:' + ex.id;
it.textContent = (typeof ex.label === 'function' ? ex.label(entry) : ex.label);
if (typeof ex.tooltip === 'function') it.setAttribute('data-tooltip', ex.tooltip(entry) || '');
else if (ex.tooltip) it.setAttribute('data-tooltip', ex.tooltip);
m.appendChild(it);
}
}
function closeContextMenu() {
document.getElementById('ctxMenu').classList.remove('open');
ctxEntry = null;
}
function setupContextMenu() {
document.addEventListener('click', closeContextMenu);
const m = document.getElementById('ctxMenu');
m.addEventListener('click', (e) => {
e.stopPropagation();
const act = e.target.dataset.act;
if (!act || !ctxEntry) { closeContextMenu(); return; }
const entry = ctxEntry;
closeContextMenu();
// Visibility-mutating actions go through pushVisibilityHistory so Ctrl+Z
// can undo them. Non-mutating actions (reveal, zoom, copy, properties)
// run directly to keep the undo stack clean.
switch (act) {
case 'reveal':
findInOutline(entry);
break;
case 'isolate':
pushVisibilityHistory(() => isolateEntry(entry, { frame: true }));
break;
case 'hide-others':
pushVisibilityHistory(() => {
for (const e of assemblyTree) {
if (e.depth === 0 && e.id !== ancestorAt0(entry).id) setSubtreeVisibility(e, false);
}
});
break;
case 'show':
pushVisibilityHistory(() => setSubtreeVisibility(entry, true));
break;
case 'hide':
pushVisibilityHistory(() => setSubtreeVisibility(entry, false));
// Flash the row in the outline so the user can find it again to
// restore — without this, a hidden item visually disappears from
// the canvas and the user has no breadcrumb back to its row.
// (Ctrl+Z also undoes; this is the find-by-eye fallback.)
findInOutline(entry);
showToast('Hid ' + entry.name + ' — flashed in outline (Ctrl+Z undoes)', 'ok');
break;
case 'zoom':
frameOnEntry(entry);
break;
case 'copy-name':
navigator.clipboard.writeText(entry.name).catch(() => {});
showToast('Copied: ' + entry.name, 'ok');
break;
case 'copy-json': {
// Serialize a paste-able snapshot of the entry. Skip mesh refs
// + circular children list — keep id/name/depth/parent/kind +
// computed bbox + overlay tags so a bug report has all the
// useful identity info.
const blob = {
id: entry.id,
name: entry.name,
depth: entry.depth,
parentId: entry.parentId,
kind: entry.kind || null,
visible: entry.visible,
isOverlay: !!entry.isOverlay,
overlayGroup: entry.overlayGroup || null,
meshCount: (entry.allMeshes || []).length,
childCount: (entry.children || []).length,
};
try {
const bb = combinedBoundingBox(entry.allMeshes || []);
if (bb) {
const SCENE_TO_MM = 1000;
blob.bbox_mm = {
min: [bb.min.x, bb.min.y, bb.min.z].map(v => v * SCENE_TO_MM),
max: [bb.max.x, bb.max.y, bb.max.z].map(v => v * SCENE_TO_MM),
size: [(bb.max.x - bb.min.x), (bb.max.y - bb.min.y), (bb.max.z - bb.min.z)].map(v => v * SCENE_TO_MM),
};
}
} catch (_) {}
const json = JSON.stringify(blob, null, 2);
navigator.clipboard.writeText(json).catch(() => {});
showToast('Copied JSON for ' + entry.name, 'ok');
break;
}
case 'properties':
pinInspectCard(entry);
break;
default:
if (act && act.startsWith('extra:')) {
const id = act.slice('extra:'.length);
const ex = (window.adomStepCtxMenuExtras || []).find(e => e.id === id);
if (ex && typeof ex.onClick === 'function') ex.onClick(entry);
}
break;
}
});
}
function ancestorAt0(entry) {
let cur = entry;
while (cur && cur.depth > 0) cur = cur.parentId == null ? null : nodeById.get(cur.parentId);
return cur || entry;
}
// =====================================================================
// 8. Toolbar
// =====================================================================
function setupToolbar() {
document.getElementById('tbMeasure').onclick = () => toggleMeasure();
// Toggle starts OFF (app-level override of 3d-viewer-design §4d default-on,
// see boot() for rationale). User flips it on when they want contact shadow.
let groundOn = false;
const groundBtn = document.getElementById('tbGround');
groundBtn.classList.remove('active');
groundBtn.onclick = (e) => {
groundOn = !groundOn;
viewer.setGroundVisible(groundOn);
e.currentTarget.classList.toggle('active', groundOn);
};
// Per §8a: world-origin axes toggle, default ON.
// Per §8b: mesh-local axes toggle, default OFF.
// Bottom-fill light — toggles a HemisphericLight under the model so the
// user can see BGA balls / QFN pads / through-hole leg bottoms. Persists
// via localStorage. Keyboard 'B' toggles (skipped while a text input is
// focused).
const bottomBtn = document.getElementById('tbBottomLight');
const BOTTOM_LIGHT_KEY = 'adom-step.bottomLight';
function applyBottomLight(on) {
viewer.setBottomLight(on);
bottomBtn.classList.toggle('active', on);
try { localStorage.setItem(BOTTOM_LIGHT_KEY, on ? '1' : '0'); } catch (_) {}
}
// Restore last state on init.
try {
if (localStorage.getItem(BOTTOM_LIGHT_KEY) === '1') applyBottomLight(true);
} catch (_) {}
bottomBtn.onclick = () => applyBottomLight(!viewer.getBottomLightState());
document.addEventListener('keydown', (e) => {
if (e.key !== 'b' && e.key !== 'B') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
applyBottomLight(!viewer.getBottomLightState());
});
document.getElementById('tbAxes').onclick = (e) => {
const next = !viewer.getAxesState().world;
viewer.toggleAxes('world', next);
e.currentTarget.classList.toggle('active', next);
};
document.getElementById('tbMeshAxes').onclick = (e) => {
const next = !viewer.getAxesState().meshLocal;
viewer.toggleAxes('mesh-local', next);
e.currentTarget.classList.toggle('active', next);
};
// Babylon Inspector — use the canonical viewer's showDebugLayer /
// hideDebugLayer wrappers. They `await import('@babylonjs/inspector')`
// from the bundled dependency BEFORE calling scene.debugLayer.show(),
// which avoids the CDN-load + frozen-BABYLON-namespace mess. (This
// is the same ESM-load path adom-tsci / chiplinter use.)
let inspectorOpen = false;
document.getElementById('tbInspect').onclick = async (e) => {
const btn = e.currentTarget;
try {
if (inspectorOpen) {
await viewer.hideDebugLayer();
inspectorOpen = false;
btn.classList.remove('active');
showToast('Inspector closed', 'ok');
} else {
btn.classList.add('active');
showToast('Loading Babylon Inspector…', 'ok');
await viewer.showDebugLayer();
inspectorOpen = true;
showToast('Inspector ready', 'ok');
}
} catch (err) {
console.error('Inspector failed:', err);
showToast('Inspector error: ' + (err && err.message ? err.message : err), 'err');
btn.classList.remove('active');
}
};
// Strobe is removed from adom-step (out of scope for a STEP file
// viewer — strobe-each-pin is a chip-fetcher / chiplinter concept).
// Pin clustering itself stays for the components-outline rows.
}
function setupViewButtons() {
// Toolbar ViewCube clicks: preserve the user's current orbit center +
// radius per 3d-viewer-design §7. Don't pass frameWhole.
document.querySelectorAll('[data-view]').forEach(btn => {
btn.onclick = () => setView(btn.dataset.view);
});
}
function setView(name, opts) {
// ViewCube preset for the named direction. Per 3d-viewer-design §7,
// toolbar ViewCube clicks should preserve the current orbit center +
// radius (just rotate). CLI/AI calls (opts.frameWhole = true) re-frame
// the whole model first so an AI driving the viewer always sees the
// full part regardless of prior isolate/measure state.
const cam = scene.activeCamera;
if (!cam || typeof cam.alpha !== 'number') return;
if (opts && opts.frameWhole) {
showAllEntries(); // restore visibility from any prior isolate
viewer.goHome(); // re-frame on full bounds
}
const presets = {
front: { alpha: -Math.PI / 2, beta: Math.PI / 2 },
back: { alpha: Math.PI / 2, beta: Math.PI / 2 },
left: { alpha: Math.PI, beta: Math.PI / 2 },
right: { alpha: 0, beta: Math.PI / 2 },
top: { alpha: -Math.PI / 2, beta: 0.001 },
bottom: { alpha: -Math.PI / 2, beta: Math.PI - 0.001 },
iso: { alpha: -Math.PI / 4, beta: Math.PI / 3 },
home: null,
};
const p = presets[name];
if (name === 'home' || !p) { viewer.goHome(); return; }
cam.alpha = p.alpha;
cam.beta = p.beta;
}
// =====================================================================
// 9. Drop zone
// =====================================================================
function setupDropzone() {
['dragenter', 'dragover'].forEach(ev => {
window.addEventListener(ev, (e) => {
e.preventDefault();
document.body.classList.add('dragging');
});
});
['dragleave', 'drop'].forEach(ev => {
window.addEventListener(ev, (e) => {
if (ev === 'dragleave' && e.target !== document && e.relatedTarget) return;
document.body.classList.remove('dragging');
});
});
window.addEventListener('drop', async (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (!file) return;
if (!/\.(step|stp)$/i.test(file.name)) {
showToast('Not a .step / .stp file', 'err');
return;
}
setSplash('Converting ' + file.name + '...');
const buf = await file.arrayBuffer();
try {
const r = await fetch('api/load', {
method: 'POST',
headers: { 'X-Filename': file.name },
body: buf,
});
const body = await r.json();
if (!r.ok || body.error) {
setSplash(null);
showToast('Load failed: ' + (body.error || r.statusText), 'err');
return;
}
setSourceLabel(file.name, 'upload');
materialsCache = body.materials || [];
await loadModelUrl('model.glb?cb=' + Date.now());
showToast(file.name + (body.cache_hit ? ' (cached)' : ' converted'), 'ok');
} catch (err) {
setSplash(null);
showToast('Load error: ' + err.message, 'err');
}
});
}
// =====================================================================
// 10. Measure tool
// =====================================================================
let measureActive = false;
let measureFilter = 'smart';
let measurePicks = []; // [{point, mesh, marker, type}]
function toggleMeasure() {
measureActive = !measureActive;
document.getElementById('tbMeasure').classList.toggle('active', measureActive);
document.getElementById('measureHud').classList.toggle('open', measureActive);
if (!measureActive) {
clearMeasure();
} else {
renderMeasureHud();
}
}
function clearMeasure() {
for (const p of measurePicks) {
if (p.markers) for (const m of p.markers) m.dispose();
else if (p.marker) p.marker.dispose(); // legacy single-mesh shape
}
if (measureLine) { measureLine.dispose(); measureLine = null; }
if (measureLabel) { measureLabel.dispose(); measureLabel = null; }
measurePicks = [];
clearMeasurePreview();
renderMeasureHud();
}
function setupMeasureTool() {
document.getElementById('measureClose').onclick = () => toggleMeasure();
document.querySelectorAll('.mb-filter').forEach(btn => {
btn.onclick = () => {
measureFilter = btn.dataset.filter;
document.querySelectorAll('.mb-filter').forEach(b => b.classList.toggle('active', b === btn));
};
});
scene.onPointerObservable.add((pi) => {
if (!measureActive) return;
// Click commits a measurement at the current snap point.
if (pi.type === BABYLON.PointerEventTypes.POINTERTAP && pi.event.button === 0) {
const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => isPickableMesh(m));
if (!pick || !pick.hit || !pick.pickedPoint) return;
addMeasurePick(pick);
return;
}
// Hover renders a translucent preview of what would commit on click.
// Required by human-ui-patterns §2 (click previews) and matches Fusion 360
// / SolidWorks / Onshape behavior.
if (pi.type === BABYLON.PointerEventTypes.POINTERMOVE) {
const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => isPickableMesh(m));
if (!pick || !pick.hit || !pick.pickedPoint) {
clearMeasurePreview();
return;
}
const snap = computeMeasureSnap(pick, measureFilter);
drawMeasurePreview(snap);
}
});
}
function isPickableMesh(m) {
if (!m) return false;
if (m.name && (m.name.startsWith('measure-') || m.name.startsWith('section-') || m.name.startsWith('viewCube'))) return false;
if (m.name === 'shadowGround' || m.name === 'ground' || m.name === 'skyBox') return false;
return m.isEnabled() && m.isVisible !== false;
}
// Compute the snap point + auxiliary geometry for a hover/click pick under
// the current filter. Returns: { kind, point, mesh, edgeA?, edgeB?, faceTri? }
// Smart-mode priority is edges > faces > vertexes (per user feedback) — edges
// are the most semantically meaningful pick on a mechanical part, faces are
// next, and vertexes are last because they're often dense on tessellated
// curves and snap to noise.
function computeMeasureSnap(pick, filter) {
const point = pick.pickedPoint.clone();
const mesh = pick.pickedMesh;
// Snap radius scales with scene extent so it feels right on a 2 mm SOT-23
// and a 200 mm board alike. ~2% of scene extent in scene-meters.
const ext = sceneExtentMeters();
const snapRadius = Math.max(ext * 0.015, 0.0003);
function tryEdge() {
return nearestEdgeFull(mesh, pick, snapRadius);
}
function tryFace() {
// 1° tolerance for "coplanar" — same threshold most CAD tools use.
return findConnectedFace(mesh, pick, 1);
}
function tryVertex() {
const v = nearestVertex(mesh, point, snapRadius);
return v ? { point: v } : null;
}
if (filter === 'body') {
const bb = combinedBoundingBox([mesh]);
if (bb) return { kind: 'body', point: bb.center.clone(), mesh };
return { kind: 'point', point, mesh };
}
if (filter === 'edge') {
const e = tryEdge();
if (e) return { kind: 'edge', point: e.point, mesh, edgeA: e.a, edgeB: e.b };
return { kind: 'point', point, mesh };
}
if (filter === 'face') {
const f = tryFace();
return { kind: 'face', point: f ? f.point : point, mesh, face: f, faceTri: f && f.tri };
}
if (filter === 'vertex') {
const v = tryVertex();
if (v) return { kind: 'vertex', point: v.point, mesh };
return { kind: 'point', point, mesh };
}
// Smart: edge > face > vertex priority (per user feedback).
const e = tryEdge();
if (e) return { kind: 'edge', point: e.point, mesh, edgeA: e.a, edgeB: e.b };
const f = tryFace();
if (f) return { kind: 'face', point: f.point, mesh, face: f, faceTri: f.tri };
const v = tryVertex();
if (v) return { kind: 'vertex', point: v.point, mesh };
return { kind: 'point', point, mesh };
}
function addMeasurePick(pick) {
if (measurePicks.length >= 2) clearMeasure();
const snap = computeMeasureSnap(pick, measureFilter);
const markerMeshes = makeMeasureMarker(snap, measurePicks.length + 1);
// Preserve the snap's auxiliary geometry (edgeA/edgeB/faceTri) so
// computeMeasureEndpoints can do edge-edge shortest-segment, etc.
measurePicks.push({
point: snap.point, mesh: snap.mesh, kind: snap.kind, markers: markerMeshes,
edgeA: snap.edgeA, edgeB: snap.edgeB,
face: snap.face, faceTri: snap.faceTri,
});
if (measurePicks.length === 2) drawMeasureLine();
clearMeasurePreview();
renderMeasureHud();
}
function nearestVertex(mesh, point, snapRadius) {
if (!mesh.getVerticesData) return null;
const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);
if (!positions) return null;
const matrix = mesh.computeWorldMatrix(true);
let best = null;
let bestD = snapRadius * snapRadius;
const tmp = new BABYLON.Vector3();
for (let i = 0; i < positions.length; i += 3) {
tmp.set(positions[i], positions[i + 1], positions[i + 2]);
const w = BABYLON.Vector3.TransformCoordinates(tmp, matrix);
const dx = w.x - point.x, dy = w.y - point.y, dz = w.z - point.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 < bestD) { bestD = d2; best = w.clone(); }
}
return best;
}
function nearestEdgeMidpoint(mesh, pick, snapRadius) {
const e = nearestEdgeFull(mesh, pick, snapRadius);
return e ? e.point : null;
}
// Returns the closest edge to the pick point as { a, b, point } where point
// is the closest point ON that edge to the cursor (not necessarily the
// midpoint). Distance metric: cursor-to-line, not cursor-to-midpoint, so the
// snap is stable as the user slides the cursor along the edge.
function nearestEdgeFull(mesh, pick, snapRadius) {
if (!mesh.getIndices || !mesh.getVerticesData || pick.faceId == null) return null;
const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);
const indices = mesh.getIndices();
if (!positions || !indices) return null;
const matrix = mesh.computeWorldMatrix(true);
const f = pick.faceId;
if (f * 3 + 2 >= indices.length) return null;
const ia = indices[f * 3], ib = indices[f * 3 + 1], ic = indices[f * 3 + 2];
const verts = [ia, ib, ic].map(idx => {
const p = new BABYLON.Vector3(positions[idx * 3], positions[idx * 3 + 1], positions[idx * 3 + 2]);
return BABYLON.Vector3.TransformCoordinates(p, matrix);
});
const c = pick.pickedPoint;
let best = null;
let bestD = snapRadius * snapRadius;
for (let i = 0; i < 3; i++) {
const a = verts[i], b = verts[(i + 1) % 3];
const ab = b.subtract(a);
const lenSq = ab.lengthSquared();
if (lenSq <= 1e-12) continue;
let t = BABYLON.Vector3.Dot(c.subtract(a), ab) / lenSq;
t = Math.max(0, Math.min(1, t));
const closest = a.add(ab.scale(t));
const dx = closest.x - c.x, dy = closest.y - c.y, dz = closest.z - c.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 < bestD) { bestD = d2; best = { a: a.clone(), b: b.clone(), point: closest.clone() }; }
}
return best;
}
// Find the LOGICAL face that contains the picked triangle by flood-filling
// adjacent coplanar triangles. Returns { worldPositions, indices, centroid,
// normal, point, triCount, tri /* legacy: first triangle */ } in world space.
//
// User feedback (May 2026): showing a single triangle for the "Face" filter
// was confusing — users think of a flat surface as ONE face, not "the
// triangle the cursor happened to land on". Flood-fill groups all triangles
// whose normals match within 1° (cos ≈ 0.9998), giving the human-meaningful
// "face". Curved surfaces (gull-wing pin tops, chip leads) break out of the
// fill at the curvature boundary, so each curved patch stays its own face.
function findConnectedFace(mesh, pick, epsilonDeg) {
if (!mesh.getIndices || !mesh.getVerticesData || pick.faceId == null) return null;
const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);
const indices = mesh.getIndices();
if (!positions || !indices) return null;
const matrix = mesh.computeWorldMatrix(true);
const numTris = indices.length / 3;
if (pick.faceId < 0 || pick.faceId >= numTris) return null;
function vec(i) {
return new BABYLON.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]);
}
function triNormal(fi) {
const ia = indices[fi * 3], ib = indices[fi * 3 + 1], ic = indices[fi * 3 + 2];
const a = vec(ia), b = vec(ib), c = vec(ic);
const n = BABYLON.Vector3.Cross(b.subtract(a), c.subtract(a));
if (n.lengthSquared() < 1e-20) return null;
return n.normalize();
}
function edgeKey(a, b) { return Math.min(a, b) + ',' + Math.max(a, b); }
// Edge → triangle adjacency.
const edgeMap = new Map();
for (let f = 0; f < numTris; f++) {
const ia = indices[f * 3], ib = indices[f * 3 + 1], ic = indices[f * 3 + 2];
const edges = [[ia, ib], [ib, ic], [ic, ia]];
for (const e of edges) {
const k = edgeKey(e[0], e[1]);
let arr = edgeMap.get(k);
if (!arr) { arr = []; edgeMap.set(k, arr); }
arr.push(f);
}
}
const seedNormal = triNormal(pick.faceId);
if (!seedNormal) return null;
const cosEps = Math.cos((epsilonDeg || 1) * Math.PI / 180);
const visited = new Set([pick.faceId]);
const stack = [pick.faceId];
while (stack.length) {
const f = stack.pop();
const ia = indices[f * 3], ib = indices[f * 3 + 1], ic = indices[f * 3 + 2];
const edges = [[ia, ib], [ib, ic], [ic, ia]];
for (const e of edges) {
const list = edgeMap.get(edgeKey(e[0], e[1]));
if (!list) continue;
for (const n of list) {
if (visited.has(n)) continue;
const nn = triNormal(n);
if (!nn) continue;
if (BABYLON.Vector3.Dot(seedNormal, nn) >= cosEps) {
visited.add(n);
stack.push(n);
}
}
}
}
// Bake all triangles into one set of world-space positions + indices,
// and compute the AREA-WEIGHTED centroid (the proper geometric centroid
// of the polygon, not just the average of triangle vertices — large
// triangles influence the centroid more than tiny ones).
const worldPositions = [];
const triIndices = [];
let cx = 0, cy = 0, cz = 0, totalArea = 0, vIdx = 0;
for (const f of visited) {
const ia = indices[f * 3], ib = indices[f * 3 + 1], ic = indices[f * 3 + 2];
const wa = BABYLON.Vector3.TransformCoordinates(vec(ia), matrix);
const wb = BABYLON.Vector3.TransformCoordinates(vec(ib), matrix);
const wc = BABYLON.Vector3.TransformCoordinates(vec(ic), matrix);
worldPositions.push(wa.x, wa.y, wa.z, wb.x, wb.y, wb.z, wc.x, wc.y, wc.z);
triIndices.push(vIdx, vIdx + 1, vIdx + 2, vIdx + 2, vIdx + 1, vIdx);
vIdx += 3;
// Triangle area = 0.5 × |edge1 × edge2|
const cross = BABYLON.Vector3.Cross(wb.subtract(wa), wc.subtract(wa));
const area = cross.length() * 0.5;
// Triangle centroid = average of its 3 vertices
const tcx = (wa.x + wb.x + wc.x) / 3;
const tcy = (wa.y + wb.y + wc.y) / 3;
const tcz = (wa.z + wb.z + wc.z) / 3;
cx += tcx * area; cy += tcy * area; cz += tcz * area;
totalArea += area;
}
const centroid = totalArea > 0
? new BABYLON.Vector3(cx / totalArea, cy / totalArea, cz / totalArea)
: new BABYLON.Vector3(worldPositions[0], worldPositions[1], worldPositions[2]);
// Legacy `tri` field for backwards compat — first triangle as 3 Vector3s.
const f0 = pick.faceId;
const tri = [indices[f0 * 3], indices[f0 * 3 + 1], indices[f0 * 3 + 2]].map(idx =>
BABYLON.Vector3.TransformCoordinates(vec(idx), matrix)
);
return {
worldPositions, indices: triIndices, centroid,
normal: seedNormal, point: pick.pickedPoint.clone(),
triCount: visited.size, tri,
};
}
// Backwards-compat shim.
function faceTriFromPick(mesh, pick) {
return findConnectedFace(mesh, pick, 1);
}
function sceneExtentMeters() {
// Measure / section / pivot markers must scale with the scene, not be
// hard-coded — a 1m sphere on a 12 mm chip means the camera sits INSIDE
// the marker. Scale by current scene bounds. (Per 3d-viewer-design §6c.)
const meshes = scene.meshes.filter(isPickableMesh);
if (!meshes.length) return 0.05; // 50mm fallback
const bb = combinedBoundingBox(meshes);
return bb ? bb.radius * 2 : 0.05;
}
// Build the committed marker meshes for a confirmed pick. Shape matches
// the snap kind so the user sees the same geometry they hovered: edge →
// tube, vertex → sphere, face → translucent triangle, body → bbox volume.
// The dimension line still connects the two pick.point values regardless.
function makeMeasureMarker(snap, label) {
const ext = sceneExtentMeters();
const meshes = [];
function makeMat(name, alpha) {
const m = new BABYLON.StandardMaterial(name, scene);
m.disableLighting = true;
m.emissiveColor = BABYLON.Color3.FromHexString('#00b8b1');
m.alpha = alpha == null ? 1 : alpha;
m.backFaceCulling = false;
return m;
}
function reg(m) {
m.renderingGroupId = 3;
m.isPickable = false;
if (measureGlowLayer) measureGlowLayer.addIncludedOnlyMesh(m);
meshes.push(m);
}
if (snap.kind === 'edge' && snap.edgeA && snap.edgeB) {
const tube = BABYLON.MeshBuilder.CreateTube('measure-edge-' + label, {
path: [snap.edgeA, snap.edgeB], radius: ext * 0.005,
}, scene);
tube.material = makeMat('measure-edge-mat-' + label, 1);
reg(tube);
} else if (snap.kind === 'face' && snap.face) {
// Whole logical face on commit, same as the preview.
const facePlane = new BABYLON.Mesh('measure-face-' + label, scene);
const vd = new BABYLON.VertexData();
vd.positions = snap.face.worldPositions;
vd.indices = snap.face.indices;
vd.applyToMesh(facePlane);
facePlane.material = makeMat('measure-face-mat-' + label, 0.65);
reg(facePlane);
} else if (snap.kind === 'body') {
const bb = combinedBoundingBox([snap.mesh]);
if (bb) {
const box = BABYLON.MeshBuilder.CreateBox('measure-body-' + label, {
width: Math.max(bb.max.x - bb.min.x, ext * 0.005),
height: Math.max(bb.max.y - bb.min.y, ext * 0.005),
depth: Math.max(bb.max.z - bb.min.z, ext * 0.005),
}, scene);
box.position = bb.center.clone();
box.material = makeMat('measure-body-mat-' + label, 0.22);
reg(box);
}
} else {
// 'vertex' or fallback 'point' — sphere at the snap point.
const sphere = BABYLON.MeshBuilder.CreateSphere('measure-vertex-' + label,
{ diameter: ext * 0.04 }, scene);
sphere.position = snap.point.clone();
sphere.material = makeMat('measure-vertex-mat-' + label, 1);
reg(sphere);
}
return meshes;
}
// =====================================================================
// Measure preview — emissive geometric helpers that highlight whatever
// the next click would snap to. Wrapped in a GlowLayer so previews bloom
// against the dark scene. NO cursor sphere — the geometric primitive
// itself (edge tube / face triangle / vertex ring / body sphere) IS the
// preview. The HUD's "Snap: <kind>" badge tells the user what they're
// looking at.
// =====================================================================
let measurePreviewMeshes = [];
let measureGlowLayer = null;
function ensureGlowLayer() {
if (measureGlowLayer) return measureGlowLayer;
measureGlowLayer = new BABYLON.GlowLayer('measure-glow', scene, {
mainTextureFixedSize: 1024,
blurKernelSize: 64,
});
measureGlowLayer.intensity = 0.7;
// Per-mesh emissive override: vertex spheres glow at ~30% so the bloom
// doesn't wash out the material's alpha=0.5 (otherwise the back face's
// emissive light fills the front face's see-through). Edges / faces /
// bodies use full bright emissive — they're thin/flat and the glow
// doesn't fight the transparency the same way.
measureGlowLayer.customEmissiveColorSelector = (mesh, _sub, _mat, result) => {
const isVertex = mesh && mesh.name && mesh.name.startsWith('measure-prev-vertex');
if (isVertex) {
result.set(0.0, 0.35, 0.34, 1.0); // dim teal — gentle bloom
} else {
result.set(0.0, 0.72, 0.69, 1.0); // full brand teal
}
};
return measureGlowLayer;
}
function clearMeasurePreview() {
if (measureGlowLayer) {
for (const m of measurePreviewMeshes) measureGlowLayer.removeIncludedOnlyMesh(m);
}
for (const m of measurePreviewMeshes) m.dispose();
measurePreviewMeshes = [];
setMeasureSnapBadge(null);
}
function setMeasureSnapBadge(kind) {
const el = document.getElementById('measureSnapKind');
if (!el) return;
if (!kind) { el.textContent = '—'; el.removeAttribute('data-kind'); return; }
// Title-case for display.
const labels = { edge: 'Edge', face: 'Face', vertex: 'Vertex', body: 'Body', point: 'Point' };
el.textContent = labels[kind] || kind;
el.setAttribute('data-kind', kind);
}
// Preview rendering parameters — kept in one place so they stay coherent.
// User feedback 2026-05-02: previews must be ~50% transparent (so the user
// can see the underlying geometry they're picking) and ~30% smaller than
// the committed marker so the two are visually distinct.
const PREVIEW_ALPHA = 0.5;
const PREVIEW_SIZE_SCALE = 0.7;
function previewMaterial(name) {
const mat = new BABYLON.StandardMaterial(name, scene);
mat.disableLighting = true;
mat.emissiveColor = BABYLON.Color3.FromHexString('#00b8b1'); // brand teal
mat.alpha = PREVIEW_ALPHA;
mat.backFaceCulling = false;
return mat;
}
function drawMeasurePreview(snap) {
clearMeasurePreview();
if (!snap) return;
setMeasureSnapBadge(snap.kind);
const ext = sceneExtentMeters();
const glow = ensureGlowLayer();
const S = PREVIEW_SIZE_SCALE;
function regGlow(m) {
m.renderingGroupId = 3;
m.isPickable = false;
glow.addIncludedOnlyMesh(m);
measurePreviewMeshes.push(m);
}
if (snap.kind === 'edge' && snap.edgeA && snap.edgeB) {
const tube = BABYLON.MeshBuilder.CreateTube('measure-prev-edge', {
path: [snap.edgeA, snap.edgeB], radius: ext * 0.004 * S,
}, scene);
tube.material = previewMaterial('measure-prev-edge-mat');
regGlow(tube);
return;
}
if (snap.kind === 'face' && snap.face) {
// Show the FULL logical face (all coplanar adjacent triangles), not
// just the picked triangle. Per user feedback May 2026 — humans think
// of a flat surface as one face, not as the triangulation underneath.
const facePlane = new BABYLON.Mesh('measure-prev-face', scene);
const vd = new BABYLON.VertexData();
vd.positions = snap.face.worldPositions;
vd.indices = snap.face.indices;
vd.applyToMesh(facePlane);
facePlane.material = previewMaterial('measure-prev-face-mat');
regGlow(facePlane);
return;
}
if (snap.kind === 'vertex') {
// Translucent sphere AT the vertex (not a ring around it) with a
// softened glow so the alpha-0.5 transparency actually shows. The
// GlowLayer's customEmissiveColorSelector dims spheres specifically
// so the bloom doesn't fill the see-through.
const sphere = BABYLON.MeshBuilder.CreateSphere('measure-prev-vertex', {
diameter: ext * 0.025 * S, segments: 24,
}, scene);
sphere.position = snap.point.clone();
const mat = new BABYLON.StandardMaterial('measure-prev-vertex-mat', scene);
mat.disableLighting = true;
mat.emissiveColor = BABYLON.Color3.FromHexString('#00b8b1');
mat.alpha = PREVIEW_ALPHA;
mat.backFaceCulling = true; // back face would fill the see-through
mat.alphaMode = BABYLON.Engine.ALPHA_COMBINE;
sphere.material = mat;
regGlow(sphere);
return;
}
if (snap.kind === 'body') {
const bb = combinedBoundingBox([snap.mesh]);
if (!bb) return;
const box = BABYLON.MeshBuilder.CreateBox('measure-prev-body', {
width: Math.max(bb.max.x - bb.min.x, ext * 0.005),
height: Math.max(bb.max.y - bb.min.y, ext * 0.005),
depth: Math.max(bb.max.z - bb.min.z, ext * 0.005),
}, scene);
box.position = bb.center.clone();
box.material = previewMaterial('measure-prev-body-mat');
regGlow(box);
return;
}
// 'point' fallback: no preview, let the user keep moving until they
// land on a pickable feature.
}
// =====================================================================
// Dimension geometry — compute the SHORTEST path between two picks given
// their snap-kinds. Edge-edge becomes skew-line shortest segment; vertex-
// edge becomes perpendicular projection; everything else falls through to
// the cursor pick points (snap.point) which already lie on the geometry.
// =====================================================================
function closestPointOnSegment(p, a, b) {
const ab = b.subtract(a);
const lenSq = ab.lengthSquared();
if (lenSq < 1e-14) return a.clone();
let t = BABYLON.Vector3.Dot(p.subtract(a), ab) / lenSq;
t = Math.max(0, Math.min(1, t));
return a.add(ab.scale(t));
}
// Standard skew-line closest-segment algorithm. Handles parallel + degenerate
// cases. Returns the two world points that minimize |a - b|.
function shortestSegmentBetweenSegments(p0, p1, q0, q1) {
const d1 = p1.subtract(p0);
const d2 = q1.subtract(q0);
const r = p0.subtract(q0);
const a = d1.lengthSquared();
const e = d2.lengthSquared();
const f = BABYLON.Vector3.Dot(d2, r);
let s, t;
if (a < 1e-14 && e < 1e-14) return { a: p0.clone(), b: q0.clone() };
if (a < 1e-14) { s = 0; t = Math.max(0, Math.min(1, f / e)); }
else {
const c = BABYLON.Vector3.Dot(d1, r);
if (e < 1e-14) { t = 0; s = Math.max(0, Math.min(1, -c / a)); }
else {
const b = BABYLON.Vector3.Dot(d1, d2);
const denom = a * e - b * b;
s = denom !== 0 ? Math.max(0, Math.min(1, (b * f - c * e) / denom)) : 0;
t = (b * s + f) / e;
if (t < 0) { t = 0; s = Math.max(0, Math.min(1, -c / a)); }
else if (t > 1) { t = 1; s = Math.max(0, Math.min(1, (b - c) / a)); }
}
}
return { a: p0.add(d1.scale(s)), b: q0.add(d2.scale(t)) };
}
// Centroid of a picked entity, in scene meters. Per-user UX choice: the
// dimension line connects the **center** of each picked thing — predictable
// and appealing for users who think in "from center of this to center of
// that". Edge-edge → midpoint-to-midpoint, face-face → centroid-to-centroid,
// vertex/point → as-is, body → bbox center.
function pickCentroid(p) {
if (p.kind === 'edge' && p.edgeA && p.edgeB) {
return p.edgeA.add(p.edgeB).scale(0.5);
}
if (p.kind === 'face' && p.face && p.face.centroid) {
return p.face.centroid.clone(); // centroid of the whole logical face
}
if (p.kind === 'face' && p.faceTri) {
// Legacy single-tri fallback (shouldn't happen post-flood-fill).
const [a, b, c] = p.faceTri;
return a.add(b).add(c).scale(1 / 3);
}
return p.point.clone();
}
// The dimension line endpoints. We always go centroid-to-centroid so the
// line is straight, deterministic, and visually predictable. Auxiliary
// helpers (closestPointOnSegment, shortestSegmentBetweenSegments) are kept
// in the codebase for future "perpendicular" / "shortest" annotation modes.
function computeMeasureEndpoints(p1, p2) {
return { a: pickCentroid(p1), b: pickCentroid(p2) };
}
// Direction vector for a pick (unit). Edges → along the edge. Faces → face
// normal (so face-face angle is the dihedral angle). Vertex/body/point →
// null (no meaningful direction).
function pickDirection(p) {
if (p.kind === 'edge' && p.edgeA && p.edgeB) {
const d = p.edgeB.subtract(p.edgeA);
if (d.lengthSquared() < 1e-14) return null;
return d.normalize();
}
if (p.kind === 'face' && p.face && p.face.normal) {
return p.face.normal.clone(); // averaged normal of the whole face
}
if (p.kind === 'face' && p.faceTri) {
const [a, b, c] = p.faceTri;
const n = BABYLON.Vector3.Cross(b.subtract(a), c.subtract(a));
if (n.lengthSquared() < 1e-14) return null;
return n.normalize();
}
return null;
}
// Angle between two picks in degrees (0..180), or null if either pick has
// no direction (vertex/body/point). Edge-edge → angle between edges.
// Face-face → dihedral. Edge-face → angle between edge and face normal,
// returned as the COMPLEMENT (angle from edge to face plane = 90 - angle).
function computeMeasureAngle(p1, p2) {
const d1 = pickDirection(p1);
const d2 = pickDirection(p2);
if (!d1 || !d2) return null;
const dot = Math.abs(BABYLON.Vector3.Dot(d1, d2));
const clamped = Math.max(-1, Math.min(1, dot));
let deg = Math.acos(clamped) * 180 / Math.PI;
// Edge-face: report angle FROM edge TO face plane (= 90 - angle to normal).
const isEF = (p1.kind === 'edge' && p2.kind === 'face') ||
(p1.kind === 'face' && p2.kind === 'edge');
if (isEF) deg = 90 - deg;
return deg;
}
let measureLine = null;
let measureLabel = null;
function makeDimensionLabel(mainText, subText, midpoint) {
const ext = sceneExtentMeters();
// 2-row pill: distance on top, kind-context + mils underneath.
// 2026-05-06 user feedback: main was reading ~32pt on screen — way too
// large for a UI label. Subtitle was fine. Brought main from 96px →
// 56px (≈14pt rendered) and tightened texture height + padding so the
// pill is proportionally smaller without wrapping.
const texW = 1024, texH = 192;
const dt = new BABYLON.DynamicTexture('measure-label-tex', { width: texW, height: texH }, scene, true);
const ctx = dt.getContext();
ctx.clearRect(0, 0, texW, texH);
ctx.font = 'bold 56px ui-monospace, "SF Mono", Menlo, monospace';
const mainW = ctx.measureText(mainText).width;
ctx.font = '500 44px -apple-system, "Inter", "Segoe UI", system-ui, sans-serif';
const subW = subText ? ctx.measureText(subText).width : 0;
const padX = 40, padY = 24;
const w = Math.min(Math.max(mainW, subW) + padX * 2, texW - 4);
const h = texH - padY * 0.5;
const x0 = (texW - w) / 2, y0 = (texH - h) / 2;
// Pill background — translucent dark so it never blends into a dark chip body
ctx.fillStyle = 'rgba(18, 22, 28, 0.92)';
ctx.beginPath();
const r = 22;
ctx.moveTo(x0 + r, y0);
ctx.arcTo(x0 + w, y0, x0 + w, y0 + h, r);
ctx.arcTo(x0 + w, y0 + h, x0, y0 + h, r);
ctx.arcTo(x0, y0 + h, x0, y0, r);
ctx.arcTo(x0, y0, x0 + w, y0, r);
ctx.closePath();
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = '#64abff';
ctx.stroke();
// Distance — bold white, smaller than v1.0 to read as UI text not headline
ctx.font = 'bold 56px ui-monospace, "SF Mono", Menlo, monospace';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(mainText, texW / 2, subText ? texH / 2 - 22 : texH / 2);
// Sub text — kind context like "Edge ↔ Edge" in brand blue-light
if (subText) {
ctx.font = '500 44px -apple-system, "Inter", "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = '#64abff';
ctx.fillText(subText, texW / 2, texH / 2 + 36);
}
dt.update();
const planeWidth = ext * 0.26;
const planeHeight = planeWidth * (texH / texW);
const plane = BABYLON.MeshBuilder.CreatePlane('measure-label', {
width: planeWidth, height: planeHeight, sideOrientation: BABYLON.Mesh.DOUBLESIDE,
}, scene);
// Lift the label slightly above the dimension line midpoint so it's not
// covered by chip body / pin geometry. Offset is a small fraction of
// scene extent and always toward the camera-up direction.
const upOffset = ext * 0.04;
plane.position = midpoint.add(new BABYLON.Vector3(0, 0, upOffset));
plane.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
const mat = new BABYLON.StandardMaterial('measure-label-mat', scene);
mat.diffuseTexture = dt;
mat.diffuseTexture.hasAlpha = true;
mat.useAlphaFromDiffuseTexture = true;
mat.emissiveColor = new BABYLON.Color3(1, 1, 1);
mat.disableLighting = true;
mat.backFaceCulling = false;
// Label MUST render on top of all geometry so the user can read the
// measurement even when the line midpoint is inside / behind the part.
// depthFunction = ALWAYS makes the depth test always pass, and a higher
// renderingGroupId puts it after the main scene draw.
mat.disableDepthWrite = true;
mat.depthFunction = BABYLON.Engine.ALWAYS;
plane.material = mat;
plane.renderingGroupId = 3;
plane.alwaysSelectAsActiveMesh = true;
plane.isPickable = false;
return plane;
}
function drawMeasureLine() {
if (measureLine) { measureLine.dispose(); measureLine = null; }
if (measureLabel) { measureLabel.dispose(); measureLabel = null; }
const ep = computeMeasureEndpoints(measurePicks[0], measurePicks[1]);
const distance_m = BABYLON.Vector3.Distance(ep.a, ep.b);
const ext = sceneExtentMeters();
// Dimension line: brand blue-light (#64ABFF) so it reads as "measurement"
// rather than "selection" (which is teal). Slightly thinner than
// committed-edge tubes to keep the visual hierarchy clean.
measureLine = BABYLON.MeshBuilder.CreateTube('measure-line', {
path: [ep.a, ep.b], radius: ext * 0.0035, updatable: false,
}, scene);
const mat = new BABYLON.StandardMaterial('measure-line-mat', scene);
mat.disableLighting = true;
mat.emissiveColor = BABYLON.Color3.FromHexString('#64abff');
measureLine.material = mat;
measureLine.renderingGroupId = 3;
measureLine.isPickable = false;
// 3D billboard label at midpoint with kind-context + angle subtitle.
const mid = ep.a.add(ep.b).scale(0.5);
const distance_mm = distance_m * M_TO_MM;
const mils = distance_mm / 0.0254;
const mainText = (Math.round(distance_mm * 1000) / 1000).toFixed(3) + ' mm';
const milsText = (Math.round(mils * 10) / 10).toFixed(1) + ' mils';
const k = (s) => s.charAt(0).toUpperCase() + s.slice(1);
const angle = computeMeasureAngle(measurePicks[0], measurePicks[1]);
const angleText = angle != null ? (Math.round(angle * 10) / 10).toFixed(1) + '°' : null;
// Subtitle order: angle (if any) · kind ↔ kind · mils
const parts = [];
if (angleText) parts.push(angleText);
parts.push(k(measurePicks[0].kind) + ' ↔ ' + k(measurePicks[1].kind));
parts.push(milsText);
const subText = parts.join(' · ');
measureLabel = makeDimensionLabel(mainText, subText, mid);
return { endpointA: ep.a, endpointB: ep.b, distance_mm };
}
function renderMeasureHud() {
const body = document.getElementById('measureBody');
if (!measurePicks.length) {
body.innerHTML = '<div class="mh-hint">Click two points on the model.</div>';
return;
}
let html = '';
for (let i = 0; i < measurePicks.length; i++) {
const p = measurePicks[i];
html += '<div class="mh-row"><span class="lbl">Sel ' + (i + 1) + '</span><span class="val">' + p.kind + ' on ' + (p.mesh && p.mesh.name ? p.mesh.name : '?') + '</span></div>';
html += '<div class="mh-row"><span class="lbl">Position</span><span class="val">' + fmt(p.point.x) + ', ' + fmt(p.point.y) + ', ' + fmt(p.point.z) + '</span></div>';
}
if (measurePicks.length === 2) {
// Use the same dimension endpoints as the 3D line, so HUD numbers
// match the visible measurement. Edge-edge gives the SHORTEST
// distance; vertex-edge gives perpendicular; etc.
const ep = computeMeasureEndpoints(measurePicks[0], measurePicks[1]);
const dx = Math.abs(ep.b.x - ep.a.x);
const dy = Math.abs(ep.b.y - ep.a.y);
const dz = Math.abs(ep.b.z - ep.a.z);
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
html += '<div class="mh-sep"></div>';
html += '<div class="mh-row"><span class="lbl">ΔX</span><span class="val">' + dualUnits(dx) + '</span></div>';
html += '<div class="mh-row"><span class="lbl">ΔY</span><span class="val">' + dualUnits(dy) + '</span></div>';
html += '<div class="mh-row"><span class="lbl">ΔZ</span><span class="val">' + dualUnits(dz) + '</span></div>';
html += '<div class="mh-sep"></div>';
html += '<div class="mh-row"><span class="lbl">Distance</span><span class="val mh-distance">' + dualUnits(d) + '</span></div>';
const ang = computeMeasureAngle(measurePicks[0], measurePicks[1]);
if (ang != null) {
html += '<div class="mh-row"><span class="lbl">Angle</span><span class="val">' + (Math.round(ang * 100) / 100).toFixed(2) + '°</span></div>';
}
}
body.innerHTML = html;
}
// Babylon scene works in meters (glTF / OCCT convention). All UI labels
// are in mm + mils — convert at the display boundary, never propagate
// meters into measure/inspect output.
const M_TO_MM = 1000;
function fmt(meters) { return (Math.round(meters * M_TO_MM * 1000) / 1000).toFixed(3); }
function dualUnits(meters) {
const mm = meters * M_TO_MM;
const mils = mm / 0.0254;
return '<span>' + (Math.round(mm * 1000) / 1000).toFixed(3) + ' mm</span> <span class="mh-secondary">/ ' + (Math.round(mils * 10) / 10).toFixed(1) + ' mils</span>';
}
// =====================================================================
// 11. Inspect tool — hover the model, show an amber card with details.
// =====================================================================
let inspectActive = true; // hover-inspect is always on by default
let inspectPinned = false;
let lastHoverEntry = null;
// Find the SECOND-LEVEL (depth-1) ancestor of an entry. Used by hover-
// highlight so we light up the meaningful sub-part the cursor is over,
// not the entire chip (which would be too coarse) and not the random
// material/triangle leaf the picker landed on (which would be too fine).
// Per user feedback May 2026: top-level highlight feels like the whole
// thing lights up; second-level matches what they want to inspect (Body
// vs Pin 17 vs Contacts vs MODULE_NAUO2 etc.).
function secondLevelOf(entry) {
if (!entry) return null;
if (entry.depth <= 1) return entry;
let cur = entry;
while (cur && cur.depth > 1) {
cur = cur.parentId == null ? null : nodeById.get(cur.parentId);
}
return cur || entry;
}
let lastHoverTopLevel = null;
function setHoverHighlight(entry) {
// Clear previous overlay
if (lastHoverTopLevel && lastHoverTopLevel !== entry) {
for (const m of lastHoverTopLevel.allMeshes) {
if (m && m.renderOverlay) m.renderOverlay = false;
}
}
lastHoverTopLevel = entry;
if (!entry) return;
// mesh.renderOverlay + overlayColor/overlayAlpha gives a gentle tint
// that you can still see geometry through. Brand blue-light at α 0.3
// — visibly highlights the part without obliterating it like
// HighlightLayer does.
for (const m of entry.allMeshes) {
if (!m) continue;
m.overlayColor = BABYLON.Color3.FromHexString('#64abff');
m.overlayAlpha = 0.3;
m.renderOverlay = true;
}
}
function findInOutline(entry) {
if (!entry) return false;
// Expand every ancestor so the row is visible.
let cur = entry;
while (cur) {
cur.expanded = true;
cur = cur.parentId == null ? null : nodeById.get(cur.parentId);
}
renderTree();
// Scroll the row into view + flash it.
const row = document.querySelector('.tree-row[data-id="' + entry.id + '"]');
if (!row) return false;
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
row.classList.remove('flash');
// Force reflow so the animation restarts even if the same row is re-found.
void row.offsetWidth;
row.classList.add('flash');
setTimeout(() => row.classList.remove('flash'), 1400);
return true;
}
function setupInspectTool() {
// POINTERMOVE: gentle hover-highlight of the top-level object the cursor
// is over. NO popup card (intrusive, gets in the way). User can still
// open the inspect card explicitly via right-click → Properties.
scene.onPointerObservable.add((pi) => {
if (!inspectActive || measureActive) return;
if (pi.type !== BABYLON.PointerEventTypes.POINTERMOVE) return;
if (inspectPinned) return;
const pick = scene.pick(scene.pointerX, scene.pointerY, isPickableMesh);
if (!pick || !pick.hit) {
setHoverHighlight(null);
return;
}
const entry = entryForMesh(pick.pickedMesh);
if (!entry) { setHoverHighlight(null); return; }
// Highlight the DEEPEST matching entry (per user feedback May 2026):
// hovering a pin in 3D should light up just that pin, not the whole
// Pins group. entryForMesh already returns the deepest match.
if (entry !== lastHoverTopLevel) setHoverHighlight(entry);
});
// Left-click: post selection to server (CLI inspect support).
scene.onPointerObservable.add((pi) => {
if (!inspectActive || measureActive) return;
if (pi.type !== BABYLON.PointerEventTypes.POINTERTAP) return;
if (pi.event.button !== 0) return;
const pick = scene.pick(scene.pointerX, scene.pointerY, isPickableMesh);
if (pick && pick.hit) {
const entry = entryForMesh(pick.pickedMesh);
if (entry) {
fetch('api/selection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: entry.id, name: entry.name, depth: entry.depth }),
}).catch(() => {});
}
}
});
// Right-click on the 3D canvas: open the same context menu the sidebar
// rows use (Reveal in Outline / Isolate / Hide Others / Show / Hide /
// Zoom To / Copy Name / Properties). Mirrors Fusion 360 — right-click
// on a body in the canvas gives the same actions as right-click on its
// browser row. All visibility actions are Ctrl+Z-undoable; "Hide" also
// flashes the row in the sidebar so the user can find their way back.
const renderContainer = document.getElementById('renderContainer');
if (renderContainer) {
renderContainer.addEventListener('contextmenu', (e) => {
e.preventDefault(); // suppress browser menu
const pick = scene.pick(scene.pointerX, scene.pointerY, isPickableMesh);
if (!pick || !pick.hit) {
showToast('Nothing under the cursor', 'err');
return;
}
const entry = entryForMesh(pick.pickedMesh);
if (!entry) {
showToast('Picked mesh has no outline entry', 'err');
return;
}
openContextMenu(e.clientX, e.clientY, entry);
});
}
}
function entryForMesh(mesh) {
// Find the MOST SPECIFIC (deepest) entry whose mesh-set contains this
// mesh. The previous version returned whichever entry came first in
// nodeById insertion order — that was always the depth-0 root, so
// `secondLevelOf` had nothing to walk up to. Now we prefer the deepest
// match so secondLevelOf can correctly demote to depth-1.
let best = null;
for (const e of nodeById.values()) {
if (!e.allMeshes && !e.directMeshes) continue;
const has = (e.allMeshes && e.allMeshes.includes(mesh)) ||
(e.directMeshes && e.directMeshes.includes(mesh));
if (has && (!best || e.depth > best.depth)) best = e;
}
return best;
}
function hideInspectCard() {
if (inspectPinned) return;
document.getElementById('inspectCard').classList.remove('open');
}
function showInspectCard(entry, x, y, hoveredMesh) {
const card = document.getElementById('inspectCard');
card.classList.add('open');
document.getElementById('icName').textContent = entry.name;
document.getElementById('icPath').textContent = pathOf(entry);
document.getElementById('icBody').innerHTML = inspectBodyHtml(entry, hoveredMesh);
moveInspectCard(x, y);
}
function pinInspectCard(entry) {
inspectPinned = true;
const card = document.getElementById('inspectCard');
card.classList.add('open', 'pinned');
document.getElementById('icName').textContent = entry.name;
document.getElementById('icPath').textContent = pathOf(entry);
document.getElementById('icBody').innerHTML = inspectBodyHtml(entry, null);
// Default position: top-right of canvas, just below the section/measure HUD
// line. Avoid leaving the card wherever the last hover put it (which can
// be off-screen if pin was triggered from CLI without a mouse position).
const r = document.getElementById('renderContainer').getBoundingClientRect();
card.style.left = Math.round(r.right - 280) + 'px';
card.style.top = Math.round(r.top + 12) + 'px';
}
function unpinInspectCard() {
inspectPinned = false;
document.getElementById('inspectCard').classList.remove('pinned', 'open');
}
function moveInspectCard(x, y) {
const card = document.getElementById('inspectCard');
const offsetX = 16, offsetY = 16;
let left = x + offsetX, top = y + offsetY;
const w = card.offsetWidth, h = card.offsetHeight;
const W = window.innerWidth, H = window.innerHeight;
if (left + w > W - 8) left = x - w - offsetX;
if (top + h > H - 8) top = y - h - offsetY;
card.style.left = left + 'px';
card.style.top = top + 'px';
}
function pathOf(entry) {
const parts = [];
let cur = entry;
while (cur) {
parts.unshift(cur.name);
cur = cur.parentId == null ? null : nodeById.get(cur.parentId);
}
return parts.join(' › ');
}
function inspectBodyHtml(entry, hoveredMesh) {
const bb = combinedBoundingBox(entry.allMeshes);
let html = '';
if (bb) {
const w = bb.max.x - bb.min.x, d = bb.max.y - bb.min.y, h = bb.max.z - bb.min.z;
html += '<span class="lbl">Size</span><span class="val">' + dualUnits(w) + ' × ' + fmt(d) + ' × ' + fmt(h) + ' mm</span>';
}
let mesh = hoveredMesh;
if (!mesh && entry.allMeshes.length) mesh = entry.allMeshes[0];
if (mesh && mesh.material) {
const matName = mesh.material.name || '?';
const found = materialsCache.find(m => m.name === matName);
let swatch = '';
if (found && found.baseColor && found.baseColor.length >= 3) {
const [r, g, b] = found.baseColor;
const css = 'rgb(' + Math.round(r * 255) + ',' + Math.round(g * 255) + ',' + Math.round(b * 255) + ')';
swatch = '<span class="swatch" style="background:' + css + '"></span>';
} else if (mesh.material.albedoColor) {
const c = mesh.material.albedoColor;
const css = 'rgb(' + Math.round(c.r * 255) + ',' + Math.round(c.g * 255) + ',' + Math.round(c.b * 255) + ')';
swatch = '<span class="swatch" style="background:' + css + '"></span>';
} else if (mesh.material.diffuseColor) {
const c = mesh.material.diffuseColor;
const css = 'rgb(' + Math.round(c.r * 255) + ',' + Math.round(c.g * 255) + ',' + Math.round(c.b * 255) + ')';
swatch = '<span class="swatch" style="background:' + css + '"></span>';
}
html += '<span class="lbl">Material</span><span class="val">' + swatch + matName + '</span>';
}
let totalVerts = 0, totalFaces = 0;
for (const m of entry.allMeshes) {
if (m.getTotalVertices) totalVerts += m.getTotalVertices() || 0;
if (m.getIndices) {
const idx = m.getIndices();
if (idx) totalFaces += idx.length / 3;
}
}
html += '<span class="lbl">Geometry</span><span class="val">' + Math.round(totalVerts) + ' verts · ' + Math.round(totalFaces) + ' faces</span>';
html += '<span class="lbl">Children</span><span class="val">' + entry.children.length + '</span>';
return html;
}
// Section feature removed (May 2026) — see comment in HTML for context.
// =====================================================================
// 13. Keyboard
// =====================================================================
function popLastMeasurePick() {
if (!measurePicks.length) return false;
const last = measurePicks.pop();
if (last.markers) for (const m of last.markers) m.dispose();
else if (last.marker) last.marker.dispose();
if (measureLine) { measureLine.dispose(); measureLine = null; }
if (measureLabel) { measureLabel.dispose(); measureLabel = null; }
clearMeasurePreview();
renderMeasureHud();
return true;
}
function setupKeyboard() {
window.addEventListener('keydown', (e) => {
const inField = e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA');
if (inField) return;
if (e.key === 'Escape') {
// Inspect-pinned: unpin first.
if (inspectPinned) { unpinInspectCard(); return; }
// Measure: progressive backout — pop the most recent pick first, then
// the earlier one, then close the HUD on the third Esc. Matches the
// adom-tsci behavior the user expects.
if (measureActive) {
if (measurePicks.length > 0) { popLastMeasurePick(); return; }
toggleMeasure();
return;
}
// (Section tool removed.)
}
if (e.key === 'm' || e.key === 'M') { toggleMeasure(); }
// 'S' shortcut retired with the Section tool.
if (e.key === 'h' || e.key === 'H') { viewer.goHome(); }
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undoVisibility(); }
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); redoVisibility(); }
});
}
// =====================================================================
// 14. Eval pipeline + window.adomStep API
// =====================================================================
window.adomStep = {
listComponents() {
return assemblyTree.map(e => ({
id: e.id, name: e.name, depth: e.depth, parentId: e.parentId,
visible: e.visible, childCount: e.children.length, meshCount: e.allMeshes.length,
}));
},
isolate(name, opts) {
// CLI / AI default re-frames on the isolated subtree — without it, an
// AI screenshot after isolate often shows the model off-camera. Pass
// { frame: false } to opt out (sidebar icon does this).
const e = findEntry(name);
if (!e) return { error: 'no node named ' + name };
const o = Object.assign({ frame: true }, opts || {});
pushVisibilityHistory(() => isolateEntry(e, o));
return { ok: true, name: e.name };
},
show(name) {
const e = findEntry(name);
if (!e) return { error: 'no node named ' + name };
pushVisibilityHistory(() => setSubtreeVisibility(e, true));
return { ok: true };
},
hide(name) {
const e = findEntry(name);
if (!e) return { error: 'no node named ' + name };
pushVisibilityHistory(() => setSubtreeVisibility(e, false));
return { ok: true };
},
showAll() { pushVisibilityHistory(() => showAllEntries()); return { ok: true }; },
frame(name) {
if (!name) { viewer.goHome(); return { ok: true }; }
const e = findEntry(name);
if (!e) return { error: 'no node named ' + name };
frameOnEntry(e);
return { ok: true };
},
/**
* Bounding box of a named entry (or the whole model if `name` is omitted).
* Returns world-space coords + size in scene units AND in mm. Service
* step2glb authors GLBs in metres (1 unit = 1 m), so the mm conversion
* is just ×1000. Call without args to get the full-model bbox; pass
* a node name like "Body" or "VL53L8 MODULE ASSEMBLY" to scope.
*
* Used by chipsmith Tab 2 (Measurements) to compare detected body
* dims against the manifest's declared body_dims_mm.
*/
/**
* Direct Babylon scene access. Returns the active Scene reference
* so consumers (chipsmith) can author overlay meshes via
* BABYLON.MeshBuilder. Pair with addOverlay / removeOverlay /
* setOverlayVisible for tracked groups so cleanup on chip-reload
* is automatic.
*/
getScene() {
return viewer.getScene();
},
/**
* Babylon namespace shortcut for consumers that don't want to walk
* window.Adom3DViewerBabylon9.BABYLON every time.
*/
getBabylon() {
return window.Adom3DViewerBabylon9 && window.Adom3DViewerBabylon9.BABYLON;
},
// Overlay-group registry: chipsmith adds tagged mesh groups (footprint
// pads, faux PCB, heatsink vias, solder-jet domes) and toggles them
// visible/hidden as a unit. The viewer holds the registry so
// chip-reload (clearScene) can dispose every group automatically.
//
// Per gallia/skills/3d-viewer-design §14 — every overlay group is
// ALSO a first-class Outline entry: it gets a synthetic assemblyTree
// row (id range < -10000 so it never collides with GLB-derived
// negative ids like the material-cluster groups), eye toggle,
// click-to-isolate, search-filter visibility — same affordances as a
// regular GLB node.
_overlayGroups: {},
_overlayNextId: -10001,
/**
* Author an overlay layer with optional per-feature child rows.
*
* addOverlay({
* group: 'fp-kicad',
* label: 'KiCad footprint (18 pads)',
* kind: 'overlay-footprint',
* parent: 'overlays-root', // optional outline parent
* children: [ // optional per-feature rows
* { meshes: [pad1Mesh], label: 'fp pad 1 (A1) rect 0.30×0.45 c=(1.95,2.45)', kind: 'overlay-pad' },
* { meshes: [pad2Mesh], label: 'fp pad 2 rect 0.30×0.45 c=(1.45,2.45)', kind: 'overlay-pad' },
* ...
* ],
* // Or the legacy single-bag shape:
* meshes: [...], // bag of meshes attached to the parent row
* });
*
* When `children` is present, each child becomes its own outline row
* with its own meshes, eye toggle, click-to-isolate, etc. The parent
* row's eye toggles ALL children at once (group toggle per
* gallia/skills/3d-viewer-design §14).
*
* Legacy 2-arg form addOverlay(group, meshes) still works.
*/
addOverlay(groupOrOpts, maybeMeshes) {
let group, meshes, label, parent, kind, children, tooltip;
if (typeof groupOrOpts === 'string') {
group = groupOrOpts;
meshes = Array.isArray(maybeMeshes) ? maybeMeshes : [maybeMeshes];
label = group;
kind = 'overlay';
children = null;
tooltip = null;
} else {
({ group, meshes, label, parent, kind, children, tooltip } = groupOrOpts);
if (meshes && !Array.isArray(meshes)) meshes = [meshes];
label = label || group;
kind = kind || 'overlay';
}
const rec = this._overlayGroups[group] || { meshes: [], entryId: null, childIds: [] };
if (!this._overlayGroups[group]) this._overlayGroups[group] = rec;
// Compute the union mesh list for the parent row's allMeshes
// (eye toggle on parent walks this).
const allUnion = [];
if (Array.isArray(meshes)) allUnion.push(...meshes);
if (Array.isArray(children)) {
for (const c of children) {
if (Array.isArray(c.meshes)) allUnion.push(...c.meshes);
}
}
rec.meshes.push(...allUnion);
// First call for this group: create the parent outline entry.
if (rec.entryId === null && groupOrOpts && typeof groupOrOpts !== 'string') {
const id = this._overlayNextId--;
let parentId = null;
let depth = 0;
if (parent) {
const pe = findEntry(parent);
if (pe) { parentId = pe.id; depth = pe.depth + 1; }
}
const parentEntry = {
id, name: label, depth, parentId,
visible: true, expanded: false,
children: [],
meshes: meshes ? meshes.slice() : [],
allMeshes: allUnion.slice(),
kind,
isOverlay: true,
overlayGroup: group,
overlayTooltip: tooltip || null,
};
assemblyTree.push(parentEntry);
nodeById.set(id, parentEntry); // ← REQUIRED for collapse-chain traversal
rec.entryId = id;
// Per-child rows.
if (Array.isArray(children) && children.length) {
for (const c of children) {
const cid = this._overlayNextId--;
const childMeshes = Array.isArray(c.meshes) ? c.meshes : (c.meshes ? [c.meshes] : []);
const childEntry = {
id: cid,
name: c.label || (label + ' child'),
depth: depth + 1,
parentId: id,
visible: true, expanded: false,
children: [],
meshes: childMeshes,
allMeshes: childMeshes.slice(),
kind: c.kind || (kind + '-child'),
isOverlay: true,
overlayGroup: group,
overlayChildIndex: rec.childIds.length,
};
assemblyTree.push(childEntry);
nodeById.set(cid, childEntry); // ← REQUIRED for collapse-chain traversal
rec.childIds.push(cid);
parentEntry.children.push(childEntry);
}
}
if (typeof renderTree === 'function') renderTree();
}
return { ok: true, group, count: rec.meshes.length, entryId: rec.entryId, childCount: rec.childIds.length };
},
removeOverlay(group) {
const rec = this._overlayGroups[group];
if (!rec) return { ok: false, error: 'no group named ' + group };
for (const m of rec.meshes) {
try { m.dispose(); } catch (_) {}
}
// Remove parent + every child entry from assemblyTree AND nodeById.
const removeIds = new Set([rec.entryId, ...(rec.childIds || [])].filter(x => x !== null && x !== undefined));
if (removeIds.size) {
for (let i = assemblyTree.length - 1; i >= 0; i--) {
if (removeIds.has(assemblyTree[i].id)) assemblyTree.splice(i, 1);
}
for (const id of removeIds) nodeById.delete(id);
if (typeof renderTree === 'function') renderTree();
}
delete this._overlayGroups[group];
return { ok: true };
},
setOverlayVisible(group, visible) {
const rec = this._overlayGroups[group];
if (!rec) return { ok: false, error: 'no group named ' + group };
for (const m of rec.meshes) m.setEnabled(!!visible);
// Sync parent + child outline entries' visible state.
if (rec.entryId !== null) {
const e = assemblyTree.find(x => x.id === rec.entryId);
if (e) e.visible = !!visible;
}
if (Array.isArray(rec.childIds)) {
for (const cid of rec.childIds) {
const e = assemblyTree.find(x => x.id === cid);
if (e) e.visible = !!visible;
}
}
if (typeof renderTree === 'function') renderTree();
return { ok: true, count: rec.meshes.length };
},
bbox(name) {
let meshes;
let matchedNames = [];
if (!name) {
meshes = viewer.getScene().meshes.filter(m =>
m.getTotalVertices && m.getTotalVertices() > 0 &&
!['skyBox','shadowGround','ground','laserEtch','ViewerRoot','axisHelperWorld','axisHelperLocal'].includes(m.name) &&
!(m.name || '').startsWith('viewCube') &&
!(m.name || '').startsWith('__'));
} else {
// Union across EVERY entry whose name === query (case-insensitive).
// VL53L8CX-style assemblies have multiple "Body" entries (one per
// NAUO sub-product); chipsmith Tab 2 wants their union, not just
// the first match.
const lc = name.toLowerCase();
const entries = assemblyTree.filter(e => (e.name || '').toLowerCase() === lc);
if (entries.length === 0) return { error: 'no node named ' + name };
meshes = [];
for (const e of entries) meshes.push(...e.allMeshes);
matchedNames = entries.map(e => e.id);
}
const b = combinedBoundingBox(meshes);
if (!b) return { error: 'no meshes' };
const sx = b.max.x - b.min.x, sy = b.max.y - b.min.y, sz = b.max.z - b.min.z;
const SCENE_TO_MM = 1000;
return {
ok: true,
name: name || '__model__',
matchCount: matchedNames.length,
min: [b.min.x, b.min.y, b.min.z],
max: [b.max.x, b.max.y, b.max.z],
size: [sx, sy, sz],
mm: [sx * SCENE_TO_MM, sy * SCENE_TO_MM, sz * SCENE_TO_MM],
sceneToMm: SCENE_TO_MM,
};
},
measure(nameA, nameB) {
const a = findEntry(nameA), b = findEntry(nameB);
if (!a || !b) return { error: 'no node(s) named ' + nameA + ' / ' + nameB };
const ba = combinedBoundingBox(a.allMeshes), bb = combinedBoundingBox(b.allMeshes);
if (!ba || !bb) return { error: 'no geometry on one of the nodes' };
if (!measureActive) toggleMeasure();
clearMeasure();
measurePicks.push({
point: ba.center.clone(), mesh: a.allMeshes[0], kind: 'body',
markers: makeMeasureMarker({ kind: 'body', point: ba.center.clone(), mesh: a.allMeshes[0] }, 1),
});
measurePicks.push({
point: bb.center.clone(), mesh: b.allMeshes[0], kind: 'body',
markers: makeMeasureMarker({ kind: 'body', point: bb.center.clone(), mesh: b.allMeshes[0] }, 2),
});
drawMeasureLine();
renderMeasureHud();
const dx = Math.abs(bb.center.x - ba.center.x) * M_TO_MM;
const dy = Math.abs(bb.center.y - ba.center.y) * M_TO_MM;
const dz = Math.abs(bb.center.z - ba.center.z) * M_TO_MM;
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
return { ok: true, from: a.name, to: b.name, distance_mm: d, dx, dy, dz };
},
inspect(name) {
const e = findEntry(name);
if (!e) return { error: 'no node named ' + name };
pinInspectCard(e);
const bb = combinedBoundingBox(e.allMeshes);
return {
name: e.name, depth: e.depth, path: pathOf(e),
visible: e.visible, childCount: e.children.length, meshCount: e.allMeshes.length,
size_mm: bb ? { x: (bb.max.x - bb.min.x) * M_TO_MM, y: (bb.max.y - bb.min.y) * M_TO_MM, z: (bb.max.z - bb.min.z) * M_TO_MM } : null,
};
},
async screenshot(opts) {
const eng = viewer.getEngine();
const cam = viewer.getCamera();
const w = (opts && opts.width) || 1600;
const h = (opts && opts.height) || 1000;
return new Promise((resolve, reject) => {
try {
BABYLON.Tools.CreateScreenshotUsingRenderTarget(eng, cam, { width: w, height: h }, (data) => resolve(data), 'image/png', 4, true);
} catch (e) { reject(e); }
});
},
setView(name, opts) { setView(name, opts || { frameWhole: true }); return { ok: true, view: name }; },
saveView(name) {
const cam = scene.activeCamera;
if (!cam) return { error: 'no camera' };
const v = { alpha: cam.alpha, beta: cam.beta, radius: cam.radius, target: { x: cam.target.x, y: cam.target.y, z: cam.target.z } };
const all = JSON.parse(localStorage.getItem('adom-step:views') || '{}');
all[name] = v;
localStorage.setItem('adom-step:views', JSON.stringify(all));
return { ok: true };
},
recallView(name) {
const all = JSON.parse(localStorage.getItem('adom-step:views') || '{}');
const v = all[name];
if (!v) return { error: 'no saved view ' + name };
const cam = scene.activeCamera;
cam.alpha = v.alpha; cam.beta = v.beta; cam.radius = v.radius;
cam.target = new BABYLON.Vector3(v.target.x, v.target.y, v.target.z);
return { ok: true };
},
listViews() {
return JSON.parse(localStorage.getItem('adom-step:views') || '{}');
},
// Section JS API removed (May 2026) along with the feature.
// Pin clustering — detected from material grouping + spatial clustering.
// Centroids are in scene units (meters); multiply by 1000 for mm.
pinCount() { return pinClusters.length; },
pins() {
return pinClusters.map(c => ({
id: c.id, side: c.side,
centroid_mm: { x: c.centroid.x * M_TO_MM, y: c.centroid.y * M_TO_MM, z: c.centroid.z * M_TO_MM },
meshCount: c.meshes.length,
}));
},
isolatePin(id) {
const cluster = pinClusters.find(c => c.id === id);
if (!cluster) return { error: 'no pin ' + id + ' (have ' + pinClusters.length + ')' };
// Use the unified isolateEntry path so the JS API matches the sidebar
// diamond exactly (hides everything except the cluster's subtree).
const entry = assemblyTree.find(e => e.isPinEntry && e.pinId === id);
if (!entry) return { error: 'no entry for pin ' + id };
pushVisibilityHistory(() => isolateEntry(entry, { frame: false }));
return { ok: true, id, side: cluster.side, centroid_mm: { x: cluster.centroid.x * M_TO_MM, y: cluster.centroid.y * M_TO_MM, z: cluster.centroid.z * M_TO_MM } };
},
showAllPins() {
pushVisibilityHistory(() => {
for (const c of pinClusters) for (const m of c.meshes) m.isVisible = true;
});
return { ok: true };
},
// strobePins / stopStrobe removed — strobe is out of scope for a
// STEP viewer (chip-fetcher / chiplinter own per-pin verification).
// =================================================================
// DEMO HELPERS — drive visible animations for screen recordings.
//
// Pup CDP screencast is paint-throttled by Chrome unless the canvas
// is actively repainting. Babylon's rAF render loop CAN throttle
// when the tab loses foreground or is otherwise idle. The demo pump
// bypasses rAF by calling scene.render() on a setInterval at 30 Hz —
// forces the canvas to repaint regardless of compositor state, so
// CDP captures real frames during a recording.
// =================================================================
demoPumpStart(opts) {
if (window.__demoPumpHandle) {
try { clearInterval(window.__demoPumpHandle); } catch (_) {}
window.__demoPumpHandle = null;
}
const eng = viewer.getEngine();
const cam = scene.activeCamera;
try {
if (cam.inputs && cam.inputs.attached) {
for (const t of Object.keys(cam.inputs.attached)) cam.inputs.removeByType(t);
}
} catch (_) {}
// Continuous orbit + render at 30Hz. UI events (hover/ctxmenu/isolate)
// stack on top — they don't replace the orbit. The orbit rate is
// configurable; default 30°/sec.
const orbitRatePerTick = ((opts && opts.orbitDegPerSec) != null ? opts.orbitDegPerSec : 30) * (Math.PI / 180) / 30;
window.__demoPumpHandle = setInterval(() => {
try {
if (window.__demoOrbitOn) cam.alpha += orbitRatePerTick;
eng.beginFrame(); scene.render(); eng.endFrame();
} catch (_) {}
}, 33);
window.__demoOrbitOn = true;
// Plus a visible CSS animation in the corner — keeps the document
// compositor busy even if the canvas would otherwise idle.
let dot = document.getElementById('__demo_pump_dot__');
if (!dot) {
dot = document.createElement('div');
dot.id = '__demo_pump_dot__';
dot.style.cssText = 'position:fixed;right:8px;bottom:8px;width:6px;height:6px;border-radius:50%;background:#00b8b1;z-index:99998;animation:__demo_pump_pulse__ 0.8s infinite ease-in-out;opacity:0.4;pointer-events:none;';
const css = document.createElement('style');
css.textContent = '@keyframes __demo_pump_pulse__ { 0%,100%{transform:scale(1);opacity:0.3} 50%{transform:scale(1.6);opacity:0.7} }';
document.head.appendChild(css);
document.body.appendChild(dot);
}
return { ok: true };
},
demoPumpStop() {
if (window.__demoPumpHandle) { clearInterval(window.__demoPumpHandle); window.__demoPumpHandle = null; }
const dot = document.getElementById('__demo_pump_dot__');
if (dot) dot.remove();
return { ok: true };
},
// Smooth camera orbit — drives alpha via setInterval(33ms) AND forces
// scene.render() each tick. ArcRotateCamera's input handlers (keyboard,
// mousewheel, pointers) recompute alpha from stored input state EVERY
// render frame, overriding direct alpha writes. We must remove the
// inputs entirely (cam.inputs.removeByType / clear), not merely
// detachControl, because attachControl/detachControl just toggles
// event listeners — the input objects still mutate camera state.
async demoOrbit(opts) {
const dur = (opts && opts.durationMs) || 5000;
const rev = (opts && opts.revolutions) || 0.3;
const cam = scene.activeCamera;
const eng = viewer.getEngine();
const canvas = eng.getRenderingCanvas ? eng.getRenderingCanvas() : document.querySelector('canvas');
const start = cam.alpha;
const end = start + rev * 2 * Math.PI;
const t0 = Date.now();
if (window.__demoOrbitHandle) { clearInterval(window.__demoOrbitHandle); window.__demoOrbitHandle = null; }
// Save then strip all camera inputs so they can't clobber our writes.
let savedInputs = null;
try {
if (cam.inputs && cam.inputs.attached) {
savedInputs = Object.keys(cam.inputs.attached).slice();
cam.detachControl(canvas);
for (const t of savedInputs) cam.inputs.removeByType(t);
}
} catch (_) {}
return new Promise(resolve => {
const h = setInterval(() => {
const t = Math.min(1, (Date.now() - t0) / dur);
const k = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
cam.alpha = start + (end - start) * k;
try { eng.beginFrame(); scene.render(); eng.endFrame(); } catch (_) {}
if (t >= 1) {
clearInterval(h);
if (window.__demoOrbitHandle === h) window.__demoOrbitHandle = null;
// Re-add inputs (use Babylon's defaults — addKeyboard / addMouseWheel
// / addPointers are convenience methods on ArcRotateCameraInputsManager).
try {
if (cam.inputs && cam.inputs.addPointers) cam.inputs.addPointers();
if (cam.inputs && cam.inputs.addMouseWheel) cam.inputs.addMouseWheel();
if (cam.inputs && cam.inputs.addKeyboard) cam.inputs.addKeyboard();
if (cam.attachControl) cam.attachControl(canvas, true);
} catch (_) {}
resolve({ ok: true, alphaEnd: cam.alpha });
}
}, 33);
window.__demoOrbitHandle = h;
});
},
// Generic camera tween — alpha / beta / radius / target.
async demoTweenCamera(opts) {
const dur = (opts && opts.durationMs) || 1500;
const cam = scene.activeCamera;
const startAlpha = cam.alpha, endAlpha = (opts && opts.alpha != null) ? opts.alpha : startAlpha;
const startBeta = cam.beta, endBeta = (opts && opts.beta != null) ? opts.beta : startBeta;
const startRad = cam.radius,endRad = (opts && opts.radius!= null) ? opts.radius: startRad;
const t0 = Date.now();
return new Promise(resolve => {
const h = setInterval(() => {
const t = Math.min(1, (Date.now() - t0) / dur);
const k = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
cam.alpha = startAlpha + (endAlpha - startAlpha) * k;
cam.beta = startBeta + (endBeta - startBeta) * k;
cam.radius = startRad + (endRad - startRad) * k;
if (t >= 1) { clearInterval(h); resolve({ ok: true }); }
}, 33);
});
},
// Visibly walk a path through the outline: expand all ancestors,
// scroll the row into view, flash it. (Same `findInOutline` used by
// the canvas right-click → Reveal action — exposed here so a CLI
// can drive a guided tour through specific entries.)
demoExpandPath(opts) {
const e = findEntry(opts && opts.name);
if (!e) return { error: 'no entry ' + (opts && opts.name) };
if (typeof findInOutline === 'function') findInOutline(e);
return { ok: true, name: e.name };
},
// Apply the same hover overlay (translucent teal) that mouse-hover
// produces. Useful for "ahem, look at this row" demo beats.
demoHover(opts) {
const e = findEntry(opts && opts.name);
if (!e) return { error: 'no entry ' + (opts && opts.name) };
if (typeof setHoverHighlight === 'function') setHoverHighlight(e);
return { ok: true, name: e.name };
},
demoUnhover() { if (typeof setHoverHighlight === 'function') setHoverHighlight(null); return { ok: true }; },
// Open the canvas-style right-click context menu visibly + optionally
// click one of its actions. This drives the same code path as a real
// mouse right-click, including the Hide → row-flash behavior.
demoCtxMenu(opts) {
const e = findEntry(opts && opts.name);
if (!e) return { error: 'no entry ' + (opts && opts.name) };
const x = (opts && opts.x) || 720;
const y = (opts && opts.y) || 360;
if (typeof openContextMenu !== 'function') return { error: 'no openContextMenu' };
openContextMenu(x, y, e);
if (opts && opts.action) {
const m = document.getElementById('ctxMenu');
const item = m && m.querySelector('[data-act="' + opts.action + '"]');
if (item) {
// Defer the click so the menu is visible for a beat.
setTimeout(() => item.click(), (opts && opts.delayMs) || 600);
}
}
return { ok: true, name: e.name, action: (opts && opts.action) || null };
},
// Frame the camera tightly on a detected pin cluster. Useful to
// showcase pin detection on QFN / LGA / through-hole.
async demoFramePin(opts) {
const id = (opts && opts.id) || 1;
const entry = assemblyTree.find(x => x.isPinEntry && x.pinId === id);
if (!entry) return { error: 'no pin entry id=' + id };
frameOnEntry(entry);
return { ok: true, id, name: entry.name };
},
// Source-label readback so a demo driver can verify the right STEP
// is loaded BEFORE pressing record. Returns the topbar text + mesh
// count — both authoritative for "is the WAGO loaded yet".
demoState() {
const tb = document.getElementById('topSource');
const eng = viewer && viewer.getEngine && viewer.getEngine();
// Babylon 9 inspector mounts at #babylon-inspector-container (not the
// older Babylon 8 #topPart). Check for the new mount point.
const inspectorMount = document.getElementById('babylon-inspector-container');
return {
sourceLabel: tb ? tb.textContent.trim().slice(0, 200) : null,
meshCount: scene && scene.meshes ? scene.meshes.length : 0,
pinCount: pinClusters ? pinClusters.length : 0,
treeRootName: assemblyTree.find(e => e.depth === 0) ? assemblyTree.find(e => e.depth === 0).name : null,
fps: eng && eng.getFps ? Math.round(eng.getFps()) : null,
inspectorOpen: !!(scene && scene.debugLayer && scene.debugLayer.isVisible && scene.debugLayer.isVisible()),
inspectorMounted: !!inspectorMount && inspectorMount.children.length > 0,
};
},
// measurePick — synthesize a real Babylon PickingInfo on a named entry's
// mesh and route it through the same addMeasurePick handler the mouse
// click fires. Drives the demo measure-tool clip without faking the HUD.
//
// Args: { entryName: 'Pin 1', faceFraction: 0.5 }
// - entryName: the components-outline entry to pick on (matches findEntry).
// - faceFraction: 0..1, which triangle in the mesh's face buffer to pick.
// 0.5 = middle triangle; vary to pick distinct faces on the same mesh.
//
// Returns: { ok, kind, point, faceId, mesh } or { error }
measurePick(opts) {
if (!measureActive) toggleMeasure();
const e = findEntry(opts && opts.entryName);
if (!e) return { error: 'no entry ' + (opts && opts.entryName) };
const mesh = (e.allMeshes || []).find(m => m && m.getTotalIndices && m.getTotalIndices() > 0);
if (!mesh) return { error: 'entry has no pickable mesh' };
const indices = mesh.getIndices();
const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);
if (!indices || !positions) return { error: 'mesh has no triangle data' };
const triCount = indices.length / 3;
const ff = (opts && opts.faceFraction != null) ? opts.faceFraction : 0.5;
const faceId = Math.max(0, Math.min(triCount - 1, Math.round(ff * (triCount - 1))));
const i0 = indices[faceId * 3], i1 = indices[faceId * 3 + 1], i2 = indices[faceId * 3 + 2];
const v0 = new BABYLON.Vector3(positions[i0 * 3], positions[i0 * 3 + 1], positions[i0 * 3 + 2]);
const v1 = new BABYLON.Vector3(positions[i1 * 3], positions[i1 * 3 + 1], positions[i1 * 3 + 2]);
const v2 = new BABYLON.Vector3(positions[i2 * 3], positions[i2 * 3 + 1], positions[i2 * 3 + 2]);
// Centroid of the picked triangle in world space.
const localCentroid = v0.add(v1).add(v2).scale(1 / 3);
const worldMatrix = mesh.computeWorldMatrix(true);
const worldPoint = BABYLON.Vector3.TransformCoordinates(localCentroid, worldMatrix);
// Build a PickingInfo with the same fields scene.pick() returns.
const pi = new BABYLON.PickingInfo();
pi.hit = true;
pi.pickedPoint = worldPoint;
pi.pickedMesh = mesh;
pi.faceId = faceId;
pi.distance = BABYLON.Vector3.Distance(scene.activeCamera.position, worldPoint);
// Route through the same addMeasurePick handler the mouse click fires.
addMeasurePick(pi);
return {
ok: true,
entryName: e.name, meshName: mesh.name,
faceId,
point_mm: { x: worldPoint.x * M_TO_MM, y: worldPoint.y * M_TO_MM, z: worldPoint.z * M_TO_MM },
pickIndex: measurePicks.length,
};
},
// measureClear — drop both picks and reset the HUD without toggling
// measure mode off. Useful between staged demo beats.
measureClear() {
if (typeof clearMeasure === 'function') clearMeasure();
return { ok: true };
},
};
function findEntry(name) {
if (typeof name === 'number') return nodeById.get(name);
if (!name) return null;
// Exact match first.
let e = assemblyTree.find(e => e.name === name);
if (e) return e;
// Case-insensitive fallback.
const lower = name.toLowerCase();
return assemblyTree.find(e => e.name.toLowerCase() === lower);
}
function setupEvalPolling() {
async function tick() {
try {
const r = await fetch('eval/pending');
const body = await r.json();
const job = body.job;
if (job) {
let result = null;
try {
const fn = new (Object.getPrototypeOf(async function () {}).constructor)(job.code);
result = await fn();
} catch (e) {
result = { error: e.message || String(e) };
}
await fetch('eval/' + job.id + '/result', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(result),
});
}
} catch (e) { /* server may be transiently down */ }
setTimeout(tick, 200);
}
setTimeout(tick, 250);
}
function setupCommandPolling() {
// Reserved for HTTP commands posted to /api/command. Kept lightweight —
// most action verbs go through /eval rather than the command bus.
async function tick() {
try {
const r = await fetch('api/poll-command');
const body = await r.json();
// No-op handler for now; expanded as needed.
void body;
} catch (e) {}
setTimeout(tick, 1000);
}
setTimeout(tick, 1000);
}
// =====================================================================
// Boot
// =====================================================================
boot().catch((e) => {
console.error('boot failed:', e);
setSplash('Boot failed — see console: ' + e.message);
});
</script>
</body>
</html>