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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038
// chipsmith-overlay.js — chipsmith-specific UI layered on top of the
// inherited adom-step base (vendor/adom-step/src/assets/shell.html).
//
// The shell.html itself comes verbatim from adom-step via git submodule
// — this file is everything that makes the running app "chipsmith" vs
// "adom-step": branding swap, the 8-tab right pane, detection pipeline,
// sign-off gates, multi-source footprint cross-validation, faux PCB,
// heatsink vias, solder-jet domes, pin-1 detection, and provenance
// captions. Loaded from /chipsmith-overlay.js, auto-injected before
// </body> of the inherited shell.html by chipsmith's server.
//
// Architecture per gallia/skills/3d-viewer-design §2 — chipsmith is
// Tier-3 (canonical viewer + adom-step base, inherited via submodule).
// Upstream-fix flow: edit adom-step → push → chipsmith
// `git submodule update --remote --recursive && cargo build` → fix
// inherits automatically.
//
// UI design skills (READ BEFORE TOUCHING):
// gallia/skills/human-ui-patterns/SKILL.md
// gallia/skills/human-ui-patterns/playbooks/icons-and-displays.md
// §4b — NO Unicode emoji as UI icons. All chipsmith icons live in
// the ICONS map below and ship as monochrome currentColor SVGs.
// gallia/skills/adom-app-header/SKILL.md
// gallia/skills/3d-viewer-design/SKILL.md
// gallia/skills/electrical-engineering/SKILL.md
// gallia/skills/measure-tool-design/SKILL.md
// gallia/skills/adom-vscode/SKILL.md (reveal buttons → POST /api/reveal)
// gallia/skills/adom-pdf-viewer/SKILL.md (datasheet viewer port 8985)
// PLAN.md "UI design compliance" section enumerates the same with the
// per-subsystem ladder.
(function chipsmithOverlay() {
// Chipsmith app icon — custom BGA-from-below silhouette designed
// specifically for chipsmith. Chip body has a chamfered top-left
// corner that doubles as the pin-1 indicator (the EE-canonical
// diagonal cut on QFN/QFP/BGA datasheet drawings), with a 3x3 grid of
// BGA ball cut-outs inside. Cut-outs rely on fill-rule:evenodd so
// the balls render as transparent holes punched through the teal body.
// Built from scratch — not derived from any MDI / Lucide / external
// icon set so chipsmith has a unique mark.
const CHIPSMITH_ICON_PATH = [
// Body: square 5..19 with sharp 1-radius rounded corners on three
// corners and a 3-unit diagonal CHAMFER at the top-left = pin-1.
'M8 5 H18 A1 1 0 0 1 19 6 V18 A1 1 0 0 1 18 19 H6 A1 1 0 0 1 5 18 V8 L8 5 Z',
// 3x3 BGA balls — radius 1 at (8|12|16, 8|12|16). The pin-1 chamfer
// intentionally clips into the top-left ball area, reinforcing the
// pin-1 corner reading.
'M7 8 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',
'M11 8 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',
'M15 8 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',
'M7 12 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',
'M11 12 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',
'M15 12 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',
'M7 16 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',
'M11 16 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',
'M15 16 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',
].join(' ');
// ─── 1. Branding swap ────────────────────────────────────────────
// Wait for the adom-step shell to mount, then swap "STEP Viewer" /
// "adom-step" → "Chipsmith" in the canonical 44px header.
function applyBranding() {
document.title = 'Chipsmith';
const appName = document.querySelector('.app-name');
if (appName) appName.textContent = 'Chipsmith';
const popoverTitle = document.querySelector('.popover-title');
if (popoverTitle) popoverTitle.textContent = 'Chipsmith';
// Swap the inline header logo SVG to the chip icon. The adom-step
// shell hard-codes a single <path> inside the .logo svg, so we just
// overwrite its `d` attribute. The viewBox stays at 0 0 24 24.
// fill-rule:evenodd is required so the BGA ball sub-paths punch
// transparent holes through the body fill instead of stacking.
const logoPath = document.querySelector('.brand .logo path');
if (logoPath && logoPath.getAttribute('d') !== CHIPSMITH_ICON_PATH) {
logoPath.setAttribute('d', CHIPSMITH_ICON_PATH);
logoPath.setAttribute('fill-rule', 'evenodd');
}
// Also swap the favicon so the browser tab + the Hydrogen tab
// strip both pick up the chip icon. evenodd fill-rule keeps the BGA
// balls as transparent holes in the favicon too.
const fav = document.querySelector('link[rel="icon"]');
if (fav) {
const dataUri = 'data:image/svg+xml;utf8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00b8b1" fill-rule="evenodd">' +
'<path d="' + CHIPSMITH_ICON_PATH + '"/></svg>');
fav.setAttribute('href', dataUri);
fav.setAttribute('type', 'image/svg+xml');
}
// Wiki + GitHub links per adom-app-header skill — verified at publish.
document.querySelectorAll('.popover-link').forEach(a => {
const href = a.getAttribute('href') || '';
if (href.includes('/apps/adom-step')) a.setAttribute('href', href.replace('/apps/adom-step', '/apps/adom-chipsmith'));
if (href.includes('adom-inc/adom-step')) a.setAttribute('href', href.replace('adom-inc/adom-step', 'adom-inc/adom-chipsmith'));
});
}
// Run as soon as DOM is ready; re-run on header re-render if it
// happens (defensive — re-render normally only happens on hot reload).
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyBranding);
} else {
applyBranding();
}
// One delayed pass — adom-step's header sometimes finalizes after
// initial DOMContentLoaded as the canonical viewer attaches.
setTimeout(applyBranding, 200);
setTimeout(applyBranding, 1000);
// ─── 2. Right pane — 8 tabs (left-to-right validation flow) ─────
// Source → Measurements → Detection → Footprint → Pin-1 → Sign-off →
// Heatsink → Solder-jet. Each tab has a status pill (●/◐/○/✗/─).
// The pane floats over the canvas like the outline (transparent bg
// with text-shadow for legibility) and is collapsible — chevron at
// the right edge of the title row, expand-tab at canvas-right when
// collapsed, keyboard 'P' toggles. Persistent via localStorage.
// Pin/feature DETECTION was removed in d8b02af — the heuristic
// approaches (Mode 0 named-walk, Mode 1 surface-type clustering)
// didn't generalize across enough chips to be trustworthy. Pin-1
// indicator placement, footprint pad rendering, and sign-off all
// continue to work without it. Tab numbering 1..7 (was 1..8).
const TABS = [
{ id: 'source', label: 'Source', hotkey: '1' },
{ id: 'measurements', label: 'Measurements', hotkey: '2' },
{ id: 'footprint', label: 'Footprint', hotkey: '3' },
{ id: 'pin1', label: 'Pin-1', hotkey: '4' },
{ id: 'signoff', label: 'Sign-off', hotkey: '5' },
{ id: 'heatsink', label: 'Heatsink', hotkey: '6' },
{ id: 'solderjet', label: 'Solder-jet', hotkey: '7' },
];
// Shared state — populated as data lands from /api/* endpoints. Tabs
// read from this; future detection / measurements / sign-off phases
// also write into it. Status field per tab drives the pill glyph.
const state = {
paths: null, // /api/paths response
spec: null, // /api/spec response (info.json)
footprintSources: null, // /api/footprint-sources response
activeTab: 'source',
status: { // gate state per tab — '○' pending, '◐' partial, '●' pass, '✗' fail, '─' n/a
source: '○', measurements: '○', footprint: '○',
pin1: '○', signoff: '○', heatsink: '─', solderjet: '─',
},
// Names of GLB-product entries the user has flagged (or auto-flagged
// by name match) as "decorative" — manufacturer-mark logos, lens
// cosmetic features, vendor-brand reliefs. On bake, these get
// reparented under a `chipsmith_decorative` branch (visibility OFF
// by default in downstream Adom tools, toggleable on). NEVER
// deleted, only moved.
decorativeProductNames: [],
};
// ── Custom monochrome SVG icons ─────────────────────────────────
// Per gallia/skills/human-ui-patterns/playbooks/icons-and-displays.md
// §4b: no Unicode emoji as UI icons (they render multi-color and mix
// poorly with hand-drawn SVG). All icons in chipsmith are monochrome
// currentColor SVGs at viewBox 0 0 24 24, stroke style ≈1.6, custom
// drawn for the specific concept they represent. MDI is only the
// fallback for fully-generic things (folder, eye, cog).
const ICONS = {
// Folder (outline) — for "reveal in Explorer" affordances. The lift
// tab on top distinguishes a folder from a generic rectangle.
folder:
'<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 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>' +
'</svg>',
// Open book — chipsmith-specific custom icon for "open the
// datasheet in a viewer". Two facing pages with a binding seam,
// not a generic book emoji. Drawn with the same stroke weight as
// the folder so the action-button row reads as a coherent set.
bookOpen:
'<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 5.5c2.4-.6 5.1-.9 8-.5 1.4.2 2.5.6 4 1.5 1.5-.9 2.6-1.3 4-1.5 2.9-.4 5.6-.1 8 .5"/>' +
'<path d="M3 5.5v13c2.4-.6 5.1-.9 8-.5 1.4.2 2.5.6 4 1.5 1.5-.9 2.6-1.3 4-1.5 2.9-.4 5.6-.1 8 .5v-13"/>' +
'<path d="M12 6.5v13"/>' +
'</svg>',
};
function svgIcon(name) { return `<span class="cs-ic">${ICONS[name] || ''}</span>`; }
// Inject chipsmith-specific styles. Kept inside the overlay so adom-step
// doesn't pick them up. Tracks the outline's design language: transparent
// bg, text-shadow, thin overlay scrollbar, slim collapse chevron.
function injectStyles() {
if (document.getElementById('chipsmith-overlay-styles')) return;
const style = document.createElement('style');
style.id = 'chipsmith-overlay-styles';
style.textContent = `
#cs-pane {
/* Floating HUD — positioned via top/left at runtime (saved in
localStorage). Default landing spot is set in init() to a
location that clears the ViewCube top-right zone. The cube
occupies viewport (0.85, 0.85, 0.15, 0.15) and at 1500px-tall
windows that's ~225px of vertical space. top:170 leaves a
comfortable gap below the cube. */
position: absolute;
top: 170px;
right: 10px;
width: 360px;
max-height: calc(100vh - 240px);
min-height: 280px;
z-index: 50;
display: flex;
flex-direction: column;
background: rgba(20,24,30,0.55);
backdrop-filter: blur(6px) saturate(1.1);
-webkit-backdrop-filter: blur(6px) saturate(1.1);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 6px;
box-shadow: 0 6px 24px rgba(0,0,0,0.35);
transition: opacity 200ms ease;
font-family: inherit;
color: var(--text);
resize: none;
}
#cs-pane.dragging {
transition: none;
opacity: 0.92;
}
#cs-pane.collapsed {
display: none;
}
.cs-tabs {
cursor: grab; /* drag handle: tab strip is the grab target */
}
.cs-tabs:active { cursor: grabbing; }
#cs-pane *,
#cs-pane {
text-shadow: 0 1px 2px rgba(0,0,0,0.85), 0 0 4px rgba(0,0,0,0.55);
}
.cs-tabs {
display: flex;
gap: 2px;
padding: 6px 8px 0 8px;
flex-wrap: wrap;
border-bottom: 1px solid rgba(255,255,255,0.10);
user-select: none;
}
.cs-tab {
background: rgba(20,24,30,0.45);
border: 1px solid rgba(255,255,255,0.10);
border-bottom: none;
color: var(--text-dim);
padding: 5px 10px 5px 8px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
border-radius: 4px 4px 0 0;
display: inline-flex;
align-items: center;
gap: 5px;
line-height: 1.2;
}
.cs-tab:hover {
background: rgba(0,184,177,0.15);
color: var(--accent);
}
.cs-tab.active {
background: rgba(0,184,177,0.20);
color: var(--accent);
border-color: var(--accent);
}
.cs-tab .pill {
font-size: 10px;
opacity: 0.85;
}
.cs-tab .pill.ok { color: #1de9b6; }
.cs-tab .pill.partial { color: #ffb74d; }
.cs-tab .pill.fail { color: #ff7043; }
.cs-tab .pill.pending { color: #b0bec5; opacity: 0.55; }
.cs-tab .pill.na { color: #b0bec5; opacity: 0.4; }
.cs-body {
flex: 1 1 auto;
overflow-y: overlay;
overflow-y: auto;
padding: 10px 12px 14px 12px;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.18) transparent;
}
.cs-body::-webkit-scrollbar { width: 6px; background: transparent; }
.cs-body::-webkit-scrollbar-track { background: transparent; }
.cs-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.10); border-radius: 3px; }
#cs-pane:hover .cs-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.28); }
.cs-section { margin-bottom: 14px; }
.cs-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin: 0 0 6px 0;
font-weight: 600;
}
.cs-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
padding: 3px 0;
}
.cs-row .label { color: var(--text-dim); flex: 0 0 90px; }
.cs-row .value { color: var(--text); flex: 1 1 auto; word-break: break-word; }
.cs-row .value.mono { font-family: ui-monospace, monospace; font-size: 11px; }
.cs-row .value.dim { color: var(--text-dim); font-style: italic; }
/* Inline-row reveal icon button — sits next to a file path in the
Files section. Slim 22x22 square, just the 📂 glyph; the
tooltip carries the full label per human-ui-patterns §1a. */
.cs-reveal-icon {
background: transparent;
border: 1px solid rgba(255,255,255,0.14);
color: var(--text-dim);
cursor: pointer;
font-size: 12px;
padding: 0;
margin-left: 4px;
line-height: 1;
font-family: inherit;
width: 22px;
height: 22px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.cs-reveal-icon:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
/* Action button row — primary + secondary CTAs at the bottom of a
section. Per human-ui-patterns §1: never ALL CAPS, tooltip on
every button, ≥28px height (real-pointer friendly hit target),
visible focus state. Primary uses the brand teal; secondary is
outline-only. */
.cs-actions {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.cs-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 12px;
border-radius: 5px;
font-family: inherit;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.cs-btn .lbl { font-weight: 500; }
/* Inline SVG icon container — keeps icons monochrome via
currentColor and sized consistently across buttons + inline
rows (16px in CTAs, 14px in slim inline buttons). */
.cs-ic { display: inline-flex; align-items: center; line-height: 1; }
.cs-ic svg { width: 16px; height: 16px; display: block; }
.cs-reveal-icon .cs-ic svg { width: 14px; height: 14px; }
.cs-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.cs-btn-primary {
background: rgba(0,184,177,0.18);
border: 1px solid var(--accent);
color: var(--accent);
}
.cs-btn-primary:hover {
background: rgba(0,184,177,0.32);
}
.cs-btn-secondary {
background: rgba(20,24,30,0.45);
border: 1px solid rgba(255,255,255,0.14);
color: var(--text);
}
.cs-btn-secondary:hover {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
/* Shape radio (Pin-1 tab): three options laid out horizontally
with the active one tinted with the brand teal. Uses native
<input type=radio> for keyboard + screen-reader, but visually
styled as a button group. */
.cs-shape-radio {
display: flex;
gap: 6px;
margin: 6px 0 4px 0;
flex-wrap: wrap;
}
.cs-shape-opt {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
height: 28px;
border-radius: 5px;
background: rgba(20,24,30,0.45);
border: 1px solid rgba(255,255,255,0.14);
color: var(--text);
font-size: 12px;
cursor: pointer;
user-select: none;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.cs-shape-opt:hover { border-color: var(--accent); color: var(--accent); }
.cs-shape-opt input { margin: 0; cursor: pointer; }
.cs-shape-opt.active {
background: rgba(0,184,177,0.18);
border-color: var(--accent);
color: var(--accent);
}
.cs-collapse {
width: 22px;
height: 22px;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
cursor: pointer;
font-size: 12px;
font-family: inherit;
border-radius: 4px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
margin-left: auto;
}
.cs-collapse:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
#cs-expander {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 22px;
height: 64px;
background: rgba(20,24,30,0.55);
border: 1px solid var(--border);
border-right: none;
border-radius: 6px 0 0 6px;
color: var(--text-dim);
cursor: pointer;
font-size: 14px;
font-family: inherit;
display: none;
align-items: center;
justify-content: center;
z-index: 51;
padding: 0;
}
#cs-expander:hover { background: rgba(0,184,177,0.18); color: var(--accent); }
body.cs-collapsed #cs-expander { display: flex; }
.cs-amber { color: #ffb74d; }
.cs-empty { color: var(--text-dim); font-size: 11px; font-style: italic; padding: 6px 0; }
/* Status block — large readable banner explaining the measurement
outcome. Color-keyed via .pill-* class on the parent. */
.cs-meas-status {
font-size: 12px;
line-height: 1.45;
padding: 8px 10px;
border-radius: 4px;
border-left: 3px solid;
}
.cs-meas-status.pill-ok { background: rgba(29,233,182,0.10); border-color: #1de9b6; color: #b9f6e2; }
.cs-meas-status.pill-partial { background: rgba(255,183, 77,0.10); border-color: #ffb74d; color: #ffe0b2; }
.cs-meas-status.pill-fail { background: rgba(255,112, 67,0.12); border-color: #ff7043; color: #ffccbc; }
.cs-meas-status code { font-family: ui-monospace, monospace; font-size: 11px; }
/* Candidate table — compact list of every rescale candidate, %.
The winning row gets a brand-teal left border. */
.cs-tbl {
width: 100%;
border-collapse: collapse;
font-size: 11px;
margin-top: 4px;
}
.cs-tbl th {
text-align: left;
font-weight: 500;
color: var(--text-dim);
padding: 4px 6px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
.cs-tbl th.num, .cs-tbl td.num { text-align: right; }
.cs-tbl td {
padding: 4px 6px;
border-bottom: 1px solid rgba(255,255,255,0.06);
vertical-align: top;
}
.cs-tbl td.dim { color: var(--text-dim); }
.cs-tbl td.mono { font-family: ui-monospace, monospace; }
.cs-tbl tr.is-winner td:first-child { border-left: 2px solid var(--accent); padding-left: 4px; }
.cs-tbl tr.is-winner td { color: var(--text); }
.cs-tbl tr.is-ok td.num { color: #1de9b6; }
/* Clickable feature row in Tab 3 — hover hints at its
click-to-isolate behaviour. */
.cs-row.cs-clickable {
cursor: pointer;
border-radius: 3px;
padding: 3px 6px;
margin: 0 -6px;
}
.cs-row.cs-clickable:hover {
background: rgba(0,184,177,0.12);
}
/* Spec editor inline form. */
.cs-edit-spec {
background: transparent;
border: 1px solid rgba(255,255,255,0.18);
color: var(--text-dim);
font-size: 10px;
padding: 1px 6px;
margin-left: 6px;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
text-transform: none;
letter-spacing: 0;
}
.cs-edit-spec:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.cs-spec-input {
background: rgba(20,24,30,0.55);
border: 1px solid rgba(255,255,255,0.14);
color: var(--text);
padding: 4px 6px;
border-radius: 3px;
font-size: 12px;
font-family: ui-monospace, monospace;
outline: none;
width: 100%;
}
.cs-spec-input:focus { border-color: var(--accent); }
.cs-spec-input.cs-body-dim {
width: 60px;
margin-right: 4px;
}
/* Per-source color swatch — small dot before the source label
so the user immediately knows which 3D color each source maps
to. Per the multi-source-cross-validation plan section. */
.cs-src-swatch {
display: inline-block;
width: 10px; height: 10px;
border-radius: 2px;
margin-right: 6px;
flex: 0 0 auto;
border: 1px solid rgba(255,255,255,0.18);
}
/* Floating Sign-off HUD — always visible, top-left default
(top-right is taken by the right-pane HUD + ViewCube).
Drag the title row, collapse to compact badge, 'g' toggle. */
#cs-signoff-hud {
position: absolute;
top: 90px;
left: 300px; /* clear of the Outline panel (280 wide) */
width: 280px;
z-index: 56;
background: rgba(20,24,30,0.78);
backdrop-filter: blur(6px) saturate(1.1);
-webkit-backdrop-filter: blur(6px) saturate(1.1);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 6px;
box-shadow: 0 4px 18px rgba(0,0,0,0.45);
font-family: inherit;
color: var(--text);
font-size: 11px;
transition: opacity 200ms ease;
text-shadow: 0 1px 2px rgba(0,0,0,0.55);
}
#cs-signoff-hud.dragging { transition: none; opacity: 0.92; }
#cs-signoff-hud.collapsed .cs-so-body,
#cs-signoff-hud.collapsed .cs-so-foot { display: none; }
#cs-signoff-hud.collapsed { width: auto; }
#cs-signoff-hud .cs-so-head {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: grab;
border-bottom: 1px solid rgba(255,255,255,0.08);
user-select: none;
}
#cs-signoff-hud.collapsed .cs-so-head { border-bottom: none; cursor: pointer; }
#cs-signoff-hud .cs-so-head:active { cursor: grabbing; }
#cs-signoff-hud .cs-so-title {
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
}
#cs-signoff-hud .cs-so-tally {
font-family: ui-monospace, monospace;
font-size: 11px;
margin-left: auto;
margin-right: 4px;
padding: 2px 6px;
border-radius: 3px;
}
#cs-signoff-hud .cs-so-tally.pass { background: rgba(29,233,182,0.18); color: #b9f6e2; }
#cs-signoff-hud .cs-so-tally.block { background: rgba(255,112, 67,0.18); color: #ffccbc; }
#cs-signoff-hud .cs-so-collapse {
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
cursor: pointer;
font-size: 11px;
padding: 0;
width: 18px; height: 18px;
border-radius: 3px;
}
#cs-signoff-hud .cs-so-collapse:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
#cs-signoff-hud .cs-so-body { padding: 4px 8px 6px 8px; max-height: 60vh; overflow-y: auto; }
#cs-signoff-hud .cs-so-section { margin-top: 6px; }
#cs-signoff-hud .cs-so-section:first-child { margin-top: 0; }
#cs-signoff-hud .cs-so-section-title {
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin: 4px 0 2px 0;
opacity: 0.75;
}
#cs-signoff-hud .cs-so-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 4px;
border-radius: 3px;
cursor: pointer;
line-height: 1.3;
}
#cs-signoff-hud .cs-so-row:hover { background: rgba(0,184,177,0.12); }
#cs-signoff-hud .cs-so-glyph {
font-family: ui-monospace, monospace;
width: 12px;
text-align: center;
flex: 0 0 auto;
}
#cs-signoff-hud .cs-so-glyph.ok { color: #1de9b6; }
#cs-signoff-hud .cs-so-glyph.partial { color: #ffb74d; }
#cs-signoff-hud .cs-so-glyph.fail { color: #ff7043; }
#cs-signoff-hud .cs-so-glyph.pending { color: #b0bec5; opacity: 0.65; }
#cs-signoff-hud .cs-so-glyph.na { color: #b0bec5; opacity: 0.45; }
#cs-signoff-hud .cs-so-label.soft { color: var(--text-dim); font-style: italic; }
#cs-signoff-hud .cs-so-foot { padding: 6px 8px 8px 8px; border-top: 1px solid rgba(255,255,255,0.08); }
#cs-signoff-hud .cs-so-foot button { width: 100%; }
/* Pin-reveal strobe badge animation — pop in then fade. */
@keyframes cs-strobe-pop {
0% { opacity: 0; transform: translate(-50%, -90%) scale(0.7); }
15% { opacity: 1; transform: translate(-50%, -110%) scale(1.15); }
30% { opacity: 1; transform: translate(-50%, -100%) scale(1.0); }
100% { opacity: 0; transform: translate(-50%, -90%) scale(0.95); }
}
`;
document.head.appendChild(style);
}
// Build right-pane DOM. Tabs strip on top + body container that swaps
// content per active tab. Inserted into <body> after .canvas-wrap so
// it's a sibling of the absolute-positioned outline.
function buildPane() {
if (document.getElementById('cs-pane')) return;
const pane = document.createElement('aside');
pane.id = 'cs-pane';
pane.innerHTML = `
<div class="cs-tabs" role="tablist"></div>
<div class="cs-body" id="cs-body"></div>
`;
const tabsEl = pane.querySelector('.cs-tabs');
TABS.forEach(t => {
const btn = document.createElement('button');
btn.className = 'cs-tab' + (t.id === state.activeTab ? ' active' : '');
btn.dataset.tab = t.id;
btn.setAttribute('role', 'tab');
btn.setAttribute('data-tooltip', `${t.label} — ${t.hotkey} or click. Status pill: ● pass · ◐ partial · ○ pending · ✗ fail · ─ n/a.`);
btn.innerHTML = `<span class="label">${t.label}</span><span class="pill pending">○</span>`;
btn.onclick = () => setActiveTab(t.id);
tabsEl.appendChild(btn);
});
// Add the collapse chevron on the rightmost tab row — sits inline at
// the end of the tab strip so it doesn't waste a header row.
const collapseBtn = document.createElement('button');
collapseBtn.className = 'cs-collapse';
collapseBtn.id = 'csCollapse';
collapseBtn.textContent = '❯';
collapseBtn.setAttribute('aria-label', 'Collapse pane');
collapseBtn.setAttribute('data-tooltip', 'Collapse the pane (P) — slides out, leaves the canvas full-width. Click the tab at the right edge to expand.');
tabsEl.appendChild(collapseBtn);
document.body.appendChild(pane);
const expander = document.createElement('button');
expander.id = 'cs-expander';
expander.textContent = '❮';
expander.setAttribute('aria-label', 'Expand pane');
expander.setAttribute('data-tooltip', 'Expand the chipsmith pane (P).');
document.body.appendChild(expander);
const KEY_COLLAPSED = 'chipsmith.pane.collapsed';
const KEY_POS = 'chipsmith.pane.pos';
function setCollapsed(c) {
pane.classList.toggle('collapsed', c);
document.body.classList.toggle('cs-collapsed', c);
try { localStorage.setItem(KEY_COLLAPSED, c ? '1' : '0'); } catch (_) {}
}
try {
if (localStorage.getItem(KEY_COLLAPSED) === '1') setCollapsed(true);
} catch (_) {}
collapseBtn.onclick = () => setCollapsed(true);
expander.onclick = () => setCollapsed(false);
// ── Drag logic ────────────────────────────────────────────
// Grab anywhere in the tab strip (NOT on a tab/collapse button) to
// drag. Position persists in localStorage. Constrained to keep at
// least a corner of the HUD on-screen so the user can never lose it.
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function applyPos(pos) {
pane.style.top = pos.y + 'px';
pane.style.left = pos.x + 'px';
pane.style.right = 'auto';
}
function loadPos() {
try {
const raw = localStorage.getItem(KEY_POS);
if (raw) return JSON.parse(raw);
} catch (_) {}
return null;
}
function savePos(pos) {
try { localStorage.setItem(KEY_POS, JSON.stringify(pos)); } catch (_) {}
}
const initialPos = loadPos();
if (initialPos) applyPos(initialPos);
let dragOrigin = null;
tabsEl.addEventListener('mousedown', (e) => {
// Don't start drag if click is on a tab button or collapse button.
const onTab = e.target.closest('.cs-tab, .cs-collapse');
if (onTab) return;
const r = pane.getBoundingClientRect();
dragOrigin = {
startX: e.clientX, startY: e.clientY,
baseX: r.left, baseY: r.top,
};
pane.classList.add('dragging');
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragOrigin) return;
const dx = e.clientX - dragOrigin.startX;
const dy = e.clientY - dragOrigin.startY;
// Header is 44 px; canvas content starts there. Clamp keeps at
// least 60×60 of the pane on-screen so it can never be lost.
const minX = -pane.offsetWidth + 60;
const maxX = window.innerWidth - 60;
const minY = 44;
const maxY = window.innerHeight - 60;
const x = clamp(dragOrigin.baseX + dx, minX, maxX);
const y = clamp(dragOrigin.baseY + dy, minY, maxY);
applyPos({ x, y });
});
document.addEventListener('mouseup', () => {
if (!dragOrigin) return;
const r = pane.getBoundingClientRect();
savePos({ x: r.left, y: r.top });
pane.classList.remove('dragging');
dragOrigin = null;
});
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'p' || e.key === 'P') {
setCollapsed(!pane.classList.contains('collapsed'));
return;
}
// Number-key tab hotkeys.
const tab = TABS.find(x => x.hotkey === e.key);
if (tab) {
if (pane.classList.contains('collapsed')) setCollapsed(false);
setActiveTab(tab.id);
}
});
}
function setActiveTab(id) {
state.activeTab = id;
document.querySelectorAll('.cs-tab').forEach(el => {
el.classList.toggle('active', el.dataset.tab === id);
});
renderActiveTab();
}
// Recompute every tab's status pill from current state, without
// rendering any tab. Called on every data mutation (spec save,
// detection run, footprint load, sign-off action) so cross-tab
// status cascades correctly even when a downstream tab isn't the
// active tab.
function recomputeAllStatuses() {
// Source.
const s = state.spec;
if (s) {
const pin = s.pin_count, struct = s.structural_type, body = Array.isArray(s.body_dims_mm) ? s.body_dims_mm : null;
const present = [pin, struct, body].filter(v => v !== undefined && v !== null && v !== '' && v !== '?').length;
state.status.source = present === 3 ? '●' : (present > 0 ? '◐' : '○');
}
// Measurements.
if (state.measurements) {
state.status.measurements = state.measurements.status;
}
// Footprint.
const fps = state.footprintSources;
if (fps) {
const sourcesPresent = ['kicad','fusion','altium','datasheet','ds2sf'].filter(k => fps[k] && !fps[k]._stub && fps[k].pad_count > 0);
state.status.footprint = sourcesPresent.length === 0 ? '✗' : (sourcesPresent.length >= 2 ? '◐' : '○');
}
// Pin-1: chip-side = user-placed indicator (state.userPin1Corner);
// fp-side = silkscreen marker on any footprint source.
{
const chipPin1 = !!state.userPin1Corner;
const fpPin1 = ['kicad','fusion','altium','datasheet','ds2sf'].some(k => fps && fps[k] && fps[k].pin1_silk && fps[k].pin1_silk.found);
state.status.pin1 = (chipPin1 || fpPin1) ? '◐' : '✗';
}
// Heatsink + solderjet recompute themselves on render — not
// status-data-cheap to recompute without running their algorithms.
refreshStatusPills();
renderSignoffHud();
}
function refreshStatusPills() {
document.querySelectorAll('.cs-tab').forEach(el => {
const id = el.dataset.tab;
const glyph = state.status[id] || '○';
const pill = el.querySelector('.pill');
if (!pill) return;
pill.textContent = glyph;
pill.className = 'pill ' + ({
'●': 'ok', '◐': 'partial', '○': 'pending', '✗': 'fail', '─': 'na',
}[glyph] || 'pending');
});
}
// ── Tab 1: Source ───────────────────────────────────────────────
// Manifest provenance + datasheet PDF inline + reveal-in-VS-Code
// buttons next to every file path. Status:
// ● when pin_count + structural_type + body_dims_mm are present
// ◐ when at least one is present
// ○ when none present (default until chip-fetcher / user populates)
async function renderSourceTab() {
const body = document.getElementById('cs-body');
if (!state.spec || !state.paths) {
body.innerHTML = `<div class="cs-empty">Loading source…</div>`;
return;
}
const s = state.spec, p = state.paths;
const has = (k) => s[k] !== undefined && s[k] !== null && s[k] !== '' && s[k] !== '?';
const declaredPinCount = has('pin_count') ? s.pin_count : null;
const declaredStruct = has('structural_type') ? s.structural_type : null;
const declaredBody = Array.isArray(s.body_dims_mm) ? s.body_dims_mm : null;
const specReady = (declaredPinCount && declaredStruct && declaredBody) ? '●'
: (declaredPinCount || declaredStruct || declaredBody) ? '◐' : '○';
state.status.source = specReady;
refreshStatusPills();
const reveal = (path) => path
? `<button class="cs-reveal-icon" data-path="${escAttr(path)}" aria-label="Reveal in Explorer" data-tooltip="Reveal in the VS Code Explorer sidebar (delegates to adom-vscode reveal).">${svgIcon('folder')}</button>`
: '';
const fmtBody = declaredBody ? `${declaredBody[0]} × ${declaredBody[1]} × ${declaredBody[2]} mm`
: '<span class="cs-amber">declare body_dims_mm</span>';
const fmtPin = declaredPinCount || '<span class="cs-amber">declare pin_count</span>';
const fmtStr = declaredStruct || '<span class="cs-amber">declare structural_type</span>';
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Manifest</h3>
<div class="cs-row"><span class="label">MPN</span><span class="value mono">${esc(s.mpn || '?')}</span></div>
<div class="cs-row"><span class="label">Manufacturer</span><span class="value">${esc(s.manufacturer || '?')}</span></div>
<div class="cs-row"><span class="label">Package</span><span class="value">${esc(s.package || '?')}</span></div>
<div class="cs-row"><span class="label">Family</span><span class="value">${esc(s.family || '?')}</span></div>
<div class="cs-row"><span class="label">Description</span><span class="value dim">${esc(s.description || '—')}</span></div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Spec <button class="cs-edit-spec" data-tooltip="Edit pin_count / structural_type / body_dims_mm inline. Saves into info.json so the values persist across reloads. The Measurements + Detection tabs cascade automatically.">${state.editingSpec ? '✕ cancel' : 'edit ✎'}</button></h3>
${state.editingSpec ? `
<div class="cs-row"><span class="label">Pin count</span><input type="number" class="cs-spec-input" id="spec-pin_count" min="0" value="${declaredPinCount || ''}" placeholder="e.g. 16"/></div>
<div class="cs-row"><span class="label">Structure</span>
<select class="cs-spec-input" id="spec-structural_type">
<option value="">— pick —</option>
<option value="smd_pad" ${declaredStruct === 'smd_pad' ? 'selected' : ''}>smd_pad (QFN/LGA/DFN)</option>
<option value="smd_leg" ${declaredStruct === 'smd_leg' ? 'selected' : ''}>smd_leg (SOIC/QFP/SOT)</option>
<option value="ball" ${declaredStruct === 'ball' ? 'selected' : ''}>ball (BGA)</option>
<option value="thru_hole_pin"${declaredStruct === 'thru_hole_pin' ? 'selected' : ''}>thru_hole_pin (DIP)</option>
<option value="ep_thermal" ${declaredStruct === 'ep_thermal' ? 'selected' : ''}>ep_thermal (EP only)</option>
<option value="lead_with_ep" ${declaredStruct === 'lead_with_ep' ? 'selected' : ''}>lead_with_ep (QFN with center EP)</option>
<option value="hybrid_smd_thru" ${declaredStruct === 'hybrid_smd_thru' ? 'selected' : ''}>hybrid_smd_thru (USB-C)</option>
</select>
</div>
<div class="cs-row"><span class="label">Body (mm)</span>
<input type="number" step="0.1" class="cs-spec-input cs-body-dim" id="spec-body-x" placeholder="W" value="${declaredBody?.[0] || ''}"/>
<input type="number" step="0.1" class="cs-spec-input cs-body-dim" id="spec-body-y" placeholder="H" value="${declaredBody?.[1] || ''}"/>
<input type="number" step="0.1" class="cs-spec-input cs-body-dim" id="spec-body-z" placeholder="D" value="${declaredBody?.[2] || ''}"/>
</div>
<div class="cs-actions">
<button class="cs-btn cs-btn-primary" id="cs-spec-save" data-tooltip="Save → POST /api/spec → write into info.json. Cascades to Measurements + Detection.">${svgIcon('bookOpen')}<span class="lbl">Save</span></button>
</div>
` : `
<div class="cs-row"><span class="label">Pin count</span><span class="value mono">${fmtPin}</span></div>
<div class="cs-row"><span class="label">Structure</span><span class="value mono">${fmtStr}</span></div>
<div class="cs-row"><span class="label">Body (mm)</span><span class="value mono">${fmtBody}</span></div>
`}
</div>
<div class="cs-section">
<h3 class="cs-section-title">Files</h3>
<div class="cs-row"><span class="label">STEP</span><span class="value mono" title="${escAttr(p.step||'')}">${esc(basename(p.step))}</span>${reveal(p.step)}</div>
<div class="cs-row"><span class="label">Footprint</span><span class="value mono" title="${escAttr(p.kicad_mod||'')}">${esc(basename(p.kicad_mod))}</span>${reveal(p.kicad_mod)}</div>
<div class="cs-row"><span class="label">Datasheet</span><span class="value mono" title="${escAttr(p.datasheet||'')}">${esc(basename(p.datasheet))}</span>${reveal(p.datasheet)}</div>
<div class="cs-row"><span class="label">info.json</span><span class="value mono" title="${escAttr(p.info_json||'')}">${esc(basename(p.info_json))}</span>${reveal(p.info_json)}</div>
<div class="cs-row"><span class="label">Folder</span><span class="value mono" title="${escAttr(p.dir||'')}">${esc(p.dir||'?')}</span>${reveal(p.dir)}</div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Datasheet</h3>
${p.datasheet
? `<div class="cs-row"><span class="label">File</span><span class="value mono" title="${escAttr(p.datasheet)}">${esc(basename(p.datasheet))}</span></div>
<div class="cs-actions">
<button class="cs-btn cs-btn-primary cs-open-pdf"
data-href="${escAttr(pdfViewerUrl(s, p))}"
data-tooltip="Open in the Adom PDF viewer (PDF.js + thumbnail strip + page deep-links). Pops a full-screen tab so you can read alongside chipsmith. The viewer also remembers cited pages once detection runs.">
${svgIcon('bookOpen')}<span class="lbl">Open in viewer</span>
</button>
<button class="cs-btn cs-btn-secondary cs-reveal"
data-path="${escAttr(p.datasheet)}"
data-tooltip="Reveal the datasheet PDF in the VS Code Explorer sidebar — useful for confirming where it lives on disk.">
${svgIcon('folder')}<span class="lbl">Reveal</span>
</button>
</div>`
: `<div class="cs-empty">No datasheet attached.</div>`}
</div>
<div class="cs-section">
<h3 class="cs-section-title">Cosmetic — laser etch</h3>
<div class="cs-row dim"><span class="value">Engrave the MPN onto the chip's top face. Cosmetic only — auto-positioned to avoid the pin-1 indicator. Baked into <code>.chipsmith.step</code> at sign-off so the engraving survives every future load.</span></div>
<div class="cs-row">
<span class="label">Text</span>
<input type="text" id="cs-laser-text" value="${escAttr(state.laserEtchText || (state.spec?.mpn || ''))}" style="background:rgba(20,24,30,0.65); border:1px solid rgba(255,255,255,0.14); color:var(--text); padding:4px 8px; border-radius:4px; font-family:inherit; font-size:12px; min-width:140px" placeholder="MPN" data-tooltip="Defaults to the chip's MPN. Override for variant SKUs (e.g. 'LM358 / Ti')."/>
</div>
<div class="cs-actions">
<button class="cs-btn cs-btn-${state.laserEtch ? 'primary' : 'secondary'}" id="cs-laser-toggle" data-tooltip="${state.laserEtch ? 'Hide the laser etch on the chip top.' : 'Render the MPN as a 50µm-deep darker recess on the body top face. Auto-sized + auto-positioned away from the pin-1 indicator.'}">${svgIcon('bookOpen')}<span class="lbl">${state.laserEtch ? 'Hide etch' : 'Show etch'}</span></button>
</div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Debug — anchor bboxes</h3>
<div class="cs-row dim"><span class="value">Show four colored slabs marking where chipsmith thinks each canonical surface is: chip-top (cyan), chip-bottom (magenta), footprint-area (yellow @ Y=0), pcb-top (green @ Y=0). Use when chipsmith's placements look wrong — the slabs reveal whether it's a placement bug or a wrong-anchor bug.</span></div>
<div class="cs-actions">
<button class="cs-btn cs-btn-${state.debugAnchorsOn ? 'primary' : 'secondary'}" id="cs-debug-anchors-toggle" data-tooltip="${state.debugAnchorsOn ? 'Hide the four debug anchor slabs.' : 'Render four 50µm-thick colored slabs at the chipsmith-internal anchor planes (chip-top, chip-bottom, footprint-area, pcb-top). Each appears in the Outline as a child of Debug — anchor bboxes.'}">${svgIcon('bookOpen')}<span class="lbl">${state.debugAnchorsOn ? 'Hide anchors' : 'Show anchors'}</span></button>
</div>
</div>
`;
// Wire spec editor controls.
const editBtn = body.querySelector('.cs-edit-spec');
if (editBtn) editBtn.onclick = () => {
state.editingSpec = !state.editingSpec;
renderSourceTab();
};
const saveBtn = document.getElementById('cs-spec-save');
if (saveBtn) saveBtn.onclick = async () => {
const merged = Object.assign({}, state.spec || {});
const pin = parseInt(document.getElementById('spec-pin_count').value, 10);
if (Number.isFinite(pin) && pin > 0) merged.pin_count = pin;
const struct = document.getElementById('spec-structural_type').value;
if (struct) merged.structural_type = struct;
const bx = parseFloat(document.getElementById('spec-body-x').value);
const by = parseFloat(document.getElementById('spec-body-y').value);
const bz = parseFloat(document.getElementById('spec-body-z').value);
if (Number.isFinite(bx) && Number.isFinite(by) && Number.isFinite(bz)) {
merged.body_dims_mm = [bx, by, bz];
}
saveBtn.disabled = true;
saveBtn.querySelector('.lbl').textContent = 'Saving…';
try {
const r = await fetch('api/spec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(merged),
});
const j = await r.json();
state.spec = merged;
state.editingSpec = false;
// Cascade: recompute every status pill, then re-render whichever
// tab is currently visible.
recomputeAllStatuses();
if (state.activeTab === 'source') renderSourceTab();
if (state.activeTab === 'measurements' && state.measurements) renderMeasurementsTab();
if (state.activeTab === 'signoff') renderSignoffTab();
} catch (e) {
console.error('[chipsmith] spec save failed', e);
saveBtn.disabled = false;
saveBtn.querySelector('.lbl').textContent = 'Save (failed — retry)';
}
};
// Laser-etch toggle + text input. Defaults to MPN; user can
// override per-chip. Re-renders the source tab so the button label
// flips between "Show etch" / "Hide etch".
const laserBtn = document.getElementById('cs-laser-toggle');
const laserInput = document.getElementById('cs-laser-text');
if (laserInput) {
laserInput.oninput = () => {
state.laserEtchText = laserInput.value;
// Live-update the etch if it's currently visible.
if (state.laserEtch) renderLaserEtch({ text: laserInput.value });
};
}
if (laserBtn) {
laserBtn.onclick = () => {
const text = (laserInput?.value || state.spec?.mpn || '').trim();
if (state.laserEtch) {
ensureOverlayDisposed('laser-etch');
state.laserEtch = null;
} else {
renderLaserEtch({ text });
}
renderSourceTab();
};
}
const debugBtn = document.getElementById('cs-debug-anchors-toggle');
if (debugBtn) {
debugBtn.onclick = () => {
if (state.debugAnchorsOn) {
ensureOverlayDisposed('debug-anchors');
state.debugAnchorsOn = false;
} else {
renderDebugAnchors();
}
renderSourceTab();
};
}
// Wire reveal buttons → POST /api/reveal { path }. Both the inline
// file-row 📂 buttons (cs-reveal-icon) and the datasheet "Reveal"
// CTA (cs-reveal) carry data-path; one handler covers both.
body.querySelectorAll('.cs-reveal-icon, .cs-reveal').forEach(btn => {
btn.onclick = async () => {
const path = btn.dataset.path;
try {
await fetch('api/reveal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
} catch (e) { console.error('[chipsmith] reveal failed', e); }
};
});
// Wire "Open full viewer" → opens the same adom-pdf-viewer URL in a
// new browser tab so the user can read at full screen alongside the
// chipsmith webview. The inline iframe stays for at-a-glance.
body.querySelectorAll('.cs-open-pdf').forEach(btn => {
btn.onclick = () => {
const href = btn.dataset.href;
if (href) window.open(href, '_blank', 'noopener');
};
});
}
// Build the adom-pdf-viewer URL for the loaded chip. The viewer runs
// on port 8985 (per its SKILL.md) and is reached via the Hydrogen
// proxy. We pass the datasheet through chipsmith's /api/datasheet
// endpoint so the viewer fetches a same-origin URL — no CORS surprises.
// `title` and `reveal` give the viewer's topbar a label + the
// reveal-in-Explorer button.
function pdfViewerUrl(spec, paths) {
// Locate this page's origin + proxy prefix so the constructed URL
// works under both /proxy/8872/ (Hydrogen) and direct localhost.
const proxyMatch = location.pathname.match(/^(\/proxy\/\d+\/)/);
const origin = location.origin;
// Same-origin URL to chipsmith's /api/datasheet — the pdf-viewer
// fetches this and renders it in PDF.js.
const datasheetSrc = (proxyMatch ? proxyMatch[1] : '/') + 'api/datasheet';
const viewerBase = origin + (proxyMatch ? proxyMatch[1].replace(/\d+/, '8985') : '/proxy/8985/');
const params = new URLSearchParams();
params.set('src', datasheetSrc);
if (spec && spec.mpn) params.set('title', spec.mpn + ' datasheet');
if (paths && paths.datasheet) params.set('reveal', paths.datasheet);
return viewerBase + 'v?' + params.toString();
}
// ── Tab 2: Measurements ─────────────────────────────────────────
// Read declared body_dims_mm from the manifest (or extract from the
// package field as a fallback — many chip-fetcher entries write
// "OLGA-16 (6.4×3×1.75 mm)" into `package` without populating the
// structured body_dims_mm yet). Then read the detected body bbox via
// window.adomStep.bbox('Body') — adom-step returns it in mm — and run
// a rescale negotiation:
//
// ×1 (declared unit honored)
// ÷25.4 (file says mm but is really inches)
// ÷1000 (file says mm but is really metres)
// ×25.4 (file says inches but is really mm)
// ×1000 (file says metres but is really mm)
// ÷10 (file says mm but is really cm)
//
// Pick the scale whose product (detected × scale) lands within ±5%
// on every axis (sorted-extents — orientation-agnostic, since chips
// can ship Y-up vs Z-up; the absolute axis-by-axis match is too
// brittle here). Status pill: ● if ×1, ◐ if non-1 rescale, ✗ if no
// scale fits.
function parseBodyDims(spec) {
if (!spec) return null;
if (Array.isArray(spec.body_dims_mm) && spec.body_dims_mm.length === 3) return spec.body_dims_mm.map(Number);
// Fallback: extract "<W>×<H>×<D> mm" or "<W>x<H>x<D> mm" from the
// free-text package field. Examples: "OLGA-16 (6.4×3×1.75 mm)"
// "QFN-32 5x5x0.9 mm".
const text = String(spec.package || spec.description || '');
const m = text.match(/(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*mm/i);
if (!m) return null;
return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3])];
}
const RESCALE_CANDIDATES = [
{ factor: 1, label: '×1', reason: 'Declared unit honored — STEP says mm and is mm.' },
{ factor: 1/25.4, label: '÷25.4', reason: 'STEP says mm but the file is really inches (US-mfr CAD export).' },
{ factor: 1/1000, label: '÷1000', reason: 'STEP says mm but the file is really metres (Fusion export bug).' },
{ factor: 25.4, label: '×25.4', reason: 'STEP says inches but the file is really mm.' },
{ factor: 1000, label: '×1000', reason: 'STEP says metres but the file is really mm.' },
{ factor: 1/10, label: '÷10', reason: 'STEP says mm but the file is really cm.' },
];
// Score a rescale candidate. Sort BOTH declared + detected extents
// ascending (so [4.4, 5.4, 1.5] vs detected [1.5, 5.4, 4.4] still
// matches), then compute max-axis percent deviation. Lower is better;
// ≤5% counts as a match.
function scoreScale(declaredMm, detectedMm, factor) {
const d = [...declaredMm].sort((a, b) => a - b);
const dt = detectedMm.map(v => v * factor).sort((a, b) => a - b);
let maxPct = 0;
for (let i = 0; i < 3; i++) {
const pct = Math.abs(dt[i] - d[i]) / d[i];
if (pct > maxPct) maxPct = pct;
}
return { factor, maxPct, scaledMm: detectedMm.map(v => v * factor) };
}
function negotiateScale(declaredMm, detectedMm) {
let best = null;
for (const c of RESCALE_CANDIDATES) {
const s = scoreScale(declaredMm, detectedMm, c.factor);
const cand = { ...c, ...s };
if (!best || cand.maxPct < best.maxPct) best = cand;
}
return best;
}
// Get the body bbox via window.adomStep.bbox('Body'). If "Body" isn't
// in the assembly tree (some STEPs ship a flat hierarchy), fall back
// to the whole model's bbox.
function detectBodyBbox() {
if (!window.adomStep || typeof window.adomStep.bbox !== 'function') return null;
let r = window.adomStep.bbox('Body');
if (!r || r.error) r = window.adomStep.bbox();
if (!r || r.error || !Array.isArray(r.mm)) return null;
return { mm: r.mm.map(v => Math.abs(v)), source: r.name };
}
function renderMeasurementsTab() {
const body = document.getElementById('cs-body');
const declared = parseBodyDims(state.spec);
const detected = detectBodyBbox();
if (!declared) {
state.status.measurements = '○';
refreshStatusPills();
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Measurements</h3>
<div class="cs-row"><span class="value dim">No <code>body_dims_mm</code> declared in info.json — and the <code>package</code> field doesn't carry a parseable <code>W×H×D mm</code> tuple either. Add <code>"body_dims_mm": [w, h, d]</code> to info.json, then come back.</span></div>
</div>`;
return;
}
if (!detected) {
state.status.measurements = '○';
refreshStatusPills();
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Measurements</h3>
<div class="cs-row"><span class="value dim">Waiting for the model to load — body bbox not available yet. <button class="cs-btn cs-btn-secondary" id="cs-meas-retry" style="margin-top:8px"><span class="lbl">Retry</span></button></span></div>
</div>`;
const r = document.getElementById('cs-meas-retry');
if (r) r.onclick = () => renderMeasurementsTab();
return;
}
const result = negotiateScale(declared, detected.mm);
const passes = result.maxPct <= 0.05;
const isUnscaled = Math.abs(result.factor - 1) < 1e-9;
const status = passes ? (isUnscaled ? '●' : '◐') : '✗';
state.status.measurements = status;
refreshStatusPills();
state.measurements = {
declared_mm: declared,
detected_mm: detected.mm,
detected_source: detected.source,
applied_scale: passes ? result.factor : 1,
applied_label: passes ? result.label : '×1',
max_axis_pct: result.maxPct,
passes, status,
};
const fmt = (a) => a.map(v => v.toFixed(3)).join(' × ');
const fmtMils = (mm) => (mm / 0.0254).toFixed(1);
const declaredMils = declared.map(fmtMils).join(' × ');
const scaledMils = result.scaledMm.map(fmtMils).join(' × ');
const pctReadout = (result.maxPct * 100).toFixed(2) + '%';
const statusText = passes
? (isUnscaled
? 'Match within ±5% — declared mm honored, no rescale needed.'
: `Auto-rescaled by ${result.label} — ${escAttr(result.reason)} Sign-off will record the applied scale in <code><mpn>.chipsmith.json</code>.`)
: `No rescale lands within ±5% on every axis (best: ${result.label}, max Δ ${pctReadout}). Either the declared <code>body_dims_mm</code> is wrong or the STEP is geometrically broken. Sign-off blocked.`;
const statusClass = passes ? (isUnscaled ? 'ok' : 'partial') : 'fail';
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Declared vs detected</h3>
<div class="cs-row"><span class="label">Declared</span><span class="value mono">${esc(fmt(declared))} mm <span class="dim">(${esc(declaredMils)} mils)</span></span></div>
<div class="cs-row"><span class="label">Detected</span><span class="value mono">${esc(fmt(detected.mm))} mm <span class="dim">(${esc(detected.mm.map(fmtMils).join(' × '))} mils)</span></span></div>
<div class="cs-row"><span class="label">Source node</span><span class="value mono">${esc(detected.source)}</span></div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Rescale negotiation</h3>
<div class="cs-row"><span class="label">Best scale</span><span class="value mono">${esc(result.label)}</span></div>
<div class="cs-row"><span class="label">Scaled</span><span class="value mono">${esc(fmt(result.scaledMm))} mm <span class="dim">(${esc(scaledMils)} mils)</span></span></div>
<div class="cs-row"><span class="label">Max Δ</span><span class="value mono">${esc(pctReadout)}</span></div>
<div class="cs-row"><span class="label">Tolerance</span><span class="value mono">±5%</span></div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Status</h3>
<div class="cs-meas-status pill-${statusClass}">${statusText}</div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Candidates</h3>
<table class="cs-tbl">
<thead><tr><th>Scale</th><th class="num">Max Δ</th><th>Reason</th></tr></thead>
<tbody>
${RESCALE_CANDIDATES.map(c => {
const s = scoreScale(declared, detected.mm, c.factor);
const isWinner = Math.abs(c.factor - result.factor) < 1e-9;
const ok = s.maxPct <= 0.05;
return `<tr class="${isWinner ? 'is-winner' : ''} ${ok ? 'is-ok' : ''}">
<td class="mono">${esc(c.label)}</td>
<td class="num mono">${(s.maxPct * 100).toFixed(2)}%</td>
<td class="dim">${esc(c.reason)}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
`;
}
// ── Tab 4: Footprint ────────────────────────────────────────────
// /api/footprint-sources already returns parsed pads from
// KiCad / Fusion (.lbr) / Altium / datasheet. v1.0 renders a summary
// + cross-source agreement matrix; full 3D rendering of the pads at
// z=0 with bbox-fit raycast is v1.1 (needs Babylon scene mutation
// hooks beyond the current adom-step API surface).
function fpExtent(pads) {
if (!pads || !pads.length) return null;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of pads) {
const cx = p.at?.[0] || 0, cy = p.at?.[1] || 0;
const w = p.size?.[0] || 0, h = p.size?.[1] || 0;
minX = Math.min(minX, cx - w/2); maxX = Math.max(maxX, cx + w/2);
minY = Math.min(minY, cy - h/2); maxY = Math.max(maxY, cy + h/2);
}
return { width: maxX - minX, height: maxY - minY };
}
// ── 3D overlay layers ──────────────────────────────────────────
// Add footprint pads as thin extruded rectangles at z≈0, faux PCB
// plate as a 1.6mm FR4 box below the chip, and heatsink vias as
// cylinders inside the EP. Each overlay is registered with adom-step
// under a named group so it can be toggled / disposed as a unit.
//
// Coordinate system: GLB is in metres (1 unit = 1 mm × 0.001). All
// footprint pad coords + sizes from /api/footprint-sources are in
// mm — convert via × 0.001 before authoring meshes.
const MM = 0.001;
// ── World-up convention ─────────────────────────────────────────
// chip-fetcher GLBs come authored Z-up (CAD/EE convention; camera
// upVector = [0,0,1]). Older Adom 3D viewers were Y-up. Every
// chipsmith renderer that places overlays at "z above the seat
// plane" must consult these helpers — picking Y vs Z literally
// changes which way is "up" in the world.
//
// worldUpIdx() → 1 (Y-up) | 2 (Z-up)
// planeAxes() → [0, 2] for Y-up, [0, 1] for Z-up
// The two scene-axis indices that make up the
// horizontal plane (where the PCB lives).
// fpPlace(x, y, h_mm)
// → returns [worldX, worldY, worldZ] (in scene
// units, metres) for placing a feature at
// footprint-x = x mm, footprint-y = y mm,
// height-above-ground = h mm. Use this for every
// overlay mesh that sits on / above / below the
// PCB plane.
// upRotation() → Babylon Euler-angle rotation needed to make a
// default-Z-facing plane (CreatePlane) lie flat
// on the ground. Y-up = π/2 about X; Z-up = 0
// (default plane already faces +Z).
function worldUpIdx() {
if (typeof window === 'undefined' || !window.adomStep || !window.adomStep.getScene) return 1;
try {
const cam = window.adomStep.getScene().activeCamera;
if (cam && cam.upVector && Math.abs(cam.upVector.z) > 0.5) return 2;
} catch (_) {}
return 1;
}
function planeAxes() {
return worldUpIdx() === 2 ? [0, 1] : [0, 2];
}
function fpPlace(fpX_mm, fpY_mm, h_mm) {
const upI = worldUpIdx();
const ax = planeAxes();
const out = [0, 0, 0];
out[ax[0]] = (fpX_mm || 0) * MM;
out[ax[1]] = (fpY_mm || 0) * MM;
out[upI] = (h_mm || 0) * MM;
return out;
}
function upRotation() {
return worldUpIdx() === 1 ? Math.PI / 2 : 0;
}
// For CreateBox — pick the (width, height, depth) tuple so that the
// box's THIN axis aligns with world-up. plane_w / plane_h map to the
// two horizontal axes; thickness maps to world-up.
function flatBoxDims(plane_w_mm, plane_h_mm, thickness_mm) {
const upI = worldUpIdx();
if (upI === 1) {
// Y-up: thickness in Y, big dims in X (width) + Z (depth)
return { width: plane_w_mm * MM, height: thickness_mm * MM, depth: plane_h_mm * MM };
}
// Z-up: thickness in Z, big dims in X (width) + Y (height)
return { width: plane_w_mm * MM, height: plane_h_mm * MM, depth: thickness_mm * MM };
}
// Read body bbox extent along the world-up axis. Use this anywhere
// the legacy code grabbed bb.max[1] / bb.min[1] expecting "top" /
// "bottom" — those work in Y-up but read the chip's LENGTH axis in
// Z-up. Returns { min, max, center } in scene units (metres).
function bodyHeightExtent() {
const bb = window.adomStep && window.adomStep.bbox && window.adomStep.bbox('Body');
if (!bb || bb.error) return null;
const upI = worldUpIdx();
return {
min: bb.min[upI],
max: bb.max[upI],
center: (bb.min[upI] + bb.max[upI]) / 2,
// Plane-axis extents (everything not the up axis), useful for
// sizing decals that should fit the chip's top face.
planeW: bb.max[planeAxes()[0]] - bb.min[planeAxes()[0]],
planeH: bb.max[planeAxes()[1]] - bb.min[planeAxes()[1]],
planeCx: (bb.min[planeAxes()[0]] + bb.max[planeAxes()[0]]) / 2,
planeCy: (bb.min[planeAxes()[1]] + bb.max[planeAxes()[1]]) / 2,
};
}
// Fill in the spec fields chipsmith needs to run gates from a
// single chip-fetcher-style `package` string. chip-fetcher writes
// strings like "SOIC-8 (D, 4.9×3.9 mm, 1.27 mm pitch)" and
// "VQFN-32 (RRG, 5×5×0.9 mm)" but leaves the structured fields
// (pin_count / package_kind / structural_type / body_dims_mm /
// expected_pitch_mm / has_ep) null. We parse the string here so the
// user doesn't have to hand-populate every chip's info.json before
// chipsmith works. Existing non-null fields are NEVER overwritten.
//
// Coverage: every package family in
// gallia/skills/electrical-engineering/SKILL.md §"Package families".
// When a string fails to parse, returns the input unchanged.
function enrichSpec(spec) {
if (!spec || typeof spec !== 'object') return spec;
const out = Object.assign({}, spec);
const pkgStr = (spec.package || spec.package_family || '').toString();
// Family + pin count: leading word then optional dash + integer.
// "VQFN-32 (RRG, 5×5×0.9 mm)" → kind "VQFN", pins 32, family "VQFN-32"
// "SOIC-8 (D, 4.9×3.9 mm, 1.27 mm pitch)" → kind "SOIC", pins 8, family "SOIC-8"
// "SOT-23 (3-lead)" → kind "SOT", pins 3, family "SOT-23-3"
// "LGA-16 (4.4x5.4mm Pitch0.5mm)" → kind "LGA", pins 16, family "LGA-16"
let m = pkgStr.match(/^([A-Z][A-Z0-9-]*?)-(\d+)\b/i);
if (m) {
if (out.package_kind == null) out.package_kind = m[1].toUpperCase().replace(/^V?Q?FN$/, 'QFN').replace(/^[VST]?SO[PI]C?$/, 'SOIC');
if (out.pin_count == null) out.pin_count = parseInt(m[2], 10);
if (out.package_family == null) out.package_family = `${m[1].toUpperCase()}-${m[2]}`;
} else if ((m = pkgStr.match(/^(SOT-23(?:-\d+)?|TO-220|TO-247|DPAK|D2PAK|USB-?C|RJ-?45|DIP-?\d*|SIP-?\d*)/i))) {
// Connector-ish + special non-dash families.
if (out.package_kind == null) out.package_kind = m[1].toUpperCase().replace(/-.*$/, '');
if (out.package_family == null) out.package_family = m[1].toUpperCase();
}
// body_dims_mm: parse "5×5×0.9 mm" / "4.9x3.9 mm" / "5×5×0.9mm" /
// "4.4×5.4×1.5 mm". Fall back to two-axis if no third found.
if (!Array.isArray(out.body_dims_mm)) {
m = pkgStr.match(/(\d+(?:\.\d+)?)\s*[×xX]\s*(\d+(?:\.\d+)?)(?:\s*[×xX]\s*(\d+(?:\.\d+)?))?\s*mm/);
if (m) {
const w = parseFloat(m[1]), h = parseFloat(m[2]), z = m[3] ? parseFloat(m[3]) : null;
// Per-package-kind default thickness when not given. Datasheet
// typical, NOT max. Conservative — gates use ±5%.
const kind = out.package_kind || '';
const defaultZ = ({
SOIC: 1.65, SSOP: 1.20, TSSOP: 1.10, MSOP: 1.10, VSSOP: 1.10,
QFN: 0.85, DFN: 0.85, VQFN: 0.85, LGA: 0.93, BGA: 1.10,
SOT: 1.10, TQFP: 1.40, LQFP: 1.40, QFP: 1.40, DIP: 4.00,
}[kind] || 1.00);
out.body_dims_mm = z != null ? [w, h, z] : [w, h, defaultZ];
}
}
// expected_pitch_mm: parse "0.5 mm pitch" / "Pitch 0.5mm" / "1.27 mm pitch".
if (out.expected_pitch_mm == null) {
m = pkgStr.match(/(?:Pitch\s*|pitch[ :]*|P)(\d+(?:\.\d+)?)\s*mm|(\d+(?:\.\d+)?)\s*mm\s*pitch/i);
if (m) out.expected_pitch_mm = parseFloat(m[1] || m[2]);
else if (out.package_kind) {
// Fallback by package kind (canonical pitch per family).
out.expected_pitch_mm = ({
SOIC: 1.27, SSOP: 0.65, TSSOP: 0.65, MSOP: 0.65, VSSOP: 0.50,
QFN: 0.50, DFN: 0.50, VQFN: 0.50, LGA: 0.50, BGA: 0.50,
DIP: 2.54, SOT: 0.95, TQFP: 0.50, LQFP: 0.50, QFP: 0.65,
}[out.package_kind] || null);
}
}
// structural_type: derived from package_kind.
if (out.structural_type == null && out.package_kind) {
out.structural_type = ({
SOIC: 'smd_leg', SSOP: 'smd_leg', TSSOP: 'smd_leg', MSOP: 'smd_leg', VSSOP: 'smd_leg',
TQFP: 'smd_leg', LQFP: 'smd_leg', QFP: 'smd_leg', SOT: 'smd_leg',
QFN: 'smd_pad', DFN: 'smd_pad', VQFN: 'smd_pad', LGA: 'smd_pad',
BGA: 'ball',
DIP: 'thru_hole_pin', PDIP: 'thru_hole_pin',
}[out.package_kind] || null);
}
// has_ep: QFN/DFN/VQFN almost always. LGA sometimes. Others rarely.
if (out.has_ep == null && out.package_kind) {
const epKinds = new Set(['QFN', 'DFN', 'VQFN', 'TQFN']);
// Mark LGA "maybe EP" — datasheet-specific. Conservative default true
// for LGA so the heatsink-via gate runs.
if (epKinds.has(out.package_kind)) out.has_ep = true;
}
return out;
}
// Apply the standard "lay down on the ground plane" rotation to a
// mesh whose long axis is Babylon's default +Y (cylinder, capsule).
// Y-up: leave Y-axis alone. Z-up: rotate so Y maps to Z.
function alignVerticalToWorldUp(mesh) {
if (worldUpIdx() === 2) {
mesh.rotation.x = Math.PI / 2;
}
}
// Place a mesh at footprint (fpX, fpY) mm with its center at world-up
// height h_mm. Assigns to mesh.position directly.
function placeMeshAt(mesh, fpX_mm, fpY_mm, h_mm) {
const p = fpPlace(fpX_mm, fpY_mm, h_mm);
mesh.position.x = p[0];
mesh.position.y = p[1];
mesh.position.z = p[2];
}
// Source-tinted overlay colors per the plan §"Multi-source footprint
// cross-validation". Datasheet = canonical truth (orange); KiCad =
// green; Fusion = blue; Altium = purple.
const SRC_COLOR = {
datasheet: { r: 1.00, g: 0.55, b: 0.10 },
kicad: { r: 0.30, g: 0.85, b: 0.45 },
fusion: { r: 0.30, g: 0.55, b: 0.95 },
altium: { r: 0.65, g: 0.40, b: 0.85 },
// ds2sf v0.6.0 — 5th independent pad-position source. Warm amber
// distinct from datasheet-orange and kicad-green.
ds2sf: { r: 0.95, g: 0.75, b: 0.20 },
// Stock-library ghost — service-kicad-backed authoritative footprint.
// Translucent off-white so it reads as a "ghost" against any of the
// four user sources without competing for the eye.
stock: { r: 0.85, g: 0.85, b: 0.92 },
};
function ensureOverlayDisposed(group) {
if (window.adomStep && window.adomStep.removeOverlay) {
try { window.adomStep.removeOverlay(group); } catch (_) {}
}
}
const SRC_LABEL = { kicad: 'KiCad', fusion: 'Fusion (.lbr)', altium: 'Altium (.PcbLib)', datasheet: 'Datasheet-derived', ds2sf: 'ds2sf pads' };
function renderFootprintOverlay(srcKey, padsMm) {
if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;
const scene = window.adomStep.getScene();
const B = window.adomStep.getBabylon();
if (!scene || !B) return null;
const group = 'fp-' + srcKey;
ensureOverlayDisposed(group);
const color = SRC_COLOR[srcKey] || { r: 0.5, g: 0.5, b: 0.5 };
const mat = new B.StandardMaterial(group + '-mat', scene);
mat.diffuseColor = new B.Color3(color.r, color.g, color.b);
mat.alpha = 0.55;
mat.specularColor = new B.Color3(0, 0, 0);
mat.backFaceCulling = false;
mat.zOffset = -1;
// Per-source Z stagger — when multiple sources render their pads at
// the same height they Z-fight. Stagger 5 µm between sources so the
// user can visibly stack them in the outline without flicker.
// datasheet sits lowest (it's the canonical truth, behind everything),
// then kicad / fusion / altium ascending.
const Z_STAGGER = { datasheet: 0.020, kicad: 0.025, fusion: 0.030, altium: 0.035, ds2sf: 0.040 };
const padHeight_mm = Z_STAGGER[srcKey] != null ? Z_STAGGER[srcKey] : 0.025;
// Build per-pad children — each pad gets its own outline row with
// inline metadata: number, shape, size, layers, rotation.
// Pad rendering covers KiCad's full shape vocabulary:
// • rect / rounded — box (rounded uses box too; corner radius
// would need CSG which isn't worth it for an overlay).
// • circle — cylinder along world-up axis.
// • oval — capsule along world-up; long axis = footprint X
// (rotation applied after).
// • custom polygon — fallback to rect bbox.
// Pad rotation (third value of `at`, in degrees) rotates the mesh
// around the world-up axis. Critical for SOIC/BGA layouts where
// pads are rotated 90° vs the part's symbol orientation.
const children = [];
for (const p of padsMm) {
if (!p.size || p.size[0] <= 0 || p.size[1] <= 0) continue;
const fpX = p.at?.[0] || 0;
const fpY = p.at?.[1] || 0;
const rotDeg = p.at?.[2] || 0;
const w = p.size[0], h = p.size[1];
const shape = (p.shape || 'rect').toLowerCase();
let m;
const cr = p.corner_radius || 0;
if (shape === 'circle') {
m = B.MeshBuilder.CreateCylinder(`${group}-pad-${p.number}`, {
diameter: Math.max(w, h) * MM, height: 0.04 * MM, tessellation: 24,
}, scene);
alignVerticalToWorldUp(m);
} else if (shape === 'oval') {
const longer = Math.max(w, h) * MM;
const shorter = Math.min(w, h) * MM;
m = B.MeshBuilder.CreateCapsule(`${group}-pad-${p.number}`, {
radius: shorter / 2, height: longer, tessellation: 18,
}, scene);
alignVerticalToWorldUp(m);
if (w >= h) {
if (worldUpIdx() === 1) m.rotation.z = Math.PI / 2;
else m.rotation.y = Math.PI / 2;
}
m.scaling.y = (0.04 * MM) / longer;
} else if ((shape === 'roundrect' || cr > 0.01) && typeof window.earcut === 'function') {
// Rounded-rect pad via earcut. Quarter-circle arcs at each
// corner, 6 points per arc. Two face layers (top + bottom at
// ±0.02mm) so the mesh has Z-extent and scene.pick works.
const hw = w / 2, hh = h / 2;
const r = Math.min(cr, hw, hh);
const N = 6;
const pts2d = [];
for (let i = 0; i <= N; i++) { const a = -Math.PI/2 + (Math.PI/2)*i/N; pts2d.push([hw - r + r*Math.cos(a), -hh + r + r*Math.sin(a)]); }
for (let i = 0; i <= N; i++) { const a = 0 + (Math.PI/2)*i/N; pts2d.push([hw - r + r*Math.cos(a), hh - r + r*Math.sin(a)]); }
for (let i = 0; i <= N; i++) { const a = Math.PI/2 + (Math.PI/2)*i/N; pts2d.push([-hw + r + r*Math.cos(a), hh - r + r*Math.sin(a)]); }
for (let i = 0; i <= N; i++) { const a = Math.PI + (Math.PI/2)*i/N; pts2d.push([-hw + r + r*Math.cos(a), -hh + r + r*Math.sin(a)]); }
const flat = new Float64Array(pts2d.length * 2);
for (let i = 0; i < pts2d.length; i++) { flat[i*2] = pts2d[i][0]; flat[i*2+1] = pts2d[i][1]; }
const tris = window.earcut(flat);
if (tris && tris.length >= 3) {
const upI = worldUpIdx();
const nPts = pts2d.length;
const thick = 0.04 * MM;
const positions = new Float32Array(nPts * 2 * 3);
const normals = new Float32Array(nPts * 2 * 3);
for (let k = 0; k < nPts; k++) {
const px = pts2d[k][0] * MM, py = pts2d[k][1] * MM;
if (upI === 2) {
positions[k*3] = px; positions[k*3+1] = py; positions[k*3+2] = thick/2;
positions[(nPts+k)*3] = px; positions[(nPts+k)*3+1] = py; positions[(nPts+k)*3+2] = -thick/2;
} else {
positions[k*3] = px; positions[k*3+1] = thick/2; positions[k*3+2] = -py;
positions[(nPts+k)*3] = px; positions[(nPts+k)*3+1] = -thick/2; positions[(nPts+k)*3+2] = -py;
}
normals[k*3+upI] = 1;
normals[(nPts+k)*3+upI] = -1;
}
const indices = new Uint32Array(tris.length * 2);
for (let k = 0; k < tris.length; k += 3) {
indices[k] = tris[k]; indices[k+1] = tris[k+1]; indices[k+2] = tris[k+2];
indices[tris.length+k] = tris[k]+nPts; indices[tris.length+k+1] = tris[k+2]+nPts; indices[tris.length+k+2] = tris[k+1]+nPts;
}
m = new B.Mesh(`${group}-pad-${p.number}`, scene);
const vd = new B.VertexData();
vd.positions = positions; vd.indices = indices; vd.normals = normals;
vd.applyToMesh(m, false);
} else {
m = B.MeshBuilder.CreateBox(`${group}-pad-${p.number}`, flatBoxDims(w, h, 0.04), scene);
}
} else {
m = B.MeshBuilder.CreateBox(`${group}-pad-${p.number}`, flatBoxDims(w, h, 0.04), scene);
}
const pos = fpPlace(fpX, fpY, padHeight_mm);
m.position.x = pos[0]; m.position.y = pos[1]; m.position.z = pos[2];
// Apply pad rotation around the world-up axis.
if (rotDeg) {
const rotRad = -rotDeg * Math.PI / 180; // KiCad rotates CW; Babylon Y-rotation is CCW
if (worldUpIdx() === 1) m.rotation.y = (m.rotation.y || 0) + rotRad;
else m.rotation.z = (m.rotation.z || 0) + rotRad;
}
m.material = mat;
const layerStr = (p.layers || []).map(l => l.replace(/^F\./,'F.').replace(/^B\./,'B.').replace(/^\*\./, '*.')).join('+');
const rotStr = rotDeg ? ` rot ${rotDeg.toFixed(0)}°` : '';
const nameStr = p.name ? ` (${p.name})` : '';
const shapeStr = cr > 0.01 ? `roundrect r=${cr.toFixed(2)}` : shape;
const label = `pad ${p.number}${nameStr} ${shapeStr} ${w.toFixed(2)}×${h.toFixed(2)} mm c=(${fpX.toFixed(2)},${fpY.toFixed(2)})${rotStr} ${layerStr}`;
children.push({ meshes: [m], label, kind: 'overlay-fp-pad' });
}
const SRC_PROVENANCE = {
kicad: 'Parsed from the .kicad_mod file in the chip-fetcher library.\nThird-party footprint — a KiCad library maintainer traced the\nmanufacturer datasheet into this format.',
fusion: 'Parsed from the Eagle .lbr file (Fusion 360 format) in the\nchip-fetcher library. Third-party footprint — authored by\nSnapEDA, SnapMagic, or Ultra Librarian.',
altium: 'Parsed from the .PcbLib file in the chip-fetcher library.\nThird-party footprint — authored by the Altium library\necosystem (SnapEDA, SnapMagic, Ultra Librarian, or CSE).',
datasheet: 'Derived from the manufacturer datasheet PDF. First-party\nsource — the pad positions trace directly to the mechanical\ndrawing page, not to any EDA library.',
ds2sf: 'Extracted by ds2sf directly from the manufacturer datasheet\nmechanical drawing. This is the only source that traces pad\npositions back to the manufacturer — every other source is a\nthird-party interpretation. Disagreements here mean the EDA\nlibraries may have gotten it wrong.',
};
const provStr = SRC_PROVENANCE[srcKey] || '';
const displayLabel = `${SRC_LABEL[srcKey] || srcKey} footprint (${children.length} pads)`;
window.adomStep.addOverlay({
group,
label: displayLabel,
tooltip: provStr ? `${displayLabel}\n\n${provStr}` : displayLabel,
kind: 'overlay-footprint',
children,
});
return { group, count: children.length };
}
// Render a 1.6 mm FR4 plate sized to the footprint extent + 5 mm
// margin, CSG-drilled with every hole declared in the footprint
// pads. Four hole types (per the plan):
// • plated round (PTH) — pad.type == 'thru_hole', drill.shape circle
// • unplated round (NPTH) — pad.type == 'np_thru_hole', drill.shape circle
// • plated slot — pad.type == 'thru_hole', drill.shape oval
// • unplated slot — pad.type == 'np_thru_hole', drill.shape oval
// Plated entries get a Cu-tone (0xC79A4F) inner-bore shell + a top
// and bottom annular ring so the plating reads visibly on zoom. CSG
// is via Babylon's B.CSG; we union all holes once then subtract in
// a single pass so a 12-pin DIP doesn't take 12 round-trips.
function renderFauxPcbPlate(extentMm, pads) {
if (!extentMm) return null;
if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;
const scene = window.adomStep.getScene();
const B = window.adomStep.getBabylon();
if (!scene || !B) return null;
const group = 'faux-pcb';
ensureOverlayDisposed(group);
const padMargin = 5;
const plateW_mm = extentMm.width + padMargin * 2;
const plateH_mm = extentMm.height + padMargin * 2;
const w = plateW_mm * MM;
const h = plateH_mm * MM;
const t_mm = 1.6;
const plate = B.MeshBuilder.CreateBox('faux-pcb-plate', flatBoxDims(plateW_mm, plateH_mm, t_mm), scene);
// Plate top face flush with seat plane (height = 0). Center of
// the plate is therefore 0.8 mm BELOW the seat plane.
placeMeshAt(plate, 0, 0, -t_mm / 2);
const matFR4 = new B.StandardMaterial('faux-pcb-mat', scene);
matFR4.diffuseColor = new B.Color3(0.08, 0.35, 0.18);
matFR4.alpha = 1.0;
matFR4.specularColor = new B.Color3(0.05, 0.10, 0.07);
matFR4.backFaceCulling = false;
plate.material = matFR4;
// Bucket pads by hole kind. SMD (no drill) is filtered out.
// ALSO filter out thru-hole pads that fall inside the EP region
// when chipsmith has its own heatsink-via grid: those holes belong
// to the heatsink overlay (rendered as hollow Cu barrels), not to
// the faux PCB. Otherwise the user sees the same holes drilled
// twice — once by the faux PCB CSG, once by the heatsink overlay.
// (Manufacturer .kicad_mod files often include the recommended
// heatsink-via grid as thru_hole pads inside the EP. chipsmith
// owns those when its grid is on.)
const ep = state.heatsinkGrid?.ep;
const epHalfX = ep ? ep.size_mm[0] / 2 : 0;
const epHalfY = ep ? ep.size_mm[1] / 2 : 0;
const epOx = ep ? ep.origin_mm.x : 0;
const epOy = ep ? ep.origin_mm.y : 0;
function isInsideEp(x_mm, y_mm) {
if (!ep) return false;
return Math.abs(x_mm - epOx) <= epHalfX && Math.abs(y_mm - epOy) <= epHalfY;
}
let suppressedByEp = 0;
const drilled = [];
if (Array.isArray(pads)) {
for (const p of pads) {
const d = p && p.drill;
if (!d || typeof d !== 'object') continue;
const isPlated = (p.type === 'thru_hole');
const isNP = (p.type === 'np_thru_hole');
if (!isPlated && !isNP) continue;
const isSlot = d.shape === 'oval';
const at = Array.isArray(p.at) ? p.at : [0, 0, 0];
let dw, dh;
if (isSlot && Array.isArray(d.size)) { dw = d.size[0]; dh = d.size[1]; }
else { dw = d.diameter || 0; dh = d.diameter || 0; }
if (dw <= 0 || dh <= 0) continue;
// EP suppression — owned by the heatsink overlay.
if (isInsideEp(at[0] || 0, at[1] || 0)) { suppressedByEp++; continue; }
drilled.push({
number: p.number || '?',
x_mm: at[0] || 0,
y_mm: at[1] || 0,
dw_mm: dw,
dh_mm: dh,
isPlated, isSlot,
});
}
}
if (suppressedByEp > 0) {
console.log(`[chipsmith] faux PCB: suppressed ${suppressedByEp} thru-hole pad(s) inside the EP (owned by heatsink overlay)`);
}
state.fauxPcbSuppressedEpHoles = suppressedByEp;
// CSG-subtract every hole at once. Babylon CSG is slow to construct
// per-mesh, so we union in a tight loop. Hole height is plate
// thickness + 0.2 mm overrun on each side to guarantee a clean cut
// through both faces (CSG is sensitive to coplanar shells).
let plateMesh = plate;
if (drilled.length) {
const overrun_mm = t_mm + 0.4;
let cutCSG = null;
const cutMeshes = [];
for (const hole of drilled) {
let cutter;
if (hole.isSlot) {
// Capsule (slot cutter) — Babylon's default capsule axis is +Y;
// we lay it along world-up so it cuts through the plate's
// thickness, then orient it within the plane so its length
// runs along whichever footprint axis is longer.
const longer = Math.max(hole.dw_mm, hole.dh_mm) * MM;
const shorter = Math.min(hole.dw_mm, hole.dh_mm) * MM;
cutter = B.MeshBuilder.CreateCapsule('cutter-' + hole.number, {
radius: shorter / 2, height: longer, tessellation: 18,
}, scene);
// 1) Lay along world-up. 2) Rotate within the plane so the
// capsule's length runs along the longer footprint axis.
alignVerticalToWorldUp(cutter);
if (hole.dw_mm >= hole.dh_mm) {
// Long axis along plane[0] (footprint X)
if (worldUpIdx() === 1) cutter.rotation.z = Math.PI / 2;
else cutter.rotation.y = Math.PI / 2;
}
cutter.scaling.y = (overrun_mm * MM) / longer;
placeMeshAt(cutter, hole.x_mm, hole.y_mm, -t_mm / 2);
} else {
// Cylinder cutter — default Babylon axis is +Y, align to
// world-up so it cuts through plate thickness.
cutter = B.MeshBuilder.CreateCylinder('cutter-' + hole.number, {
diameter: hole.dw_mm * MM, height: overrun_mm * MM, tessellation: 24,
}, scene);
alignVerticalToWorldUp(cutter);
placeMeshAt(cutter, hole.x_mm, hole.y_mm, -t_mm / 2);
}
cutter.isVisible = false;
cutMeshes.push(cutter);
const csg = B.CSG.FromMesh(cutter);
cutCSG = cutCSG ? cutCSG.union(csg) : csg;
}
try {
const plateCSG = B.CSG.FromMesh(plate);
const drilledCSG = plateCSG.subtract(cutCSG);
const drilledMesh = drilledCSG.toMesh('faux-pcb-plate-drilled', matFR4, scene);
// CSG.toMesh re-anchors at world origin; plate top face stays
// flush with seat plane (height=0) because the source plate
// was already in that pose.
plate.dispose();
plateMesh = drilledMesh;
} catch (e) {
console.warn('[chipsmith] CSG drill failed; rendering undrilled plate', e);
}
for (const c of cutMeshes) c.dispose();
}
// Cu plating + annular rings for plated holes/slots only.
const matCu = new B.StandardMaterial('faux-pcb-cu', scene);
matCu.diffuseColor = new B.Color3(0.78, 0.55, 0.25);
matCu.specularColor = new B.Color3(0.30, 0.20, 0.10);
const plateMeshes = [plateMesh];
const annularT = 0.05 * MM;
const ringExtra = 0.20 * MM; // ring extends 0.20 mm past hole edge
const platedPTH = drilled.filter(h => h.isPlated && !h.isSlot);
const platedSLT = drilled.filter(h => h.isPlated && h.isSlot);
const npPTH = drilled.filter(h => !h.isPlated && !h.isSlot);
const npSLT = drilled.filter(h => !h.isPlated && h.isSlot);
function addPlatingForRound(hole, target) {
const innerR_mm = hole.dw_mm / 2;
const outerR_mm = innerR_mm + (ringExtra / MM);
// Inner-bore plating (cylinder along world-up axis, plate-thick
// minus a hair so it's visible inside the drilled hole).
const bore = B.MeshBuilder.CreateCylinder('plating-bore-' + hole.number, {
diameter: innerR_mm * 2 * MM + 0.005 * MM,
height: (t_mm - 0.001) * MM,
tessellation: 24,
}, scene);
alignVerticalToWorldUp(bore);
placeMeshAt(bore, hole.x_mm, hole.y_mm, -t_mm / 2);
bore.material = matCu;
target.push(bore);
// Top + bottom annular pad rings.
for (const h_mm of [0.001, -(t_mm + 0.001)]) {
const ring = B.MeshBuilder.CreateCylinder('plating-ring-' + hole.number + '-' + h_mm.toFixed(4), {
diameterTop: outerR_mm * 2 * MM, diameterBottom: outerR_mm * 2 * MM,
height: annularT, tessellation: 24,
}, scene);
alignVerticalToWorldUp(ring);
placeMeshAt(ring, hole.x_mm, hole.y_mm, h_mm);
ring.material = matCu;
target.push(ring);
}
}
function addPlatingForSlot(hole, target) {
const longer = Math.max(hole.dw_mm, hole.dh_mm) * MM;
const shorter = Math.min(hole.dw_mm, hole.dh_mm) * MM;
const outerLong = longer + ringExtra * 2;
const outerShort = shorter + ringExtra * 2;
for (const h_mm of [0.001, -(t_mm + 0.001)]) {
const ring = B.MeshBuilder.CreateCapsule('slot-ring-' + hole.number + '-' + h_mm.toFixed(4), {
radius: outerShort / 2, height: outerLong, tessellation: 18,
}, scene);
// 1) Lay along world-up; 2) rotate within plane so length runs
// along the longer footprint axis. 3) Squash along its long
// axis to annular-thickness.
alignVerticalToWorldUp(ring);
if (hole.dw_mm >= hole.dh_mm) {
if (worldUpIdx() === 1) ring.rotation.z = Math.PI / 2;
else ring.rotation.y = Math.PI / 2;
}
ring.scaling.y = annularT / outerLong;
placeMeshAt(ring, hole.x_mm, hole.y_mm, h_mm);
ring.material = matCu;
target.push(ring);
}
}
const platingMeshes = [];
for (const h of platedPTH) addPlatingForRound(h, platingMeshes);
for (const h of platedSLT) addPlatingForSlot(h, platingMeshes);
// Build per-bucket child rows. Each leaf row carries a list of
// pad numbers in its label so the user can scan which holes ended
// up where without expanding nine times.
function summary(holes, kindLabel, allMeshes) {
if (!holes.length) {
return { meshes: [], label: `${kindLabel} 0`, kind: 'overlay-pcb-' + kindLabel.toLowerCase().replace(/[^a-z]/g, '-') };
}
const nums = holes.map(h => h.number).join(', ');
const sizeStr = holes[0].isSlot
? holes.map(h => `${h.dw_mm.toFixed(2)}×${h.dh_mm.toFixed(2)}`).join(', ')
: holes.map(h => `Ø${h.dw_mm.toFixed(2)}`).join(', ');
return {
meshes: allMeshes,
label: `${kindLabel} ${holes.length} pads ${nums} ${sizeStr} mm`,
kind: 'overlay-pcb-' + kindLabel.toLowerCase().replace(/[^a-z]/g, '-'),
};
}
const children = [
{ meshes: [plateMesh, ...platingMeshes], label: `FR4 plate ${plateW_mm.toFixed(1)}×${plateH_mm.toFixed(1)}×1.6 mm translucent green${platingMeshes.length ? ` + ${platingMeshes.length} Cu plating shells` : ''}`, kind: 'overlay-pcb-plate' },
summary(platedPTH, 'Plated holes (PTH)', platingMeshes),
summary(npPTH, 'Unplated holes (NPTH)', []),
summary(platedSLT, 'Plated slots', platingMeshes),
summary(npSLT, 'Unplated slots', []),
];
if (suppressedByEp > 0) {
children.push({
meshes: [],
label: `↳ ${suppressedByEp} EP-region thru-hole(s) suppressed — owned by Heatsink overlay`,
kind: 'overlay-pcb-ep-deferred',
});
}
const epNote = suppressedByEp > 0 ? ` · ${suppressedByEp} deferred to Heatsink` : '';
window.adomStep.addOverlay({
group,
label: `Faux PCB (1.6 mm FR4, ${plateW_mm.toFixed(1)}×${plateH_mm.toFixed(1)} mm · ${drilled.length} drill${drilled.length === 1 ? '' : 's'}${epNote})`,
kind: 'overlay-pcb',
children,
});
state.fauxPcbDims = { w_mm: plateW_mm, h_mm: plateH_mm, drilled };
return { group, w_mm: plateW_mm, h_mm: plateH_mm, drilled };
}
// ── ds2sf SVG vector parser ─────────────────────────────────────
// ds2sf gives PURE VECTOR DATA (mm-accurate <path> elements with
// viewBox in mm). Rasterizing it to a texture and slapping it on
// a plane was the original approach — looked nasty the moment you
// zoomed in (texture filtering blur + mipmap loss). This pass
// parses each <path>'s d-attribute into 2D points and builds real
// Babylon polygon meshes via earcut, so the geometry stays sharp
// at any zoom — same way the chip body and pad overlays do.
function parseSvgPathD(d) {
if (!d) return [];
const tokens = d.replace(/\s+/g, ' ').match(/[MLZmlz]|-?\d*\.?\d+(?:[eE][+-]?\d+)?/g) || [];
const points = [];
let cmd = null;
let i = 0;
while (i < tokens.length) {
const t = tokens[i];
if (/^[MLZmlz]$/.test(t)) {
cmd = t.toUpperCase();
i++;
if (cmd === 'Z') continue;
continue;
}
const x = parseFloat(tokens[i]);
const y = parseFloat(tokens[i + 1]);
if (isFinite(x) && isFinite(y)) points.push([x, y]);
i += 2;
if (cmd === 'M') cmd = 'L';
}
return points;
}
// Resolve an SVG element's fill color. Checks (in order):
// 1. Inline style="fill:#xxx"
// 2. fill="..." attribute
// 3. CSS class rules from the <style> block (ds2sf-synthesized SVGs
// use classes like .pad, .body, .pin1 with fills defined in <style>)
// 4. Walk up the parent chain (inherited fills from <g> groups —
// pcbnew stdlib SVGs use this)
function svgResolveFill(el, cssMap) {
const styleAttr = (e, key) => {
const s = e.getAttribute('style') || '';
const m = s.match(new RegExp(key + '\\s*:\\s*([^;]+)', 'i'));
return m ? m[1].trim() : null;
};
let cur = el;
while (cur && cur.nodeType === 1) {
const f = styleAttr(cur, 'fill') || cur.getAttribute('fill');
if (f && f.toLowerCase() !== 'none') return f;
if (cssMap) {
const cls = (cur.getAttribute('class') || '').split(/\s+/);
for (const c of cls) {
if (c && cssMap[c]) return cssMap[c];
}
}
cur = cur.parentElement;
}
return null;
}
function svgResolveFillOpacity(el, cssMap) {
const styleAttr = (e, key) => {
const s = e.getAttribute('style') || '';
const m = s.match(new RegExp(key + '\\s*:\\s*([^;]+)', 'i'));
return m ? m[1].trim() : null;
};
let cur = el;
while (cur && cur.nodeType === 1) {
const f = styleAttr(cur, 'fill-opacity');
if (f) return parseFloat(f) || 1;
cur = cur.parentElement;
}
return 1;
}
// Parse a <style> block into a className → fill map so class-based
// SVGs (ds2sf-synthesized) resolve fills without getComputedStyle.
function svgParseCssMap(doc) {
const map = {};
const styleEls = doc.querySelectorAll('style');
for (const sEl of styleEls) {
const txt = sEl.textContent || '';
const re = /\.([a-zA-Z0-9_-]+)\s*\{[^}]*fill\s*:\s*([^;}\s]+)/g;
let m;
while ((m = re.exec(txt))) map[m[1]] = m[2].trim();
}
return map;
}
// Convert an SVG <rect> to 4 corner points (with optional rx rounding
// approximated as straight corners — good enough for the overlay).
function svgRectToPoints(el) {
const x = parseFloat(el.getAttribute('x')) || 0;
const y = parseFloat(el.getAttribute('y')) || 0;
const w = parseFloat(el.getAttribute('width')) || 0;
const h = parseFloat(el.getAttribute('height')) || 0;
if (w <= 0 || h <= 0) return [];
return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
}
// Convert an SVG <circle> to N-point approximation.
function svgCircleToPoints(el, n) {
const cx = parseFloat(el.getAttribute('cx')) || 0;
const cy = parseFloat(el.getAttribute('cy')) || 0;
const r = parseFloat(el.getAttribute('r')) || 0;
if (r <= 0) return [];
const pts = [];
for (let i = 0; i < (n || 24); i++) {
const a = (2 * Math.PI * i) / (n || 24);
pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
}
return pts;
}
// ds2sf v0.5.1 authoritative footprint — parsed as REAL VECTOR
// POLYGONS via earcut (NOT a texture-mapped raster — that approach
// looked nasty at any zoom level). First-class Outline citizen
// alongside the kicad / fusion / altium overlays; one sub-row per
// SVG path so the user can isolate / inspect any individual pad
// outline, silk stroke, fab label, or body silhouette.
//
// SVG → world coord transform:
// • viewBox is in millimetres (pcbnew SVG convention).
// • Translate so chip-center → world origin (0,0).
// • SVG-Y matches the kicad/fusion/altium pad coord convention
// (Y increases "down the page" — pcbnew authored both), so
// pads from ds2sf and from .kicad_mod end up at the same
// world XY. NO Y-flip.
//
// Layer Z-stacking from pcbnew's standard fill colors:
// #C83434 → F.Cu / pad copper (z = 0.006 mm above seat)
// #B4A09A → solder mask (z = 0.002 mm)
// #D864FF → silkscreen (z = 0.013 mm)
// #F2EDA1 → F.Fab labels (z = 0.016 mm)
// any other colour (z = 0.008 mm)
async function renderDs2sfOverlay(opts = {}) {
if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;
const ds2sf = state.ds2sfResult;
if (!ds2sf || !ds2sf.available) return { error: 'no ds2sf result for this chip' };
if (typeof window.earcut !== 'function') return { error: 'earcut not loaded — vector polygon build needs window.earcut' };
const source = ds2sf.outputs?.footprint_svg_source || 'unknown';
const scene = window.adomStep.getScene();
const B = window.adomStep.getBabylon();
const group = 'ds2sf';
ensureOverlayDisposed(group);
// Pull the SVG text and parse the DOM client-side.
let svgText;
try {
svgText = await fetch('api/ds2sf-svg?cb=' + Date.now()).then(r => r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status)));
} catch (e) {
return { error: 'fetch ds2sf SVG: ' + (e?.message || e) };
}
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
const svg = doc.documentElement;
const vb = (svg.getAttribute('viewBox') || '0 0 10 10').trim().split(/\s+/).map(parseFloat);
const vbX = vb[0] || 0, vbY = vb[1] || 0, vbW = vb[2] || 10, vbH = vb[3] || 10;
// Detect SVG format. pcbnew stdlib SVGs use mm in the viewBox
// (width="22.6568mm"). ds2sf-synthesized SVGs use px (width="277",
// no unit suffix) and carry an extent_mm in the extraction result.
// We need a scale factor from SVG units → mm.
const widthAttr = svg.getAttribute('width') || '';
const isPx = !widthAttr.includes('mm');
let svgToMm = 1;
if (isPx) {
const ext = ds2sf.outputs?.footprint_extent_mm;
if (ext && Array.isArray(ext) && ext.length === 2 && ext[0] > 0) {
svgToMm = ext[0] / vbW;
} else {
// ds2sf-synthesized SVGs embed a <rect class="body"> whose
// px dimensions correspond to the chip's body_dims_mm.
// Use that to derive px → mm.
const bodyRect = doc.querySelector('rect.body');
const bodyDims = state.spec?.body_dims_mm;
if (bodyRect && bodyDims && Array.isArray(bodyDims) && bodyDims[0] > 0) {
const bodyPxW = parseFloat(bodyRect.getAttribute('width')) || 1;
svgToMm = bodyDims[0] / bodyPxW;
} else if (bodyDims && Array.isArray(bodyDims) && bodyDims[0] > 0) {
svgToMm = bodyDims[0] / vbW * 3;
} else {
svgToMm = 0.01;
}
}
}
const cx = vbX + vbW / 2;
const cy = vbY + vbH / 2;
// Parse CSS <style> for class-based fills (ds2sf-synthesized format)
const cssMap = svgParseCssMap(doc);
function zForFill(fill) {
const f = (fill || '').toLowerCase();
if (f === '#c83434') return 0.006; // F.Cu (copper) — pcbnew
if (f === '#b4a09a') return 0.002; // solder mask — pcbnew
if (f === '#d864ff') return 0.013; // silkscreen — pcbnew
if (f === '#f2eda1') return 0.016; // F.Fab labels — pcbnew
if (f === '#d68830') return 0.006; // pad — ds2sf synthesized
if (f === '#2c5f2d') return 0.003; // body — ds2sf synthesized
if (f === '#ff5555') return 0.010; // pin-1 — ds2sf synthesized
if (f === '#0d1117') return -0.001; // background — skip rendering
return 0.008;
}
function rgbForFill(fill) {
const f = (fill || '').toLowerCase();
if (/^#[0-9a-f]{6}$/.test(f)) {
return {
r: parseInt(f.slice(1, 3), 16) / 255,
g: parseInt(f.slice(3, 5), 16) / 255,
b: parseInt(f.slice(5, 7), 16) / 255,
};
}
return { r: 0.5, g: 0.5, b: 0.5 };
}
const matCache = {};
function getMat(fill, opacity) {
const key = fill + '@' + opacity;
if (matCache[key]) return matCache[key];
const c = rgbForFill(fill);
const m = new B.StandardMaterial(group + '-mat-' + Object.keys(matCache).length, scene);
m.diffuseColor = new B.Color3(c.r, c.g, c.b);
m.emissiveColor = new B.Color3(c.r * 0.4, c.g * 0.4, c.b * 0.4);
m.specularColor = new B.Color3(0, 0, 0);
m.alpha = (opacity != null ? opacity : 1) * 0.9;
m.backFaceCulling = false;
m.zOffset = -1;
matCache[key] = m;
return m;
}
// Build one VertexData mesh per filled SVG shape (path / rect /
// circle) using earcut for the triangulation. Handles BOTH SVG
// formats: pcbnew stdlib (<path> with inline styles) and ds2sf
// synthesized (<rect>/<circle> with CSS-class fills).
const meshes = [];
const childGroups = [];
let kept = 0, skipped = 0;
const counterByLayer = {};
// Collect shapes from all renderable element types.
const shapeEls = doc.querySelectorAll('path, rect, circle');
for (let i = 0; i < shapeEls.length; i++) {
const el = shapeEls[i];
const tag = el.tagName.toLowerCase();
const fill = svgResolveFill(el, cssMap);
if (!fill || fill.toLowerCase() === 'none') { skipped++; continue; }
if (zForFill(fill) < 0) { skipped++; continue; }
const opacity = svgResolveFillOpacity(el, cssMap);
let pts;
if (tag === 'path') {
pts = parseSvgPathD(el.getAttribute('d'));
} else if (tag === 'rect') {
pts = svgRectToPoints(el);
} else if (tag === 'circle') {
pts = svgCircleToPoints(el, 24);
} else { skipped++; continue; }
if (!pts || pts.length < 3) { skipped++; continue; }
const z = zForFill(fill);
const layerKey = fill.toLowerCase();
counterByLayer[layerKey] = (counterByLayer[layerKey] || 0) + 1;
// Triangulate via earcut. earcut takes a flat [x0,y0,x1,y1,...]
// array and returns a flat triangle-index list.
const flat = new Array(pts.length * 2);
for (let k = 0; k < pts.length; k++) {
flat[k * 2] = pts[k][0];
flat[k * 2 + 1] = pts[k][1];
}
const tris = window.earcut(flat);
if (!tris || tris.length < 3) { skipped++; continue; }
// Build positions in world coords. svgToMm converts SVG units
// (px for synthesized, mm for pcbnew) into millimetres. Then
// MM (0.001) converts mm → scene metres.
const upI = worldUpIdx();
const positions = new Float32Array(pts.length * 3);
const zMeters = z * MM;
if (upI === 2) {
for (let k = 0; k < pts.length; k++) {
positions[k * 3] = (pts[k][0] - cx) * svgToMm * MM;
positions[k * 3 + 1] = (pts[k][1] - cy) * svgToMm * MM;
positions[k * 3 + 2] = zMeters;
}
} else {
for (let k = 0; k < pts.length; k++) {
positions[k * 3] = (pts[k][0] - cx) * svgToMm * MM;
positions[k * 3 + 1] = zMeters;
positions[k * 3 + 2] = -(pts[k][1] - cy) * svgToMm * MM;
}
}
// Up-facing normals along world-up.
const normals = new Float32Array(pts.length * 3);
for (let k = 0; k < pts.length; k++) normals[k * 3 + upI] = 1;
// Earcut returns CCW or CW depending on input winding. We want
// the up-facing side. Test the first triangle's signed area in
// input (svg) space — pcbnew authored Y-down so a CCW input is
// "wound the wrong way" for our +Z-up normals; flip the index
// order in that case so the front face points up.
const a = pts[tris[0]], b = pts[tris[1]], c = pts[tris[2]];
const cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
const indices = new Uint32Array(tris.length);
if (cross < 0) {
// Already wound the way we want — keep as-is.
for (let k = 0; k < tris.length; k++) indices[k] = tris[k];
} else {
// Flip every triangle.
for (let k = 0; k < tris.length; k += 3) {
indices[k] = tris[k];
indices[k + 1] = tris[k + 2];
indices[k + 2] = tris[k + 1];
}
}
const mesh = new B.Mesh(`${group}-poly-${i}`, scene);
const vd = new B.VertexData();
vd.positions = positions;
vd.indices = indices;
vd.normals = normals;
vd.applyToMesh(mesh, false);
mesh.material = getMat(fill, opacity);
// Render on top of source overlays at same z but we let the
// material's zOffset push us forward in the depth test.
mesh.alphaIndex = 5;
mesh.isPickable = false;
meshes.push(mesh);
// Outline child entry — one sub-row per polygon, labelled by
// layer. Use the per-layer counter so the row reads
// "F.Cu polygon 3 of N", "silk polygon 2 of N", etc.
const layerName = ({ '#c83434': 'F.Cu', '#b4a09a': 'mask', '#d864ff': 'silk', '#f2eda1': 'fab' })[layerKey] || 'other';
childGroups.push({
mesh, layer: layerName, idx: counterByLayer[layerKey],
verts: pts.length,
});
kept++;
}
if (!meshes.length) {
return { error: 'no renderable paths in ds2sf SVG' };
}
const sourceTag = source === 'service-kicad-stdlib' ? '✓ stdlib (pixel-trustworthy)' : '◐ synthesized (layout-only)';
const vbMmW = vbW * svgToMm, vbMmH = vbH * svgToMm;
const layerMap = { '#c83434':'F.Cu','#b4a09a':'mask','#d864ff':'silk','#f2eda1':'fab',
'#d68830':'pad','#2c5f2d':'body','#ff5555':'pin1' };
const layerSummary = Object.entries(counterByLayer)
.map(([f, n]) => `${layerMap[f] || 'other'}:${n}`)
.join(' ');
window.adomStep.addOverlay({
group,
meshes,
label: `ds2sf svg ${vbMmW.toFixed(2)}×${vbMmH.toFixed(2)} mm ${kept} polys (${layerSummary}) ${sourceTag}`,
tooltip: `ds2sf svg ${vbMmW.toFixed(2)}×${vbMmH.toFixed(2)} mm ${kept} polys\n\n` +
(source === 'ds2sf-datasheet-derived'
? 'Rendered by ds2sf from the manufacturer datasheet mechanical\ndrawing. Every shape traces back to a specific figure on a\nspecific page of the PDF. This is NOT a KiCad or third-party\nlibrary render — it is drawn from the manufacturer\'s own data.'
: source === 'service-kicad-stdlib'
? 'Rendered by KiCad pcbnew from the standard library footprint.\nThis is a third-party render (a KiCad maintainer traced the\ndatasheet). Kept for visual comparison against the ds2sf pads.'
: 'SVG source: ' + source),
kind: 'overlay-ds2sf',
});
state.ds2sfOverlayOn = true;
return { ok: true, source, widthMm: vbW, heightMm: vbH, polygonCount: kept, skippedCount: skipped, byLayer: counterByLayer };
}
// Combined heatsink renderer — one overlay group "heatsink" with
// children: the BOTTOM-side EP copper pour (B.Cu, on the underside of
// the PCB), and the via grid that connects EP-on-top to pour-on-bottom.
//
// The pour goes on B.Cu, NOT F.Cu. The whole point of the heatsink
// architecture is to dump heat from the EP (on top, where the chip
// sits) THROUGH the plated vias TO a copper region on the bottom that
// radiates / conducts away (often a free zone fill that connects to
// a larger ground plane or external heatsink). Putting the pour on
// F.Cu would just duplicate the EP — useless.
//
// Outline structure: one Heatsink parent row, two children:
// • EP copper pour (B.Cu, bottom)
// • Heatsink vias (N × Ø d mm) — one sub-row per via
// The user can eye-toggle either child independently. Default on
// for both; sidecar always carries them regardless of visibility.
function renderHeatsink(epOriginMm, epSize, grid, opts = {}) {
if (!grid || !grid.vias || !grid.vias.length) return null;
if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;
const scene = window.adomStep.getScene();
const B = window.adomStep.getBabylon();
if (!scene || !B) return null;
const group = 'heatsink';
ensureOverlayDisposed(group);
const matCu = new B.StandardMaterial(group + '-cu', scene);
matCu.diffuseColor = new B.Color3(0.78, 0.55, 0.25);
matCu.specularColor = new B.Color3(0.30, 0.20, 0.10);
matCu.emissiveColor = new B.Color3(0.10, 0.07, 0.03);
const ox_mm = epOriginMm?.x || 0;
const oy_mm = epOriginMm?.y || 0;
const PCB_THICKNESS_MM = 1.6;
const drillD_mm = grid.drill;
const outerD_mm = grid.outerCu;
const annularT = 0.05 * MM;
const ringMm = Math.max(0, (grid.outerCu - grid.drill) / 2);
const children = [];
// ── Child 1: EP copper pour on B.Cu (bottom of PCB) ──
// The pour is CSG-drilled with a hole at each via position. Real
// PCB convention: the bottom Cu pour around plated through-holes
// has a clearance hole at each drill (the donut around each via
// is part of the pour itself; what's "missing" from the rectangle
// is the via's drilled hole). Without these holes the pour is a
// solid sheet that the via barrels appear to pass THROUGH at the
// mesh-intersection level — visually wrong and electrically wrong
// (downstream tools would extrude a solid pour with no clearance).
const expansion_mm = opts.expansion_mm ?? 0;
const pourW_mm = epSize.width + expansion_mm * 2;
const pourH_mm = epSize.height + expansion_mm * 2;
const pourThick_mm = 0.04;
const pourSolid = B.MeshBuilder.CreateBox(group + '-pour-solid', flatBoxDims(pourW_mm, pourH_mm, pourThick_mm), scene);
placeMeshAt(pourSolid, ox_mm, oy_mm, -(PCB_THICKNESS_MM + 0.02));
pourSolid.material = matCu;
// CSG-drill holes at each via position. Use the drill diameter
// (no expansion) so the via barrel passes cleanly through.
let pour = pourSolid;
try {
let holesCSG = null;
const cutters = [];
for (const v of grid.vias) {
const fpX_mm = v.x_mm + ox_mm;
const fpY_mm = v.y_mm + oy_mm;
const cutter = B.MeshBuilder.CreateCylinder(group + '-pour-cut-' + v.index, {
diameter: drillD_mm * MM,
height: (pourThick_mm + 0.4) * MM, // overrun for clean cut
tessellation: 18,
}, scene);
alignVerticalToWorldUp(cutter);
placeMeshAt(cutter, fpX_mm, fpY_mm, -(PCB_THICKNESS_MM + 0.02));
cutter.isVisible = false;
cutters.push(cutter);
const csg = B.CSG.FromMesh(cutter);
holesCSG = holesCSG ? holesCSG.union(csg) : csg;
}
if (holesCSG) {
const pourCSG = B.CSG.FromMesh(pourSolid);
const drilledCSG = pourCSG.subtract(holesCSG);
const drilled = drilledCSG.toMesh(group + '-pour', matCu, scene);
pourSolid.dispose();
pour = drilled;
}
for (const c of cutters) c.dispose();
} catch (e) {
console.warn('[chipsmith] EP pour CSG drill failed; rendering solid pour', e);
pourSolid.name = group + '-pour'; // still keep the solid mesh
}
children.push({
meshes: [pour],
label: `EP copper pour (B.Cu, bottom, ${grid.vias.length} via clearances) ${pourW_mm.toFixed(2)} × ${pourH_mm.toFixed(2)} mm ${expansion_mm > 0 ? `(+${expansion_mm.toFixed(2)} mm expansion)` : '(matches EP)'}`,
kind: 'overlay-heatsink-pour',
});
state.epCopperPour = {
enabled: true,
layer: 'B.Cu',
origin_mm: { x: ox_mm, y: oy_mm },
size_mm: { width: pourW_mm, height: pourH_mm },
expansion_mm,
world_height_mm: -PCB_THICKNESS_MM,
};
// ── Children 2..N+1: per-via meshes ──
// Each via is a real plated through-hole:
// 1. Hollow Cu barrel — a tube (cylinder with NO_CAP) at the
// drill radius, DOUBLE-SIDE so both inner + outer faces of
// the Cu wall render. The interior is empty — you can see
// light through the hole if you look down it from the top
// face. (Real PTHs are plated with ~17.5 µm Cu on the wall;
// we render the wall as a 2D shell for performance, not a
// true thickness — visually indistinguishable at chip scale.)
// 2. Top + bottom annular rings — thin Cu discs around the
// outside of the hole, on F.Cu and B.Cu. These close the
// "donut" view from above + below.
for (const v of grid.vias) {
const fpX_mm = v.x_mm + ox_mm;
const fpY_mm = v.y_mm + oy_mm;
const viaMeshes = [];
// Hollow Cu barrel. Babylon's CreateTube wants a path; a single
// straight segment along Y (Babylon's default cylinder axis).
// We then alignVerticalToWorldUp() to lay it along world-up if
// needed (Z-up), and placeMeshAt() to position. cap = NO_CAP
// means open ends so the hole is visibly through-and-through.
const halfH = (PCB_THICKNESS_MM / 2) * MM;
const tubePath = [
new B.Vector3(0, -halfH, 0),
new B.Vector3(0, halfH, 0),
];
const barrel = B.MeshBuilder.CreateTube(`${group}-barrel-${v.index}`, {
path: tubePath,
radius: (drillD_mm / 2) * MM,
tessellation: 18,
cap: B.Mesh.NO_CAP,
sideOrientation: B.Mesh.DOUBLESIDE,
}, scene);
alignVerticalToWorldUp(barrel);
// Center the tube at the PCB's mid-thickness so it spans the
// full plate (z = -PCB_THICKNESS to 0). Putting it at z=0
// would float half the tube above the seat plane.
placeMeshAt(barrel, fpX_mm, fpY_mm, -PCB_THICKNESS_MM / 2);
barrel.material = matCu;
viaMeshes.push(barrel);
// Top + bottom annular rings — real DONUTS (with the hole in
// the middle), not solid discs. Lathe a rectangular cross-
// section (inner = drillR, outer = outerR, height = annularT)
// around the lathe axis to produce a washer-shaped ring. The
// inner edge lines up with the barrel's outer wall so the
// hole reads cleanly down through both the top ring + the
// barrel + the bottom ring.
const innerR = (drillD_mm / 2) * MM;
const outerR = (outerD_mm / 2) * MM;
const ringProfile = [
new B.Vector3(innerR, 0, 0),
new B.Vector3(outerR, 0, 0),
new B.Vector3(outerR, annularT, 0),
new B.Vector3(innerR, annularT, 0),
new B.Vector3(innerR, 0, 0), // close the loop
];
for (const h_mm of [0.001, -(PCB_THICKNESS_MM + annularT / MM + 0.001)]) {
const ring = B.MeshBuilder.CreateLathe(`${group}-ring-${v.index}-${h_mm.toFixed(4)}`, {
shape: ringProfile,
radius: 1, // ignored when shape carries absolute coords
tessellation: 18,
sideOrientation: B.Mesh.DOUBLESIDE,
}, scene);
alignVerticalToWorldUp(ring);
placeMeshAt(ring, fpX_mm, fpY_mm, h_mm);
ring.material = matCu;
viaMeshes.push(ring);
}
const label = `via ${v.index} (${v.x_mm.toFixed(2)}, ${v.y_mm.toFixed(2)}) mm plated PTH (hollow) Ø${grid.drill} drill / Ø${grid.outerCu} outer Cu / ${ringMm.toFixed(3)} mm ring`;
viaMeshes.parentGroup = 'heatsink-vias';
children.push({ meshes: viaMeshes, label, kind: 'overlay-via' });
}
window.adomStep.addOverlay({
group,
label: `Heatsink (${grid.vias.length} vias + EP pour ${pourW_mm.toFixed(1)}×${pourH_mm.toFixed(1)} mm on B.Cu)`,
kind: 'overlay-heatsink',
children,
});
return { group, vias: grid.vias.length, pour: state.epCopperPour };
}
// Legacy aliases — preserved so existing callers (CLI verbs, the
// signoff payload tooling) don't break. Both delegate to the
// combined renderHeatsink().
function renderHeatsinkVias(epOriginMm, grid) {
if (!state.heatsinkGrid) return null;
return renderHeatsink(epOriginMm, { width: state.heatsinkGrid.ep.size_mm[0], height: state.heatsinkGrid.ep.size_mm[1] }, grid);
}
function renderEpCopperPour(epOriginMm, epSize, opts) {
if (!state.heatsinkGrid) return null;
return renderHeatsink(epOriginMm, epSize, state.heatsinkGrid.grid, opts);
}
function pairAgreement(a, b, tolMm = 0.05) {
if (!a || !b || !a.pads || !b.pads || a._stub || b._stub) return null;
const byNum = (pads) => Object.fromEntries(pads.map(p => [p.number, p]));
const ai = byNum(a.pads), bi = byNum(b.pads);
const keys = new Set([...Object.keys(ai), ...Object.keys(bi)]);
let matched = 0, posOff = 0, sizeOff = 0, missing = 0, maxDelta = 0;
for (const k of keys) {
const pa = ai[k], pb = bi[k];
if (!pa || !pb) { missing++; continue; }
const dx = (pa.at[0] - pb.at[0]), dy = (pa.at[1] - pb.at[1]);
const dist = Math.sqrt(dx*dx + dy*dy);
const ds = Math.max(Math.abs(pa.size[0] - pb.size[0]), Math.abs(pa.size[1] - pb.size[1]));
if (dist > tolMm) posOff++;
if (ds > tolMm) sizeOff++;
maxDelta = Math.max(maxDelta, dist);
matched++;
}
const total = keys.size;
return { matched, missing, posOff, sizeOff, maxDelta, total, ok: posOff === 0 && sizeOff === 0 && missing === 0 };
}
function renderFootprintTab() {
const body = document.getElementById('cs-body');
const fps = state.footprintSources;
if (!fps) {
body.innerHTML = `<div class="cs-empty">Loading footprint sources…</div>`;
fetch('api/footprint-sources').then(r => r.ok ? r.json() : null).then(d => {
state.footprintSources = d;
if (state.activeTab === 'footprint') renderFootprintTab();
});
return;
}
const SRCS = ['kicad', 'fusion', 'altium', 'datasheet', 'ds2sf'];
const sourcesPresent = SRCS.filter(s => fps[s] && !fps[s]._stub && fps[s].pad_count > 0);
const overview = SRCS.map(s => {
const f = fps[s];
if (!f) return null;
const stub = !!f._stub;
const pc = f.pad_count || 0;
const ext = stub ? null : fpExtent(f.pads);
const status = stub ? '○' : (pc > 0 ? '●' : '✗');
const cls = stub ? 'pending' : (pc > 0 ? 'ok' : 'fail');
return `<div class="cs-row">
<span class="label">${esc(SRC_LABEL[s])}</span>
<span class="value mono">${stub ? '— stub' : (pc + ' pads' + (ext ? ` · ${ext.width.toFixed(2)}×${ext.height.toFixed(2)} mm` : ''))}</span>
<span class="pill ${cls}" style="margin-left:auto">${status}</span>
</div>`;
}).filter(Boolean).join('');
// Agreement matrix — pairwise across whichever sources are present.
let agreementHTML = '';
if (sourcesPresent.length >= 2) {
const rows = [];
for (let i = 0; i < sourcesPresent.length; i++) {
for (let j = i + 1; j < sourcesPresent.length; j++) {
const a = sourcesPresent[i], b = sourcesPresent[j];
const agg = pairAgreement(fps[a], fps[b]);
if (!agg) continue;
const cls = agg.ok ? 'is-ok' : (agg.matched > 0 ? '' : '');
rows.push(`<tr class="${cls}">
<td>${esc(SRC_LABEL[a])} ↔ ${esc(SRC_LABEL[b])}</td>
<td class="num mono">${agg.matched}</td>
<td class="num mono">${agg.missing}</td>
<td class="num mono">${agg.posOff}</td>
<td class="num mono">${agg.maxDelta.toFixed(3)}</td>
</tr>`);
}
}
agreementHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Cross-source agreement</h3>
<table class="cs-tbl">
<thead><tr><th>Pair</th><th class="num">Matched</th><th class="num">Missing</th><th class="num">Off</th><th class="num">Max Δ mm</th></tr></thead>
<tbody>${rows.join('')}</tbody>
</table>
<div class="cs-row dim" style="margin-top:8px"><span class="value">Tolerance: position ±0.05 mm. Per-source counts include only pads with matching pad numbers.</span></div>
</div>`;
}
// Bbox-fit: compare chip body extent (from Tab 2) vs the larger of
// the two pad-extent bboxes. The chip's body should fit INSIDE the
// footprint pad envelope (or be very close to it).
let bboxFitHTML = '';
if (state.measurements && sourcesPresent.length) {
const chipMm = state.measurements.detected_mm.slice().sort((a, b) => b - a); // desc
const fpMaxExt = sourcesPresent
.map(s => fpExtent(fps[s].pads))
.filter(Boolean)
.reduce((acc, e) => acc && acc.width > e.width ? acc : e, null);
if (fpMaxExt) {
const dx = chipMm[0] - fpMaxExt.width;
const dy = chipMm[1] - fpMaxExt.height;
const fits = (dx <= 0.2) && (dy <= 0.2);
bboxFitHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Bbox fit (chip ⊂ footprint)</h3>
<div class="cs-row"><span class="label">Chip XY</span><span class="value mono">${chipMm[0].toFixed(2)} × ${chipMm[1].toFixed(2)} mm</span></div>
<div class="cs-row"><span class="label">Footprint XY</span><span class="value mono">${fpMaxExt.width.toFixed(2)} × ${fpMaxExt.height.toFixed(2)} mm</span></div>
<div class="cs-row"><span class="label">Δ</span><span class="value mono">${dx.toFixed(3)} × ${dy.toFixed(3)} mm</span></div>
<div class="cs-meas-status pill-${fits ? 'ok' : 'fail'}">${fits ? 'Chip body fits within the footprint envelope.' : 'Chip body exceeds the footprint envelope by >0.2mm — likely Y-up vs Z-up mismatch or wrong footprint.'}</div>
</div>`;
}
}
state.status.footprint = (sourcesPresent.length > 0)
? (sourcesPresent.length >= 2 ? '◐' : '○')
: '✗';
refreshStatusPills();
// Overlay toggles per source. Tracks state.fpOverlaysOn so toggles
// survive tab switches. On the FIRST render of Tab 4 with at least
// one non-stub source we auto-show every present source — the user
// came to this tab to compare libraries, so the comparison should
// be visible by default. Subsequent renders honor the user's
// toggles (state.fpOverlaysAutoShown is the latch).
state.fpOverlaysOn = state.fpOverlaysOn || {};
if (!state.fpOverlaysAutoShown && sourcesPresent.length > 0) {
for (const s of sourcesPresent) {
if (!state.fpOverlaysOn[s]) {
renderFootprintOverlay(s, fps[s].pads);
state.fpOverlaysOn[s] = true;
}
}
state.fpOverlaysAutoShown = true;
}
// ds2sf authoritative SVG plane — auto-shown once on first Tab 4
// visit when a result is available. Same auto-show convention as
// the per-source overlays. User can hide via the toggle below.
if (!state.ds2sfAutoShown && state.ds2sfResult?.available) {
renderDs2sfOverlay();
state.ds2sfAutoShown = true;
}
const allSourcesOn = sourcesPresent.length > 0 && sourcesPresent.every(s => state.fpOverlaysOn[s]);
const someSourcesOn = sourcesPresent.some(s => state.fpOverlaysOn[s]);
const overlayRows = sourcesPresent.map(s => {
const on = !!state.fpOverlaysOn[s];
const c = SRC_COLOR[s] || { r: 0.5, g: 0.5, b: 0.5 };
const swatch = `<span class="cs-src-swatch" style="background:rgba(${Math.round(c.r*255)},${Math.round(c.g*255)},${Math.round(c.b*255)},0.55)"></span>`;
return `<div class="cs-row">
${swatch}<span class="label">${esc(SRC_LABEL[s])}</span>
<button class="cs-btn cs-btn-${on ? 'primary' : 'secondary'}" data-fpsrc="${esc(s)}" data-tooltip="Toggle the ${esc(s)} footprint as a translucent overlay at z=0 in the 3D scene. Each source has its own color (datasheet orange, kicad green, fusion blue, altium purple) — stack multiple to see disagreement visually.">${svgIcon('bookOpen')}<span class="lbl">${on ? 'Hide' : 'Show'} in 3D</span></button>
</div>`;
}).join('');
// Stock-library overlay — service-kicad-backed ghost. Visible only
// when the spec carries footprint_name (chip-fetcher's responsibility
// to populate at fetch time). Internally fetched from KiCad's
// library, but never labeled "KiCad" in the UI per the plan's
// EE-tool-neutrality rule.
const stockFpName = state.spec?.footprint_name || state.spec?.stock_footprint_id || null;
const stockState = state.stockOverlay || {}; // { loading, loaded, parsed, fetched_url, error }
const stockOn = !!state.fpOverlaysOn?.stock;
// ds2sf v0.5.1 authoritative SVG. Two flavours:
// • service-kicad-stdlib — pixel-trustworthy KiCad render (green/✓)
// • ds2sf-synthesized — best-effort layout reference (amber/◐)
// The state.ds2sfResult is hydrated lazily on first Tab 4 render.
const ds2sf = state.ds2sfResult || null;
const ds2sfSrc = ds2sf?.outputs?.footprint_svg_source;
const ds2sfAvailable = !!ds2sf?.available && !!ds2sfSrc;
const ds2sfStdlib = ds2sfSrc === 'service-kicad-stdlib';
const ds2sfHTML = ds2sfAvailable ? `
<div class="cs-section">
<h3 class="cs-section-title">ds2sf svg <span class="dim">(pcbnew render)</span></h3>
<div class="cs-row">
<span class="label">Source</span>
<span class="value mono">${esc(ds2sfSrc)}</span>
<span class="pill ${ds2sfStdlib ? 'ok' : 'partial'}" style="margin-left:auto" data-tooltip="${ds2sfStdlib
? 'service-kicad-stdlib — canonical KiCad render. Pixel-trustworthy: pad positions, pad sizes, body outline, pin-1 marker are all authoritative ground truth. Diff your 3D rendering against this.'
: 'ds2sf-synthesized — best-effort 2D layout from extracted body + lead dimensions. Layout-only reference: pad shapes + absolute positions are best-effort, NOT pixel-accurate. Use as visual sanity-check, not as a measurement.'}">${ds2sfStdlib ? '●' : '◐'}</span>
</div>
<div class="cs-row dim" style="font-size:11px"><span class="value">${ds2sfStdlib
? 'Pixel-trustworthy ground truth — pad positions, body outline, and pin-1 marker (red dot) match the canonical KiCad library exactly. Use for diffing your 3D rendering.'
: 'Layout-only reference — body + leads from datasheet extraction. Stamped as such in the SVG itself. Treat as visual sanity-check, not as a pad-position oracle.'}</span></div>
<div class="cs-svg-frame" style="margin-top:8px;background:rgba(20,24,30,0.45);border:1px solid rgba(255,255,255,0.14);border-radius:5px;padding:10px;text-align:center">
<img src="api/ds2sf-svg" alt="${esc(ds2sfSrc)} authoritative footprint" style="max-width:100%;max-height:280px;display:inline-block"/>
</div>
</div>` : '';
const stockHTML = stockFpName ? `
<div class="cs-section">
<h3 class="cs-section-title">Stock library footprint</h3>
<div class="cs-row">
<span class="label">Library identity</span>
<span class="value mono">${esc(stockFpName)}</span>
</div>
${stockState.loaded ? `
<div class="cs-row">
<span class="label">Pads</span>
<span class="value mono">${stockState.parsed?.pad_count || 0}</span>
<span class="pill ok" style="margin-left:auto">●</span>
</div>
${stockState.parsed?.pin1_silk?.found ? `<div class="cs-row"><span class="label">Pin-1 silk</span><span class="value mono">marker present</span></div>` : ''}
<div class="cs-row">
<span class="cs-src-swatch" style="background:rgba(217,217,235,0.55)"></span>
<span class="label">Ghost overlay</span>
<button class="cs-btn cs-btn-${stockOn ? 'primary' : 'secondary'}" id="cs-stock-toggle" data-tooltip="Render the authoritative library footprint as a translucent off-white ghost stacked alongside the four user sources. Per-pad delta against each present source surfaces below.">${svgIcon('bookOpen')}<span class="lbl">${stockOn ? 'Hide' : 'Show'} stock ghost</span></button>
</div>
` : (stockState.loading
? `<div class="cs-row dim"><span class="value">Fetching stock library footprint…</span></div>`
: (stockState.error
? `<div class="cs-row"><span class="label">Status</span><span class="value mono">unavailable</span><span class="pill fail" style="margin-left:auto">✗</span></div>
<div class="cs-row dim" style="font-size:11px"><span class="value">${esc(stockState.error)}</span></div>
<div class="cs-actions"><button class="cs-btn cs-btn-secondary" id="cs-stock-retry" data-tooltip="Re-fetch /api/kicad-stock?footprint_name=${esc(stockFpName)}">${svgIcon('bookOpen')}<span class="lbl">Retry</span></button></div>`
: `<div class="cs-actions"><button class="cs-btn cs-btn-primary" id="cs-stock-load" data-tooltip="Pull the authoritative library footprint from service-kicad and parse it into the same pad/silk schema as the four user sources, so it joins cross-validation.">${svgIcon('bookOpen')}<span class="lbl">Fetch stock library</span></button></div>`))}
</div>` : '';
const ds2sfOn = !!state.ds2sfOverlayOn;
const ds2sfRowHTML = ds2sfAvailable ? `
<div class="cs-row">
<span class="cs-src-swatch" style="background:${ds2sfStdlib ? 'rgba(120,200,140,0.55)' : 'rgba(220,170,90,0.55)'}"></span>
<span class="label">ds2sf svg</span>
<button class="cs-btn cs-btn-${ds2sfOn ? 'primary' : 'secondary'}" id="cs-ds2sf-toggle" data-tooltip="${ds2sfStdlib
? 'KiCad pcbnew SVG render — rounded copper pads, silk outline, pin-1 dot, solder mask. The prettiest ground truth.'
: 'ds2sf-synthesized SVG — simpler rects, layout-only reference.'}">${svgIcon('bookOpen')}<span class="lbl">${ds2sfOn ? 'Hide' : 'Show'} ds2sf svg</span></button>
</div>` : '';
const overlayHTML = sourcesPresent.length ? `
<div class="cs-section">
<h3 class="cs-section-title">3D overlays</h3>
<div class="cs-row">
<span class="label">All sources</span>
<button class="cs-btn cs-btn-${allSourcesOn ? 'primary' : 'secondary'}" id="cs-fp-all-toggle" data-tooltip="${allSourcesOn ? 'Hide every source overlay' : 'Show every available source as a translucent overlay simultaneously, stacked. Each source uses its own tint so disagreements pop visually — datasheet orange + kicad green + fusion blue + altium purple.'}">${svgIcon('bookOpen')}<span class="lbl">${allSourcesOn ? 'Hide all' : 'Show all (' + sourcesPresent.length + ')'}</span></button>
</div>
${overlayRows}
${ds2sfRowHTML}
<div class="cs-row">
<span class="label">Faux PCB plate</span>
<button class="cs-btn cs-btn-${state.fauxPcbOn ? 'primary' : 'secondary'}" id="cs-fpplate-toggle" data-tooltip="1.6mm FR4 plate sized to the footprint extent + 5mm margin. Visualizes how the chip will sit on a real board.">${svgIcon('bookOpen')}<span class="lbl">${state.fauxPcbOn ? 'Hide' : 'Show'} 1.6mm faux PCB</span></button>
</div>
</div>` : '';
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Sources</h3>
${overview || '<div class="cs-empty">No footprint sources available.</div>'}
</div>
${bboxFitHTML}
${ds2sfHTML}
${stockHTML}
${overlayHTML}
${agreementHTML}
`;
body.querySelectorAll('button[data-fpsrc]').forEach(btn => {
btn.onclick = () => {
const s = btn.dataset.fpsrc;
const on = !state.fpOverlaysOn[s];
if (on) {
renderFootprintOverlay(s, fps[s].pads);
} else {
ensureOverlayDisposed('fp-' + s);
}
state.fpOverlaysOn[s] = on;
renderFootprintTab();
};
});
const allBtn = document.getElementById('cs-fp-all-toggle');
if (allBtn) {
allBtn.onclick = () => {
const turnOn = !allSourcesOn;
for (const s of sourcesPresent) {
if (turnOn && !state.fpOverlaysOn[s]) {
renderFootprintOverlay(s, fps[s].pads);
} else if (!turnOn && state.fpOverlaysOn[s]) {
ensureOverlayDisposed('fp-' + s);
}
state.fpOverlaysOn[s] = turnOn;
}
renderFootprintTab();
};
}
const plateBtn = document.getElementById('cs-fpplate-toggle');
if (plateBtn) {
plateBtn.onclick = () => {
const on = !state.fauxPcbOn;
if (on) {
// Use the largest extent across present sources.
// Pick the first source with the widest extent; pass its pads
// through so CSG-drilling has access to drill geometry.
let bestSrc = null, bestExt = null;
for (const s of sourcesPresent) {
const e = fpExtent(fps[s].pads);
if (e && (!bestExt || e.width > bestExt.width)) { bestExt = e; bestSrc = s; }
}
renderFauxPcbPlate(bestExt, bestSrc ? fps[bestSrc].pads : null);
} else {
ensureOverlayDisposed('faux-pcb');
}
state.fauxPcbOn = on;
renderFootprintTab();
};
}
const ds2sfBtn = document.getElementById('cs-ds2sf-toggle');
if (ds2sfBtn) {
ds2sfBtn.onclick = () => {
const on = !state.ds2sfOverlayOn;
if (on) {
renderDs2sfOverlay();
} else {
ensureOverlayDisposed('ds2sf');
state.ds2sfOverlayOn = false;
}
renderFootprintTab();
};
}
const stockLoadBtn = document.getElementById('cs-stock-load');
if (stockLoadBtn) stockLoadBtn.onclick = () => loadStockLibraryOverlay();
const stockRetryBtn = document.getElementById('cs-stock-retry');
if (stockRetryBtn) stockRetryBtn.onclick = () => loadStockLibraryOverlay({ force: true });
const stockToggleBtn = document.getElementById('cs-stock-toggle');
if (stockToggleBtn) {
stockToggleBtn.onclick = () => {
state.fpOverlaysOn = state.fpOverlaysOn || {};
const on = !state.fpOverlaysOn.stock;
if (on) {
const parsed = state.stockOverlay?.parsed;
if (parsed && Array.isArray(parsed.pads)) renderFootprintOverlay('stock', parsed.pads);
} else {
ensureOverlayDisposed('fp-stock');
}
state.fpOverlaysOn.stock = on;
renderFootprintTab();
};
}
}
// Pull the authoritative library footprint from service-kicad via
// /api/kicad-stock, parse server-side (Rust .kicad_mod parser), and
// store on state so renderFootprintTab can offer the toggle. Uses
// spec.footprint_name (chip-fetcher persists this at fetch time).
async function loadStockLibraryOverlay(opts) {
const fpName = state.spec?.footprint_name || state.spec?.stock_footprint_id;
if (!fpName) return { error: 'no footprint_name in spec' };
if (!opts?.force && state.stockOverlay?.loaded) return { ok: true, cached: true };
state.stockOverlay = { loading: true };
if (state.activeTab === 'footprint') renderFootprintTab();
try {
const url = 'api/kicad-stock?footprint_name=' + encodeURIComponent(fpName)
+ (state.spec?.kicad_3d_name ? '&kicad_3d_name=' + encodeURIComponent(state.spec.kicad_3d_name) : '');
const r = await fetch(url);
const j = await r.json();
if (j.parsed) {
state.stockOverlay = {
loaded: true,
parsed: j.parsed,
fetched_url: j.kicad_mod_url || null,
matched: j.matched_footprint_name || fpName,
kicad_3d_url: j.kicad_3d_url || null,
};
} else {
state.stockOverlay = {
loaded: false,
error: j.error_kicad_mod?.error || 'no parsed pads in response (service-kicad probably 404)',
};
}
} catch (e) {
state.stockOverlay = { loaded: false, error: String(e) };
}
if (state.activeTab === 'footprint') renderFootprintTab();
return state.stockOverlay;
}
// ── Tab 5: Pin-1 ────────────────────────────────────────────────
// Chip-side: user-placed indicator (state.userPin1Corner) — chipsmith
// does NOT auto-detect manufacturer-shipped pin-1 dots; the heuristic
// approaches were too unreliable. The user clicks NW/NE/SW/SE below
// and chipsmith places a synthetic dimple. Footprint-side: every
// footprint source's pin1_silk field (KiCad/Fusion/Altium parsers
// report the silkscreen marker if found).
function renderPin1Tab() {
const body = document.getElementById('cs-body');
const fps = state.footprintSources || {};
const chipPin1 = state.userPin1Corner ? { corner: state.userPin1Corner } : null;
const SRCS = ['kicad', 'fusion', 'altium', 'datasheet', 'ds2sf'];
const fpPin1Found = SRCS.some(s => fps[s] && fps[s].pin1_silk && fps[s].pin1_silk.found);
let status = '○';
let summary = '';
if (chipPin1 && fpPin1Found) {
status = '◐';
summary = `Chip-side indicator (user-placed at ${esc(chipPin1.corner)}) + footprint silkscreen marker both present. For visual corner-agreement, hover the chip top to spot the dimple and compare to the silk dot on the footprint overlay.`;
} else if (chipPin1 && !fpPin1Found) {
status = '◐';
summary = `Chip-side indicator placed at ${esc(chipPin1.corner)}, but no silkscreen marker on any of the ${SRCS.length} footprint sources. Footprint may be missing its pin-1 dot — fix in upstream library.`;
} else if (!chipPin1 && fpPin1Found) {
status = '◐';
summary = `Footprint silkscreen marker present. Place a chip-side indicator using one of the corner buttons below.`;
} else {
status = '✗';
summary = `No pin-1 indicator on either side. Place a chip-side indicator using one of the corner buttons below — sign-off needs at least one.`;
}
state.status.pin1 = status;
refreshStatusPills();
const fpRows = SRCS.map(s => {
const f = fps[s];
if (!f) return null;
if (f._stub) return `<div class="cs-row"><span class="label">${esc(s)}</span><span class="value dim">stub — not parsed</span></div>`;
const found = !!(f.pin1_silk && f.pin1_silk.found);
return `<div class="cs-row">
<span class="label">${esc(s)}</span>
<span class="value mono">${found ? 'silkscreen marker present' : 'no marker'}</span>
<span class="pill ${found ? 'ok' : 'pending'}" style="margin-left:auto">${found ? '●' : '○'}</span>
</div>`;
}).filter(Boolean).join('');
// Place-pin-1 affordance — only shown when chip-side is missing.
// Three placement modes (per the plan's Pin-1 spec):
// 1. "Use footprint marker" — project pin1_silk from any footprint
// source onto the body top + snap to nearest corner. Default
// when a marker exists; chip + silk agree by construction.
// 2. "Pick a corner" — explicit NW/NE/SW/SE buttons.
// 3. "Click to place" — JS API only for now: window.adomChipsmith
// .placePin1({ mode: 'click', xyMm: [x,y] }). UI canvas-pick
// lands in v1.1.
// Three shapes (radio): dimple (default, recessed cylinder), raised
// dot (protruding), corner chamfer (angled cut). All tagged
// 'pin1_indicator_user_placed' + registered as first-class Outline
// overlays + baked into .chipsmith.step at sign-off.
const curShape = state.userPin1Shape || 'dimple';
const SHAPES = [
{ id: 'dimple', label: 'Dimple', tip: 'Recessed cylinder, Ø 0.40 × 0.05 mm deep, slightly darker tint than the body. The default — matches QFN/QFP/LGA datasheet conventions.' },
{ id: 'raisedDot', label: 'Raised dot', tip: 'Protruding cylinder, Ø 0.40 × 0.05 mm tall, body color. Common on SOIC and some moulded plastic packages where the dot is silk-printed not embossed.' },
{ id: 'chamfer', label: 'Corner chamfer', tip: 'Angled cut on the body\'s top corner edge. Common on TQFP and some BGA where the corner itself is shaved.' },
];
const shapeRadio = SHAPES.map(s => `
<label class="cs-shape-opt${curShape === s.id ? ' active' : ''}" data-shape="${s.id}" data-tooltip="${escAttr(s.tip)}">
<input type="radio" name="cs-pin1-shape" value="${s.id}"${curShape === s.id ? ' checked' : ''}>
<span class="lbl">${esc(s.label)}</span>
</label>`).join('');
const fpMarkerBtn = fpPin1Found
? `<button class="cs-btn cs-btn-primary cs-pin1-fpmarker" data-tooltip="Project the silkscreen pin-1 marker from the first available footprint source onto the body top, snap to the nearest corner. Chip-side and silkscreen agree by construction — the safest default when a footprint marker exists.">${svgIcon('bookOpen')}<span class="lbl">Use footprint marker</span></button>`
: `<button class="cs-btn cs-btn-secondary" disabled data-tooltip="No silkscreen pin-1 marker on any footprint source. Use a corner button below or place via the JS API: window.adomChipsmith.placePin1({mode:'click', xyMm:[x,y]}).">${svgIcon('bookOpen')}<span class="lbl">Use footprint marker — unavailable</span></button>`;
const placeUI = chipPin1 ? '' : `
<div class="cs-section">
<h3 class="cs-section-title">Place a synthetic indicator</h3>
<div class="cs-row dim"><span class="value">Manufacturer's STEP has no pin-1 dot. Place one here — it'll be tagged <code>pin1_indicator_user_placed</code> and baked into <code>.chipsmith.step</code> at sign-off (v1.1) so it survives every future load.</span></div>
<div class="cs-row" style="margin-top:6px"><span class="label">Shape</span></div>
<div class="cs-shape-radio">${shapeRadio}</div>
<div class="cs-row" style="margin-top:8px"><span class="label">Mode</span></div>
<div class="cs-actions" style="flex-wrap:wrap;gap:6px">
${fpMarkerBtn}
</div>
<div class="cs-row" style="margin-top:6px"><span class="label">Or pick a corner</span></div>
<div class="cs-actions">
<button class="cs-btn cs-btn-secondary cs-place-pin1" data-corner="NW" data-tooltip="Place at the chip's top-left (NW) corner — the canonical pin-1 location for QFN/QFP/LGA datasheet drawings.">${svgIcon('bookOpen')}<span class="lbl">NW</span></button>
<button class="cs-btn cs-btn-secondary cs-place-pin1" data-corner="NE" data-tooltip="Place at the top-right (NE) corner.">${svgIcon('bookOpen')}<span class="lbl">NE</span></button>
<button class="cs-btn cs-btn-secondary cs-place-pin1" data-corner="SW" data-tooltip="Place at the bottom-left (SW) corner.">${svgIcon('bookOpen')}<span class="lbl">SW</span></button>
<button class="cs-btn cs-btn-secondary cs-place-pin1" data-corner="SE" data-tooltip="Place at the bottom-right (SE) corner.">${svgIcon('bookOpen')}<span class="lbl">SE</span></button>
</div>
<div class="cs-row" style="margin-top:6px"><span class="label">Or click on the body</span></div>
<div class="cs-actions">
<button class="cs-btn cs-btn-${state.pin1ClickArmed ? 'primary' : 'secondary'}" id="cs-pin1-click-arm" data-tooltip="Arm canvas-pick mode — your next click on the chip body's top face places the indicator at that point. Hold Alt to use the exact click point; otherwise chipsmith snaps to the nearest body corner. Press Esc to cancel.">${svgIcon('bookOpen')}<span class="lbl">${state.pin1ClickArmed ? 'Cancel click-to-place' : 'Click-to-place mode'}</span></button>
</div>
<div class="cs-row dim" style="margin-top:6px"><span class="value" style="font-size:11px">JS API: <code>window.adomChipsmith.placePin1({mode:'click', xyMm:[x,y], shape:'${curShape}'})</code></span></div>
</div>`;
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Chip-side indicator</h3>
<div class="cs-row">
<span class="label">Detected</span>
<span class="value mono">${chipPin1 ? esc(chipPin1.name) : '<span class="cs-amber">none — manufacturer STEP has no pin-1 product</span>'}</span>
<span class="pill ${chipPin1 ? 'ok' : 'pending'}" style="margin-left:auto">${chipPin1 ? '●' : '○'}</span>
</div>
</div>
${placeUI}
<div class="cs-section">
<h3 class="cs-section-title">Footprint silkscreen marker</h3>
${fpRows}
</div>
<div class="cs-section">
<h3 class="cs-section-title">Agreement</h3>
<div class="cs-meas-status pill-${status === '✗' ? 'fail' : (status === '●' ? 'ok' : 'partial')}">${summary}</div>
</div>
`;
body.querySelectorAll('.cs-place-pin1').forEach(btn => {
btn.onclick = () => placePin1({ corner: btn.dataset.corner, shape: state.userPin1Shape || 'dimple' });
});
body.querySelectorAll('.cs-shape-opt input').forEach(inp => {
inp.onchange = () => {
state.userPin1Shape = inp.value;
renderPin1Tab();
};
});
const fpBtn = body.querySelector('.cs-pin1-fpmarker');
if (fpBtn) {
fpBtn.onclick = () => {
const r = placePin1({ mode: 'fpMarker', shape: state.userPin1Shape || 'dimple' });
if (r && r.error) { console.warn('[chipsmith] placePin1 fpMarker:', r.error); alert(r.error); }
};
}
const armBtn = document.getElementById('cs-pin1-click-arm');
if (armBtn) {
armBtn.onclick = () => {
if (state.pin1ClickArmed) { disarmPin1Click(); }
else { armPin1Click(); }
renderPin1Tab();
};
}
}
// Click-to-place pin-1 — single-shot canvas-pick handler. When armed,
// the next pointer-up on a body mesh resolves to (x,y) in mm relative
// to the body bbox center, snaps to the nearest corner unless Alt is
// held, then calls placePin1({mode:'click'}). Esc cancels. The crosshair
// cursor + dim banner-overlay tells the user something is armed; auto-
// disarm after one successful pick or on Esc.
let _pin1ClickHandlers = null;
function armPin1Click() {
if (state.pin1ClickArmed) return;
if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return;
const scene = window.adomStep.getScene();
const canvas = scene.getEngine().getRenderingCanvas();
if (!canvas) return;
const bb = window.adomStep.bbox('Body');
if (!bb || bb.error) {
alert('Click-to-place needs a body bbox. Tab 2 must finish loading first.');
return;
}
state.pin1ClickArmed = true;
canvas.style.cursor = 'crosshair';
showClickArmBanner();
function onPointerUp(ev) {
const rect = canvas.getBoundingClientRect();
const x = ev.clientX - rect.left;
const y = ev.clientY - rect.top;
const pick = scene.pick(x, y);
if (!pick || !pick.hit || !pick.pickedPoint) {
return; // miss — keep armed for another click
}
// Convert hit point to footprint-mm coordinates (the chipsmith
// canonical XY plane perpendicular to world-up). placePin1's
// 'click' mode interprets xyMm as positions in this same plane.
const ax = planeAxes();
const hit = [pick.pickedPoint.x, pick.pickedPoint.y, pick.pickedPoint.z];
const planeCx = (bb.min[ax[0]] + bb.max[ax[0]]) / 2;
const planeCy = (bb.min[ax[1]] + bb.max[ax[1]]) / 2;
const dxMm = (hit[ax[0]] - planeCx) / MM;
const dyMm = (hit[ax[1]] - planeCy) / MM;
const altHeld = !!ev.altKey;
const opts = altHeld
? { mode: 'click', xyMm: [dxMm, dyMm], shape: state.userPin1Shape || 'dimple' }
: { mode: 'corner', shape: state.userPin1Shape || 'dimple',
corner: (dxMm < 0 ? (dyMm < 0 ? 'SW' : 'NW') : (dyMm < 0 ? 'SE' : 'NE')) };
const r = placePin1(opts);
if (r && r.error) { console.warn('[chipsmith] placePin1 click:', r.error); alert(r.error); }
disarmPin1Click();
if (state.activeTab === 'pin1') renderPin1Tab();
}
function onKey(ev) {
if (ev.key === 'Escape') disarmPin1Click();
}
canvas.addEventListener('pointerup', onPointerUp);
document.addEventListener('keydown', onKey);
_pin1ClickHandlers = { canvas, onPointerUp, onKey };
}
function disarmPin1Click() {
if (!state.pin1ClickArmed) return;
state.pin1ClickArmed = false;
if (_pin1ClickHandlers) {
const { canvas, onPointerUp, onKey } = _pin1ClickHandlers;
canvas.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('keydown', onKey);
canvas.style.cursor = '';
_pin1ClickHandlers = null;
}
hideClickArmBanner();
if (state.activeTab === 'pin1') renderPin1Tab();
}
function showClickArmBanner() {
let b = document.getElementById('cs-pin1-arm-banner');
if (!b) {
b = document.createElement('div');
b.id = 'cs-pin1-arm-banner';
b.style.cssText = 'position:fixed;top:48px;left:50%;transform:translateX(-50%);background:rgba(0,184,177,0.92);color:#0a0c10;font-size:12px;padding:6px 12px;border-radius:5px;z-index:99998;pointer-events:none;font-family:inherit;font-weight:500;box-shadow:0 4px 16px rgba(0,0,0,0.4)';
document.body.appendChild(b);
}
b.textContent = 'Click on the chip body to place pin-1 (Alt = exact point, otherwise snap to nearest corner; Esc cancels)';
}
function hideClickArmBanner() {
const b = document.getElementById('cs-pin1-arm-banner');
if (b) b.remove();
}
// Place a synthetic pin-1 indicator on the chip's top face.
//
// placePin1(cornerOrOpts, optsOrUndef)
//
// Calling shapes:
// placePin1('NW') // legacy — corner + dimple
// placePin1({ corner: 'NW', shape: 'dimple' }) // explicit
// placePin1({ mode: 'fpMarker', shape: 'raisedDot' }) // snap to footprint silk
// placePin1({ mode: 'click', xyMm: [1.5, -0.8], shape: 'chamfer' })
//
// Modes:
// corner — pick from NW/NE/SW/SE (default if `corner` is given)
// fpMarker — project pin1_silk from a footprint source onto body top,
// snap to nearest corner; use this when you want chip + silk
// to agree by construction
// click — caller passes xyMm (relative to chip body center)
//
// Shapes:
// dimple — Ø 0.40 × 0.05 mm recessed cylinder (default)
// raisedDot — Ø 0.40 × 0.05 mm protruding cylinder, body color
// chamfer — angled cut on the corner edge (cone segment)
function placePin1(cornerOrOpts, optsOrUndef) {
if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon && window.adomStep.bbox)) return { error: 'adom-step API not ready' };
let mode, corner, xyMm, shape;
if (typeof cornerOrOpts === 'string') {
// Legacy form: placePin1('NW')
mode = 'corner'; corner = cornerOrOpts;
shape = (optsOrUndef && optsOrUndef.shape) || 'dimple';
} else {
const o = cornerOrOpts || {};
mode = o.mode || (o.corner ? 'corner' : 'corner');
corner = o.corner;
xyMm = o.xyMm;
shape = o.shape || 'dimple';
}
const scene = window.adomStep.getScene();
const B = window.adomStep.getBabylon();
const bb = window.adomStep.bbox('Body');
if (!bb || bb.error) return { error: 'no body bbox' };
ensureOverlayDisposed('user-pin1');
// World-up-aware corner geometry. The body's "top" is along the
// world-up axis; the corners live in the plane perpendicular to it.
const upI = worldUpIdx();
const ax = planeAxes(); // [planeA, planeB]
const inset_mm = 0.4;
const minA = bb.min[ax[0]], maxA = bb.max[ax[0]]; // body extent along plane-A
const minB = bb.min[ax[1]], maxB = bb.max[ax[1]]; // body extent along plane-B
const topUp_mm = bb.max[upI] / MM;
const bottomUp_mm = bb.min[upI] / MM;
const bodyCenterA = (minA + maxA) / 2;
const bodyCenterB = (minB + maxB) / 2;
// Pin-1 corner naming uses NW/NE/SW/SE — North/South map to the
// higher/lower plane-B value, West/East map to the lower/higher
// plane-A value. Same convention as legacy code; just plumbed
// through the actual plane axes.
function nearestCornerFromPlanePos_mm(a_mm, b_mm) {
const left = (a_mm * MM) < bodyCenterA;
const back = (b_mm * MM) < bodyCenterB;
if ( left && !back) return 'NW';
if (!left && !back) return 'NE';
if ( left && back) return 'SW';
return 'SE';
}
let centerA_mm, centerB_mm, sourceLabel = '';
if (mode === 'fpMarker') {
const fps = state.footprintSources || {};
let pad = null, src = null;
for (const s of ['kicad','fusion','altium','datasheet','ds2sf']) {
const f = fps[s];
if (f && f.pin1_silk && f.pin1_silk.found && Array.isArray(f.pin1_silk.center)) {
pad = f.pin1_silk; src = s; break;
}
}
if (!pad) return { error: 'no pin1_silk marker on any footprint source — try corner or click mode' };
// Footprint marker is in footprint XY mm; project to body plane.
corner = nearestCornerFromPlanePos_mm(pad.center[0], pad.center[1]);
sourceLabel = `from ${src} silk marker`;
}
if (mode === 'click' && Array.isArray(xyMm)) {
centerA_mm = xyMm[0];
centerB_mm = xyMm[1];
sourceLabel = `at click (${xyMm[0].toFixed(2)}, ${xyMm[1].toFixed(2)})`;
} else {
// corner mode (or fpMarker after corner-snap).
const minA_mm = minA / MM, maxA_mm = maxA / MM;
const minB_mm = minB / MM, maxB_mm = maxB / MM;
switch (corner) {
case 'NW': centerA_mm = minA_mm + inset_mm; centerB_mm = maxB_mm - inset_mm; break;
case 'NE': centerA_mm = maxA_mm - inset_mm; centerB_mm = maxB_mm - inset_mm; break;
case 'SW': centerA_mm = minA_mm + inset_mm; centerB_mm = minB_mm + inset_mm; break;
case 'SE':
default: centerA_mm = maxA_mm - inset_mm; centerB_mm = minB_mm + inset_mm; break;
}
sourceLabel = sourceLabel || `corner ${corner}`;
}
// Build the indicator mesh per shape, with its long axis aligned
// to world-up. placeMeshAt uses footprint-coords semantics (X,Y in
// the plane, height along world-up) — same convention as every
// other chipsmith overlay so domes / etch / pin-1 all agree.
const meshes = [];
if (shape === 'raisedDot') {
const dot = B.MeshBuilder.CreateCylinder('user-pin1-raisedDot', {
diameter: 0.40 * MM, height: 0.05 * MM, tessellation: 24,
}, scene);
alignVerticalToWorldUp(dot);
// Cylinder centered 0.025 mm above body top so it protrudes.
placeMeshAt(dot, centerA_mm, centerB_mm, topUp_mm + 0.025);
const m = new B.StandardMaterial('user-pin1-mat', scene);
m.diffuseColor = new B.Color3(0.20, 0.20, 0.22);
m.specularColor = new B.Color3(0.05, 0.05, 0.05);
dot.material = m;
meshes.push(dot);
} else if (shape === 'chamfer') {
// Angled cut on the corner edge. Box has width along plane-A,
// depth along plane-B (per flatBoxDims), thickness along world-up.
// Then we rotate ABOUT the world-up axis so the long edge points
// inward to the body center.
const cham = B.MeshBuilder.CreateBox('user-pin1-chamfer', flatBoxDims(0.85, 0.10, 0.10), scene);
placeMeshAt(cham, centerA_mm, centerB_mm, topUp_mm - 0.05);
const dirA = ((centerA_mm * MM) > bodyCenterA ? 1 : -1);
const dirB = ((centerB_mm * MM) > bodyCenterB ? 1 : -1);
const yaw = Math.atan2(-dirA, -dirB) + Math.PI / 4;
// Rotate around world-up axis so the chamfer's long edge points
// toward the body center diagonally.
if (upI === 1) cham.rotation.y = yaw;
else cham.rotation.z = yaw;
const m = new B.StandardMaterial('user-pin1-mat', scene);
m.diffuseColor = new B.Color3(0.08, 0.08, 0.09);
m.specularColor = new B.Color3(0.05, 0.05, 0.05);
cham.material = m;
meshes.push(cham);
} else {
// dimple (default): recessed cylinder.
const dim = B.MeshBuilder.CreateCylinder('user-pin1-dimple', {
diameter: 0.40 * MM, height: 0.05 * MM, tessellation: 24,
}, scene);
alignVerticalToWorldUp(dim);
// Cylinder centered 0.025 mm BELOW body top so it reads as recessed.
placeMeshAt(dim, centerA_mm, centerB_mm, topUp_mm - 0.025);
const m = new B.StandardMaterial('user-pin1-mat', scene);
m.diffuseColor = new B.Color3(0.10, 0.10, 0.12);
m.specularColor = new B.Color3(0.05, 0.05, 0.05);
dim.material = m;
meshes.push(dim);
}
const finalCorner = corner || nearestCornerFromPlanePos_mm(centerA_mm, centerB_mm);
window.adomStep.addOverlay({
group: 'user-pin1', meshes,
label: `Pin-1 indicator ${shape} ${sourceLabel}`,
kind: 'overlay-pin1-user',
});
state.userPin1Corner = finalCorner;
state.userPin1Shape = shape;
state.userPin1Mode = mode;
recomputeAllStatuses();
if (state.activeTab === 'pin1') renderPin1Tab();
return { ok: true, corner: finalCorner, shape, mode };
}
// ── Laser-etch (cosmetic MPN engraving on the chip's top face) ──
// Per the plan: "Optional laser-etch of the MPN onto the chip's top
// surface — toggleable per-chip cosmetic feature that engraves the
// part number into the body's top face (50µm-deep recess, slightly
// darker than body color, auto-sized + auto-positioned to avoid the
// pin-1 indicator). EEs love seeing the part number on the chip
// when they're laying out a board downstream."
//
// Implementation: a thin Babylon plane sits 1µm above the body's top
// face (visually "engraved" via a darker emissive vs the body), with
// a DynamicTexture that paints the MPN text. Auto-sized to fit
// within max_size_mm bounds (default 0.6 of body XZ). Auto-positioned
// away from the pin-1 corner: if the user-placed pin-1 indicator is
// at NW, the etch shifts SE-ward by 0.4 mm so the two don't visually
// collide. Tagged 'overlay-laser-etch' for outline + hover-HUD.
function renderLaserEtch(opts) {
if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon && window.adomStep.bbox)) return null;
const scene = window.adomStep.getScene();
const B = window.adomStep.getBabylon();
const bb = window.adomStep.bbox('Body');
if (!bb || bb.error) return { error: 'no body bbox — cannot place laser etch' };
ensureOverlayDisposed('laser-etch');
const text = (opts && opts.text) || state.spec?.mpn || '???';
const depthMm = (opts && opts.depth_mm) || 0.05;
// World-up-aware sizing: pick the body's two PLANE-axis extents
// for the etch's width/height (not its world-up height).
const upI = worldUpIdx();
const ax = planeAxes();
const bodyW_mm = (bb.max[ax[0]] - bb.min[ax[0]]) / MM; // plane-A
const bodyD_mm = (bb.max[ax[1]] - bb.min[ax[1]]) / MM; // plane-B
const maxW_mm = (opts && opts.max_size_mm && opts.max_size_mm[0]) || Math.min(bodyW_mm * 0.7, 3.0);
const maxH_mm = (opts && opts.max_size_mm && opts.max_size_mm[1]) || Math.min(bodyD_mm * 0.18, 0.6);
// Base position = body top-face center along the plane axes;
// height = body bbox max along world-up.
const planeCx_mm = ((bb.min[ax[0]] + bb.max[ax[0]]) / 2) / MM;
const planeCy_mm = ((bb.min[ax[1]] + bb.max[ax[1]]) / 2) / MM;
const topUp_mm = bb.max[upI] / MM;
let dxMm = 0, dyMm = 0;
// Auto-shift away from the pin-1 indicator if one is placed.
if (state.userPin1Corner) {
const c = state.userPin1Corner;
const off = 0.4;
if (c === 'NW') { dxMm = off; dyMm = -off; }
if (c === 'NE') { dxMm = -off; dyMm = -off; }
if (c === 'SW') { dxMm = off; dyMm = off; }
if (c === 'SE') { dxMm = -off; dyMm = off; }
}
// DynamicTexture label. Real chip silkscreen is light text on the
// body's matte top — we paint a SOLID body-colored background with
// bright off-white text on top, then render with full emissive so
// the text reads regardless of scene lighting. No alpha-blending
// (transparency was causing the text to wash out into the body
// through mipmap sampling). The plane sits a hair above the body
// top so it doesn't z-fight.
const txW = 1024, txH = 192;
const dt = new B.DynamicTexture('laser-etch-tex', { width: txW, height: txH }, scene, true, B.Texture.TRILINEAR_SAMPLINGMODE);
const ctx = dt.getContext();
// Background = matte-black body color (matches typical SOIC/QFN body).
ctx.fillStyle = 'rgb(20, 20, 22)';
ctx.fillRect(0, 0, txW, txH);
let fontPx = Math.floor(txH * 0.72);
ctx.fillStyle = 'rgb(232, 232, 220)'; // off-white silkscreen ink
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
while (fontPx > 12) {
ctx.font = `bold ${fontPx}px sans-serif`;
const w = ctx.measureText(text).width;
if (w <= txW - 24) break;
fontPx -= 4;
}
ctx.font = `bold ${fontPx}px sans-serif`;
ctx.fillText(text, txW / 2, txH / 2);
dt.update();
const mat = new B.StandardMaterial('laser-etch-mat', scene);
mat.diffuseTexture = dt;
mat.emissiveTexture = dt;
mat.emissiveColor = new B.Color3(1, 1, 1);
mat.diffuseColor = new B.Color3(1, 1, 1);
mat.specularColor = new B.Color3(0, 0, 0);
// Render both faces so the etch reads regardless of which side
// the camera approaches from. (The plane's "up" face after rotation
// depends on Babylon's handedness; doublesided sidesteps it.)
mat.backFaceCulling = false;
// Plane sized in scene units. Babylon's CreatePlane defaults to
// facing +Z (normal along +Z, lies in XY plane). For Z-up worlds
// the default orientation already faces world-up — perfect.
// For Y-up worlds we rotate +π/2 about X so the painted face
// points to +Y. dt.vScale = -1 flips the V axis so text reads
// right-side-up when viewed from above.
const planeW = maxW_mm * MM;
const planeH = maxH_mm * MM;
const plane = B.MeshBuilder.CreatePlane('laser-etch-plane', {
width: planeW, height: planeH,
}, scene);
if (upI === 1) {
plane.rotation.x = Math.PI / 2; // Y-up: rotate to face +Y
dt.vScale = -1; dt.vOffset = 1; // flip V so text reads right-side up
}
// Position 10 µm above the body's top face along world-up. Plane
// axes get the offset+center; world-up gets topUp + 0.010.
placeMeshAt(plane, planeCx_mm + dxMm, planeCy_mm + dyMm, topUp_mm + 0.010);
plane.material = mat;
window.adomStep.addOverlay({
group: 'laser-etch',
meshes: [plane],
label: `Laser etch "${text}" ${maxW_mm.toFixed(2)}×${maxH_mm.toFixed(2)} mm depth ${(depthMm*1000).toFixed(0)} µm`,
kind: 'overlay-laser-etch',
});
state.laserEtch = { text, depth_mm: depthMm, max_size_mm: [maxW_mm, maxH_mm], position: { dx_mm: dxMm, dy_mm: dyMm } };
return { ok: true, ...state.laserEtch };
}
// ── Debug: anchor bboxes ────────────────────────────────────────
// Four colored thin slabs that visualize EXACTLY where chipsmith
// thinks each canonical surface is in the scene. The user toggles
// these on when reporting "your placement is wrong" so we can both
// see whether the discrepancy is a placement bug (chipsmith put the
// dome at the right Y but wrong XZ) or a model bug (chipsmith thinks
// the body's top is at Y=1.65 mm but actually it's at Y=2.10 mm).
//
// Anchors:
// chip-top body bbox max-Y plane (cyan)
// chip-bottom body bbox min-Y plane (magenta)
// footprint-area bbox of all pad centers from best source (yellow)
// sitting at the same Y-plane chipsmith uses
// when rendering footprint overlays (z=0 in
// KiCad coords → Y=0 in scene)
// pcb-top 1.6mm faux PCB top face plane (green)
// derived from the PCB plate's expected
// footprint extent + 5mm margin, even when
// the plate isn't currently rendered
//
// Each anchor is a thin (50µm) horizontal slab so it reads as a
// "this is the plane I'm anchoring on" surface, not a shape that
// could be confused with real geometry.
function renderDebugAnchors() {
if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon && window.adomStep.bbox)) {
return { error: 'adom-step API not ready' };
}
const scene = window.adomStep.getScene();
const B = window.adomStep.getBabylon();
const group = 'debug-anchors';
ensureOverlayDisposed(group);
const SLAB = 0.05 * MM;
const out = [];
const children = [];
// World-up detection — the chip-fetcher GLBs are authored with
// Z as the height axis (CAD/EE convention; camera.upVector =
// [0,0,1]). The chipsmith renderers were originally authored
// Y-up; the debug anchors here use the SAME convention as the
// active camera so they actually anchor where the chip lives,
// and they expose to the user when other chipsmith renderers
// disagree with that convention.
const cam = scene.activeCamera;
const up = cam && cam.upVector
? Math.abs(cam.upVector.z) > 0.5 ? 'Z' : (Math.abs(cam.upVector.y) > 0.5 ? 'Y' : 'X')
: 'Y';
const upIdx = up === 'Z' ? 2 : (up === 'Y' ? 1 : 0);
const planeAxes = up === 'Z' ? [0, 1] : (up === 'Y' ? [0, 2] : [1, 2]); // axes orthogonal to up
// makeSlab(name, w_m, h_m, center_xyz, color, labelExtra, slabUpAxis)
// slabUpAxis: 'X' | 'Y' | 'Z' — which axis the slab is THIN along.
// Defaults to the world-up axis. Override for slabs that anchor
// chipsmith's CURRENT placement (legacy Y-up footprint pads) so
// the user sees if those placements disagree with the world-up.
function makeSlab(name, w_m, h_m, center_xyz, color, labelExtra, slabUpAxis) {
const slabUp = slabUpAxis || up;
const slabIdx = slabUp === 'Z' ? 2 : (slabUp === 'Y' ? 1 : 0);
const slabPlane = slabUp === 'Z' ? [0, 1] : (slabUp === 'Y' ? [0, 2] : [1, 2]);
const dims = { width: 0.001, height: 0.001, depth: 0.001 };
const dimNames = ['width', 'height', 'depth'];
dims[dimNames[slabPlane[0]]] = w_m;
dims[dimNames[slabPlane[1]]] = h_m;
dims[dimNames[slabIdx]] = SLAB;
const m = B.MeshBuilder.CreateBox(name, dims, scene);
m.position.x = center_xyz[0];
m.position.y = center_xyz[1];
m.position.z = center_xyz[2];
const mat = new B.StandardMaterial(name + '-mat', scene);
mat.diffuseColor = new B.Color3(color.r, color.g, color.b);
mat.emissiveColor = new B.Color3(color.r * 0.7, color.g * 0.7, color.b * 0.7);
mat.specularColor = new B.Color3(0, 0, 0);
mat.alpha = 0.55;
m.material = mat;
out.push(m);
const labelW_mm = (w_m / MM).toFixed(2);
const labelH_mm = (h_m / MM).toFixed(2);
const upMm = (center_xyz[slabIdx] / MM).toFixed(3);
const axisNote = slabUp === up ? '' : ` (THIN-${slabUp} ⚠ world-up=${up})`;
children.push({
meshes: [m],
label: `${name.replace(/^debug-/, '')} ${labelW_mm}×${labelH_mm} mm @ ${slabUp}=${upMm} mm${axisNote} ${labelExtra || ''}`.trim(),
kind: 'overlay-debug-anchor',
});
}
// ── chip-top + chip-bottom ──────────────────────────
// Use the body's bbox along the world-UP axis, with the slab
// spanning the body's full footprint in the two plane axes.
const bb = window.adomStep.bbox('Body');
if (bb && !bb.error) {
const planeW = bb.max[planeAxes[0]] - bb.min[planeAxes[0]];
const planeH = bb.max[planeAxes[1]] - bb.min[planeAxes[1]];
const ctrA = (bb.min[planeAxes[0]] + bb.max[planeAxes[0]]) / 2;
const ctrB = (bb.min[planeAxes[1]] + bb.max[planeAxes[1]]) / 2;
function pos(upVal) {
const p = [0, 0, 0];
p[planeAxes[0]] = ctrA;
p[planeAxes[1]] = ctrB;
p[upIdx] = upVal;
return p;
}
makeSlab('debug-chip-top', planeW, planeH, pos(bb.max[upIdx]),
{ r: 0.30, g: 0.85, b: 0.95 }, `— body bbox max-${up}`);
makeSlab('debug-chip-bottom', planeW, planeH, pos(bb.min[upIdx]),
{ r: 0.95, g: 0.30, b: 0.75 }, `— body bbox min-${up}`);
} else {
children.push({ meshes: [], label: 'chip-top — unavailable (no Body bbox)', kind: 'overlay-debug-anchor' });
children.push({ meshes: [], label: 'chip-bottom — unavailable (no Body bbox)', kind: 'overlay-debug-anchor' });
}
// ── footprint-area ──────────────────────────────────
// Compute extent of pad centers from the most-pad-trustworthy
// source. The pads themselves are placed by renderFootprintOverlay
// in world XZ (legacy Y-up convention) — we mirror THAT placement
// so the anchor coincides with where chipsmith currently puts
// its overlays, even if that placement disagrees with world-up.
// (When the user sees the yellow slab in a different plane than
// the green pcb-top slab, that's the bug.)
const fps = state.footprintSources || {};
const candidates = ['kicad', 'fusion', 'altium', 'datasheet']
.map(s => ({ src: s, fp: fps[s] }))
.filter(x => x.fp && !x.fp._stub && Array.isArray(x.fp.pads) && x.fp.pads.length > 0);
if (candidates.length) {
const best = candidates.sort((a, b) => b.fp.pad_count - a.fp.pad_count)[0];
const ext = fpExtent(best.fp.pads);
if (ext) {
const w = ext.width * MM;
const h = ext.height * MM;
const xs = best.fp.pads.map(p => p.at?.[0] || 0);
const ys = best.fp.pads.map(p => p.at?.[1] || 0);
const cxMm = (Math.min(...xs) + Math.max(...xs)) / 2;
const cyMm = (Math.min(...ys) + Math.max(...ys)) / 2;
// chipsmith places footprint pads at fpPlace(fpX, fpY, 0.02mm),
// so the anchor goes there too. The slab is thin along the
// world-up axis (matching the pad's flat orientation) — when
// the chip body and footprint are correctly aligned, this
// anchor sits flat on the seat plane right under chip-bottom.
const center = fpPlace(cxMm, cyMm, 0);
makeSlab('debug-footprint-area',
w, h, center,
{ r: 0.95, g: 0.85, b: 0.20 },
`— ${best.fp.pad_count} pads from ${best.src}`);
}
} else {
children.push({ meshes: [], label: 'footprint-area — unavailable (no parsed pads)', kind: 'overlay-debug-anchor' });
}
// ── pcb-top ─────────────────────────────────────────
// Where chipsmith currently puts the faux PCB plate's +Y face:
// world Y=0, sized footprint extent + 5mm margin in world XZ.
// (Same legacy Y-up convention as the footprint pads.)
if (candidates.length) {
const best = candidates.sort((a, b) => b.fp.pad_count - a.fp.pad_count)[0];
const ext = fpExtent(best.fp.pads);
if (ext) {
const padMargin = 5;
const w = (ext.width + padMargin * 2) * MM;
const h = (ext.height + padMargin * 2) * MM;
// PCB plate top face is at world-up height = 0 (renderFauxPcbPlate
// places the plate so its +up face is flush with the seat plane).
makeSlab('debug-pcb-top', w, h, fpPlace(0, 0, 0),
{ r: 0.30, g: 0.85, b: 0.45 },
'— FR4 plate +up face @ height=0');
}
} else {
children.push({ meshes: [], label: 'pcb-top — unavailable (no faux PCB extent)', kind: 'overlay-debug-anchor' });
}
window.adomStep.addOverlay({
group,
label: `Debug — anchor bboxes (${children.length}) · world-up=${up}`,
kind: 'overlay-debug-anchors',
children,
});
state.debugAnchorsOn = true;
return { ok: true, count: out.length, world_up_axis: up };
}
// ── Tab 7: Heatsink vias ────────────────────────────────────────
// Auto-generate a square via grid under any detected exposed pad
// (EP / ep_thermal). Drill 0.3mm, outer Cu 0.6mm, pitch 1.2mm,
// 0.4mm edge margin per the plan. Output: positions in the EP's
// local frame. v1.0 surfaces the data; v1.1 renders the 3D vias as
// an overlay layer the user can toggle.
function generateViaGrid(epSize, opts = {}) {
const drill = opts.drill_mm || 0.30;
const outerCu = opts.outer_cu_mm || 0.60;
const pitch = opts.pitch_mm || 1.20;
const margin = opts.margin_mm || 0.40;
if (!epSize || epSize.width <= 2 * margin) return [];
const usableX = epSize.width - 2 * margin;
const usableY = epSize.height - 2 * margin;
const nx = Math.max(1, Math.floor(usableX / pitch) + 1);
const ny = Math.max(1, Math.floor(usableY / pitch) + 1);
const startX = -((nx - 1) * pitch) / 2;
const startY = -((ny - 1) * pitch) / 2;
const vias = [];
for (let iy = 0; iy < ny; iy++) {
for (let ix = 0; ix < nx; ix++) {
vias.push({
index: iy * nx + ix + 1,
x_mm: startX + ix * pitch,
y_mm: startY + iy * pitch,
drill_mm: drill,
outer_cu_mm: outerCu,
});
}
}
return { drill, outerCu, pitch, margin, nx, ny, count: nx * ny, vias };
}
function renderHeatsinkTab() {
const body = document.getElementById('cs-body');
const fps = state.footprintSources || {};
if (!fps || Object.keys(fps).length === 0) {
body.innerHTML = `<div class="cs-empty">Loading footprint sources…</div>`;
fetch('api/footprint-sources').then(r => r.ok ? r.json() : null).then(d => {
state.footprintSources = d;
if (state.activeTab === 'heatsink') renderHeatsinkTab();
});
return;
}
// Find the EP from any footprint source (its size is what we need).
const SRCS = ['kicad', 'fusion', 'altium', 'datasheet', 'ds2sf'];
let epPad = null, epSrc = null;
for (const s of SRCS) {
const f = fps[s];
if (!f || f._stub || !f.pads) continue;
const ep = f.pads.find(p => p.size && p.size[0] >= 1.5 && p.size[1] >= 1.5 && p.size[0] === p.size[1]);
// Heuristic: a square SMD pad ≥1.5×1.5 mm is almost always the EP/thermal.
// Or look for the largest pad if the heuristic fails.
const largest = f.pads.reduce((acc, p) => {
if (!p.size) return acc;
const a = p.size[0] * p.size[1];
return (!acc || a > acc.area) ? { pad: p, area: a } : acc;
}, null);
const candidate = ep || (largest && largest.area > 2.0 ? largest.pad : null);
if (candidate) { epPad = candidate; epSrc = s; break; }
}
if (!epPad) {
state.status.heatsink = '─';
refreshStatusPills();
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Heatsink vias</h3>
<div class="cs-meas-status pill-partial">No exposed pad / thermal pad detected on any footprint source. Heatsink-via grid is N/A for this chip.</div>
</div>`;
return;
}
const epSize = { width: epPad.size[0], height: epPad.size[1] };
const grid = generateViaGrid(epSize);
const epOriginMm = { x: epPad.at?.[0] || 0, y: epPad.at?.[1] || 0 };
state.heatsinkGrid = { ep: { source: epSrc, pad_number: epPad.number, size_mm: epPad.size, origin_mm: epOriginMm }, grid };
state.status.heatsink = grid.count > 0 ? '●' : '─';
refreshStatusPills();
// Derived spec figures EEs care about. ringMm = (outer Cu - drill) / 2.
// InstaPCB minimum: 0.10 mm. The InstaPCB skill (gallia/skills/instapcb)
// doesn't yet publish a min annular ring; 0.10 mm is the working value
// confirmed by the user, derived from UV-fiber-laser positional
// accuracy (~±25–50 µm) + drill-kerf tolerance (~±25 µm). For
// reference: IPC-2221 Class 2 (commercial) is 0.05 mm, Class 3
// (mil/medical) is 0.13 mm.
const ringMm = Math.max(0, (grid.outerCu - grid.drill) / 2);
const ringClass = ringMm >= 0.13 ? 'InstaPCB ✓ + IPC-2221 Class 3'
: ringMm >= 0.10 ? 'InstaPCB-OK (≥0.10 mm)'
: ringMm >= 0.05 ? '⚠ below InstaPCB min (0.10 mm) — IPC-2221 Class 2 only'
: '✗ below InstaPCB min (0.10 mm) and below IPC-2221 Class 2';
// EP coverage: cumulative drill-hole area over the EP area.
const drillArea = grid.count * Math.PI * Math.pow(grid.drill / 2, 2);
const epArea = epSize.width * epSize.height;
const epCoveragePct = epArea > 0 ? (drillArea / epArea) * 100 : 0;
const sample = grid.vias.slice(0, 16).map((v, i) => `
<div class="cs-row">
<span class="label">via ${v.index}</span>
<span class="value mono">(${v.x_mm.toFixed(3)}, ${v.y_mm.toFixed(3)}) mm</span>
<span class="value dim">drill Ø${v.drill_mm} / outer Ø${v.outer_cu_mm} / ring ${ringMm.toFixed(3)} mm</span>
</div>`).join('');
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Exposed pad</h3>
<div class="cs-row"><span class="label">Source</span><span class="value mono">${esc(epSrc)}</span></div>
<div class="cs-row"><span class="label">Pad number</span><span class="value mono">${esc(epPad.number)}</span></div>
<div class="cs-row"><span class="label">Size</span><span class="value mono">${esc(epSize.width)} × ${esc(epSize.height)} mm</span></div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Via grid</h3>
<div class="cs-row" data-tooltip="Finished hole diameter — what the drill bit produces. InstaPCB minimum is 0.20 mm / 8 mil; 0.30 mm gives comfortable margin for laser-ablated barrels at 0.5 oz Cu plating."><span class="label">Drill (finished hole)</span><span class="value mono">Ø ${grid.drill.toFixed(2)} mm</span></div>
<div class="cs-row" data-tooltip="Outer pad diameter (the copper land around the hole). Defines the via's outer Cu disc on top + bottom layers."><span class="label">Outer Cu pad</span><span class="value mono">Ø ${grid.outerCu.toFixed(2)} mm</span></div>
<div class="cs-row" data-tooltip="Annular ring width = (outer pad Ø − drill Ø) / 2. The copper that surrounds the hole on each face. InstaPCB minimum: 0.10 mm (derived from UV-fiber-laser positional accuracy + kerf tolerance). IPC-2221 Class 2 ≥ 0.05 mm; Class 3 ≥ 0.13 mm."><span class="label">Annular ring</span><span class="value mono">${ringMm.toFixed(3)} mm <span class="dim">(${ringClass})</span></span></div>
<div class="cs-row" data-tooltip="Cu wall thickness inside the barrel. InstaPCB plates 0.5 oz Cu (≈ 17.5 µm / 0.0175 mm) per the published capabilities table. Affects the via's current-carrying capacity + thermal conductance through the barrel."><span class="label">Plating thickness</span><span class="value mono">17.5 µm <span class="dim">(0.5 oz Cu, InstaPCB)</span></span></div>
<div class="cs-row"><span class="label">Pitch (center-to-center)</span><span class="value mono">${grid.pitch.toFixed(2)} mm</span></div>
<div class="cs-row"><span class="label">Edge margin (from EP edge)</span><span class="value mono">${grid.margin.toFixed(2)} mm</span></div>
<div class="cs-row"><span class="label">Grid</span><span class="value mono">${grid.nx} × ${grid.ny} = ${grid.count} vias</span></div>
<div class="cs-row" data-tooltip="Fraction of the EP's copper area that is via opening (drill cross-section). High coverage = better thermal path but more solder loss into the holes during reflow. ~3–8% is typical."><span class="label">EP coverage</span><span class="value mono">${epCoveragePct.toFixed(1)}% <span class="dim">(via Cu / EP area)</span></span></div>
<div class="cs-meas-status pill-ok">${grid.count} plated through-hole vias auto-generated under the EP. Sign-off bakes the grid into <code><mpn>.chipsmith.json</code> so downstream board-design tools can ingest as a single "place this heatsink-via grid" action.</div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">EP copper pour (B.Cu, bottom side)</h3>
<div class="cs-row dim"><span class="value">Copper region on the <strong>bottom</strong> of the PCB that the heatsink vias dump into. The chip's EP solders to F.Cu on top; vias drill through the FR4; pour on B.Cu radiates the heat away. <strong>On by default</strong> — drawing this pour by hand is busywork; chipsmith bakes its outline into <code><mpn>.chipsmith.json</code> so downstream board-design tools can drop it as a single zone fill.</span></div>
<div class="cs-row"><span class="label">Pour size</span><span class="value mono">${epSize.width.toFixed(2)} × ${epSize.height.toFixed(2)} mm <span class="dim">(matches EP)</span></span></div>
<div class="cs-row"><span class="label">Layer</span><span class="value mono">B.Cu (bottom)</span></div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">3D overlay</h3>
<div class="cs-row dim"><span class="value">Both the via grid and the EP pour are children of the <code>Heatsink</code> row in the Outline (left). Eye-toggle each independently from the Outline. The button below toggles the whole heatsink (both children) at once.</span></div>
<div class="cs-row">
<span class="label">Visibility</span>
<button class="cs-btn cs-btn-${state.heatsinkOn ? 'primary' : 'secondary'}" id="cs-heatsink-toggle" data-tooltip="Toggle the entire heatsink overlay (vias + EP pour) on/off. For per-piece control, use the eye icons in the Outline panel on the heatsink's child rows.">${svgIcon('bookOpen')}<span class="lbl">${state.heatsinkOn ? 'Hide' : 'Show'} heatsink (${grid.count} vias + EP pour)</span></button>
</div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Positions (first ${Math.min(16, grid.count)} of ${grid.count})</h3>
${sample}
</div>
`;
const hsBtn = document.getElementById('cs-heatsink-toggle');
if (hsBtn) {
hsBtn.onclick = () => {
const on = !state.heatsinkOn;
if (on) renderHeatsink(epOriginMm, epSize, grid);
else { ensureOverlayDisposed('heatsink'); state.epCopperPour = null; }
state.heatsinkOn = on;
state.viasOn = on; // legacy state mirrors
state.epPourOn = on;
renderHeatsinkTab();
};
}
// Default-ON behaviour — first time the heatsink tab renders for
// this chip, draw the entire heatsink (vias + B.Cu pour) automatically.
// The user can flip it off, but the sidecar always carries the spec
// regardless of visibility — downstream tools inherit it.
if (state.heatsinkOn === undefined && epOriginMm && epSize) {
state.heatsinkOn = true;
state.viasOn = true;
state.epPourOn = true;
renderHeatsink(epOriginMm, epSize, grid);
}
}
// ── Tab 8: Solder-jet ───────────────────────────────────────────
// Per gallia/skills/solder-jetting/SKILL.md: 300µm dome per dispense,
// intra-pad spacing ≥100µm edge-to-edge, inter-pad spacing ≥300µm
// edge-to-edge. Always at least 1 dot per pad — pads narrower than
// the droplet get a single centred dot.
function generateSolderJets(pads, opts = {}) {
const D = (opts.droplet_um || 300) / 1000; // mm
const INTRA = (opts.intra_um || 100) / 1000; // mm dot-edge to dot-edge inside a pad
const INTER = (opts.inter_um || 300) / 1000; // mm dot-edge to dot-edge across pads
const all = [];
for (const pad of pads) {
if (!pad.size || pad.size[0] <= 0 || pad.size[1] <= 0) continue;
const padW = pad.size[0], padH = pad.size[1];
const cx = pad.at?.[0] || 0, cy = pad.at?.[1] || 0;
// Pad too narrow for the dot? center one dot.
if (Math.min(padW, padH) < D) {
all.push({ x: cx, y: cy, padId: pad.number, droplet: D });
continue;
}
const nx = Math.max(1, Math.floor((padW - D) / (D + INTRA)) + 1);
const ny = Math.max(1, Math.floor((padH - D) / (D + INTRA)) + 1);
const stepX = nx > 1 ? (padW - D) / (nx - 1) : 0;
const stepY = ny > 1 ? (padH - D) / (ny - 1) : 0;
const startX = cx - (padW - D) / 2;
const startY = cy - (padH - D) / 2;
for (let iy = 0; iy < ny; iy++) {
for (let ix = 0; ix < nx; ix++) {
all.push({
x: startX + ix * stepX,
y: startY + iy * stepY,
padId: pad.number,
droplet: D,
});
}
}
}
// Prune cross-pad violations: any pair with different pad IDs
// closer than D + INTER (centre to centre) drops the one whose
// pad has more remaining dots.
const minPair = D + INTER;
const counts = {};
for (const c of all) counts[c.padId] = (counts[c.padId] || 0) + 1;
const drop = new Set();
for (let i = 0; i < all.length; i++) {
if (drop.has(i)) continue;
for (let j = i + 1; j < all.length; j++) {
if (drop.has(j)) continue;
const a = all[i], b = all[j];
if (a.padId === b.padId) continue;
const dx = a.x - b.x, dy = a.y - b.y;
if (Math.hypot(dx, dy) < minPair) {
// Drop from whichever has more remaining dots.
const losing = counts[a.padId] >= counts[b.padId] ? i : j;
drop.add(losing);
counts[all[losing].padId]--;
}
}
}
const kept = all.filter((_, i) => !drop.has(i));
return {
droplet_mm: D,
intra_mm: INTRA,
inter_mm: INTER,
total: kept.length,
pruned: drop.size,
domes: kept,
per_pad: counts,
};
}
function renderSolderJetOverlay(domes) {
if (!domes || !domes.length) return null;
if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;
const scene = window.adomStep.getScene();
const B = window.adomStep.getBabylon();
if (!scene || !B) return null;
const group = 'solder-jet';
ensureOverlayDisposed(group);
const mat = new B.StandardMaterial(group + '-mat', scene);
mat.diffuseColor = new B.Color3(0.75, 0.78, 0.85);
mat.specularColor = new B.Color3(0.30, 0.32, 0.36);
mat.emissiveColor = new B.Color3(0.08, 0.08, 0.10);
// Group domes by their parent pad, one outline child per pad.
// Babylon's CreateSphere with slice:0.5 keeps the +Y hemisphere
// (flat side at y=0, curved side bulging up to y=+R). For Y-up
// worlds that's already correct. For Z-up worlds we rotate by
// -π/2 about X so the curved side bulges toward +Z (up).
const byPad = new Map();
let total = 0;
for (const d of domes) {
const dome = B.MeshBuilder.CreateSphere(`${group}-${total}`, {
diameter: d.droplet * MM, segments: 8, slice: 0.5,
}, scene);
if (worldUpIdx() === 2) dome.rotation.x = -Math.PI / 2;
// Sit just above the pad's top face. Pad top is at height = 0.04
// (renderFootprintOverlay puts pads at h = 0.02 ± 0.02 thickness),
// dome flat face anchored at h=0.05 leaves a tiny visible gap so
// the dome doesn't z-fight the pad.
placeMeshAt(dome, d.x, d.y, 0.05);
dome.material = mat;
total++;
const key = String(d.padId);
if (!byPad.has(key)) byPad.set(key, { padId: key, meshes: [], drop: d.droplet });
byPad.get(key).meshes.push(dome);
}
// Approx volume per dome: hemisphere V = (2/3)πr³, in nL (mm³).
function volNl(r) { return (2/3) * Math.PI * r * r * r; }
const children = Array.from(byPad.values()).map(p => {
const r = (p.drop / 2);
const v = p.meshes.length * volNl(r);
return {
meshes: p.meshes,
label: `pad ${p.padId} ${p.meshes.length} dome${p.meshes.length === 1 ? '' : 's'} Ø ${(p.drop*1000).toFixed(0)} µm vol Σ ${v.toFixed(2)} nL`,
kind: 'overlay-solderjet-pad',
};
});
window.adomStep.addOverlay({
group,
label: `Solder-jet (${total} × Ø${(domes[0].droplet * 1000).toFixed(0)} µm intra ≥0.10 inter ≥0.30)`,
kind: 'overlay-solderjet',
children,
});
return { group, count: total };
}
function renderSolderjetTab() {
const body = document.getElementById('cs-body');
const fps = state.footprintSources || {};
// Source-picker policy: filter out sources whose pads are clearly
// garbage-scaled (the kicad library entry for VL53L8CX, for example,
// ships pads at 0.1×0.2 mm — smaller than the 300 µm solder droplet,
// which would force one centered dome per pad regardless of the
// real package geometry). A pad whose smaller side is < 0.15 mm
// (smaller than half the droplet) is treated as untrustworthy; a
// source where the MEDIAN pad area is < 0.04 mm² is rejected
// entirely. Among trustworthy sources, prefer the one with the
// most pads. Fall back to raw-most-pads if every source fails the
// sanity gate so we still render something.
const candidates = ['kicad', 'fusion', 'altium', 'datasheet']
.map(s => ({ src: s, fp: fps[s] }))
.filter(x => x.fp && !x.fp._stub && Array.isArray(x.fp.pads) && x.fp.pads.length > 0);
function padArea(p) {
const w = p?.size?.[0] || 0, h = p?.size?.[1] || 0;
return w * h;
}
function padMinSide(p) {
const w = p?.size?.[0] || 0, h = p?.size?.[1] || 0;
return Math.min(w, h);
}
function median(arr) {
if (!arr.length) return 0;
const s = arr.slice().sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
const trustworthy = candidates.filter(c => {
const minSides = c.fp.pads.map(padMinSide).filter(v => v > 0);
const areas = c.fp.pads.map(padArea).filter(v => v > 0);
if (minSides.length === 0) return false;
const minMin = Math.min(...minSides);
const medArea = median(areas);
// 0.15 mm = half the 300 µm droplet; below that a single dome
// can't sit on the pad without overhang. 0.04 mm² ≈ 200×200 µm,
// smaller than just about any real SMD pad.
return minMin >= 0.15 && medArea >= 0.04;
});
const pool = trustworthy.length ? trustworthy : candidates;
pool.sort((a, b) => b.fp.pad_count - a.fp.pad_count);
const best = pool[0];
const sourceDistrust = trustworthy.length === 0 && candidates.length > 0;
if (!best) {
state.status.solderjet = '─';
refreshStatusPills();
body.innerHTML = `<div class="cs-empty">No footprint pads — load a footprint source first.</div>`;
return;
}
const sj = generateSolderJets(best.fp.pads);
state.solderjet = { source: best.src, ...sj };
state.status.solderjet = sj.total > 0 ? '●' : '─';
refreshStatusPills();
// Per-pad row breakdown.
const padRows = best.fp.pads.slice(0, 12).map(p => {
const c = sj.per_pad[p.number] || 0;
return `<div class="cs-row">
<span class="label">${esc(p.number)}</span>
<span class="value mono">${esc(p.size?.[0] || '?')} × ${esc(p.size?.[1] || '?')} mm</span>
<span class="value dim">${c} dot${c === 1 ? '' : 's'}</span>
</div>`;
}).join('');
// Build a per-source diagnostic so the user can see exactly why we
// picked the source we did (and which sources were rejected).
const sourceDiagRows = candidates.map(c => {
const minSides = c.fp.pads.map(padMinSide).filter(v => v > 0);
const areas = c.fp.pads.map(padArea).filter(v => v > 0);
const minMin = minSides.length ? Math.min(...minSides) : 0;
const medArea = median(areas);
const trust = (minMin >= 0.15 && medArea >= 0.04);
const isPicked = c.src === best.src;
const reason = trust
? (isPicked ? 'picked' : 'eligible')
: (minMin < 0.15
? `rejected — smallest pad side ${(minMin * 1000).toFixed(0)} µm (< 150 µm)`
: `rejected — median pad area ${(medArea).toFixed(3)} mm² (< 0.04 mm²)`);
const cls = isPicked ? 'ok' : (trust ? 'pending' : 'fail');
const glyph = isPicked ? '●' : (trust ? '○' : '✗');
return `<div class="cs-row">
<span class="label">${esc(c.src)}</span>
<span class="value mono">${c.fp.pads.length} pads · min side ${(minMin*1000).toFixed(0)} µm · med area ${medArea.toFixed(3)} mm²</span>
<span class="value dim" style="margin-left:auto">${reason}</span>
<span class="pill ${cls}" style="margin-left:6px">${glyph}</span>
</div>`;
}).join('');
const distrustNote = sourceDistrust
? `<div class="cs-meas-status pill-fail">Every available source has untrustworthy pad sizes (smaller than the 300 µm droplet) — falling back to raw pad count. The dome layout below may not reflect the real datasheet pad geometry; fix the upstream chip-fetcher data or load a better source.</div>`
: '';
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Source pick</h3>
${sourceDiagRows}
${distrustNote}
</div>
<div class="cs-section">
<h3 class="cs-section-title">Spec (per gallia/skills/solder-jetting)</h3>
<div class="cs-row"><span class="label">Droplet Ø</span><span class="value mono">${(sj.droplet_mm * 1000).toFixed(0)} µm</span></div>
<div class="cs-row"><span class="label">Intra-pad gap</span><span class="value mono">≥ ${(sj.intra_mm * 1000).toFixed(0)} µm</span></div>
<div class="cs-row"><span class="label">Inter-pad gap</span><span class="value mono">≥ ${(sj.inter_mm * 1000).toFixed(0)} µm</span></div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Result</h3>
<div class="cs-row"><span class="label">Total domes</span><span class="value mono">${sj.total}</span></div>
<div class="cs-row"><span class="label">Pruned</span><span class="value mono">${sj.pruned} <span class="dim">(no-bridging override)</span></span></div>
<div class="cs-meas-status pill-${sj.total > 0 ? 'ok' : 'fail'}">${sj.total} half-sphere domes auto-laid out across ${best.fp.pads.length} pads. Each dome ≥ 1 per pad. Sign-off bakes the dome layout into <code><mpn>.chipsmith.json</code> for the InstaPCB solder-jet head.</div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">3D overlay</h3>
<div class="cs-row">
<span class="label">Visibility</span>
<button class="cs-btn cs-btn-${state.solderjetOn ? 'primary' : 'secondary'}" id="cs-sj-toggle" data-tooltip="Toggle the ${sj.total} solder-jet domes as half-spheres at z>0 above each footprint pad. Pre-reflow paste preview.">${svgIcon('bookOpen')}<span class="lbl">${state.solderjetOn ? 'Hide' : 'Show'} ${sj.total} domes in 3D</span></button>
</div>
</div>
<div class="cs-section">
<h3 class="cs-section-title">Per-pad (first 12)</h3>
${padRows}
</div>
`;
const sjBtn = document.getElementById('cs-sj-toggle');
if (sjBtn) {
sjBtn.onclick = () => {
const on = !state.solderjetOn;
if (on) renderSolderJetOverlay(sj.domes);
else ensureOverlayDisposed('solder-jet');
state.solderjetOn = on;
renderSolderjetTab();
};
}
}
// ── Tab 6: Sign-off ─────────────────────────────────────────────
// Gate evaluator. Reads every state.status pill + the underlying
// state objects, builds the canonical sign-off gate list per the
// plan, evaluates pass/blocked, and offers a "Sign off" CTA when
// every required gate passes. v1.0 writes <mpn>.chipsmith.json
// sidecar via POST /api/signoff (the Rust endpoint already exists).
// Baking <mpn>.chipsmith.step is v1.1 (needs upstream service-step2glb
// /bake endpoint).
function gateState() {
const status = state.status;
return [
{ id: 'source', label: 'Manifest + spec complete', pill: status.source, hard: true, tab: 'source' },
{ id: 'measurements', label: 'Measurements within ±5%', pill: status.measurements, hard: true, tab: 'measurements' },
{ id: 'footprint', label: 'Footprint source(s) loaded', pill: status.footprint, hard: true, tab: 'footprint' },
{ id: 'pin1', label: 'Pin-1 indicator', pill: status.pin1, hard: false, tab: 'pin1' },
{ id: 'heatsink', label: 'Heatsink vias generated (if EP)', pill: status.heatsink, hard: false, tab: 'heatsink' },
{ id: 'solderjet', label: 'Solder-jet layout', pill: status.solderjet, hard: false, tab: 'solderjet' },
];
}
function canSignOff() {
const gates = gateState();
const blockers = gates.filter(g => g.hard && (g.pill === '○' || g.pill === '✗'));
return { ok: blockers.length === 0, blockers };
}
function renderSignoffTab() {
const body = document.getElementById('cs-body');
const gates = gateState();
const { ok, blockers } = canSignOff();
state.status.signoff = ok ? '●' : (blockers.length === gates.length ? '○' : '◐');
refreshStatusPills();
const rows = gates.map(g => {
const cls = ({ '●': 'ok', '◐': 'partial', '○': 'pending', '✗': 'fail', '─': 'na' }[g.pill] || 'pending');
const label = g.hard ? esc(g.label) : `${esc(g.label)} <span class="dim">(soft)</span>`;
return `<div class="cs-row cs-clickable" data-tab="${g.tab}" data-tooltip="Click to jump to the ${esc(g.tab)} tab and address this gate.">
<span class="label">${label}</span>
<span class="pill ${cls}" style="margin-left:auto">${g.pill}</span>
</div>`;
}).join('');
// Already signed off? Show the emitted artifact paths with reveal
// buttons so the user can jump straight to them in the VS Code
// Explorer. The `state.signoffResult` is set in the click handler;
// on tab re-render after page reload, fetch GET /api/signoff to
// re-hydrate from server state.
let artifactsHTML = '';
function buildArtifactsHTML(r) {
if (!r) return '';
const reveal = (path) => path
? `<button class="cs-reveal-icon" data-path="${escAttr(path)}" aria-label="Reveal in Explorer" data-tooltip="Reveal this file in the VS Code Explorer sidebar (delegates to adom-vscode reveal).">${svgIcon('folder')}</button>`
: '';
const dirOf = (p) => p ? p.replace(/\/[^/]+$/, '') : null;
const enclosing = r.sidecar_path ? dirOf(r.sidecar_path) : null;
const rows = [];
if (r.sidecar_path) rows.push(`<div class="cs-row"><span class="label">.chipsmith.json</span><span class="value mono" title="${escAttr(r.sidecar_path)}">${esc(basename(r.sidecar_path))}</span>${reveal(r.sidecar_path)}</div>`);
if (r.step_path) rows.push(`<div class="cs-row"><span class="label">.chipsmith.step</span><span class="value mono" title="${escAttr(r.step_path)}">${esc(basename(r.step_path))}</span>${reveal(r.step_path)}</div>`);
if (enclosing) rows.push(`<div class="cs-row"><span class="label">Enclosing folder</span><span class="value mono" title="${escAttr(enclosing)}">${esc(enclosing)}</span>${reveal(enclosing)}</div>`);
const bakeNote = r.bake_status
? `<div class="cs-row dim" style="margin-top:6px"><span class="value" style="font-size:11px">${esc(r.bake_status)}</span></div>`
: '';
return `
<div class="cs-section">
<h3 class="cs-section-title">Sign-off artifacts</h3>
${rows.join('')}
${bakeNote}
</div>`;
}
if (state.signoffResult && state.signoffResult.ok) {
artifactsHTML = buildArtifactsHTML(state.signoffResult);
}
body.innerHTML = `
<div class="cs-section">
<h3 class="cs-section-title">Gates</h3>
${rows}
</div>
<div class="cs-section">
<h3 class="cs-section-title">Sign-off</h3>
<div class="cs-meas-status pill-${ok ? 'ok' : 'partial'}">
${ok
? 'All hard gates pass. Click <strong>Sign off</strong> to write <code><mpn>.chipsmith.json</code> with the applied scale, detected breakdown, and pin-1 agreement. Baking <code>.chipsmith.step</code> is v1.1.'
: `${blockers.length} hard gate(s) blocking: <strong>${blockers.map(b => esc(b.label)).join(', ')}</strong>. Click each red row above to jump to its tab.`}
</div>
<div class="cs-actions">
<button class="cs-btn cs-btn-primary" id="cs-signoff-btn" ${ok ? '' : 'disabled style="opacity:0.5;cursor:not-allowed"'} data-tooltip="Write the <mpn>.chipsmith.json sidecar with the full chipsmith state — applied scale, detected breakdown, pin-1 agreement, sign-off timestamp. Disabled until every hard gate passes.">${svgIcon('bookOpen')}<span class="lbl">Sign off this chip</span></button>
</div>
</div>
${artifactsHTML}
`;
// Hydrate sign-off artifacts from server (in case the page was
// reloaded after a previous sign-off).
if (!state.signoffResult) {
fetch('api/signoff').then(r => r.ok ? r.json() : null).then(j => {
if (j && j.signed) {
state.signoffResult = { ok: true, sidecar_path: j.sidecar_path, step_path: j.step_path, bake_status: j.bake_status };
if (state.activeTab === 'signoff') renderSignoffTab();
}
}).catch(()=>{});
}
// Wire reveal icons inside the artifacts section.
body.querySelectorAll('.cs-reveal-icon').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const path = btn.dataset.path;
try {
await fetch('api/reveal', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({path}) });
btn.classList.add('cs-reveal-ok');
setTimeout(() => btn.classList.remove('cs-reveal-ok'), 500);
} catch (err) { console.error('[chipsmith] reveal failed', err); }
};
});
body.querySelectorAll('.cs-clickable[data-tab]').forEach(row => {
row.onclick = () => setActiveTab(row.dataset.tab);
});
const btn = document.getElementById('cs-signoff-btn');
if (btn && ok) {
btn.onclick = async () => {
btn.disabled = true;
btn.querySelector('.lbl').textContent = 'Signing off…';
try {
const payload = {
mpn: state.spec?.mpn,
measurements: state.measurements,
heatsink: state.heatsinkGrid, ep_copper_pour: state.epCopperPour || null,
user_placed_pin1: state.userPin1Corner ? { corner: state.userPin1Corner } : null,
decorative_product_names: state.decorativeProductNames || [],
signed_at: new Date().toISOString(),
};
const r = await fetch('api/signoff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const j = await r.json();
btn.querySelector('.lbl').textContent = j.ok ? 'Signed off ✓' : 'Failed — see console';
if (!j.ok) console.error('[chipsmith] signoff failed', j);
else {
state.signoffResult = j;
renderSignoffTab(); // re-render to surface the artifacts panel + reveal buttons
}
} catch (e) {
console.error('[chipsmith] signoff error', e);
btn.querySelector('.lbl').textContent = 'Failed — see console';
}
};
}
}
// ── Other tabs — placeholder content for now (Phase 9+) ─────────
function renderPlaceholder(label, what) {
const body = document.getElementById('cs-body');
body.innerHTML = `
<div class="cs-empty">
<strong>${esc(label)}</strong> — ${esc(what)}
</div>
`;
}
function renderActiveTab() {
const id = state.activeTab;
if (id === 'source') return renderSourceTab();
if (id === 'measurements') return renderMeasurementsTab();
if (id === 'footprint') return renderFootprintTab();
if (id === 'pin1') return renderPin1Tab();
if (id === 'signoff') return renderSignoffTab();
if (id === 'heatsink') return renderHeatsinkTab();
if (id === 'solderjet') return renderSolderjetTab();
}
// ── tiny helpers ──────────────────────────────────────────────
function esc(s) {
if (s === undefined || s === null) return '';
return String(s).replace(/[&<>]/g, c => ({ '&':'&','<':'<','>':'>' }[c]));
}
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
function basename(p) { return p ? String(p).split('/').pop() : '—'; }
// ── Wire it up: build pane, fetch backend state, render Tab 1 ─
async function init() {
injectStyles();
buildPane();
refreshStatusPills();
try {
const [paths, spec, fps, ds2sf, ds2sfPads, chip3d] = await Promise.all([
fetch('api/paths').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/spec').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/footprint-sources').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/ds2sf').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/ds2sf-pads').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/ds2sf-chip3d').then(r => r.ok ? r.json() : null).catch(() => null),
]);
state.paths = paths;
state.spec = enrichSpec(spec);
state.footprintSources = fps;
state.ds2sfResult = ds2sf;
state.chip3d = chip3d;
// ds2sf v0.6.0 per-pad data → inject as a 5th footprint source
// alongside kicad / fusion / altium / datasheet. Same schema.
if (ds2sfPads && Array.isArray(ds2sfPads.pads) && ds2sfPads.pads.length > 0 && fps) {
fps.ds2sf = {
pads: ds2sfPads.pads,
pad_count: ds2sfPads.pad_count || ds2sfPads.pads.length,
source: ds2sfPads.source || 'ds2sf',
};
}
enrichSubjectPopover();
// Show loading placeholders in the outline while OCCT generates.
const generating = [];
if (chip3d && chip3d.chip_3d) generating.push('chip');
if (ds2sfPads && ds2sfPads.pads && ds2sfPads.pads.length > 0) generating.push('footprint');
if (generating.length && window.adomStep?.addOverlay) {
for (const g of generating) {
const group = g === 'chip' ? 'adom-chip' : 'adom-footprint';
const label = g === 'chip'
? 'Adom 3D chip ⏳ generating from datasheet via OCCT...'
: 'Adom footprint ⏳ generating extruded copper pads...';
window.adomStep.addOverlay({ group, meshes: [], label, kind: 'overlay-generating' });
}
}
for (const g of generating) loadGeneratedGlb(g);
} catch (e) {
console.error('[chipsmith] initial fetch failed', e);
}
renderActiveTab();
}
async function loadGeneratedGlb(kind) {
const tag = kind === 'chip' ? 'chip' : 'footprint';
const url = `api/generate-${tag === 'chip' ? 'chip' : 'footprint'}-glb?cb=${Date.now()}`;
try {
const resp = await fetch(url);
if (!resp.ok) { console.log(`[chipsmith] generate-${tag}-glb: ${resp.status}`); return; }
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
const B = window.adomStep?.getBabylon();
const scene = window.adomStep?.getScene();
if (!B || !scene) return;
const result = await B.SceneLoader.ImportMeshAsync('', '', blobUrl, scene, null, '.glb');
const allMeshes = result.meshes.filter(m => m.getTotalVertices && m.getTotalVertices() > 0);
if (!allMeshes.length) return;
const group = tag === 'chip' ? 'adom-chip' : 'adom-footprint';
ensureOverlayDisposed(group);
// Read annotations from the server cache (the chipsmith_metadata
// STEP product doesn't survive STEP→GLB conversion).
let annotations = null;
try {
const annotResp = await fetch(`api/generated-${tag === 'chip' ? 'chip' : 'footprint'}-annotations`);
if (annotResp.ok) {
const config = await annotResp.json();
// Build annotation structure from the generation config.
const doc = config.document_metadata || {};
const products = {};
if (tag === 'chip') {
products.chipsmith_body = { body_width_mm: config.body_width_mm, body_depth_mm: config.body_depth_mm, body_height_mm: config.body_height_mm, standoff_mm: config.standoff_height_mm };
for (const la of (config.lead_annotations || [])) {
products[`chipsmith_lead_${la.number}`] = { ...la, lead_shape: config.lead_shape, source: 'OCCT' };
}
} else {
for (const p of (config.pads || [])) {
products[`chipsmith_fp_pad_${p.number}`] = { pin_number: p.number, signal: p.name, function: p.function, shape: p.shape, corner_radius_mm: p.corner_radius, size_mm: p.size, center_mm: p.at, type: p.type, layers: p.layers, source: 'OCCT' };
}
}
annotations = { document: doc, products };
}
} catch (_) {}
const prodMeta = annotations?.products || {};
// Group meshes by their parent node. Assembly hierarchy produces
// =>[0:1:1:N] usage-occurrence names — extract the index N and
// map to the annotation by position (index 1 = first product).
const groups = new Map();
for (const m of allMeshes) {
const parentName = m.parent?.name || m.name;
if (parentName.startsWith('chipsmith_metadata')) continue;
if (parentName.includes('metadata')) continue;
if (!groups.has(parentName)) groups.set(parentName, []);
groups.get(parentName).push(m);
}
// Build an ordered annotation list so we can map by index.
// The STEP assembly orders children: for chip = body, leads 1-N, pin1, ep, metadata.
// For footprint = pads 1-N, metadata.
const annotList = [];
if (tag === 'chip') {
annotList.push({ key: 'chipsmith_body', ...prodMeta.chipsmith_body });
for (let i = 1; i <= 100; i++) {
const k = `chipsmith_lead_${i}`;
if (prodMeta[k]) annotList.push({ key: k, ...prodMeta[k] });
else break;
}
if (prodMeta.chipsmith_pin1) annotList.push({ key: 'chipsmith_pin1', ...prodMeta.chipsmith_pin1 });
if (prodMeta.chipsmith_ep) annotList.push({ key: 'chipsmith_ep', ...prodMeta.chipsmith_ep });
} else {
for (let i = 1; i <= 100; i++) {
const k = `chipsmith_fp_pad_${i}`;
if (prodMeta[k]) annotList.push({ key: k, ...prodMeta[k] });
else break;
}
}
// Build children from groups, matching by index.
const children = [];
let groupIdx = 0;
for (const [prodName, meshes] of groups) {
if (groupIdx >= annotList.length) break;
const annot = annotList[groupIdx] || {};
groupIdx++;
let childLabel;
const key = annot.key || prodName;
if (key === 'chipsmith_body') {
childLabel = `body ${annot.body_width_mm||'?'}×${annot.body_depth_mm||'?'}×${annot.body_height_mm||'?'} mm`;
} else if (key === 'chipsmith_pin1') {
childLabel = 'pin-1 indicator';
} else if (key === 'chipsmith_ep') {
childLabel = `exposed pad ${annot.ep_width_mm||'?'}×${annot.ep_depth_mm||'?'} mm`;
} else if (annot.signal || annot.name) {
childLabel = `${annot.pin_number || annot.number || '?'} (${annot.signal || annot.name})`;
} else if (annot.pin_number || annot.number) {
childLabel = `pad ${annot.pin_number}`;
} else {
childLabel = key.replace('chipsmith_', '').replace(/_/g, ' ');
}
children.push({ meshes, label: childLabel, kind: 'overlay-generated-child' });
}
const parentLabel = tag === 'chip'
? `Adom 3D chip (${children.length} parts, generated from datasheet)`
: `Adom footprint (${children.length} pads, extruded copper at z=0)`;
const tooltip = tag === 'chip'
? `Adom 3D chip (generated)\n\nParametric chip body + leads built by Adom from the\nmanufacturer datasheet mechanical drawing via ds2sf +\nservice-step2glb /create-chip (OCCT). Copyright-free.\n\n${annotations?.document?.license || ''}`
: `Adom footprint (generated)\n\nExtruded copper pads built by Adom from ds2sf pad\npositions via /create-footprint (OCCT). Pad top at\nz=0, copper extends 35 µm downward.\n\n${annotations?.document?.license || ''}`;
window.adomStep.addOverlay({ group, meshes: [], label: parentLabel, tooltip, kind: 'overlay-generated', children });
state[tag === 'chip' ? 'generatedChipLoaded' : 'generatedFpLoaded'] = true;
state[tag === 'chip' ? 'chipAnnotations' : 'fpAnnotations'] = annotations;
console.log(`[chipsmith] ${tag} GLB loaded: ${allMeshes.length} meshes, ${children.length} products`);
} catch (e) {
console.log(`[chipsmith] generate-${tag}-glb failed: ${e.message || e}`);
}
}
// ── AI-drivability surface ────────────────────────────────────
// Every UI verb on the right-pane gets a JS hook so demo scripts and
// tests can drive it without click-by-click DOM walking. CLI parity
// is via `adom-chipsmith eval "<expr>"`.
window.adomChipsmith = window.adomChipsmith || {};
Object.assign(window.adomChipsmith, {
overlayLoaded: true,
version: 'overlay-v0.3',
// Core state access + control.
state: () => state,
setActiveTab,
refreshStatus: refreshStatusPills,
reload: init,
// Per-tab actions.
runMeasurements: () => { renderMeasurementsTab(); recomputeAllStatuses(); return state.measurements; },
recomputeStatuses: recomputeAllStatuses,
placePin1, // (corner | {mode, shape, …})
// Click-to-place arming — UI counterpart calls these from the
// Pin-1 tab toggle. Programmatic callers can also arm + simulate
// a pick via window.adomChipsmith.placePin1({mode:'click', xyMm}).
armPin1Click,
disarmPin1Click,
// Laser-etch — cosmetic MPN engraving on the chip's top face.
// Toggle via setLaserEtchVisible(true|false). renderLaserEtch
// takes opts: {text, depth_mm, max_size_mm:[w,h]}.
renderLaserEtch,
setLaserEtchVisible: (on, opts) => {
if (on) return renderLaserEtch(opts || {});
ensureOverlayDisposed('laser-etch');
state.laserEtch = null;
return { ok: true, hidden: true };
},
// Debug — show four colored slabs marking the chipsmith-internal
// anchor planes (chip-top, chip-bottom, footprint-area, pcb-top).
// Toggle on when reporting "your placement is wrong" — the slabs
// show whether chipsmith's understanding of "the body's top face"
// matches reality.
renderDebugAnchors,
setDebugAnchorsVisible: (on) => {
if (on) return renderDebugAnchors();
ensureOverlayDisposed('debug-anchors');
state.debugAnchorsOn = false;
return { ok: true, hidden: true };
},
removePin1User: () => {
ensureOverlayDisposed('user-pin1');
state.userPin1Corner = null;
recomputeAllStatuses();
if (state.activeTab === 'pin1') renderPin1Tab();
return { ok: true };
},
// Alias matching the CLI verb `set-overlay pin1 off`.
removePin1Indicator: () => {
ensureOverlayDisposed('user-pin1');
state.userPin1Corner = null;
recomputeAllStatuses();
if (state.activeTab === 'pin1') renderPin1Tab();
return { ok: true };
},
// 3D overlay toggles (programmatic counterparts to the buttons).
setFootprintOverlay: (src, on) => {
const fps = state.footprintSources;
if (!fps || !fps[src] || fps[src]._stub) return { error: 'no source ' + src };
state.fpOverlaysOn = state.fpOverlaysOn || {};
if (on) renderFootprintOverlay(src, fps[src].pads);
else ensureOverlayDisposed('fp-' + src);
state.fpOverlaysOn[src] = !!on;
if (state.activeTab === 'footprint') renderFootprintTab();
return { ok: true, src, on: !!on };
},
setAllFootprintOverlays: (on) => {
const fps = state.footprintSources;
if (!fps) return { error: 'no footprint sources loaded' };
const present = ['kicad','fusion','altium','datasheet','ds2sf'].filter(s => fps[s] && !fps[s]._stub && fps[s].pad_count > 0);
state.fpOverlaysOn = state.fpOverlaysOn || {};
for (const s of present) {
if (on && !state.fpOverlaysOn[s]) renderFootprintOverlay(s, fps[s].pads);
else if (!on && state.fpOverlaysOn[s]) ensureOverlayDisposed('fp-' + s);
state.fpOverlaysOn[s] = !!on;
}
if (state.activeTab === 'footprint') renderFootprintTab();
return { ok: true, count: present.length, on: !!on };
},
// Stock-library overlay — fetch + show/hide + status. Backed by
// service-kicad via /api/kicad-stock; the spec must carry
// footprint_name (and ideally kicad_3d_name) for the lookup to
// succeed. Returns { loaded, parsed, fetched_url } on success.
loadStockLibraryOverlay: (force) => loadStockLibraryOverlay({ force: !!force }),
setStockOverlayVisible: (on) => {
const parsed = state.stockOverlay?.parsed;
if (!parsed) return { error: 'stock library not loaded — call loadStockLibraryOverlay() first' };
state.fpOverlaysOn = state.fpOverlaysOn || {};
if (on) renderFootprintOverlay('stock', parsed.pads);
else ensureOverlayDisposed('fp-stock');
state.fpOverlaysOn.stock = !!on;
if (state.activeTab === 'footprint') renderFootprintTab();
return { ok: true, on: !!on };
},
setFauxPcbVisible: (on) => {
const fps = state.footprintSources;
if (!fps) return { error: 'no footprint sources loaded' };
const present = ['kicad','fusion','altium','datasheet','ds2sf'].filter(s => fps[s] && !fps[s]._stub && fps[s].pads?.length);
if (on) {
let bestSrc = null, bestExt = null;
for (const s of present) {
const e = fpExtent(fps[s].pads);
if (e && (!bestExt || e.width > bestExt.width)) { bestExt = e; bestSrc = s; }
}
renderFauxPcbPlate(bestExt, bestSrc ? fps[bestSrc].pads : null);
} else {
ensureOverlayDisposed('faux-pcb');
}
state.fauxPcbOn = !!on;
if (state.activeTab === 'footprint') renderFootprintTab();
return { ok: true, on: !!on };
},
setDs2sfVisible: async (on) => {
if (on) {
if (!state.ds2sfResult?.available) return { error: 'no ds2sf result for this chip' };
const r = await renderDs2sfOverlay();
if (state.activeTab === 'footprint') renderFootprintTab();
return { ok: !!state.ds2sfOverlayOn, on: !!state.ds2sfOverlayOn, ...r };
} else {
ensureOverlayDisposed('ds2sf');
state.ds2sfOverlayOn = false;
if (state.activeTab === 'footprint') renderFootprintTab();
return { ok: true, on: false };
}
},
setHeatsinkVisible: (on, opts) => {
if (!state.heatsinkGrid) return { error: 'no heatsink grid — run Tab 7 first' };
const ep = state.heatsinkGrid.ep;
if (on) renderHeatsink(ep.origin_mm, { width: ep.size_mm[0], height: ep.size_mm[1] }, state.heatsinkGrid.grid, opts || {});
else { ensureOverlayDisposed('heatsink'); state.epCopperPour = null; }
state.heatsinkOn = !!on;
state.viasOn = !!on;
state.epPourOn = !!on;
if (state.activeTab === 'heatsink') renderHeatsinkTab();
return { ok: true, on: !!on, pour: state.epCopperPour };
},
setSolderJetVisible: (on) => {
if (!state.solderjet || !state.solderjet.domes) return { error: 'no solder-jet layout — run Tab 8 first' };
if (on) renderSolderJetOverlay(state.solderjet.domes);
else ensureOverlayDisposed('solder-jet');
state.solderjetOn = !!on;
if (state.activeTab === 'solderjet') renderSolderjetTab();
return { ok: true, on: !!on };
},
// Sign-off API.
signoff: {
gates: () => gateState(),
canSignOff: () => canSignOff(),
signOff: async () => {
const { ok, blockers } = canSignOff();
if (!ok) return { error: 'blocked', blockers };
const payload = {
mpn: state.spec?.mpn,
measurements: state.measurements,
heatsink: state.heatsinkGrid, ep_copper_pour: state.epCopperPour || null,
solderjet: state.solderjet ? {
droplet_mm: state.solderjet.droplet_mm,
total: state.solderjet.total,
pruned: state.solderjet.pruned,
} : null,
user_placed_pin1: state.userPin1Corner ? { corner: state.userPin1Corner } : null,
decorative_product_names: state.decorativeProductNames || [],
signed_at: new Date().toISOString(),
};
const r = await fetch('api/signoff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const j = await r.json();
// Cache the result so renderSignoffTab can render the artifacts
// panel (sidecar + step + enclosing folder rows + reveal icons)
// even when sign-off was triggered programmatically (CLI / AI
// hook), not via the button.
if (j && j.ok) {
state.signoffResult = j;
if (state.activeTab === 'signoff') renderSignoffTab();
}
return j;
},
},
});
// ── Hover-inspect HUD ───────────────────────────────────────────
// Floats next to the cursor whenever it's over the 3D canvas.
// Names what the user is looking at in plain language BEFORE they
// have to click anything. Per the PLAN "Hover-inspect HUD" section.
// Pickable filtered to chipsmith-tagged meshes + body / pads / etc;
// throttled to one update per render frame; suppressed during camera
// drag. Toggle: 'i' key or right-edge inspect button.
function setupHoverHud() {
if (!(window.adomStep && window.adomStep.getScene)) {
setTimeout(setupHoverHud, 500);
return;
}
const scene = window.adomStep.getScene();
if (!scene) {
setTimeout(setupHoverHud, 500);
return;
}
const card = document.createElement('div');
card.id = 'cs-hover-hud';
card.style.cssText = `position:absolute; pointer-events:none; z-index:55;
display:none; background:rgba(20,24,30,0.94);
border:1px solid rgba(255,255,255,0.14); border-left:3px solid #00b8b1;
border-radius:6px; padding:10px 12px; min-width:200px; max-width:320px;
font-family:'Satoshi',sans-serif; font-size:11.5px; color:#e6edf3;
box-shadow:0 6px 18px rgba(0,0,0,0.55);
backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);`;
const style = document.createElement('style');
style.textContent = `
#cs-hover-hud .title { font-family:'Familjen Grotesk',sans-serif; font-weight:600; margin-bottom:5px; }
#cs-hover-hud .row { display:flex; justify-content:space-between; gap:12px; padding:2px 0; line-height:1.45; }
#cs-hover-hud .row span { color:rgba(200,210,225,0.5); flex-shrink:0; min-width:56px; font-size:10.5px; }
#cs-hover-hud .row b { color:#e6edf3; font-weight:500; text-align:right; word-break:break-word; }
#cs-hover-hud .row b.mono { font-family:'JetBrains Mono',monospace; font-size:10.5px; }
`;
document.head.appendChild(style);
document.body.appendChild(card);
let suspended = false; // suspended during camera drag
let hoverEnabled = true; // toggleable via 'i' / Esc
let lastFrame = 0;
// Categorize a picked mesh into a kind label + tint. Mirrors the
// PLAN's category table.
const KIND_TINTS = {
body: { label: 'chip body', tint: '#888' },
pin1: { label: 'pin-1 indicator', tint: '#ffb74d' },
pad: { label: 'SMD pad', tint: '#00b8b1' },
lead: { label: 'SMD leg', tint: '#9d6cff' },
ball: { label: 'BGA ball', tint: '#cfd8dc' },
ep: { label: 'exposed pad / thermal', tint: '#ff7043' },
tab: { label: 'reinforcement tab', tint: '#90a4ae' },
marker: { label: 'silkscreen marker', tint: '#fff' },
'overlay-footprint': { label: 'footprint pad (overlay)', tint: '#1de9b6' },
'overlay-pcb': { label: 'FR4 board (faux)', tint: '#66bb6a' },
'overlay-vias': { label: 'heatsink via', tint: '#c79a4f' },
'overlay-solderjet': { label: 'solder-jet dome', tint: '#cfd8dc' },
'overlay-pin1-user': { label: 'pin-1 indicator (user)', tint: '#ffb74d' },
};
function classifyMesh(mesh) {
if (!mesh || !mesh.name) return null;
const n = mesh.name;
// Overlay meshes — name pattern starts with "fp-<src>-pad-..."
// or "faux-pcb-..." or "heatsink-vias-..." etc.
if (n.startsWith('fp-')) {
const m = n.match(/^fp-(\w+)-pad-(.+?)$/);
return m
? { kind: 'overlay-footprint', label: KIND_TINTS['overlay-footprint'].label, tint: KIND_TINTS['overlay-footprint'].tint, detail: { source: m[1], pad: m[2] } }
: null;
}
if (n === 'faux-pcb-plate') return { kind: 'overlay-pcb', ...KIND_TINTS['overlay-pcb'], detail: state.fauxPcbDims || {} };
if (n.startsWith('heatsink-vias-bore')) {
const m = n.match(/-(\d+)$/);
return { kind: 'overlay-vias', ...KIND_TINTS['overlay-vias'], detail: { via: m && m[1] } };
}
if (n.startsWith('solder-jet-')) return { kind: 'overlay-solderjet', ...KIND_TINTS['overlay-solderjet'], detail: {} };
if (n.startsWith('user-pin1-')) return { kind: 'overlay-pin1-user', ...KIND_TINTS['overlay-pin1-user'], detail: {} };
// OCCT-generated products from /create-chip and /create-footprint.
// Mesh names are "chipsmith_lead_1_primitive0" etc — walk to the
// parent TransformNode to get the product name, then look up the
// rich annotation metadata from the embedded chipsmith_metadata.
let prodName = null;
let cur = mesh.parent || mesh;
while (cur) {
const pn = cur.name || '';
if (pn.startsWith('chipsmith_')) { prodName = pn; break; }
cur = cur.parent;
}
if (!prodName && (n.startsWith('chipsmith_') || (mesh.parent && mesh.parent.name && mesh.parent.name.startsWith('chipsmith_')))) {
prodName = mesh.parent?.name || n;
}
if (prodName && !prodName.startsWith('chipsmith_metadata')) {
const chipMeta = state.chipAnnotations?.products?.[prodName];
const fpMeta = state.fpAnnotations?.products?.[prodName];
const meta = chipMeta || fpMeta || {};
if (prodName === 'chipsmith_body') {
return { kind: 'body', label: 'chip body (generated)', tint: '#888', detail: { ...meta, source: 'OCCT' } };
}
if (prodName === 'chipsmith_pin1') {
return { kind: 'pin1', label: 'pin-1 indicator (generated)', tint: '#ffb74d', detail: meta };
}
if (prodName === 'chipsmith_ep') {
return { kind: 'ep', label: 'exposed pad (generated)', tint: '#ff7043', detail: meta };
}
if (prodName.startsWith('chipsmith_lead_')) {
const sig = meta.signal || '';
const fn = meta.function || '';
return { kind: 'lead', label: sig ? `lead ${meta.pin_number} (${sig})` : prodName, tint: '#d4a84b', detail: { ...meta, source: 'OCCT' } };
}
if (prodName.startsWith('chipsmith_fp_pad_')) {
const sig = meta.signal || '';
return { kind: 'overlay-footprint', label: sig ? `pad ${meta.pin_number} (${sig})` : prodName, tint: '#c79a4f', detail: { ...meta, source: 'OCCT' } };
}
}
// Non-overlay, non-generated meshes — walk the parent chain.
cur = mesh.parent;
let parentName = null;
while (cur) {
if (cur.name && !cur.name.startsWith('__') && cur.name !== 'rootnode') {
parentName = cur.name;
break;
}
cur = cur.parent;
}
if (parentName) {
const lower = parentName.toLowerCase();
if (lower === 'body') return { kind: 'body', ...KIND_TINTS.body, detail: { name: parentName } };
if (lower.includes('contact') || lower.includes('lead') || lower.includes('pin'))
return { kind: 'lead', ...KIND_TINTS.lead, detail: { name: parentName } };
if (lower.includes('marker'))
return { kind: 'marker',...KIND_TINTS.marker,detail: { name: parentName } };
// Generic fallback — surface the PRODUCT name + raw mesh name so
// the user has SOMETHING readable rather than a silent miss.
return { kind: 'body', label: 'chip part — ' + parentName, tint: '#90a4ae', detail: { name: parentName } };
}
return null;
}
function fmtBbox(mesh) {
try {
mesh.computeWorldMatrix(true);
const bi = mesh.getBoundingInfo();
const lo = bi.boundingBox.minimumWorld, hi = bi.boundingBox.maximumWorld;
const sx = (hi.x - lo.x) * 1000, sy = (hi.y - lo.y) * 1000, sz = (hi.z - lo.z) * 1000;
return `${sx.toFixed(2)} × ${sz.toFixed(2)} × ${sy.toFixed(2)} mm`;
} catch (_) { return '—'; }
}
function lookupPadData(source, padNum) {
const fps = state.footprintSources;
if (!fps || !fps[source]) return null;
const pads = fps[source].pads;
if (!Array.isArray(pads)) return null;
return pads.find(p => String(p.number) === String(padNum));
}
function renderAnnotatedCard(detailLines, meta) {
if (meta.source === 'OCCT') detailLines.push(`<div class="row"><span>source</span><b>STEP file (OCCT-generated)</b></div>`);
if (meta.pin_number) detailLines.push(`<div class="row"><span>pad</span><b>${esc(meta.pin_number)}</b></div>`);
if (meta.signal) detailLines.push(`<div class="row"><span>signal</span><b>${esc(meta.signal)}</b></div>`);
if (meta.function) detailLines.push(`<div class="row"><span>function</span><b style="font-weight:400;font-size:10.5px">${esc(meta.function)}</b></div>`);
if (meta.lead_shape) detailLines.push(`<div class="row"><span>lead shape</span><b>${esc(meta.lead_shape)}</b></div>`);
if (meta.side) detailLines.push(`<div class="row"><span>side</span><b>${esc(meta.side)}</b></div>`);
if (meta.shape) detailLines.push(`<div class="row"><span>shape</span><b>${esc(meta.shape)}${meta.corner_radius_mm ? ' r=' + meta.corner_radius_mm + ' mm' : ''}</b></div>`);
const sz = meta.size_mm;
if (Array.isArray(sz)) detailLines.push(`<div class="row"><span>pad size</span><b class="mono">${sz[0].toFixed(2)} × ${sz[1].toFixed(2)} mm</b></div>`);
const ct = meta.center_mm;
if (Array.isArray(ct)) detailLines.push(`<div class="row"><span>center</span><b class="mono">(${ct[0].toFixed(3)}, ${ct[1].toFixed(3)}) mm</b></div>`);
const ly = meta.layers;
if (Array.isArray(ly)) detailLines.push(`<div class="row"><span>layers</span><b>${esc(ly.join(' + '))}</b></div>`);
if (meta.body_width_mm) detailLines.push(`<div class="row"><span>body</span><b class="mono">${meta.body_width_mm} × ${meta.body_depth_mm} × ${meta.body_height_mm} mm</b></div>`);
if (meta.standoff_mm) detailLines.push(`<div class="row"><span>standoff</span><b class="mono">${meta.standoff_mm} mm</b></div>`);
if (meta.ep_width_mm) detailLines.push(`<div class="row"><span>EP size</span><b class="mono">${meta.ep_width_mm} × ${meta.ep_depth_mm} mm</b></div>`);
}
function renderCard(c) {
card.style.borderLeftColor = c.cls.tint;
const detailLines = [];
const d = c.cls.detail || {};
// OCCT-generated products — read annotation metadata from the STEP
if (d.source === 'OCCT' || d.pin_number || d.signal || d.body_width_mm) {
renderAnnotatedCard(detailLines, d);
}
// Babylon overlay pads (ds2sf pads / kicad / fusion / altium)
else if (c.cls.kind === 'overlay-footprint' && d.pad) {
const pd = lookupPadData(d.source, d.pad);
const srcLabel = (SRC_LABEL[d.source] || d.source);
detailLines.push(`<div class="row"><span>source</span><b>${esc(srcLabel)}</b></div>`);
detailLines.push(`<div class="row"><span>pad</span><b>${esc(d.pad)}</b></div>`);
if (pd) {
if (pd.name) detailLines.push(`<div class="row"><span>signal</span><b>${esc(pd.name)}</b></div>`);
if (pd.function) detailLines.push(`<div class="row"><span>function</span><b style="font-weight:400;font-size:10.5px">${esc(pd.function)}</b></div>`);
detailLines.push(`<div class="row"><span>shape</span><b>${esc(pd.shape || 'rect')}${pd.corner_radius ? ' r=' + pd.corner_radius.toFixed(2) + ' mm' : ''}</b></div>`);
if (pd.size) detailLines.push(`<div class="row"><span>pad size</span><b class="mono">${pd.size[0].toFixed(2)} × ${pd.size[1].toFixed(2)} mm</b></div>`);
if (pd.at) detailLines.push(`<div class="row"><span>center</span><b class="mono">(${pd.at[0].toFixed(3)}, ${pd.at[1].toFixed(3)}) mm</b></div>`);
if (pd.layers) detailLines.push(`<div class="row"><span>layers</span><b>${esc(pd.layers.join(' + '))}</b></div>`);
if (pd.drill) detailLines.push(`<div class="row"><span>drill</span><b class="mono">${pd.drill.diameter ? pd.drill.diameter.toFixed(2) + ' mm ' + pd.drill.shape : 'none'}</b></div>`);
}
}
// Generic chip parts
else if (c.cls.kind === 'pad' || c.cls.kind === 'ball' || c.cls.kind === 'lead') {
detailLines.push(`<div class="row"><span>name</span><b>${esc(d.name || c.mesh.name)}</b></div>`);
detailLines.push(`<div class="row"><span>size</span><b>${fmtBbox(c.mesh)}</b></div>`);
} else if (c.cls.kind === 'overlay-vias') {
detailLines.push(`<div class="row"><span>via</span><b>${esc(d.via || '?')}</b></div>`);
detailLines.push(`<div class="row"><span>size</span><b>${fmtBbox(c.mesh)}</b></div>`);
} else {
detailLines.push(`<div class="row"><span>size</span><b>${fmtBbox(c.mesh)}</b></div>`);
}
card.innerHTML = `
<div class="title" style="color:${c.cls.tint};">${esc(c.cls.label)}</div>
${detailLines.join('')}`;
}
function pointerMove(e) {
if (!hoverEnabled || suspended) { card.style.display = 'none'; return; }
const now = performance.now();
if (now - lastFrame < 16) return;
lastFrame = now;
// Babylon's scene.pointerX/Y only update on real input events; for
// synthetic events (AI hook hoverAt + tests) we derive canvas-local
// coords from clientX/Y instead.
const canvasEl = scene.getEngine().getRenderingCanvas();
const r = canvasEl ? canvasEl.getBoundingClientRect() : { left: 0, top: 0 };
const pickX = (e && typeof e.clientX === 'number') ? (e.clientX - r.left) : scene.pointerX;
const pickY = (e && typeof e.clientY === 'number') ? (e.clientY - r.top) : scene.pointerY;
const pickResult = scene.pick(pickX, pickY, m => {
if (!m || !m.isEnabled() || !m.isVisible) return false;
if (!m.getTotalVertices || m.getTotalVertices() <= 0) return false;
if (['skyBox','shadowGround','ground','ViewerRoot'].includes(m.name)) return false;
if ((m.name || '').startsWith('viewCube')) return false;
// Skip ghosted meshes (isolation sets alpha ~0.1) so the focused
// overlay pads underneath are reachable by the pick ray.
const mat = m.material;
if (mat && typeof mat.alpha === 'number' && mat.alpha < 0.15) return false;
return true;
});
if (!pickResult || !pickResult.hit || !pickResult.pickedMesh) {
card.style.display = 'none';
return;
}
const cls = classifyMesh(pickResult.pickedMesh);
if (!cls) { card.style.display = 'none'; return; }
renderCard({ mesh: pickResult.pickedMesh, cls });
// Position 12px down-right of cursor; flip if near edges.
const x = e.clientX + 14, y = e.clientY + 14;
const cw = card.offsetWidth, ch = card.offsetHeight;
const ww = window.innerWidth, wh = window.innerHeight;
card.style.left = (x + cw > ww ? e.clientX - cw - 14 : x) + 'px';
card.style.top = (y + ch > wh ? e.clientY - ch - 14 : y) + 'px';
card.style.display = 'block';
}
// Listen on the canvas (only fires when cursor is over the 3D view)
const canvas = scene.getEngine().getRenderingCanvas();
if (canvas) {
canvas.addEventListener('pointermove', pointerMove);
canvas.addEventListener('pointerleave', () => { card.style.display = 'none'; });
canvas.addEventListener('pointerdown', () => { suspended = true; card.style.display = 'none'; });
window.addEventListener('pointerup', () => { suspended = false; });
}
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'i' || e.key === 'I') {
hoverEnabled = !hoverEnabled;
if (!hoverEnabled) card.style.display = 'none';
} else if (e.key === 'Escape') {
hoverEnabled = false;
card.style.display = 'none';
}
});
// AI hooks per PLAN: hoverAt({x, y}) returns same payload as the
// visible card; lastHover() returns the most recent.
let lastHover = null;
const origRender = renderCard;
renderCard = (c) => { lastHover = { name: c.mesh.name, kind: c.cls.kind, label: c.cls.label }; origRender(c); };
window.adomChipsmith.hoverAt = ({ x, y }) => {
const fakeEvent = { clientX: x, clientY: y };
pointerMove(fakeEvent);
return lastHover;
};
window.adomChipsmith.lastHover = () => lastHover;
window.adomChipsmith.setHoverEnabled = (on) => {
hoverEnabled = !!on;
if (!hoverEnabled) card.style.display = 'none';
};
}
// ── Floating Sign-off HUD (always visible from every tab) ──────
// Per PLAN.md: "A floating verification card pinned to the top-right
// of the canvas, visible from every tab." Top-right is taken by the
// ViewCube + the right-pane HUD; we land at top-LEFT instead (offset
// below the Outline header so it doesn't cover the search box).
// Drag-positionable, collapsible to a compact badge, keyboard 'g'
// toggles. State persists in localStorage.
function setupSignoffHud() {
if (document.getElementById('cs-signoff-hud')) return;
const hud = document.createElement('div');
hud.id = 'cs-signoff-hud';
hud.innerHTML = `
<div class="cs-so-head">
<span class="cs-so-title">Sign-off</span>
<span class="cs-so-tally"></span>
<button class="cs-so-collapse" aria-label="Collapse" data-tooltip="Collapse to a compact badge that stays pinned. Click the badge to re-expand.">❮</button>
</div>
<div class="cs-so-body"></div>
<div class="cs-so-foot">
<button class="cs-btn cs-btn-primary cs-so-signoff-btn" disabled data-tooltip="Sign off — POST /api/signoff → writes <mpn>.chipsmith.json + bakes <mpn>.chipsmith.step. Disabled until every hard gate passes.">${svgIcon('bookOpen')}<span class="lbl">Sign off</span></button>
</div>
`;
document.body.appendChild(hud);
const HUD_KEY_POS = 'chipsmith.signoff-hud.pos';
const HUD_KEY_COLLAPSED = 'chipsmith.signoff-hud.collapsed';
function applyPos(pos) {
hud.style.top = pos.y + 'px';
hud.style.left = pos.x + 'px';
hud.style.right = 'auto';
hud.style.bottom = 'auto';
}
try {
const raw = localStorage.getItem(HUD_KEY_POS);
if (raw) applyPos(JSON.parse(raw));
} catch (_) {}
function setCollapsed(c) {
hud.classList.toggle('collapsed', c);
try { localStorage.setItem(HUD_KEY_COLLAPSED, c ? '1' : '0'); } catch (_) {}
}
try {
if (localStorage.getItem(HUD_KEY_COLLAPSED) === '1') setCollapsed(true);
} catch (_) {}
hud.querySelector('.cs-so-collapse').onclick = () => setCollapsed(true);
hud.querySelector('.cs-so-head').onclick = (e) => {
// Click anywhere on the head area while collapsed → expand. Drag
// start handled separately via mousedown below.
if (hud.classList.contains('collapsed') && !e.target.closest('.cs-so-collapse')) setCollapsed(false);
};
// Drag.
let dragOrigin = null;
hud.querySelector('.cs-so-head').addEventListener('mousedown', (e) => {
if (e.target.closest('.cs-so-collapse')) return;
const r = hud.getBoundingClientRect();
dragOrigin = { startX: e.clientX, startY: e.clientY, baseX: r.left, baseY: r.top };
hud.classList.add('dragging');
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragOrigin) return;
const dx = e.clientX - dragOrigin.startX, dy = e.clientY - dragOrigin.startY;
const x = Math.max(-hud.offsetWidth + 60, Math.min(window.innerWidth - 60, dragOrigin.baseX + dx));
const y = Math.max(44, Math.min(window.innerHeight - 60, dragOrigin.baseY + dy));
applyPos({ x, y });
});
document.addEventListener('mouseup', () => {
if (!dragOrigin) return;
const r = hud.getBoundingClientRect();
try { localStorage.setItem(HUD_KEY_POS, JSON.stringify({ x: r.left, y: r.top })); } catch (_) {}
hud.classList.remove('dragging');
dragOrigin = null;
});
// Keyboard 'g' toggles (gates) — picked because P/O/B/I/M are taken.
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'g' || e.key === 'G') {
setCollapsed(!hud.classList.contains('collapsed'));
}
});
return hud;
}
// Re-render the floating Sign-off HUD body. Called on every status
// change. Groups gates per the PLAN spec (Inputs / Integrity /
// Footprint / Pin-1 / Manufacturing).
function renderSignoffHud() {
const hud = document.getElementById('cs-signoff-hud');
if (!hud) return;
const body = hud.querySelector('.cs-so-body');
const tallyEl = hud.querySelector('.cs-so-tally');
const btn = hud.querySelector('.cs-so-signoff-btn');
if (!body) return;
// Build the structured gate list. Sections per PLAN.md.
const s = state.spec || {};
const fps = state.footprintSources || {};
const m = state.measurements;
const sj = state.solderjet;
const hs = state.heatsinkGrid;
const has = (k) => s[k] !== undefined && s[k] !== null && s[k] !== '' && s[k] !== '?';
const fpsPresent = ['kicad','fusion','altium','datasheet','ds2sf'].filter(k => fps[k] && !fps[k]._stub && fps[k].pad_count > 0);
const fpAnyPin1 = ['kicad','fusion','altium','datasheet','ds2sf'].some(k => fps[k] && fps[k].pin1_silk && fps[k].pin1_silk.found);
const userPin1 = !!state.userPin1Corner;
function row(id, label, pill, hard, tab, reason) {
return { id, label, pill, hard, tab, reason };
}
const sections = [
{ title: 'Inputs', rows: [
row('input_step', 'STEP loaded', state.paths?.step ? '●' : '○', true, 'source', state.paths?.step || '—'),
row('input_fp', 'Footprint loaded', fpsPresent.length > 0 ? '●' : '○', true, 'source', fpsPresent.join(', ') || 'none'),
row('input_datasheet','Datasheet loaded', state.paths?.datasheet ? '●' : '─', false, 'source', state.paths?.datasheet || '—'),
row('input_spec', 'Spec complete',
(has('pin_count') && has('structural_type') && Array.isArray(s.body_dims_mm)) ? '●'
: ([has('pin_count'), has('structural_type'), Array.isArray(s.body_dims_mm)].filter(Boolean).length ? '◐' : '○'),
true, 'source',
`pin_count=${s.pin_count || '—'}, structural_type=${s.structural_type || '—'}, body_dims_mm=${Array.isArray(s.body_dims_mm) ? '['+s.body_dims_mm.join(',')+']' : '—'}`),
]},
{ title: 'Integrity', rows: [
row('measurements', 'Measurements within ±5%',
m ? m.status : '○',
true, 'measurements',
m ? `applied ${m.applied_label}, max Δ ${(m.max_axis_pct*100).toFixed(2)}%` : 'not run'),
]},
{ title: 'Footprint', rows: [
row('fp_sources_loaded', 'Sources loaded',
fpsPresent.length === 0 ? '✗' : (fpsPresent.length >= 2 ? '●' : '◐'),
true, 'footprint',
fpsPresent.join(', ') || 'none'),
row('fp_bbox_fit', 'Bbox fit (chip ⊂ fp)',
(m && fpsPresent.length) ? '◐' : '─',
false, 'footprint',
'visual check on Tab 3'),
]},
{ title: 'Pin-1', rows: [
row('pin1_chip', 'Chip-side indicator', userPin1 ? '●' : '○', false, 'pin1', userPin1 ? `placed ${state.userPin1Corner}` : 'not placed'),
row('pin1_fp', 'Footprint silk marker', fpAnyPin1 ? '●' : '○', false, 'pin1', fpAnyPin1 ? 'present in at least one source' : 'no marker on any source'),
row('pin1_agree', 'Cross-side agreement', (userPin1 && fpAnyPin1) ? '◐' : '─', false, 'pin1', 'visual check'),
]},
{ title: 'Manufacturing', rows: [
row('mfg_heatsink', 'Heatsink vias generated (if EP)', hs ? '●' : '─', false, 'heatsink', hs ? `${hs.grid.count} vias under ${hs.ep.pad_number}` : 'no EP detected'),
row('mfg_solderjet', 'Solder-jet layout', sj ? '●' : '─', false, 'solderjet', sj ? `${sj.total} domes (Ø ${(sj.droplet_mm*1000).toFixed(0)} µm)` : 'not generated'),
row('mfg_etch', 'Laser-etch', '─', false, 'source', 'optional, opt-in'),
]},
];
// Tally = count of ● + ─ over total (excluding ─ from "fail" math
// but including in "satisfied" because N/A is satisfied-by-design).
const all = sections.flatMap(sec => sec.rows);
const total = all.length;
const passed = all.filter(r => r.pill === '●' || r.pill === '─').length;
const blockers = all.filter(r => r.hard && (r.pill === '○' || r.pill === '✗'));
const canSign = blockers.length === 0;
tallyEl.textContent = `${passed} / ${total} ✓`;
tallyEl.className = 'cs-so-tally ' + (canSign ? 'pass' : 'block');
btn.disabled = !canSign;
btn.classList.toggle('cs-btn-primary', canSign);
btn.classList.toggle('cs-btn-secondary', !canSign);
btn.title = canSign ? '' : `${blockers.length} hard gate${blockers.length === 1 ? '' : 's'} blocking: ${blockers.map(b => b.label).join(', ')}`;
btn.onclick = canSign ? doSignoff : null;
// Body markup.
body.innerHTML = sections.map(sec => `
<div class="cs-so-section">
<div class="cs-so-section-title">${esc(sec.title)}</div>
${sec.rows.map(r => {
const cls = ({'●':'ok','◐':'partial','○':'pending','✗':'fail','─':'na'}[r.pill] || 'pending');
return `<div class="cs-so-row" data-tab="${esc(r.tab)}" title="${escAttr(r.reason)}" data-tooltip="${escAttr(r.reason)} — click to jump to ${esc(r.tab)} tab">
<span class="cs-so-glyph ${cls}">${r.pill}</span>
<span class="cs-so-label${r.hard ? '' : ' soft'}">${esc(r.label)}</span>
</div>`;
}).join('')}
</div>
`).join('');
body.querySelectorAll('.cs-so-row').forEach(rEl => {
rEl.onclick = () => setActiveTab(rEl.dataset.tab);
});
}
async function doSignoff() {
if (!window.adomChipsmith?.signoff?.signOff) return;
const btn = document.querySelector('#cs-signoff-hud .cs-so-signoff-btn');
if (btn) { btn.disabled = true; btn.querySelector('.lbl').textContent = 'Signing off…'; }
try {
const r = await window.adomChipsmith.signoff.signOff();
if (btn) btn.querySelector('.lbl').textContent = r && r.ok ? 'Signed ✓' : 'Failed';
if (state.activeTab === 'signoff') renderSignoffTab();
} catch (e) {
console.error('[chipsmith] sign-off failed', e);
if (btn) btn.querySelector('.lbl').textContent = 'Failed — see console';
}
}
// Decorative geometry — flagging and bake-payload contribution.
// Per the plan, on /bake the flagged products get reparented under
// a `chipsmith_decorative` branch so downstream tools can default
// them off. chipsmith's responsibility here is to (a) auto-flag
// products whose names match marketing-mark patterns, and (b)
// surface a context-menu verb so the user can flag/unflag any
// product. The baked-side reparent is upstream of us in
// service-step2glb /bake.
const DECORATIVE_NAME_PATTERNS = [
/\b(logo|brand|trademark|copyright)\b/i,
/^[A-Z]{2,3}$/, // bare 2-3 letter manufacturer mark, e.g., "ST" / "TI" / "NXP"
/\b(silkscreen|laser|engrav)/i,
];
function isAutoDecorativeName(name) {
if (!name || typeof name !== 'string') return false;
return DECORATIVE_NAME_PATTERNS.some(re => re.test(name));
}
function setDecorative(name, on) {
if (!name) return;
const set = new Set(state.decorativeProductNames || []);
if (on) set.add(name); else set.delete(name);
state.decorativeProductNames = Array.from(set);
refreshSignoffHud();
}
function isDecorative(name) {
return (state.decorativeProductNames || []).includes(name);
}
function autoFlagDecorativeFromTree() {
if (!(window.adomStep && window.adomStep.listComponents)) return;
let changed = false;
const set = new Set(state.decorativeProductNames || []);
for (const c of window.adomStep.listComponents()) {
if (isAutoDecorativeName(c.name) && !set.has(c.name)) { set.add(c.name); changed = true; }
}
if (changed) state.decorativeProductNames = Array.from(set);
}
function registerCtxExtras() {
window.adomStepCtxMenuExtras = (window.adomStepCtxMenuExtras || []).filter(e => !String(e.id).startsWith('chipsmith-'));
window.adomStepCtxMenuExtras.push({
id: 'chipsmith-decorative',
visibleFor: (entry) => entry && !entry.isOverlay && !entry.isMaterialGroup && !!entry.name,
label: (entry) => isDecorative(entry.name)
? 'Unflag decorative — keep on canonical bake'
: 'Move to decorative branch on bake',
tooltip: (entry) => isDecorative(entry.name)
? `Currently flagged decorative — will be reparented under chipsmith_decorative on sign-off (default OFF in downstream tools). Click to unflag.`
: `Mark "${entry.name}" as decorative geometry. On sign-off the bake reparents it under chipsmith_decorative — visible by default in chipsmith but OFF in downstream Adom tools (chipfit, InstaPCB preview, etc.). Use for vendor logos, lens cosmetics, embossed marks. Geometry is moved, NEVER deleted.`,
onClick: (entry) => setDecorative(entry.name, !isDecorative(entry.name)),
});
}
function refreshSignoffHud() {
if (typeof recomputeAllStatuses === 'function') recomputeAllStatuses();
}
// Run after the adom-step shell + outline mount. Slight delay so the
// viewer has finished its initial frame and the canvas/right-edge is
// measurable.
function enrichSubjectPopover() {
try {
const sp = state.spec;
if (!sp) return;
const subj = document.getElementById('subject-info');
if (!subj) return;
if (subj.querySelector('.cs-chip-meta')) return;
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const addRow = (k, v) => {
if (!v) return;
const txt = esc(v.length > 120 ? v.slice(0, 117) + '...' : v);
const row = document.createElement('div');
row.className = 'popover-row cs-chip-meta';
row.innerHTML = '<span class="k">' + esc(k) + '</span><span class="v">' + txt + '</span>';
subj.appendChild(row);
};
addRow('manufacturer', sp.manufacturer);
addRow('package', sp.package || sp.package_family);
addRow('family', sp.family);
addRow('description', sp.description);
} catch (_) {}
}
function bootChipsmith() {
init();
setupHoverHud();
setupSignoffHud();
registerCtxExtras();
autoFlagDecorativeFromTree();
// Chipsmith always needs the bottom fill light — you're inspecting
// BGA balls, QFN pads, through-hole leg bottoms, footprint overlays.
// The base viewer defaults to OFF; chipsmith overrides to ON.
try {
const btn = document.getElementById('tbBottomLight');
const viewer = window.adomStep;
if (viewer && viewer.setBottomLight && !viewer.getBottomLightState()) {
viewer.setBottomLight(true);
if (btn) btn.classList.add('active');
try { localStorage.setItem('adom-step.bottomLight', '1'); } catch (_) {}
}
} catch (_) {}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(bootChipsmith, 250));
} else {
setTimeout(bootChipsmith, 250);
}
console.log('[chipsmith-overlay] loaded — branding + Phase 1 right-pane + Tab 1 Source live');
})();