Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831import { p2, number, wrap, randInt, byteInterval17, even, radians, lerp, randIntRange, randIntArr, clamp, signedCeil, rainbow, zebra, resetRainbowCache as numResetRainbowCache, resetZebraCache as numResetZebraCache, isHexString, hexToRgb, shiftRGB, cssColors, parseColorIndex, rand,} from "./num.mjs";
import * as mat4 from "../dep/gl-matrix/mat4.mjs";import * as vec2 from "../dep/gl-matrix/vec2.mjs";import * as vec3 from "../dep/gl-matrix/vec3.mjs";import * as vec4 from "../dep/gl-matrix/vec4.mjs";
import { any, repeat, nonvalue, flip as randomFlip } from "./help.mjs";import { Box } from "./geo.mjs";import { nanoid } from "../dep/nanoid/nanoid.js";
const { round, sign: mathSign, abs, ceil, floor, sin, cos, min, max, sqrt, PI } = Math;
let width, height, pixels;// 🎬 Classic software-renderer Z-buffer. Holds per-pixel depth in NDC Z space// (after perspective divide). Convention: lower = nearer, higher = farther.// `Number.MAX_VALUE` is the "infinitely far" sentinel so any real geometry// passes the first draw. Kept as a plain Array (not typed) because disk.mjs// resizes it with `depthBuffer.length = W*H` which only works on Arrays.const depthBuffer = [];let depthEnabled = true; // global toggle (pieces can flip this for debugging)
function clearDepthBuffer() { if (!depthEnabled) return; const needed = width * height; if (needed === 0) return; if (depthBuffer.length !== needed) depthBuffer.length = needed; depthBuffer.fill(Number.MAX_VALUE);}
const writeBuffer = [];const c = [255, 255, 255, 255];let c2 = null; // Alternate / secondary color support.const panTranslation = { x: 0, y: 0 }; // For 2d shifting using `pan` and `unpan`.let activeMask; // A box for totally masking the renderer.// This should work everywhere.const skips = [];
// 🖼️ ImageBitmap pixel extraction cache (for tape previews and other runtime bitmaps)const bitmapPixelCache = typeof WeakMap !== "undefined" ? new WeakMap() : null;let bitmapPixelCanvas = null;let bitmapPixelCtx = null;
function getBitmapExtractionContext(width, height) { // Prefer OffscreenCanvas when available (workers) if (typeof OffscreenCanvas !== "undefined") { if (!(bitmapPixelCanvas instanceof OffscreenCanvas)) { bitmapPixelCanvas = new OffscreenCanvas(width, height); bitmapPixelCtx = bitmapPixelCanvas.getContext("2d"); } if (bitmapPixelCanvas.width !== width) bitmapPixelCanvas.width = width; if (bitmapPixelCanvas.height !== height) bitmapPixelCanvas.height = height; if (!bitmapPixelCtx) bitmapPixelCtx = bitmapPixelCanvas.getContext("2d"); return bitmapPixelCtx; }
// Fallback to DOM canvas when running on the main thread if (typeof document !== "undefined") { if (!bitmapPixelCanvas || typeof bitmapPixelCanvas.getContext !== "function") { bitmapPixelCanvas = document.createElement("canvas"); } if (bitmapPixelCanvas.width !== width) bitmapPixelCanvas.width = width; if (bitmapPixelCanvas.height !== height) bitmapPixelCanvas.height = height; bitmapPixelCtx = bitmapPixelCanvas.getContext("2d"); return bitmapPixelCtx; }
return null;}
function ensureBufferPixels(buffer) { if (!buffer) return null;
if (buffer.pixels && buffer.pixels.length) return buffer.pixels;
if (bitmapPixelCache && bitmapPixelCache.has(buffer)) { return bitmapPixelCache.get(buffer); }
const isImageBitmap = typeof ImageBitmap !== "undefined" && buffer instanceof ImageBitmap; const isImageData = typeof ImageData !== "undefined" && buffer instanceof ImageData;
if (isImageData) { const pixels = buffer.data; bitmapPixelCache?.set(buffer, pixels); return pixels; }
if (isImageBitmap) { const ctx = getBitmapExtractionContext(buffer.width, buffer.height); if (!ctx) return null; ctx.clearRect(0, 0, buffer.width, buffer.height); ctx.drawImage(buffer, 0, 0); try { const imageData = ctx.getImageData(0, 0, buffer.width, buffer.height); const pixels = imageData.data; bitmapPixelCache?.set(buffer, pixels); return pixels; } catch (err) { console.warn("🎨 Unable to extract pixels from ImageBitmap", err); return null; } }
if (buffer.data && buffer.data.length) { // Generic typed array fallback (ImageData-like objects) return buffer.data; }
return null;}
let forceReplaceMode = false;
function setForceReplaceMode(enabled = false) { const previous = forceReplaceMode; forceReplaceMode = !!enabled; return previous;}
function withForceReplaceMode(callback) { const previous = setForceReplaceMode(true); try { return callback?.(); } finally { setForceReplaceMode(previous); }}
// Legacy global fade variables (kept for backward compatibility)// Note: New code should use local fade system via parseLocalFade/getLocalFadeColorlet fadeMode = false;let fadeColors = [];let fadeDirection = "horizontal";let fadeNeat = false;
// 🚨 GRAPH PERFORMANCE TRACKINGconst graphPerf = { enabled: false, functions: new Map(), lastFPS: 0, // Track current FPS for frame skipping track(name, duration) { if (!this.enabled) return; if (!this.functions.has(name)) { this.functions.set(name, { count: 0, totalTime: 0, maxTime: 0 }); } const stats = this.functions.get(name); stats.count++; stats.totalTime += duration; stats.maxTime = Math.max(stats.maxTime, duration); // Warn about slow functions (reduced threshold for important debugging) if (duration > 15) { console.warn(`🐌 SLOW GRAPH FUNCTION: ${name} took ${duration.toFixed(2)}ms`); } }, reset() { this.functions.clear(); }, getStats() { const stats = []; this.functions.forEach((data, name) => { stats.push({ name, count: data.count, totalTime: data.totalTime, avgTime: data.totalTime / data.count, maxTime: data.maxTime }); }); return stats.sort((a, b) => b.totalTime - a.totalTime); }};
// Make graphPerf globally accessible for performance monitoringif (typeof window !== 'undefined') { window.graphPerf = graphPerf;}
// Also try globalThis for broader compatibilityif (typeof globalThis !== 'undefined') { globalThis.graphPerf = graphPerf;}
// 📚 DOCUMENTATION: Fade support - "fade:color1-color2-color3" syntax// Fade state - used by ink() function for smooth color transitions// Removed global fade state - everything is now local!let currentKidLispContext = null; // For dynamic evaluation of fade directionslet currentRainbowColor = null; // Cache rainbow color for current drawing operationlet currentZebraColor = null; // Cache zebra color for current drawing operation
let debug = false;export function setDebug(newDebug) { debug = newDebug;}
let matrixChunkyDebugCount = 0;
function matrixDebugEnabled() { if (typeof window !== "undefined" && window?.acMatrixDebug) return true; if (typeof globalThis !== "undefined" && globalThis?.acMatrixDebug) return true; return false;}
function inkFloodLoggingEnabled() { if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_COLORS) return true; if (typeof process !== "undefined" && process.env?.AC_LOG_INK_COLORS === "1") return true; return false;}
function inkFloodLogPrefix() { let label = null; if (typeof process !== "undefined" && process.env?.AC_LOG_INK_LABEL) { label = process.env.AC_LOG_INK_LABEL; } else if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_LABEL) { label = globalThis.AC_LOG_INK_LABEL; } return label ? `[${label}] ` : "";}
function cloneColorForLog(color) { if (Array.isArray(color)) return Array.from(color); return color;}
function cloneValueForLog(value) { if (Array.isArray(value)) return Array.from(value); if (value && typeof value === "object") { try { return JSON.parse(JSON.stringify(value)); } catch (err) { return String(value); } } return value;}
// 🎨 FADE HELPERS: New local fade detection and parsing// These replace the global fade state management system
/** * Check if a color contains a fade string * @param {Array} color - Color array that might contain fade string * @returns {boolean} - True if color contains fade string */function isFadeColor(color) { return Array.isArray(color) && color.length > 0 && typeof color[0] === 'string' && color[0].startsWith('fade:');}
/** * Parse fade color locally without setting global state * @param {Array} color - Color array containing fade string * @returns {Object|null} - Fade info object or null if not a fade *//** * Parse fade colors locally without setting any global state * @param {string} fadeType - The fade type (e.g., "red-blue", "rainbow-black-rainbow") * @param {number} alpha - Alpha value to apply to all colors * @returns {Array} - Array of color arrays */function parseLocalFade(fadeType, alpha = 255) { // Replace :frame with current frame count if available let processedFadeType = fadeType; if (fadeType.includes("frame") && currentKidLispContext && currentKidLispContext.frameCount !== undefined) { processedFadeType = fadeType.replace(/frame/g, currentKidLispContext.frameCount.toString()); } // Handle color combinations separated by dashes const colorNames = processedFadeType.split("-"); const colors = []; // For symmetric patterns, we want the same sequence colors to have the same base // but still support consecutive different colors like rainbow-rainbow-rainbow let rainbowBaseOffset = 0; let zebraBaseOffset = 0; for (let i = 0; i < colorNames.length; i++) { const colorName = colorNames[i]; if (colorName === "rainbow") { // Check if this is a consecutive rainbow (previous color was also rainbow) const consecutiveOffset = (i > 0 && colorNames[i-1] === "rainbow") ? 1 : 0; colors.push(["rainbow", 255, 255, 255, alpha, rainbowBaseOffset + consecutiveOffset]); if (consecutiveOffset > 0) rainbowBaseOffset++; } else if (colorName === "zebra") { // Check if this is a consecutive zebra (previous color was also zebra) const consecutiveOffset = (i > 0 && colorNames[i-1] === "zebra") ? 1 : 0; colors.push(["zebra", 255, 255, 255, alpha, zebraBaseOffset + consecutiveOffset]); if (consecutiveOffset > 0) zebraBaseOffset++; } else if (/^c\d+$/i.test(colorName)) { // Color index like c0, c1, etc. const indexColor = parseColorIndex(colorName.toLowerCase()); if (indexColor) { colors.push([...indexColor.slice(0, 3), alpha]); } } else { // Named CSS color const color = cssColors[colorName]; if (color) { colors.push([...color.slice(0, 3), alpha]); } else { // If color not found, use a default color colors.push([128, 128, 128, alpha]); } } } return colors.length >= 2 ? colors : null;}
function parseFadeColor(color) { if (!isFadeColor(color)) return null; const fadeString = color[0]; const alpha = color[1] || 255; // Second element is alpha if provided // Parse fade string locally without setting global state const parts = fadeString.split(":"); if (parts.length < 2 || parts.length > 5 || parts[0] !== "fade") { return null; } // Extract direction and neat flag locally (neat is now the default) let isNeat = true; // Changed: neat is now the default let fadeType = parts[1]; let direction = parts[2] || "horizontal"; // Handle different dirty syntax positions (dirty disables neat) if (parts[1] === "dirty" && parts[2]) { isNeat = false; fadeType = parts[2]; direction = parts[3] || "horizontal"; } else if (parts[2] === "dirty") { isNeat = false; direction = "horizontal"; } else if (parts[3] === "dirty") { isNeat = false; direction = parts[2]; } else if (parts.includes("dirty")) { // Handle dirty anywhere in the string isNeat = false; const filteredParts = parts.filter(p => p !== "dirty"); if (filteredParts.length >= 2) { fadeType = filteredParts[1]; direction = filteredParts[2] || "horizontal"; } } // Also still support explicit 'neat' keyword for clarity (though it's the default) else if (parts[1] === "neat" && parts[2]) { isNeat = true; fadeType = parts[2]; direction = parts[3] || "horizontal"; } else if (parts[2] === "neat") { isNeat = true; direction = "horizontal"; } else if (parts[3] === "neat") { isNeat = true; direction = parts[2]; } else if (parts.includes("neat")) { isNeat = true; const filteredParts = parts.filter(p => p !== "neat"); if (filteredParts.length >= 2) { fadeType = filteredParts[1]; direction = filteredParts[2] || "horizontal"; } } // Parse fade colors using the local fade type and alpha const fadeColors = parseLocalFade(fadeType, alpha); if (!fadeColors) return null; // Evaluate the direction (handles dynamic expressions like frame numbers) const evaluatedDirection = evaluateFadeDirection(direction);
return { colors: fadeColors, direction: evaluatedDirection, // Use evaluated direction (can be numeric angle) neat: isNeat // Local neat flag, no global state };}
/** * Get interpolated fade color locally without using global state * @param {number} t - Position along fade (0-1) * @param {number} x - X coordinate for noise (optional) * @param {number} y - Y coordinate for noise (optional) * @param {Object|Array} fadeInfo - Local fade info from parseFadeColor (object) or parseLocalFade (array) * @returns {Array} - RGBA color array */function getLocalFadeColor(t, x = 0, y = 0, fadeInfo) { if (!fadeInfo) { return c.slice(); // Return current color if no fade info } // Handle both array (from parseLocalFade) and object (from parseFadeColor) formats const colors = Array.isArray(fadeInfo) ? fadeInfo : fadeInfo.colors; if (!colors || colors.length < 2) { return c.slice(); // Return current color if invalid colors } // Clamp t to 0-1 range t = Math.max(0, Math.min(1, t)); // Calculate which colors to interpolate between const segments = colors.length - 1; const segment = Math.min(Math.floor(t * segments), segments - 1); const localT = (t * segments) - segment; const color1 = colors[segment]; const color2 = colors[segment + 1] || colors[segment]; // Fallback to same color if out of bounds // Safety check to prevent undefined access if (!color1 || !color2) { console.warn("🚨 getLocalFadeColor: Invalid color data", { segment, colors, t }); return c.slice(); // Return current color as fallback } // Handle special dynamic colors (zebra first, then rainbow) if (color1[0] === "zebra" || color2[0] === "zebra") { // Don't use cached zebra color - always get fresh colors with offsets // This fixes the cyan zebra issue when zebra is first in the chain // Properly interpolate between zebra and regular colors if (color1[0] === "zebra" && color2[0] === "zebra") { // Both are zebra, get colors with their respective offsets const offset1 = color1[5] || 0; // Get offset from 6th element const offset2 = color2[5] || 0; const zebra1 = zebra(offset1); const zebra2 = zebra(offset2); const alpha1 = color1[4] || 255; const alpha2 = color2[4] || 255; const alpha = Math.round(alpha1 + (alpha2 - alpha1) * localT); return [ Math.round(zebra1[0] + (zebra2[0] - zebra1[0]) * localT), Math.round(zebra1[1] + (zebra2[1] - zebra1[1]) * localT), Math.round(zebra1[2] + (zebra2[2] - zebra1[2]) * localT), alpha ]; } else if (color1[0] === "zebra") { // Interpolate from zebra to regular/rainbow color const offset1 = color1[5] || 0; const zebra1 = zebra(offset1); const alpha1 = color1[4] || 255; // Handle if color2 is also a special color let color2RGB; let alpha2; if (color2[0] === "rainbow") { const offset2 = color2[5] || 0; color2RGB = rainbow(offset2); alpha2 = color2[4] || 255; } else { color2RGB = [color2[0], color2[1], color2[2]]; alpha2 = color2[3] || 255; } return [ Math.round(zebra1[0] + (color2RGB[0] - zebra1[0]) * localT), Math.round(zebra1[1] + (color2RGB[1] - zebra1[1]) * localT), Math.round(zebra1[2] + (color2RGB[2] - zebra1[2]) * localT), Math.round(alpha1 + (alpha2 - alpha1) * localT) ]; } else { // Interpolate from regular color to zebra const offset2 = color2[5] || 0; const zebra2 = zebra(offset2); const alpha1 = color1[3] || 255; const alpha2 = color2[4] || 255; return [ Math.round(color1[0] + (zebra2[0] - color1[0]) * localT), Math.round(color1[1] + (zebra2[1] - color1[1]) * localT), Math.round(color1[2] + (zebra2[2] - color1[2]) * localT), Math.round(alpha1 + (alpha2 - alpha1) * localT) ]; } } if (color1[0] === "rainbow" || color2[0] === "rainbow") { // Don't use cached rainbow color - always get fresh colors with offsets // This keeps rainbow consistent with zebra behavior // Properly interpolate between rainbow and regular colors if (color1[0] === "rainbow" && color2[0] === "rainbow") { // Both are rainbow, get colors with their respective offsets const offset1 = color1[5] || 0; // Get offset from 6th element const offset2 = color2[5] || 0; const rainbow1 = rainbow(offset1); const rainbow2 = rainbow(offset2); const alpha1 = color1[4] || 255; const alpha2 = color2[4] || 255; const alpha = Math.round(alpha1 + (alpha2 - alpha1) * localT); return [ Math.round(rainbow1[0] + (rainbow2[0] - rainbow1[0]) * localT), Math.round(rainbow1[1] + (rainbow2[1] - rainbow1[1]) * localT), Math.round(rainbow1[2] + (rainbow2[2] - rainbow1[2]) * localT), alpha ]; } else if (color1[0] === "rainbow") { // Interpolate from rainbow to regular/zebra color const offset1 = color1[5] || 0; const rainbow1 = rainbow(offset1); const alpha1 = color1[4] || 255; // Handle if color2 is also a special color let color2RGB; let alpha2; if (color2[0] === "zebra") { const offset2 = color2[5] || 0; color2RGB = zebra(offset2); alpha2 = color2[4] || 255; } else { color2RGB = [color2[0], color2[1], color2[2]]; alpha2 = color2[3] || 255; } return [ Math.round(rainbow1[0] + (color2RGB[0] - rainbow1[0]) * localT), Math.round(rainbow1[1] + (color2RGB[1] - rainbow1[1]) * localT), Math.round(rainbow1[2] + (color2RGB[2] - rainbow1[2]) * localT), Math.round(alpha1 + (alpha2 - alpha1) * localT) ]; } else { // Interpolate from regular color to rainbow const offset2 = color2[5] || 0; const rainbow2 = rainbow(offset2); const alpha1 = color1[3] || 255; const alpha2 = color2[4] || 255; return [ Math.round(color1[0] + (rainbow2[0] - color1[0]) * localT), Math.round(color1[1] + (rainbow2[1] - color1[1]) * localT), Math.round(color1[2] + (rainbow2[2] - color1[2]) * localT), Math.round(alpha1 + (alpha2 - alpha1) * localT) ]; } } if (color1[0] === "zebra" || color2[0] === "zebra") { console.log("🦓 Taking zebra branch"); // Don't use cached zebra color - always get fresh colors with offsets // This fixes the cyan zebra issue when zebra is first in the chain // Properly interpolate between zebra and regular colors if (color1[0] === "zebra" && color2[0] === "zebra") { // Both are zebra, get colors with their respective offsets const offset1 = color1[5] || 0; // Get offset from 6th element const offset2 = color2[5] || 0; const zebra1 = zebra(offset1); const zebra2 = zebra(offset2); const alpha1 = color1[4] || 255; const alpha2 = color2[4] || 255; const alpha = Math.round(alpha1 + (alpha2 - alpha1) * localT); return [ Math.round(zebra1[0] + (zebra2[0] - zebra1[0]) * localT), Math.round(zebra1[1] + (zebra2[1] - zebra1[1]) * localT), Math.round(zebra1[2] + (zebra2[2] - zebra1[2]) * localT), alpha ]; } else if (color1[0] === "zebra") { // Interpolate from zebra to regular/rainbow color const offset1 = color1[5] || 0; const zebra1 = zebra(offset1); // DEBUG: Log zebra color when it's first console.log("🦓 DEBUG zebra first:", { offset1, zebra1, color1 }); const alpha1 = color1[4] || 255; // Handle if color2 is also a special color let color2RGB; let alpha2; if (color2[0] === "rainbow") { const offset2 = color2[5] || 0; color2RGB = rainbow(offset2); alpha2 = color2[4] || 255; } else { color2RGB = [color2[0], color2[1], color2[2]]; alpha2 = color2[3] || 255; } return [ Math.round(zebra1[0] + (color2RGB[0] - zebra1[0]) * localT), Math.round(zebra1[1] + (color2RGB[1] - zebra1[1]) * localT), Math.round(zebra1[2] + (color2RGB[2] - zebra1[2]) * localT), Math.round(alpha1 + (alpha2 - alpha1) * localT) ]; } else { // Interpolate from regular color to zebra const offset2 = color2[5] || 0; const zebra2 = zebra(offset2); const alpha1 = color1[3] || 255; const alpha2 = color2[4] || 255; return [ Math.round(color1[0] + (zebra2[0] - color1[0]) * localT), Math.round(color1[1] + (zebra2[1] - color1[1]) * localT), Math.round(color1[2] + (zebra2[2] - color1[2]) * localT), Math.round(alpha1 + (alpha2 - alpha1) * localT) ]; } } // Linear interpolation between regular colors const r = Math.round(color1[0] + (color2[0] - color1[0]) * localT); const g = Math.round(color1[1] + (color2[1] - color1[1]) * localT); const b = Math.round(color1[2] + (color2[2] - color1[2]) * localT); const a = Math.round(color1[3] + (color2[3] - color1[3]) * localT); // Add noise if not neat mode (same logic as getFadeColor) if (!fadeInfo.neat) { const noiseAmount = 8; const noiseR = (Math.random() - 0.5) * noiseAmount; const noiseG = (Math.random() - 0.5) * noiseAmount; const noiseB = (Math.random() - 0.5) * noiseAmount; return [ Math.max(0, Math.min(255, r + noiseR)), Math.max(0, Math.min(255, g + noiseG)), Math.max(0, Math.min(255, b + noiseB)), a ]; } return [r, g, b, a];}
// 🔧 DEBUG: Helper function to get current global dimensionsfunction getGlobalDimensions() { return `${width}x${height}`;}
// 🔧 DEBUG: Export for debugging buffer context issuesexport { getGlobalDimensions };
let twoDCommands;export function twoD(ref) { twoDCommands = ref;}
// 1. Configuration & Statefunction makeBuffer(width, height, fillProcess, painting, api) { if (!width || !height) return;
const bufferStart = performance.now(); // 🔍 PERFORMANCE DEBUG
const imageData = new ImageData(width, height);
const buffer = { pixels: imageData.data, width: imageData.width, height: imageData.height, };
buffer.api = api;
if (typeof fillProcess === "function") { // Remember the current buffer and color. const savedBuffer = getBuffer(); const rc = c.slice(); // Remember color (copy, not reference). // Save and reset panTranslation to prevent paste operations in new buffers from being offset const savedPan = { x: panTranslation.x, y: panTranslation.y }; panTranslation.x = 0; panTranslation.y = 0; setBuffer(buffer); api.screen.pixels = buffer.pixels; // Set the API's pixel buffer. // 🚀 OPTIMIZATION: Fast path for simple operations try { fillProcess(api); // Every fill process gets a painting API. // Only call paint if there's actually something to paint if (painting && typeof painting.paint === 'function') { painting.paint(true); } } catch (error) { console.warn('⚠️ makeBuffer fillProcess error:', error); } // Restore old buffer, color, and panTranslation. panTranslation.x = savedPan.x; panTranslation.y = savedPan.y; setBuffer(savedBuffer); color(...rc); }
const bufferTime = performance.now() - bufferStart; // 🔍 PERFORMANCE DEBUG // Disabled buffer creation logging for cleaner console // if (bufferTime > 10) { // Only log very slow buffer creation // console.log(`🔍 graph.makeBuffer took ${bufferTime.toFixed(2)}ms (${width}x${height})`); // }
return buffer;}
// Returns a cloned pixel buffer.function cloneBuffer(buffer) { return { width: buffer.width, height: buffer.height, pixels: new Uint8ClampedArray(buffer.pixels), };}
function getBuffer() { return { width, height, pixels };}
function setBuffer(buffer) { // Check if the buffer's pixels array is detached and recreate if necessary if (buffer.pixels && buffer.pixels.buffer && buffer.pixels.buffer.detached) { console.warn('🚨 Detected detached pixels buffer in setBuffer, recreating... (This should be rare now)'); // Recreate the buffer with the same dimensions, filled with transparent pixels buffer.pixels = new Uint8ClampedArray(buffer.width * buffer.height * 4); buffer.pixels.fill(0); // Fill with transparent black } ({ width, height, pixels } = buffer);}
function changePixels(changer) { changer(pixels, width, height);}
// Return a pixel from the main buffer or from a specified buffer.function pixel(x, y, painting = { width, height, pixels }) { const buffer = painting.pixels; if (x >= painting.width || y >= painting.height || x < 0 || y < 0) { return [0, 0, 0, 0]; } const i = (floor(x) + floor(y) * painting.width) * 4; return [buffer[i], buffer[i + 1], buffer[i + 2], buffer[i + 3]];}
// Helper function to check if two colors are the samefunction colorsMatch(color1, color2) { if (!color1) return false; return ( color1[0] === color2[0] && color1[1] === color2[1] && color1[2] === color2[2] && color1[3] === color2[3] );}
// Fill pixels with a color using a flood fill technique.function flood(x, y, fillColor = c) { // Note: Fade flood fill temporarily disabled during global fade system removal // TODO: Implement local fade flood fill later // Bounds check if (x < 0 || y < 0 || x >= width || y >= height) { return { color: [0, 0, 0, 0], area: 0 }; } // Get the target color of the pixel at (x, y) const targetColor = pixel(x, y); if (targetColor[3] === 0) { // If the target pixel is transparent, return return { color: targetColor, area: 0, }; }
const previousColorState = cloneColorForLog(c); const resolvedFillColor = findColor(fillColor); if (inkFloodLoggingEnabled()) { console.log( `${inkFloodLogPrefix()}🌊 FLOOD DEBUG`, { input: cloneValueForLog(fillColor), resolved: cloneColorForLog(resolvedFillColor), previous: previousColorState, origin: { x, y }, target: cloneColorForLog(targetColor) } ); }
// 🚀 TRY GPU FLOOD FIRST (Jump Flooding Algorithm - O(log n)) if (gpuFloodEnabled && gpuFloodAvailable && gpuAllowed("flood") && gpuSpinModule && pixels && width && height) { const floodStart = performance.now(); const result = gpuSpinModule.gpuFlood( pixels, width, height, x, y, targetColor, resolvedFillColor );
if (result.success) { gpuOk("flood"); const floodTime = performance.now() - floodStart; graphPerf.track("flood-gpu", floodTime);
if (inkFloodLoggingEnabled()) { console.log( `${inkFloodLogPrefix()}🌊 GPU FLOOD RESULT (JFA)`, { resolved: cloneColorForLog(resolvedFillColor), previous: previousColorState, area: result.area, timeMs: floodTime.toFixed(2) } ); }
return { color: targetColor, area: result.area, }; } gpuFailed("flood"); }
// 💻 CPU FALLBACK: Stack-based flood fill const cpuStart = performance.now(); let count = 0; // Use a more efficient visited tracking with numeric keys const visited = new Uint8Array(width * height); const stack = [[x, y]];
color(...resolvedFillColor); const oldColor = c.slice(); // Copy, not reference while (stack.length) { const [cx, cy] = stack.pop(); // Bounds check if (cx < 0 || cy < 0 || cx >= width || cy >= height) continue; const index = cy * width + cx; if (visited[index]) continue; visited[index] = 1;
const currentColor = pixel(cx, cy); if (colorsMatch(currentColor, targetColor)) { count++; plot(cx, cy);
// Push neighbors to stack only if they're in bounds if (cx + 1 < width) stack.push([cx + 1, cy]); if (cx > 0) stack.push([cx - 1, cy]); if (cy + 1 < height) stack.push([cx, cy + 1]); if (cy > 0) stack.push([cx, cy - 1]); } }
color(...oldColor); const cpuTime = performance.now() - cpuStart; graphPerf.track("flood-cpu", cpuTime);
if (inkFloodLoggingEnabled()) { console.log( `${inkFloodLogPrefix()}🌊 CPU FLOOD RESULT`, { resolved: cloneColorForLog(resolvedFillColor), previous: previousColorState, area: count, timeMs: cpuTime.toFixed(2) } ); }
return { color: targetColor, area: count, };}
// Gradient flood fill for fade colors// gradientFlood function temporarily removed during global fade system refactor// TODO: Implement local fade flood fill using the new local fade system
// getFadeColor function removed - everything uses local getLocalFadeColor now!
// Reset rainbow and zebra cache for new drawing operationsfunction resetRainbowCache() { currentRainbowColor = null; currentZebraColor = null; // Also reset the num.mjs frame caches numResetRainbowCache(); // Note: Don't reset zebra cache here - zebra needs to maintain its color // within the same frame, while rainbow can reset on every call. // numResetZebraCache();}
// Legacy getFadeColor function (for backward compatibility)// New code should use getLocalFadeColor insteadfunction getFadeColor(t, x = 0, y = 0) { if (!fadeMode || fadeColors.length < 2) { return c.slice(); // Return current color if no fade } // For backward compatibility, create local fade info and use local function const fadeInfo = { colors: fadeColors, direction: fadeDirection, neat: fadeNeat }; return getLocalFadeColor(t, x, y, fadeInfo);}
function normalizeColorInput(value) { if (value === undefined || value === null) return value;
// Directly handle KidLisp symbol wrappers and other objects that expose a `.name` or `.value` if (typeof value === "object") { if (typeof value.name === "string") { return normalizeColorInput(value.name); } if (typeof value.value === "string") { return normalizeColorInput(value.value); } if (typeof value.toString === "function" && value.toString !== Object.prototype.toString) { return normalizeColorInput(value.toString()); } }
if (typeof value !== "string") return value;
let normalized = value.trim();
// Strip leading KidLisp quote syntax (e.g. `'white`) and enclosing quotes/backticks if (normalized.startsWith("'") && !normalized.endsWith("'")) { normalized = normalized.slice(1); } const quotePairs = ["'", '"', "`"]; // Support ', ", ` pairs for (const quote of quotePairs) { if (normalized.startsWith(quote) && normalized.endsWith(quote) && normalized.length > 1) { normalized = normalized.slice(1, -1).trim(); break; } }
// Handle KidLisp pipe symbols (|white|) that occasionally appear if (normalized.startsWith("|") && normalized.endsWith("|") && normalized.length > 2) { normalized = normalized.slice(1, -1); }
// Normalize unicode quotes (smart quotes) by stripping them const smartQuotes = ["“", "”", "‘", "’"]; if (smartQuotes.includes(normalized[0])) normalized = normalized.slice(1); if (smartQuotes.includes(normalized[normalized.length - 1])) normalized = normalized.slice(0, -1);
// Lowercase CSS color names & indexes but keep hex casing flexible if (normalized.startsWith("fade:")) { const fadeContent = normalized.slice(5); normalized = `fade:${fadeContent.toLowerCase()}`; } else if (!(normalized.startsWith("#") || normalized.startsWith("0x"))) { normalized = normalized.toLowerCase(); }
return normalized;}
// Parse a color from a variety of inputs..function findColor() { // Fast path: plain numeric rgb / rgba. This is what every shaded 3D // triangle and most generated pieces send, so it skips the parser below. const n = arguments.length; if (n === 3 || n === 4) { const r = arguments[0], g = arguments[1], b = arguments[2]; const a = n === 4 ? arguments[3] : 255; if ( typeof r === "number" && typeof g === "number" && typeof b === "number" && typeof a === "number" && r === r && g === g && b === b && a === a ) return [r, g, b, a]; } let args = [...arguments];
if (args.length === 1 && args[0] !== undefined) { const isNumber = () => typeof args[0] === "number"; const isArray = () => Array.isArray(args[0]); const isString = () => typeof args[0] === "string"; const isFadeObject = () => typeof args[0] === "object" && args[0] !== null && args[0].type === "fade"; const isBool = typeof args[0] === "boolean";
// Handle fade objects (from parseColor) - now completely local if (isFadeObject()) { const fadeObj = args[0]; // Extract alpha value from array if needed (for range syntax like "32-64") const alphaValue = Array.isArray(fadeObj.alpha) ? fadeObj.alpha[0] : fadeObj.alpha; // Create fade color array format for local detection const fadeColorArray = [fadeObj.fadeString, alphaValue || 255]; return fadeColorArray; // Return as is for local processing }
if (isBool) { // No more global fade state to reset resetRainbowCache(); return args[0] ? [255, 255, 255, 255] : [0, 0, 0, 255]; }
// If it's not a Number or Array or String or FadeObject, then assume it's an object, // randomly pick a key & re-run. if (!isNumber() && !isArray() && !isString() && !isFadeObject()) return findColor(any(args[0]));
// Single number argument. if (isNumber()) { // No more global fade state to reset resetRainbowCache(); // Treat as raw hex if we hit a certain limit. if (args[0] > 255) { args = hexToRgb(args[0]); } else { // Otherwise, replicate the first number across all three fields. args = Array.from(args); args.push(args[0], args[0]); } } else if (isArray()) { // No more global fade state handling needed return findColor(...args[0]); } else if (isString()) { const normalizedString = normalizeColorInput(args[0]); if (typeof normalizedString === "string") { args[0] = normalizedString; }
// Check for fade syntax first (for direct fade strings) if (args[0].startsWith("fade:")) { // Return as fade color array for local processing return [args[0], 255]; // Default alpha for direct strings } // No more global fade state to reset resetRainbowCache(); // FIRST: Check for color index format like "c0", "c1", "p0", etc. const indexColor = parseColorIndex(args[0]); if (indexColor) { if (indexColor[0] === "rainbow") { args = rainbow(); // Handle p0 rainbow palette } else { args = indexColor; // Handle c0, c1, etc. } } else if (args[0] === "erase") { // TODO: Add better alpha support here... 23.09.11.22.10 // ^ See `parseColor` in `num`. // let alpha = 255; // if (args[1]) alpha = parseFloat(args[1]); const originalAlpha = arguments[1]; // Save before overwriting args args = [-1, -1, -1]; if (originalAlpha !== undefined) args.push(computeAlpha(originalAlpha)); } else if (args[0] === "rainbow") { args = rainbow(); // Cycle through roygbiv in a linear sequence. } else if (args[0] === "zebra") { args = zebra(); // Cycle through black/white in alternating sequence. } else { // See if it's a hex. const cleanedHex = args[0] .replace("#", "") .replace("0x", "") .toUpperCase(); if (isHexString(cleanedHex) === true) { args = hexToRgb(cleanedHex); } else if (args[0].startsWith("fade:")) { // Fade operations should have been handled above - this is a fallback args = [0, 0, 0]; // Fallback to black (stale fade string) args.push(255); } else { // Try CSS color table lookup const cssColor = cssColors[args[0]]; if (cssColor) { args = cssColor; } else { // Fallback to random color args = randIntArr(255, 3); args.push(255); if (debug) { console.warn("⚠️ findColor: Unknown color string", normalizedString, "→ falling back to random color"); } } } } // TODO: Add an error message here. 22.08.29.13.03 } } else if (args.length === 2) { // rainbow, alpha or zebra, alpha if (args[0] === "rainbow") { args = [...rainbow(), computeAlpha(args[1])]; } else if (args[0] === "zebra") { args = [...zebra(), computeAlpha(args[1])]; } else if (typeof args[0] === "string") { const normalizedString = normalizeColorInput(args[0]); if (typeof normalizedString === "string") { args[0] = normalizedString; } // Check for fade syntax with alpha if (args[0].startsWith("fade:")) { // Return local fade info instead of setting global state const fadeString = args[0]; const alpha = computeAlpha(args[1] || 255); return [fadeString, alpha]; // Return as fade color array for local processing } // Check for color index format with alpha const indexColor = parseColorIndex(args[0]); if (indexColor) { if (indexColor[0] === "rainbow") { args = [...rainbow(), computeAlpha(args[1])]; } else { args = [...indexColor, computeAlpha(args[1])]; } } else { // Try CSS color table lookup const cssColor = cssColors[args[0]]; if (cssColor) { args = [...cssColor, computeAlpha(args[1])]; } else { args = [0, 0, 0, computeAlpha(args[1])]; // Fallback to black if (debug) { console.warn("⚠️ findColor: Unknown color string with alpha", normalizedString, "→ defaulting to black"); } } } } else if (Array.isArray(args[0])) { const baseColor = [...args[0]]; if (args.length > 1 && args[1] !== undefined) { const alphaValue = computeAlpha(args[1]); if (baseColor.length >= 4) { baseColor[3] = alphaValue; } else { baseColor.push(alphaValue); } } args = baseColor; } else if (args[0] === undefined) { // Random color with specified alpha: (undefined, alpha) const alphaValue = args[1]; // Store the alpha before generating random RGB args = randIntArr(255, 3); args.push(computeAlpha(alphaValue)); // Use specified alpha } else { // rgb, a args = [args[0], args[0], args[0], args[1]]; } } else if ( args.length === 0 || (args.length === 1 && args[0] === undefined) ) { args = randIntArr(255, 3); args.push(255); // Generate random values here, always leave alpha 255. }
if (args.length === 3) args = [...args, 255]; // Always be sure we have alpha.
// Debug: Check for problematic color values and filter out timing expressions // Exclude valid fade strings from the invalid check if (args.some(val => val === undefined || (isNaN(val) && !(typeof val === 'string' && val.startsWith('fade:'))) || (typeof val === 'string' && val.match(/^\d*\.?\d+s\.\.\.?$/)))) { // Check if this contains a fade string that got passed incorrectly const fadeString = args.find(val => typeof val === 'string' && val.startsWith('fade:')); if (fadeString) { // Return fade color array format const alpha = args.find(val => typeof val === 'number' && !isNaN(val)) || 255; return [fadeString, alpha]; } // Filter out timing expressions that were incorrectly passed as color arguments args = args.filter(val => !(typeof val === 'string' && val.match(/^\d*\.?\d+s\.\.\.?$/))); // If we filtered out timing expressions, we might be short on arguments while (args.length < 3) { args.push(255); // Add default RGB values } if (args.length === 3) args.push(255); // Add alpha if missing // Clean up any remaining invalid values args = args.map((val, i) => { if (val === undefined || isNaN(val) || typeof val === 'string') { return i === 3 ? 255 : randInt(255); // Alpha defaults to 255, RGB gets random } return val; }); // Ensure we have exactly 4 values args = args.slice(0, 4); while (args.length < 4) { args.push(args.length === 3 ? 255 : randInt(255)); } }
// Randomized any undefined or null values across all 4 arguments. args.forEach((a, i) => { if (isNaN(args[i])) args[i] = randInt(255); });
return args;}
// TODO: How could I convert from 0->1 and 0->255 with no side effects?// TODO: What's a good way for ink to accept different ranges of alpha?// 24.08.20.20.12// Current solution:// Any number from 0-><1 will use 0-> alpha.// - 1 and above will use 0->255.// - 0 always bottoms out// Edge case near the 1 is manageable.function computeAlpha(alpha) { if (alpha > 0 && alpha < 1) alpha = round(alpha * 255); return alpha;}
// Set global color.// Internal function to set color without resetting fade modefunction setColor(r, g, b, a = 255) { c[0] = floor(r); c[1] = floor(g); c[2] = floor(b); c[3] = floor(a); return c.slice();}
// Send 0 arguements to retrieve the current one.function color(r, g, b, a = 255) { if (arguments.length === 0) return c.slice(); // Check if this is a fade color string (when called as color("fade:red-lime", 255)) if (typeof r === 'string' && r.startsWith('fade:')) { // Store the fade color array directly in c for box() to detect c[0] = r; // fade string c[1] = g || 255; // alpha from second argument c[2] = 0; // unused for fade c[3] = 255; // default alpha fallback return c.slice(); } // Check if this is a fade color array format (when called as color(["fade:red-lime", 255])) if (arguments.length === 1 && Array.isArray(r) && typeof r[0] === 'string' && r[0].startsWith('fade:')) { // Store the fade color array directly in c for box() to detect c[0] = r[0]; // fade string c[1] = r[1] || 255; // alpha c[2] = 0; // unused for fade c[3] = 255; // default alpha fallback return c.slice(); } // No more global fade state to manage - everything is local now! return setColor(r, g, b, a);}
// Support for secondary color with the ability to clear the color by// passing null or undefined as the first parameter.function color2(r, g, b, a = 255) { if (arguments.length === 0) return c2.slice(); if (r === undefined || r === null) { c2 = null; return; } if (!c2) c2 = []; c2[0] = floor(r); c2[1] = floor(g); c2[2] = floor(b); c2[3] = floor(a); return c2.slice();}
export { makeBuffer, cloneBuffer, setBuffer, getBuffer, changePixels, depthBuffer, writeBuffer, color, color2, findColor, c, // currentColor pixel, setForceReplaceMode, withForceReplaceMode, parseFadeColor, parseLocalFade, getLocalFadeColor, evaluateFadeDirection, calculateAngleFadePosition,};
// Helper function to evaluate dynamic fade directionfunction evaluateFadeDirection(directionStr) { // First try to parse as a number const numericAngle = parseFloat(directionStr); if (!isNaN(numericAngle)) { return numericAngle; } // Check for named directions const validDirections = [ "horizontal", "horizontal-reverse", "vertical", "vertical-reverse", "diagonal", "diagonal-reverse" ]; if (validDirections.includes(directionStr)) { return directionStr; } // Check if it's a string representation of a number (e.g., "30") const trimmed = directionStr.trim(); if (/^-?\d+(\.\d+)?$/.test(trimmed)) { const parsed = parseFloat(trimmed); if (!isNaN(parsed)) { return parsed; } } // Try to evaluate as KidLisp expression if we have context if (currentKidLispContext) { try { // Check if it's a JSON-encoded expression if (directionStr.startsWith('"') || directionStr.startsWith('[')) { const parsedExpression = JSON.parse(directionStr); const result = currentKidLispContext.evaluate(parsedExpression, currentKidLispContext.api, currentKidLispContext.env); const numResult = parseFloat(result); if (!isNaN(numResult)) { return numResult; } } else { // Try evaluating as a variable name const result = currentKidLispContext.evaluate(directionStr, currentKidLispContext.api, currentKidLispContext.env); const numResult = parseFloat(result); if (!isNaN(numResult)) { return numResult; } } } catch (error) { console.warn("Failed to evaluate fade direction expression:", directionStr, error); } } // Fallback to horizontal console.warn("Invalid fade direction, falling back to horizontal:", directionStr); return "horizontal";}
// Helper function to calculate fade position based on angle (0-360 degrees)function calculateAngleFadePosition(x, y, minX, minY, maxX, maxY, angle) { // Normalize angle to 0-360 range const originalAngle = angle; angle = ((angle % 360) + 360) % 360; // Get area dimensions const areaWidth = maxX - minX; const areaHeight = maxY - minY; // Calculate position relative to center of the area const centerX = minX + areaWidth / 2; const centerY = minY + areaHeight / 2; const relX = (x - centerX) / (areaWidth / 2); // Normalize to -1 to 1 const relY = (y - centerY) / (areaHeight / 2); // Normalize to -1 to 1 // Convert angle to radians and calculate direction vector const radians = (angle * Math.PI) / 180; const dirX = Math.cos(radians); const dirY = -Math.sin(radians); // Negative because screen Y increases downward // Project current position onto the direction vector const dotProduct = relX * dirX + relY * dirY; // Debug logging for angle calculation (only once per frame) // if (x === minX && y === minY) { // console.log(`🔄 ANGLE DEBUG: angle=${angle}°, radians=${radians.toFixed(3)}, dirX=${dirX.toFixed(3)}, dirY=${dirY.toFixed(3)}`); // } // Normalize to 0-1 range (dotProduct ranges from -sqrt(2) to sqrt(2)) const maxDot = Math.sqrt(2); // Maximum possible dot product const t = (dotProduct + maxDot) / (2 * maxDot); // Debug log only for first few pixels to avoid spam if (x < minX + 3 && y < minY + 3) { // Angle calculation debug log removed for performance } return Math.max(0, Math.min(1, t));}
// 2. 2D Drawingfunction clear() { // 🚀 OPTIMIZED CLEAR FUNCTION - Up to 10x faster!
// 🎬 Classic Z-buffer: reset to +Infinity at the start of each frame so // subsequent 3D form draws correctly occlude each other regardless of the // order they're submitted in. Matches the width × height of the current // screen; grows/shrinks on resolution change. clearDepthBuffer();
// Clean up blur buffers to prevent memory accumulation cleanupBlurBuffers(); // Determine the area to clear (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // Don't apply pan translation to mask bounds - mask is already set at current pan position minX = activeMask.x; minY = activeMask.y; maxX = activeMask.x + activeMask.width; maxY = activeMask.y + activeMask.height; }
// 🎨 Check if current color is a fade string const fadeInfo = parseFadeColor(c); const isLocalFade = fadeInfo !== null; if (isLocalFade) { // 🚀 OPTIMIZED FADE CLEAR: Pre-compute colors and use fast row/column filling const colors = fadeInfo.colors; const hasSpecialColors = colors.some(col => col[0] === "rainbow" || col[0] === "zebra"); // For gradients without special colors, use fast path with proper multi-stop interpolation if (!hasSpecialColors && colors.length >= 2) { const workingWidth = maxX - minX; const workingHeight = maxY - minY; const direction = fadeInfo.direction; const numColors = colors.length; const numSegments = numColors - 1; // Helper to interpolate between multiple color stops const interpolateMultiColor = (t) => { t = Math.max(0, Math.min(1, t)); const segment = Math.min(Math.floor(t * numSegments), numSegments - 1); const localT = (t * numSegments) - segment; const c1 = colors[segment]; const c2 = colors[segment + 1]; return [ (c1[0] + (c2[0] - c1[0]) * localT) | 0, (c1[1] + (c2[1] - c1[1]) * localT) | 0, (c1[2] + (c2[2] - c1[2]) * localT) | 0, ((c1[3] ?? 255) + ((c2[3] ?? 255) - (c1[3] ?? 255)) * localT) | 0 ]; }; if (direction === "vertical" || direction === "vertical-reverse") { // 🚀 VERTICAL: Pre-compute one color per row, then use copyWithin const reverse = direction === "vertical-reverse"; const maxT = Math.max(1, workingHeight - 1); for (let y = minY; y < maxY; y++) { const t = reverse ? (maxY - 1 - y) / maxT : (y - minY) / maxT; const [r, g, b, a] = interpolateMultiColor(t); const rowStart = (y * width + minX) * 4; // Set first pixel pixels[rowStart] = r; pixels[rowStart + 1] = g; pixels[rowStart + 2] = b; pixels[rowStart + 3] = a; // Copy across row using doubling strategy const rowWidth = workingWidth * 4; for (let copySize = 4; copySize < rowWidth; copySize *= 2) { const copyEnd = Math.min(copySize * 2, rowWidth); pixels.copyWithin(rowStart + copySize, rowStart, rowStart + copyEnd - copySize); } } return; } else if (direction === "horizontal" || direction === "horizontal-reverse") { // 🚀 HORIZONTAL: Pre-compute color lookup table, then blast rows const reverse = direction === "horizontal-reverse"; const maxT = Math.max(1, workingWidth - 1); // Pre-compute color for each x position (reused for all rows) const rowColors = new Uint8ClampedArray(workingWidth * 4); for (let x = 0; x < workingWidth; x++) { const t = reverse ? (workingWidth - 1 - x) / maxT : x / maxT; const [r, g, b, a] = interpolateMultiColor(t); const idx = x * 4; rowColors[idx] = r; rowColors[idx + 1] = g; rowColors[idx + 2] = b; rowColors[idx + 3] = a; } // Copy pre-computed row to each row for (let y = minY; y < maxY; y++) { const rowStart = (y * width + minX) * 4; pixels.set(rowColors, rowStart); } return; } else if (direction === "diagonal" || direction === "diagonal-reverse") { // 🚀 DIAGONAL: Direct pixel write with multi-color interpolation const reverse = direction === "diagonal-reverse"; const maxTx = Math.max(1, workingWidth - 1); const maxTy = Math.max(1, workingHeight - 1); for (let y = minY; y < maxY; y++) { const dy = reverse ? (maxY - 1 - y) / maxTy : (y - minY) / maxTy; const rowOffset = y * width; for (let x = minX; x < maxX; x++) { const dx = reverse ? (maxX - 1 - x) / maxTx : (x - minX) / maxTx; const t = (dx + dy) * 0.5; const [r, g, b, a] = interpolateMultiColor(t); const i = (rowOffset + x) * 4; pixels[i] = r; pixels[i + 1] = g; pixels[i + 2] = b; pixels[i + 3] = a; } } return; } // Fall through for angle-based fades } // Fall through to slow path for special colors or complex gradients } if (isLocalFade) { // Handle fade colors pixel by pixel (cannot use fast optimization) for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { // Calculate fade position based on direction let t = 0; const numericAngle = parseFloat(fadeInfo.direction); if (!isNaN(numericAngle)) { // Use angle-based fade calculation // if (x === minX && y === minY) { // console.log(`🎯 GRADIENT ROTATION: Using angle ${numericAngle}° for fade at (${x},${y})`); // } t = calculateAngleFadePosition(x, y, minX, minY, maxX - 1, maxY - 1, numericAngle); } else { // Use named direction if (fadeInfo.direction === "horizontal") { t = (x - minX) / Math.max(1, (maxX - 1) - minX); } else if (fadeInfo.direction === "horizontal-reverse") { t = ((maxX - 1) - x) / Math.max(1, (maxX - 1) - minX); } else if (fadeInfo.direction === "vertical") { t = (y - minY) / Math.max(1, (maxY - 1) - minY); } else if (fadeInfo.direction === "vertical-reverse") { t = ((maxY - 1) - y) / Math.max(1, (maxY - 1) - minY); } else if (fadeInfo.direction === "diagonal") { const dx = (x - minX) / Math.max(1, (maxX - 1) - minX); const dy = (y - minY) / Math.max(1, (maxY - 1) - minY); t = (dx + dy) / 2; } else if (fadeInfo.direction === "diagonal-reverse") { const dx = ((maxX - 1) - x) / Math.max(1, (maxX - 1) - minX); const dy = ((maxY - 1) - y) / Math.max(1, (maxY - 1) - minY); t = (dx + dy) / 2; } else { // Default to horizontal t = (x - minX) / Math.max(1, (maxX - 1) - minX); } } // Get the fade color for this position const fadeColor = getLocalFadeColor(t, x, y, fadeInfo); // Set pixel directly const i = (x + y * width) * 4; if (i >= 0 && i < pixels.length - 3) { pixels[i] = fadeColor[0]; // r pixels[i + 1] = fadeColor[1]; // g pixels[i + 2] = fadeColor[2]; // b pixels[i + 3] = fadeColor[3]; // alpha } } } } else if (activeMask) { // Clear only the masked area with solid color (row by row optimization) const rowWidth = (maxX - minX) * 4; for (let y = minY; y < maxY; y++) { const rowStart = (y * width + minX) * 4; // Fill first pixel of row pixels[rowStart] = c[0]; pixels[rowStart + 1] = c[1]; pixels[rowStart + 2] = c[2]; pixels[rowStart + 3] = c[3]; // Use copyWithin to duplicate the pattern across the row (much faster!) if (rowWidth > 4) { for (let copySize = 4; copySize < rowWidth; copySize *= 2) { const copyEnd = Math.min(copySize * 2, rowWidth); pixels.copyWithin(rowStart + copySize, rowStart, rowStart + copyEnd - copySize); } } } } else { // 🚀 ULTRA-FAST FULL SCREEN CLEAR using copyWithin doubling strategy // Fast path for transparent black (common case) if (c[0] === 0 && c[1] === 0 && c[2] === 0 && c[3] === 0) { pixels.fill(0); return; } // Set the first pixel with the target color pixels[0] = c[0]; // r pixels[1] = c[1]; // g pixels[2] = c[2]; // b pixels[3] = c[3]; // alpha // Use copyWithin to double the filled area repeatedly // This is much faster than for loops: O(log n) vs O(n) copies for (let copySize = 4; copySize < pixels.length; copySize *= 2) { const copyEnd = Math.min(copySize * 2, pixels.length); pixels.copyWithin(copySize, 0, copyEnd - copySize); } }}
/** * Plot a single pixel using (x, y) or {x, y} if only x is given. * (1) {x, y} * (2) (x, y) */// Where a pixel is a region in which we draw from the upper left corner. (2D)function plot(x, y) { x = floor(x); y = floor(y); if (!pixels) return; // Skip pixels that are offscreen and/or found in the `skips` list. if (x < 0 || x >= width || y < 0 || y >= height) return;
// Check pixels in the active mask. if (activeMask) { // Don't apply pan translation to mask bounds when checking plot coordinates // The mask coordinates are already set relative to the current pan position if ( y < activeMask.y || y >= activeMask.y + activeMask.height || x >= activeMask.x + activeMask.width || x < activeMask.x ) return; }
for (const s of skips) if (x === s.x && y === s.y) return; // Plot our pixel. const i = (x + y * width) * 4; // Additional safety check for array bounds if (i < 0 || i + 3 >= pixels.length) return; const alpha = c[3];
// Check if current color is a fade color and resolve it let plotColor = c; if (typeof c[0] === 'string' && c[0].startsWith('fade:')) { // Parse the fade string and get the color at this pixel position const fadeInfo = parseLocalFade(c[0]); if (fadeInfo) { const resolvedColor = getLocalFadeColor(null, x, y, fadeInfo); plotColor = [...resolvedColor, c[1] || 255]; // Use fade alpha } else { // Fallback to transparent if fade parsing fails (stale fade string) plotColor = [0, 0, 0, 0]; } }
if (forceReplaceMode) { pixels[i] = plotColor[0] ?? 0; pixels[i + 1] = plotColor[1] ?? 0; pixels[i + 2] = plotColor[2] ?? 0; pixels[i + 3] = plotColor[3] ?? alpha; return; }
// Erasing if (c[0] === -1 && c[1] === -1 && c[2] === -1) { const na = 1 - c[3] / 255; erase(pixels, i, na); if (eraseTarget) { const pi = i >> 2; eraseTarget[pi] = 255 - ((255 - eraseTarget[pi]) * na + 0.5) | 0; } } else if (alpha === 255) { // No alpha blending, just copy. pixels.set(plotColor, i); } else if (alpha !== 0) { blend(pixels, plotColor, 0, i); }}
// Adds a point to the skip list which ignores these points from being drawn// in `plot`. Passing `null` will clear the skip list.// TODO: Should the skip list clear automatically on every paint? 2022.02.03.01.16// TODO: Leaving skip blank will default to a random skipping algorithm?// Writing a function will allow you to dynamically skip pixels.function skip(...args) { if (args[0] === null) skips.length = 0; else args.forEach((p) => { skips.push({ x: floor(p.x || p[0]) + panTranslation.x, y: floor(p.y || p[1]) + panTranslation.y, }); });}
// Plots a single pixel within the panned coordinate space.// Basically a wrapper over plot, which should ultimately be renamed to set?// Accepts x, y or {x, y}function point(...args) { let x, y;
if (args.length === 1) { if (args[0].length >= 2) { // Array x = args[0][0]; y = args[0][1]; } else { // Object x = args[0].x; y = args[0].y; } } else if (args.length >= 2) { // Multiple arguments x = args[0]; y = args[1]; } else { x = randInt(width); y = randInt(height); }
// TODO: Add support for {x, y} single argument. 2022.02.02.20.39 x += panTranslation.x; y += panTranslation.y; // TODO: Eventually add rotation and scale etc.
plot(x, y); return [x, y];}
// Run a callback function to shade, then plot an array of pixel coordinates.// This runs within `pline` and `pixelPerfectPolyline`.function shadePixels(points, shader, shaderArgs = []) { points.forEach((p) => { // TODO: - [] Send current pixel under p? This can be used for cool position // reading color effects. 23.01.05.01.23
// Clip if (p.x < 0) return; if (p.x >= width) return; if (p.y < 0) return; if (p.y >= height) return;
shader.position?.(p, ...shaderArgs); // Compute position.
// Clip again if (p.x < 0) return; if (p.x >= width) return; if (p.y < 0) return; if (p.y >= height) return;
p.x = floor(p.x); // Floor position and check bounds. p.y = floor(p.y);
const n = p.x + p.y * width;
if (writeBuffer[n] !== 1) { writeBuffer[n] = 1; // Remember this point in the frame's writeBuffer. // 🪄 Pixel Shader const i = floor(p.x + p.y * width) * 4; // Get current pixel under p. const pixel = pixels.subarray(i, i + 4); // TODO: Put p.color into shaderArgs or some other automated thing? shader.color({ x: p.x, y: p.y }, pixel, c, p.color); // Modify color. } }); // Paint each filtered pixel.}
function pan(x, y) { if (typeof x === "object") { x = x.x; y = x.y; } if (y === undefined) y = x; panTranslation.x += floor(x); panTranslation.y += floor(y);}
function unpan() { panTranslation.x = 0; panTranslation.y = 0;}
let savedPanTranslation;
// Save the local transform.function savepan() { savedPanTranslation = { ...panTranslation };}
// Restore it.function loadpan() { if (savedPanTranslation) { panTranslation.x = savedPanTranslation.x; panTranslation.y = savedPanTranslation.y; }}
// 😷 Mask off pixels.function mask(box) { activeMask = box;}
// 😄 Unmask pixels.function unmask() { activeMask = null;}
function copy(destX, destY, srcX, srcY, src, alpha = 1.0) { destX = floor(destX); destY = floor(destY); srcX = floor(srcX); srcY = floor(srcY);
// Skip pixels that are offscreen or outside the src buffer. // Used in `paste`. if ( destX < 0 || destX >= width || destY < 0 || destY >= height || srcX < 0 || srcX >= src.width || srcY < 0 || srcY >= src.height ) { return; }
const di = (destX + destY * width) * 4; const si = (srcX + srcY * src.width) * 4;
blend(pixels, src.pixels, si, di, alpha);}
/*function copyRow(destX, destY, srcX, srcY, src) { destX = Math.round(destX); destY = Math.round(destY); srcX = Math.round(srcX); srcY = Math.round(srcY);
const destIndex = (destX + destY * width) * 4; const srcIndex = (srcX + srcY * src.width) * 4; const rowLength = src.width * 4 - destX * 4;
let srcStart = srcIndex; let srcEnd = srcIndex + src.width * 4;
const sub = src.pixels.subarray(srcStart, srcEnd);
pixels.set(sub, destIndex);} */
// Resize a bitmap to a new with and height, returning a new// bitmap, using nearest neighbor scaling.// Bitmaps are {pixels: uint8array, width: int, height, int}function resize(bitmap, width, height) { const ratioX = bitmap.width / width; const ratioY = bitmap.height / height; const pixels = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const index = (y * width + x) * 4; const srcX = floor(x * ratioX); const srcY = floor(y * ratioY); const srcIndex = (srcY * bitmap.width + srcX) * 4;
pixels[index] = bitmap.pixels[srcIndex]; pixels[index + 1] = bitmap.pixels[srcIndex + 1]; pixels[index + 2] = bitmap.pixels[srcIndex + 2]; pixels[index + 3] = bitmap.pixels[srcIndex + 3]; } } return { pixels, width, height };}
// Helper function to detect recording modefunction isInRecordingMode() { // Check multiple indicators that we're in recording mode return typeof globalThis !== 'undefined' && ( globalThis.tapeRecording === true || globalThis.recording === true || (typeof process !== 'undefined' && process.argv && process.argv.includes('--recording')) );}
// Fast checksum for pixel data (using sampling for performance)function getPixelChecksum(pixels, width, height, x, y, w, h) { let checksum = 0; const sampleRate = Math.max(1, Math.floor(Math.sqrt(w * h) / 32)); // Sample ~32 points for (let sy = y; sy < y + h; sy += sampleRate) { for (let sx = x; sx < x + w; sx += sampleRate) { if (sx < width && sy < height) { const i = (sy * width + sx) * 4; checksum = (checksum * 31 + pixels[i] + pixels[i + 1] + pixels[i + 2] + pixels[i + 3]) >>> 0; } } } return checksum;}
// Cached contrast lookup table to avoid re-allocation per framelet cachedContrastLUT = null;let cachedContrastLevel = null;
// Adjust the contrast of the pixel buffer// level: 1.0 = no change, >1.0 = more contrast, <1.0 = less contrastfunction contrast(level = 1.0) { // Early exit if no contrast change needed if (level === 1.0) return; const contrastStart = performance.now(); // Determine the area to adjust (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // Apply pan translation to mask bounds const maskX = activeMask.x + panTranslation.x; const maskY = activeMask.y + panTranslation.y; minX = Math.max(0, Math.floor(maskX)); minY = Math.max(0, Math.floor(maskY)); maxX = Math.min(width, Math.floor(maskX + activeMask.width)); maxY = Math.min(height, Math.floor(maskY + activeMask.height)); } // 🚀 TRY GPU CONTRAST FIRST if (gpuContrastEnabled && gpuSpinAvailable && gpuAllowed("contrast") && gpuSpinModule && pixels && width && height) { const mask = activeMask ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } : null;
const result = gpuSpinModule.gpuContrast?.(pixels, width, height, level, mask); if (result) { gpuOk("contrast"); const contrastTime = performance.now() - contrastStart; graphPerf.track("contrast-gpu", contrastTime); return; } gpuFailed("contrast"); }
// 💻 CPU FALLBACK const cpuStart = performance.now();
// Reuse cached LUT if contrast level hasn't changed if (cachedContrastLevel !== level) { cachedContrastLUT = new Uint8Array(256); for (let i = 0; i < 256; i++) { const normalized = i / 255.0; const adjusted = ((normalized - 0.5) * level + 0.5) * 255.0; cachedContrastLUT[i] = Math.max(0, Math.min(255, Math.round(adjusted))); } cachedContrastLevel = level; } const contrastLUT = cachedContrastLUT;
// Apply contrast adjustment to each pixel using lookup table if (!activeMask) { // Fast path: linear scan for full-screen contrast (no per-pixel index math) const len = pixels.length; for (let i = 0; i < len; i += 4) { if (pixels[i + 3] === 0) continue; pixels[i] = contrastLUT[pixels[i]]; pixels[i + 1] = contrastLUT[pixels[i + 1]]; pixels[i + 2] = contrastLUT[pixels[i + 2]]; } } else { for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const idx = (y * width + x) * 4; if (pixels[idx + 3] === 0) continue; pixels[idx] = contrastLUT[pixels[idx]]; pixels[idx + 1] = contrastLUT[pixels[idx + 1]]; pixels[idx + 2] = contrastLUT[pixels[idx + 2]]; } } } const cpuTime = performance.now() - cpuStart; graphPerf.track("contrast-cpu", cpuTime);}
// Adjust brightness using lookup table optimization// adjustment: -255 to +255 (0 = no change, positive = brighter, negative = darker)function brightness(adjustment = 0) { // Early exit if no adjustment needed if (adjustment === 0) return; const brightnessStart = performance.now(); // Clamp adjustment to valid range adjustment = Math.max(-255, Math.min(255, adjustment)); // Determine the area to adjust (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { const maskX = activeMask.x + panTranslation.x; const maskY = activeMask.y + panTranslation.y; minX = Math.max(0, Math.floor(maskX)); minY = Math.max(0, Math.floor(maskY)); maxX = Math.min(width, Math.floor(maskX + activeMask.width)); maxY = Math.min(height, Math.floor(maskY + activeMask.height)); }
// 🚀 TRY GPU BRIGHTNESS FIRST if (gpuContrastEnabled && gpuSpinAvailable && gpuAllowed("brightness") && gpuSpinModule && pixels && width && height) { const mask = activeMask ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } : null;
const result = gpuSpinModule.gpuBrightness?.(pixels, width, height, adjustment, mask); if (result) { gpuOk("brightness"); const brightnessTime = performance.now() - brightnessStart; graphPerf.track("brightness-gpu", brightnessTime); return; } gpuFailed("brightness"); }
// 💻 CPU FALLBACK const cpuStart = performance.now();
// 🚀 OPTIMIZATION: Pre-calculate brightness lookup table const brightnessLUT = new Uint8Array(256); for (let i = 0; i < 256; i++) { brightnessLUT[i] = Math.max(0, Math.min(255, i + adjustment)); }
// Apply brightness adjustment to each pixel using lookup table for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const idx = (y * width + x) * 4; // Skip transparent pixels if (pixels[idx + 3] === 0) continue; // Use lookup table for fast brightness adjustment (RGB channels only) pixels[idx] = brightnessLUT[pixels[idx]]; // R pixels[idx + 1] = brightnessLUT[pixels[idx + 1]]; // G pixels[idx + 2] = brightnessLUT[pixels[idx + 2]]; // B // Alpha channel stays unchanged } } const cpuTime = performance.now() - cpuStart; graphPerf.track("brightness-cpu", cpuTime);}
// Invert RGB colors (255 - value) while preserving alphafunction invert() { const invertStart = performance.now(); // Determine the area to adjust (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { const maskX = activeMask.x + panTranslation.x; const maskY = activeMask.y + panTranslation.y; minX = Math.max(0, Math.floor(maskX)); minY = Math.max(0, Math.floor(maskY)); maxX = Math.min(width, Math.floor(maskX + activeMask.width)); maxY = Math.min(height, Math.floor(maskY + activeMask.height)); }
// 🚀 TRY GPU INVERT FIRST if (gpuContrastEnabled && gpuSpinAvailable && gpuAllowed("invert") && gpuSpinModule && pixels && width && height) { const mask = activeMask ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } : null;
const result = gpuSpinModule.gpuInvert?.(pixels, width, height, mask); if (result) { gpuOk("invert"); const invertTime = performance.now() - invertStart; graphPerf.track("invert-gpu", invertTime); return; } gpuFailed("invert"); }
// 💻 CPU FALLBACK const cpuStart = performance.now();
// Apply invert to each pixel (RGB channels only, preserve alpha) for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const idx = (y * width + x) * 4; // Skip transparent pixels if (pixels[idx + 3] === 0) continue; // Invert RGB channels (255 - value) pixels[idx] = 255 - pixels[idx]; // R pixels[idx + 1] = 255 - pixels[idx + 1]; // G pixels[idx + 2] = 255 - pixels[idx + 2]; // B // Alpha channel stays unchanged } } const cpuTime = performance.now() - cpuStart; graphPerf.track("invert-cpu", cpuTime);}
// Copies pixels from a source buffer to the active buffer and returns// the source buffer.// TODO: Add dirty rectangle support here...// - What would the best parameter set be?// `from` - can either be// TODO: Replace with more generic algorithm?
// Notes:// Scale can also be a transform object: { scale, angle }// Blit only works with a scale of 1.// Nearest-neighbor crop+scale blit, sampled straight from the source buffer.// Kept as its own small function so V8 optimizes the hot loop independently// of paste()'s many other shapes. Bounds arrive pre-clamped (screen + mask);// out-of-source samples (crop overhang) skip, matching the zero-fill// semantics of the copy-based path. Alpha is the high byte (RGBA, LE).function blitCropScale( src, srcWidth, srcHeight, cX, cY, scaleX, scaleY, destX, destY, dst, dstWidth, startX, startY, endX, endY,) { // Column lookup: source x per destination x; -1 marks overhang columns. const srcXs = new Int32Array(endX - startX); for (let dx = startX; dx < endX; dx += 1) { const sx = cX + Math.floor(dx * scaleX); srcXs[dx - startX] = sx >= 0 && sx < srcWidth ? sx : -1; }
const src32 = new Uint32Array(src.buffer, src.byteOffset, src.length >> 2); const dst32 = new Uint32Array(dst.buffer, dst.byteOffset, dst.length >> 2);
for (let dy = startY; dy < endY; dy += 1) { const srcY = cY + Math.floor(dy * scaleY); if (srcY < 0 || srcY >= srcHeight) continue; const srcRow = srcY * srcWidth; let destIdx32 = (destY + dy) * dstWidth + destX + startX; for (let dx = startX; dx < endX; dx += 1, destIdx32 += 1) { const sx = srcXs[dx - startX]; if (sx < 0) continue; const s = src32[srcRow + sx]; const sA = s >>> 24; if (sA === 255) { dst32[destIdx32] = s; } else if (sA > 0) { // Translucent source — blend through the byte views. const srcIdx = (srcRow + sx) * 4; const destIdx = destIdx32 * 4; const alpha = sA + 1; const invAlpha = 256 - alpha; dst[destIdx] = (alpha * src[srcIdx] + invAlpha * dst[destIdx]) >> 8; dst[destIdx + 1] = (alpha * src[srcIdx + 1] + invAlpha * dst[destIdx + 1]) >> 8; dst[destIdx + 2] = (alpha * src[srcIdx + 2] + invAlpha * dst[destIdx + 2]) >> 8; dst[destIdx + 3] = Math.min(255, dst[destIdx + 3] + sA); } } }}
function paste(from, destX = 0, destY = 0, scale = 1, blit = false) { const pasteStart = performance.now(); // 🚨 PERFORMANCE TRACKING if (!from) { graphPerf.track('paste', 0); return; }
// 🚨 ROBOT FIX: Ensure destination pixels buffer is available if (!pixels) { console.warn('🚨 paste: No destination pixels buffer available, skipping paste operation'); graphPerf.track('paste', 0); return; }
destX += panTranslation.x; destY += panTranslation.y; // 🔧 FIX: Floor coordinates to prevent fractional pixel indices that cause color channel misalignment destX = Math.floor(destX); destY = Math.floor(destY);
// 🚀 OPTIMIZATION: Skip paste operations for tiny scales if (typeof scale === "number" && Math.abs(scale) < 0.01) { graphPerf.track('paste', 0); return; }
if (scale !== 1) { let angle = 0; let anchor; let tWidth, tHeight; let crop; // 🎨 Support crop parameter for ImageBitmap
if (typeof scale === "object") { angle = scale.angle; tWidth = scale.width; tHeight = scale.height; anchor = scale.anchor; crop = scale.crop; // 🎨 Extract crop from transform object // ^ Pull properties out of the scale object. // Only default scale to 1 if explicit dimensions are NOT provided. // This allows grid() to use tWidth/tHeight for scaling when they are present. if (scale.scale === undefined && (tWidth === undefined || tHeight === undefined)) { scale = 1; } else { scale = scale.scale; } }
// Fast path for simple integer scaling (no rotation, no custom dimensions). // It blits from.width × from.height wholesale, so it must decline anything // cropped — otherwise a cropped sprite silently pastes its entire sheet, // and only when the sheet happens to fit, which hides the bug. if ( !angle && !crop && !tWidth && !tHeight && !anchor && typeof scale === "number" && scale > 0 && scale === ~~scale && scale <= 8 ) { // Integer scale up to 8x for safety
// Ultra-fast nearest-neighbor scaling using direct pixel manipulation const srcWidth = from.width; const srcHeight = from.height; const srcPixels = from.pixels; const scaleInt = ~~scale; // Convert to integer
// Pre-calculate destination bounds const destWidth = srcWidth * scaleInt; const destHeight = srcHeight * scaleInt;
// Boundary check if ( destX >= 0 && destY >= 0 && destX + destWidth <= width && destY + destHeight <= height ) { // Direct pixel buffer manipulation for maximum speed. Opaque pixels // write their scaled block straight into the destination via a Uint32 // view (one write per dest pixel — no color()/box() call overhead); // partial-alpha pixels fall back to color()+box()/plot() so blending is // preserved. Bounds are guaranteed by the outer boundary check above. // Alignment-guarded; alpha is the high byte (little-endian RGBA). const fastBlock = (srcPixels.byteOffset & 3) === 0 && (pixels.byteOffset & 3) === 0; const src32 = fastBlock ? new Uint32Array(srcPixels.buffer, srcPixels.byteOffset, srcWidth * srcHeight) : null; const dst32 = fastBlock ? new Uint32Array(pixels.buffer, pixels.byteOffset, width * height) : null; for (let srcY = 0; srcY < srcHeight; srcY += 1) { for (let srcX = 0; srcX < srcWidth; srcX += 1) { const srcIndex = (srcX + srcY * srcWidth) << 2; // Fast * 4 if (srcIndex >= srcPixels.length) continue;
const a = srcPixels[srcIndex + 3]; if (a === 0) continue; // skip transparent
const baseDestX = destX + srcX * scaleInt; const baseDestY = destY + srcY * scaleInt;
if (a === 255 && fastBlock) { // Opaque — fill the scaled block with a single 32-bit value. const s = src32[srcX + srcY * srcWidth]; for (let dy = 0; dy < scaleInt; dy += 1) { let di = (baseDestY + dy) * width + baseDestX; for (let dx = 0; dx < scaleInt; dx += 1) dst32[di + dx] = s; } } else { // Partial alpha (or unaligned) — blend through the pen. color(srcPixels[srcIndex], srcPixels[srcIndex + 1], srcPixels[srcIndex + 2], a); if (scaleInt > 1) { box(baseDestX, baseDestY, scaleInt, scaleInt, "fill"); } else { plot(baseDestX, baseDestY); } } } } return; // Exit early for fast path } }
// 🎨 Special case: ImageBitmap with crop - convert to buffer with crop let isImageBitmap = typeof ImageBitmap !== "undefined" && from instanceof ImageBitmap; if (isImageBitmap && crop) { // Extract pixels from ImageBitmap and create a cropped buffer object const pixels = ensureBufferPixels(from); if (pixels) { // Convert to simple buffer to share logic with regular buffer path from = { width: from.width, height: from.height, pixels }; isImageBitmap = false; // Allow fall-through to next block } else { console.warn("🎨 Failed to extract pixels from ImageBitmap for cropping"); return; // Can't proceed without pixels } }
// 🎨 Special case: Regular buffer with crop if (!isImageBitmap && crop && from && from.width && from.height) { const sourcePixels = from.pixels || (from.painting && from.painting.pixels); if (sourcePixels) { // Ensure crop parameters are integers for pixel manipulation const cX = Math.floor(crop.x); const cY = Math.floor(crop.y); const cW = Math.floor(crop.w); const cH = Math.floor(crop.h); const srcWidth = from.width; const srcHeight = from.height;
// Destination-based scaling for cropped buffers (fixes downscaling gaps) const targetW = tWidth || (scale && typeof scale === 'number' ? Math.floor(cW * scale) : cW); const targetH = tHeight || (scale && typeof scale === 'number' ? Math.floor(cH * scale) : cH);
// 🚀 Fast crop+scale: nearest-neighbor straight from the source // buffer — no intermediate crop copy. The mask is rectangular, so // it clamps the loop bounds instead of branching per pixel, and // fully opaque pixels move as single 32-bit words. This is the // realtime path for full-screen video zoom (e.g. cap). if ( (targetW !== cW || targetH !== cH) && !angle && (sourcePixels.byteOffset & 3) === 0 && (pixels.byteOffset & 3) === 0 ) { const scaleX = cW / targetW; const scaleY = cH / targetH;
// Loop bounds clamped to the screen... let startY = Math.max(0, -destY); let endY = Math.min(targetH, height - destY); let startX = Math.max(0, -destX); let endX = Math.min(targetW, width - destX);
// ...and to the mask rect (pan-translated like every other blit). if (activeMask) { const maskX = activeMask.x + panTranslation.x; const maskY = activeMask.y + panTranslation.y; startX = Math.max(startX, maskX - destX); endX = Math.min(endX, maskX + activeMask.width - destX); startY = Math.max(startY, maskY - destY); endY = Math.min(endY, maskY + activeMask.height - destY); }
if (endX > startX && endY > startY) { blitCropScale( sourcePixels, srcWidth, srcHeight, cX, cY, scaleX, scaleY, destX, destY, pixels, width, startX, startY, endX, endY, ); } return; }
// Create a new buffer for the cropped area // We must copy the pixels because grid() expects a packed buffer matching width/height const croppedPixels = new Uint8ClampedArray(cW * cH * 4); for (let y = 0; y < cH; y++) { const srcY = cY + y; if (srcY >= 0 && srcY < from.height) { const srcRowStart = (srcY * srcWidth + cX) * 4; const rowLength = cW * 4; // Calculate valid copy range // We need to handle cases where crop.x is negative or extends beyond width let startOffset = 0; let copyLength = rowLength; let srcStart = srcRowStart; if (cX < 0) { startOffset = -cX * 4; copyLength -= startOffset; srcStart = (srcY * srcWidth) * 4; // Start from beginning of row } if (cX + cW > srcWidth) { copyLength -= (cX + cW - srcWidth) * 4; } if (copyLength > 0 && srcStart < sourcePixels.length) { const row = sourcePixels.subarray(srcStart, srcStart + copyLength); // Ensure we don't write out of bounds if (y * rowLength + startOffset + row.length <= croppedPixels.length) { croppedPixels.set(row, y * rowLength + startOffset); } } } } const croppedBuffer = { width: cW, height: cH, pixels: croppedPixels };
// Misaligned-buffer fallback for the scaled case (the aligned fast // path above returned already). if ((targetW !== cW || targetH !== cH) && !angle) { const scaleX = cW / targetW; const scaleY = cH / targetH; // Apply pan translation to mask bounds for proper clipping const maskX = activeMask ? activeMask.x + panTranslation.x : 0; const maskY = activeMask ? activeMask.y + panTranslation.y : 0; const maskW = activeMask ? activeMask.width : width; const maskH = activeMask ? activeMask.height : height; // Pre-calculate bounds const startY = Math.max(0, -destY); const endY = Math.min(targetH, height - destY); const startX = Math.max(0, -destX); const endX = Math.min(targetW, width - destX); for (let dy = startY; dy < endY; dy++) { const srcY = Math.floor(dy * scaleY); const destRowY = destY + dy; const destRowStart = destRowY * width * 4; for (let dx = startX; dx < endX; dx++) { const destColX = destX + dx; // Check if destination pixel is within mask if (activeMask) { if (destColX < maskX || destColX >= maskX + maskW || destRowY < maskY || destRowY >= maskY + maskH) { continue; // Skip pixels outside mask } } const srcX = Math.floor(dx * scaleX); const srcIdx = (srcY * cW + srcX) * 4; const destIdx = destRowStart + destColX * 4; // Blend pixel const sA = croppedPixels[srcIdx + 3]; if (sA > 0) { const sR = croppedPixels[srcIdx]; const sG = croppedPixels[srcIdx + 1]; const sB = croppedPixels[srcIdx + 2]; if (sA === 255) { pixels[destIdx] = sR; pixels[destIdx + 1] = sG; pixels[destIdx + 2] = sB; pixels[destIdx + 3] = 255; } else { // Alpha blend const dA = pixels[destIdx + 3]; const alpha = sA + 1; const invAlpha = 256 - alpha; pixels[destIdx] = (alpha * sR + invAlpha * pixels[destIdx]) >> 8; pixels[destIdx + 1] = (alpha * sG + invAlpha * pixels[destIdx + 1]) >> 8; pixels[destIdx + 2] = (alpha * sB + invAlpha * pixels[destIdx + 2]) >> 8; pixels[destIdx + 3] = Math.min(255, dA + sA); } } } } return; } // A per-axis {x, y} scale only means anything to grid, so always hand // it over — that is how a mirror ({x: -1, y: 1}) gets drawn. if (tWidth || tHeight || (typeof scale === 'number' && scale !== 1) || (scale && typeof scale === 'object') || angle) { grid( { box: { x: destX, y: destY, w: cW, h: cH }, transform: { scale, angle, width: tWidth, height: tHeight, anchor }, }, croppedBuffer, ); } else { paste(croppedBuffer, destX, destY, 1, blit); } return; } }
// Fall back to general grid-based scaling for complex cases if (from && from.width && from.height) { grid( { box: { x: destX, y: destY, w: from.width, h: from.height }, transform: { scale, angle, width: tWidth, height: tHeight, anchor }, }, from, ); }
return; }
// Note: Angle does not work here yet... 23.04.29.23.38
// TODO: See if from has a dirtyBox attribute. if (from.crop) { // A cropped copy - optimize with bulk pixel operations const cropW = from.crop.w; const cropH = from.crop.h; const fromPixels = from.painting?.pixels || from.pixels;
// 🚀 OPTIMIZATION: Bulk pixel copying for cropped regions if ( fromPixels && destX >= 0 && destY >= 0 && destX + cropW <= width && destY + cropH <= height ) { const fromWidth = from.painting?.width || from.width; // Apply pan translation to mask bounds for proper clipping const maskX = activeMask ? activeMask.x + panTranslation.x : 0; const maskY = activeMask ? activeMask.y + panTranslation.y : 0; const maskW = activeMask ? activeMask.width : width; const maskH = activeMask ? activeMask.height : height; for (let y = 0; y < cropH; y += 1) { const srcY = from.crop.y + y; const destRowY = destY + y; // Process entire row for better cache performance for (let x = 0; x < cropW; x += 1) { const dx = destX + x; const dy = destRowY; // Check if destination pixel is within mask if (activeMask) { if (dx < maskX || dx >= maskX + maskW || dy < maskY || dy >= maskY + maskH) { continue; // Skip pixels outside mask } } const srcX = from.crop.x + x; const srcIdx = (srcY * fromWidth + srcX) * 4; const destIdx = (destRowY * width + dx) * 4; // Skip if source index is out of bounds if (srcIdx + 3 < fromPixels.length) { const srcAlpha = fromPixels[srcIdx + 3]; if (srcAlpha > 0) { if (srcAlpha === 255) { // Opaque source - direct copy pixels[destIdx] = fromPixels[srcIdx]; pixels[destIdx + 1] = fromPixels[srcIdx + 1]; pixels[destIdx + 2] = fromPixels[srcIdx + 2]; pixels[destIdx + 3] = fromPixels[srcIdx + 3]; } else { // Alpha blending const alpha = srcAlpha + 1; const invAlpha = 256 - alpha; pixels[destIdx] = (alpha * fromPixels[srcIdx] + invAlpha * pixels[destIdx]) >> 8; pixels[destIdx + 1] = (alpha * fromPixels[srcIdx + 1] + invAlpha * pixels[destIdx + 1]) >> 8; pixels[destIdx + 2] = (alpha * fromPixels[srcIdx + 2] + invAlpha * pixels[destIdx + 2]) >> 8; pixels[destIdx + 3] = Math.min(255, pixels[destIdx + 3] + srcAlpha); } } } } } } else { // Fall back to copy() function for edge cases const sourcePainting = from.painting || from; if (sourcePainting && sourcePainting.pixels) { for (let y = 0; y < cropH; y += 1) { for (let x = 0; x < cropW; x += 1) { copy( destX + x, destY + y, from.crop.x + x, from.crop.y + y, sourcePainting, ); } } } } } else { // Check to see if we can perform a full copy here, // with no alpha blending. if (blit) { pixels.set(from.pixels, 0); } else { // 🚀 MAJOR OPTIMIZATION: Bulk pixel copying for better performance const srcWidth = from.width; const srcHeight = from.height; const srcPixels = from.pixels;
// Check if we can do ultra-fast bulk copying if ( destX >= 0 && destY >= 0 && destX + srcWidth <= width && destY + srcHeight <= height && srcPixels ) { // Ultra-fast bulk pixel blending // Apply pan translation to mask bounds for proper clipping const maskX = activeMask ? activeMask.x + panTranslation.x : 0; const maskY = activeMask ? activeMask.y + panTranslation.y : 0; const maskW = activeMask ? activeMask.width : width; const maskH = activeMask ? activeMask.height : height; if ( !activeMask && (srcPixels.byteOffset & 3) === 0 && (pixels.byteOffset & 3) === 0 ) { // 🚀 No-mask fast path. The outer bounds test already guarantees every // source/destination index is valid, so the per-pixel mask and bounds // checks are dropped entirely. Pixels are viewed as Uint32 so an opaque // pixel copies in a single 32-bit write (and a fully-transparent pixel // is skipped with one read); only partial-alpha pixels fall back to // per-channel blending. Alpha is the high byte (little-endian RGBA, as // canvas ImageData always is); the 32-bit copy itself is endian-safe. const src32 = new Uint32Array( srcPixels.buffer, srcPixels.byteOffset, srcWidth * srcHeight, ); const dst32 = new Uint32Array( pixels.buffer, pixels.byteOffset, width * height, ); for (let y = 0; y < srcHeight; y += 1) { let si = y * srcWidth; let di = (destY + y) * width + destX; for (let x = 0; x < srcWidth; x += 1, si += 1, di += 1) { const s = src32[si]; const a = s >>> 24; // source alpha if (a === 255) { dst32[di] = s; // opaque — single 32-bit write } else if (a !== 0) { // partial alpha — per-channel blend const si4 = si << 2; const di4 = di << 2; const alpha = a + 1; const invAlpha = 256 - alpha; pixels[di4] = (alpha * srcPixels[si4] + invAlpha * pixels[di4]) >> 8; pixels[di4 + 1] = (alpha * srcPixels[si4 + 1] + invAlpha * pixels[di4 + 1]) >> 8; pixels[di4 + 2] = (alpha * srcPixels[si4 + 2] + invAlpha * pixels[di4 + 2]) >> 8; pixels[di4 + 3] = Math.min(255, pixels[di4 + 3] + a); } } } } else { for (let y = 0; y < srcHeight; y += 1) { const srcRowStart = y * srcWidth * 4; const destRowStart = ((destY + y) * width + destX) * 4;
// Process entire row at once for better cache performance for (let x = 0; x < srcWidth; x += 1) { const dx = destX + x; const dy = destY + y;
// Check if destination pixel is within mask if (activeMask) { if (dx < maskX || dx >= maskX + maskW || dy < maskY || dy >= maskY + maskH) { continue; // Skip pixels outside mask } }
const srcIdx = srcRowStart + (x * 4); const destIdx = destRowStart + (x * 4);
// Bounds check to prevent reading beyond buffer if (srcIdx + 3 >= srcPixels.length || destIdx + 3 >= pixels.length) continue;
// Skip transparent pixels if (srcPixels[srcIdx + 3] > 0) { // Fast alpha blending without function call overhead const srcAlpha = srcPixels[srcIdx + 3];
if (srcAlpha === 255) { // Opaque source - direct copy (fastest path) pixels[destIdx] = srcPixels[srcIdx]; pixels[destIdx + 1] = srcPixels[srcIdx + 1]; pixels[destIdx + 2] = srcPixels[srcIdx + 2]; pixels[destIdx + 3] = srcPixels[srcIdx + 3]; } else { // Alpha blending (optimized inline version) const alpha = srcAlpha + 1; const invAlpha = 256 - alpha; pixels[destIdx] = (alpha * srcPixels[srcIdx] + invAlpha * pixels[destIdx]) >> 8; pixels[destIdx + 1] = (alpha * srcPixels[srcIdx + 1] + invAlpha * pixels[destIdx + 1]) >> 8; pixels[destIdx + 2] = (alpha * srcPixels[srcIdx + 2] + invAlpha * pixels[destIdx + 2]) >> 8; pixels[destIdx + 3] = Math.min(255, pixels[destIdx + 3] + srcAlpha); } } } } } } else { // Fallback to copy() function for edge cases for (let y = 0; y < srcHeight; y += 1) { for (let x = 0; x < srcWidth; x += 1) { const destPixelX = destX + x; const destPixelY = destY + y; // Check if pixel is within the active mask (if one exists) if (activeMask) { const maskX = destPixelX - activeMask.x - panTranslation.x; const maskY = destPixelY - activeMask.y - panTranslation.y; if ( maskX < 0 || maskY < 0 || maskX >= activeMask.width || maskY >= activeMask.height ) { continue; // Skip pixels outside the mask } } copy(destPixelX, destPixelY, x, y, from); } } } } } const pasteEnd = performance.now(); graphPerf.track('paste', pasteEnd - pasteStart);}
let stampSkipCounter = 0;
// Similar to `paste` but always centered.// Fixed: Now properly supports scale and angle parameters while maintaining center positioningfunction stamp(from, x, y, scale, angle) { const stampStart = performance.now(); // 🚨 PERFORMANCE TRACKING // Set all untruthy values like null, or undefined to a random value. if (x == null) x = randIntRange(0, width - 1); if (y == null) y = randIntRange(0, height - 1); // � DEBUG: Log stamp calls // console.log(`🎯 STAMP called: from=${from?.constructor?.name || typeof from}, scale=${scale}, angle=${angle}`); // �🚀 OPTIMIZATION: Skip stamps that are too small to be visible if (scale !== undefined && Math.abs(scale) < 0.01) { graphPerf.track('stamp', 0); return; }
// 🚀 PERFORMANCE OPTIMIZATION: Only skip frames when running very slowly if (graphPerf && graphPerf.lastFPS && graphPerf.lastFPS < 5) { stampSkipCounter++; if (stampSkipCounter % 3 !== 0) { // Skip 2 out of 3 frames when FPS < 5 // console.log(`⏭️ STAMP skipped due to low FPS: ${graphPerf.lastFPS}`); graphPerf.track('stamp', 0); return; } } else { stampSkipCounter = 0; // Reset when performance is good } if (scale !== undefined || angle !== undefined) { // For stamp, we want to center at x,y coordinates // Account for scaling when calculating the center offset const effectiveScale = scale !== undefined ? scale : 1; const scaledWidth = from.width * effectiveScale; const scaledHeight = from.height * effectiveScale; // Adjust the position so that the scaled image is centered at our target x,y const adjustedX = x - scaledWidth / 2; const adjustedY = y - scaledHeight / 2; const transform = {}; if (scale !== undefined) transform.scale = scale; if (angle !== undefined) transform.angle = angle; paste(from, adjustedX, adjustedY, transform); } else { // Fallback for simple case without scaling/rotation paste(from, x - from.width / 2, y - from.height / 2); } const stampEnd = performance.now(); graphPerf.track('stamp', stampEnd - stampStart);}
let blendingMode = "blend";function blendMode(mode = "blend") { blendingMode = mode;}
// 🧽 Erase target: when set, erase operations write their erase amounts here// so KidLisp can apply them during compositing to punch through baked layers.let eraseTarget = null;let eraseTargetWidth = 0;function setEraseTarget(target, targetWidth) { eraseTarget = target; eraseTargetWidth = targetWidth;}
// let stipple = 0;// A fast alpha blending function that looks into a pixel array.// Transcribed from C++: https://stackoverflow.com/a/12016968function blend(dst, src, si, di, alphaIn = 1) { //stipple += 1; //if (stipple < 4) { return; } //stipple = 0;
if (forceReplaceMode) { for (let i = 0; i < 3; i++) { dst[di + i] = src[si + i]; } dst[di + 3] = src[si + 3] * alphaIn; return; }
if (blendingMode === "erase") { const normalAlpha = 1 - src[si + 3] / 255; dst[di + 3] *= normalAlpha; if (dst[di + 3] === 0) { // If the alpha is zero then wipe the data. dst[di] = 32; dst[di + 1] = 32; dst[di + 2] = 32; } // 🧽 Also record erase amount in eraseTarget for cross-layer compositing if (eraseTarget) { const pi = di >> 2; // pixel index eraseTarget[pi] = 255 - ((255 - eraseTarget[pi]) * normalAlpha + 0.5) | 0; } return; }
if (src[si + 3] === 0) return; // Return early if src is invalid.
// Just do a straight up copy if we are in "blit" mode. if (blendingMode === "blit") { for (let i = 0; i < 4; i++) { if (i != 3) { dst[di + i] = src[si + i]; // For R, G, B channels } else { dst[di + i] = src[si + i] * alphaIn; // For the Alpha channel } } return; }
// A. Blend over transparent pixels. if (dst[di + 3] < 255 && src[si + 3] > 0) { const epsilon = 1e-10; // Small number to prevent division by zero const alphaSrc = (src[si + 3] * alphaIn) / 255; const alphaDst = dst[di + 3] / 255; const combinedAlpha = alphaSrc + (1.0 - alphaSrc) * alphaDst; if (combinedAlpha > epsilon) { // Check against a very small number instead of 0 for (let offset = 0; offset < 3; offset++) { // Iterate over R, G, B channels dst[di + offset] = (src[si + offset] * alphaSrc + dst[di + offset] * (1.0 - alphaSrc) * alphaDst) / (combinedAlpha + epsilon); // Add epsilon to prevent division instability } dst[di + 3] = combinedAlpha * 255; } } else { // B. Blend over opaque pixels. const alpha = src[si + 3] * alphaIn + 1; const invAlpha = 256 - alpha; dst[di] = (alpha * src[si + 0] + invAlpha * dst[di + 0]) >> 8; dst[di + 1] = (alpha * src[si + 1] + invAlpha * dst[di + 1]) >> 8; dst[di + 2] = (alpha * src[si + 2] + invAlpha * dst[di + 2]) >> 8; // Keep destination alpha opaque when blending over opaque background dst[di + 3] = 255; }}
// Blends the alpha channel only / erases pixels.function erase(pixels, i, normalizedAlpha) { pixels[i + 3] *= normalizedAlpha;}
// 🚀 FAST LINE: Inline Bresenham without array allocation or per-pixel function calls// Used when no fade, no gradient, no skip list - just pure pixel writingfunction lineFast(x0, y0, x1, y1) { if (!pixels) return; // Guard: resolve stale non-numeric color to prevent artifacts if (typeof c[0] === 'string' && !c[0].startsWith('fade:')) { setColor(0, 0, 0, c[3] || 255); }
// Floor coordinates x0 = floor(x0) || 0; y0 = floor(y0) || 0; x1 = floor(x1) || 0; y1 = floor(y1) || 0; // Get mask bounds (or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { minX = activeMask.x; minY = activeMask.y; maxX = activeMask.x + activeMask.width; maxY = activeMask.y + activeMask.height; } // Pre-cache color values const r = c[0], g = c[1], b = c[2], a = c[3]; const isErase = r === -1 && g === -1 && b === -1; const normalAlpha = isErase ? 1 - a / 255 : 0; const isOpaque = a === 255 && !isErase; const hasAlpha = a !== 0 && a !== 255 && !isErase; // Bresenham's algorithm inlined const dx = abs(x1 - x0); const dy = -abs(y1 - y0); const sx = x0 < x1 ? 1 : -1; const sy = y0 < y1 ? 1 : -1; let err = dx + dy; while (true) { // Bounds check if (x0 >= minX && x0 < maxX && y0 >= minY && y0 < maxY) { const i = (x0 + y0 * width) * 4; if (forceReplaceMode || isOpaque) { pixels[i] = r; pixels[i + 1] = g; pixels[i + 2] = b; pixels[i + 3] = isOpaque ? 255 : a; } else if (isErase) { pixels[i + 3] *= normalAlpha; if (eraseTarget) { const pi = i >> 2; eraseTarget[pi] = 255 - ((255 - eraseTarget[pi]) * normalAlpha + 0.5) | 0; } } else if (hasAlpha) { // Inline alpha blend const invA = 255 - a; pixels[i] = (r * a + pixels[i] * invA) >> 8; pixels[i + 1] = (g * a + pixels[i + 1] * invA) >> 8; pixels[i + 2] = (b * a + pixels[i + 2] * invA) >> 8; pixels[i + 3] = a + (pixels[i + 3] * invA >> 8); } } if (x0 === x1 && y0 === y1) break; const e2 = 2 * err; if (e2 >= dy) { err += dy; x0 += sx; } if (e2 <= dx) { err += dx; y0 += sy; } }}
// Draws a horizontal line. (Should be very fast...)function lineh(x0, x1, y) { x0 = floor(x0); x1 = floor(x1); y = floor(y); if (!pixels) return; // Guard: resolve stale non-numeric color to prevent artifacts if (typeof c[0] === 'string' && !c[0].startsWith('fade:')) { setColor(0, 0, 0, c[3] || 255); } if (y < 0 || y >= height || x0 >= width || x1 < 0) return; // Check if the entire line is outside the mask if (activeMask) { // Don't apply pan translation to mask bounds - mask is already set at current pan position if ( y < activeMask.y || y >= activeMask.y + activeMask.height || x1 < activeMask.x || x0 >= activeMask.x + activeMask.width ) return; }
// Clamp to screen bounds first x0 = clamp(x0, 0, width - 1); x1 = clamp(x1, 0, width - 1); // Then clamp to mask bounds if mask is active if (activeMask) { // Don't apply pan translation to mask bounds - mask is already set at current pan position x0 = clamp(x0, activeMask.x, activeMask.x + activeMask.width - 1); x1 = clamp(x1, activeMask.x, activeMask.x + activeMask.width - 1); }
const firstIndex = (x0 + y * width) * 4; const secondIndex = (x1 + y * width) * 4;
let startIndex, endIndex;
// Sort indices so we can always draw from left to right. if (firstIndex > secondIndex) { startIndex = secondIndex; endIndex = firstIndex; } else { startIndex = firstIndex; endIndex = secondIndex; }
if (forceReplaceMode) { for (let i = startIndex; i <= endIndex; i += 4) { pixels[i] = c[0]; pixels[i + 1] = c[1]; pixels[i + 2] = c[2]; pixels[i + 3] = c[3]; } return; }
// Erasing. if (blendMode !== "erase" && c[0] === -1 && c[1] === -1 && c[2] === -1) { const normalAlpha = 1 - c[3] / 255; for (let i = startIndex; i <= endIndex; i += 4) { erase(pixels, i, normalAlpha); if (eraseTarget) { const pi = i >> 2; eraseTarget[pi] = 255 - ((255 - eraseTarget[pi]) * normalAlpha + 0.5) | 0; } } // No alpha. } else if (c[3] === 255) { for (let i = startIndex; i <= endIndex; i += 4) { pixels[i] = c[0]; pixels[i + 1] = c[1]; pixels[i + 2] = c[2]; pixels[i + 3] = 255; } // Alpha blending. } else if (c[3] !== 0) { for (let i = startIndex; i <= endIndex; i += 4) { blend(pixels, c, 0, i); } }}
// Draws a line// (2) p1, p2: pairs of {x, y} or [x, y]// (4) x0, y0, x1, y1// TODO: Automatically use lineh if possible. 22.10.05.18.27// Fast thick line with rounded endcaps via scanline capsule fill.// For each row, finds the x-range where distance to the line segment <= r,// then fills with a single lineh call. No per-pixel overhead.function lineThick(x0, y0, x1, y1, r) { x0 += panTranslation.x; y0 += panTranslation.y; x1 += panTranslation.x; y1 += panTranslation.y;
const r2 = r * r + r; // +r for pixel-grid rounding to match circle stamp look. const ldx = x1 - x0, ldy = y1 - y0; const len2 = ldx * ldx + ldy * ldy;
// Degenerate: single point → filled circle scanlines. if (len2 === 0) { for (let dy = -r; dy <= r; dy++) { const h = floor(sqrt(max(0, r2 - dy * dy))); lineh(x0 - h, x0 + h, y0 + dy); } return; }
const invLen2 = 1 / len2; const minY = floor(min(y0, y1) - r); const maxY = floor(max(y0, y1) + r);
for (let y = minY; y <= maxY; y++) { // Find x-range where capsule distance ≤ r at this row. // Sample a few x candidates and bracket the span: // 1. Endcap circles at A and B. // 2. Body: closest point on segment projected to this row.
let lo = Infinity, hi = -Infinity;
// Endcap circles. const da = y - y0, db = y - y1; const da2 = da * da, db2 = db * db; if (da2 <= r2) { const h = sqrt(r2 - da2); lo = x0 - h; hi = x0 + h; } if (db2 <= r2) { const h = sqrt(r2 - db2); if (x1 - h < lo) lo = x1 - h; if (x1 + h > hi) hi = x1 + h; }
// Body: project (x, y) onto segment. The closest point on the segment // for a given y lies at t = ((x-x0)*ldx + (y-y0)*ldy) / len2. // For the body, the perpendicular distance from (x,y) to the line is // |cross| / len where cross = (y-y0)*ldx - (x-x0)*ldy. // Solving cross^2 / len2 <= r2 for x: // (C - x*ldy)^2 <= r2*len2 where C = (y-y0)*ldx + x0*ldy // x*ldy ∈ [C - D, C + D] where D = sqrt(r2*len2) // Then clamp resulting x to the segment's t ∈ [0,1] range. const C = (y - y0) * ldx + x0 * ldy; const D = sqrt(r2 * len2);
if (abs(ldy) > 0.0001) { // Solve for x from the perpendicular constraint. let bxLo = (C - D) / ldy, bxHi = (C + D) / ldy; if (bxLo > bxHi) { const tmp = bxLo; bxLo = bxHi; bxHi = tmp; }
// Clamp to segment t ∈ [0,1]: x = x0 + t*ldx, so t = (x-x0)/ldx if ldx≠0. // Instead, clamp by checking t at each body-x bound. const tLo = ((bxLo - x0) * ldx + (y - y0) * ldy) * invLen2; const tHi = ((bxHi - x0) * ldx + (y - y0) * ldy) * invLen2; const tMin = max(0, min(tLo, tHi)); const tMax = min(1, max(tLo, tHi));
if (tMin <= tMax) { // At the clamped t range, compute x and offset by the // perpendicular half-width at this row. const xA = x0 + tMin * ldx, yA = y0 + tMin * ldy; const xB = x0 + tMax * ldx, yB = y0 + tMax * ldy; // Half-width from circle at each point. const ha2 = r2 - (y - yA) * (y - yA); const hb2 = r2 - (y - yB) * (y - yB); if (ha2 >= 0) { const h = sqrt(ha2); if (xA - h < lo) lo = xA - h; if (xA + h > hi) hi = xA + h; } if (hb2 >= 0) { const h = sqrt(hb2); if (xB - h < lo) lo = xB - h; if (xB + h > hi) hi = xB + h; } } }
if (lo <= hi) { lineh(floor(lo), floor(hi), y); } }}
function line() { let x0, y0, x1, y1, thickness; if (arguments.length === 1) { // Safely access properties on the first argument const arg0 = arguments[0]; if (arg0 && typeof arg0 === "object") { x0 = arg0.x0; // Assume an object { x0, y0, x1, y1 } y0 = arg0.y0; x1 = arg0.x1; y1 = arg0.y1; thickness = arg0.thickness; } } else if (arguments.length >= 4) { x0 = arguments[0]; // Set all `undefined` or `null` values to 0. y0 = arguments[1]; x1 = arguments[2]; y1 = arguments[3]; thickness = arguments[4]; } else if (arguments.length === 2) { const arg0 = arguments[0]; const arg1 = arguments[1]; if (Array.isArray(arg0) && Array.isArray(arg1)) { // assume [x, y], [x, y] x0 = arg0[0]; y0 = arg0[1]; x1 = arg1[0]; y1 = arg1[1]; } else if ( arg0 && typeof arg0 === "object" && arg1 && typeof arg1 === "object" ) { // assume {x, y}, {x, y} x0 = arg0.x; y0 = arg0.y; x1 = arg1.x; y1 = arg1.y; } } else { // if (debug) { // console.warn( // "Line did not use the correct number of arguments:", // arguments // ); // } }
// Set all untruthy values like null, or undefined to a random value. if (x0 == null) x0 = randIntRange(0, width); if (y0 == null) y0 = randIntRange(0, height); if (x1 == null) x1 = randIntRange(0, width); if (y1 == null) y1 = randIntRange(0, height);
// Handle NaN values the same way as null - use random values if (isNaN(x0)) x0 = randIntRange(0, width); if (isNaN(y0)) y0 = randIntRange(0, height); if (isNaN(x1)) x1 = randIntRange(0, width); if (isNaN(y1)) y1 = randIntRange(0, height);
// Thick line with rounded endcaps. if (thickness > 1) { const radius = floor((thickness - 1) / 2); lineThick(floor(x0), floor(y0), floor(x1), floor(y1), radius); const out = [floor(x0), floor(y0), floor(x1), floor(y1)]; twoDCommands?.push(["line", ...out]); return out; }
// console.log("Line in:", x0, y0, x1, y1);
// Add any panTranslations. x0 += panTranslation.x; y0 += panTranslation.y; x1 += panTranslation.x; y1 += panTranslation.y;
// Lerp from primary to secondary color as needed. const cachedInk = c.slice(0); const localFadeInfo = parseFadeColor(c); const usingLocalFade = !!localFadeInfo;
// 🚀 FAST PATH: Use lineFast when no special effects are needed // This is much faster than bresenham + forEach + plot if (!fadeMode && !usingLocalFade && !c2 && skips.length === 0) { // Check if horizontal line (even faster) if (y0 === y1) { lineh(x0, x1, y0); } else { lineFast(x0, y0, x1, y1); } } else if (y0 === y1 && !c2 && !fadeMode) { // Horizontal line without gradient/fade lineh(x0, x1, y0); } else { // SLOW PATH: Need per-pixel color calculations const lineLength = sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2); bresenham(x0, y0, x1, y1).forEach((p, index, points) => { if (fadeMode) { // Calculate fade position based on line progress const t = lineLength > 0 ? sqrt((p.x - x0) ** 2 + (p.y - y0) ** 2) / lineLength : 0; const fadeColor = getFadeColor(t, p.x, p.y); setColor(...fadeColor); plot(p.x, p.y); } else if (usingLocalFade) { const totalSteps = Math.max(1, points.length - 1); const t = totalSteps === 0 ? 0 : index / totalSteps; const fadeColor = getLocalFadeColor(t, p.x, p.y, localFadeInfo); setColor(...fadeColor); plot(p.x, p.y); } else if (c2) { const step = sqrt(p.x * p.x + p.y * p.y) / 255; // Gradient step. color(...shiftRGB(c, c2, step)); plot(p.x, p.y); } else { plot(p.x, p.y); } }); if (usingLocalFade) { if (typeof cachedInk[0] === "string" && cachedInk[0].startsWith("fade:")) { color(cachedInk); } else { setColor(...cachedInk); } } else if (c2 || fadeMode) { setColor(...cachedInk); } }
const out = [x0, y0, x1, y1]; // console.log("Line out:", out); twoDCommands?.push(["line", ...out]); // Forward this call to the GPU. return out;}
// Takes an array of pixel coords `{x, y}` and filters out L shapes.// Note: It checks the previous, current, and next pixel and requires a minimum// set of 3 before it removes anything.// Draws a regular `line` if only two pixels are provided.// Transcribed from: https://rickyhan.com/jekyll/update/2018/11/22/pixel-art-algorithm-pixel-perfect.htmlfunction pixelPerfectPolyline(points, shader) { if (points.length < 2) return; // Require 2 or more points. const pixels = []; //points.reverse(); // Draw from front to back.
let last = points[0];
// TODO: Draw everything front to back similar to pline? 23.02.01.13.12 //for (let i = coords.length - 2; i >= 0; i -= 1) {
points.forEach((cur) => { // Clip offscreen segments. const xMin = min(last.x, cur.x); const xMax = max(last.x, cur.x); const yMin = min(last.y, cur.y); const yMax = max(last.y, cur.y); if (xMin >= width || xMax < 0 || yMin >= height || yMax < 0) { last = cur; return; }
//if (cur.color === "rainbow") color(rainbow());
const rb = last.color === "rainbow";
// Compute bresen pixels, filtering out duplicates. bresenham(last.x, last.y, cur.x, cur.y).forEach((p, i) => { if (i > 0 || pixels.length < 2) { pixels.push({ ...p, color: rb ? rainbow() : last.color }); // Add color for each pixel. } }); last = cur; });
const filtered = []; let c = 0;
while (c < pixels.length) { if ( c > 0 && c + 1 < pixels.length && (pixels[c - 1].x === pixels[c].x || pixels[c - 1].y === pixels[c].y) && // check left and up (pixels[c + 1].x === pixels[c].x || pixels[c + 1].y === pixels[c].y) && // check right and down pixels[c - 1].x !== pixels[c + 1].x && // check left and right of prev and next pixels[c - 1].y !== pixels[c + 1].y ) { // check top and bottom of prev and next c += 1; } filtered.push(pixels[c]); c += 1; }
if (shader) { shadePixels(filtered, shader); } else { filtered.forEach((p) => point(p)); }}
// Draws a line from a point at a distance... with an angle in degrees.function lineAngle(x1, y1, dist, degrees) { const x2 = x1 + dist * cos(radians(degrees)); const y2 = y1 + dist * sin(radians(degrees)); return line(x1, y1, x2, y2);}
// Take two vertices and plot a 3d line with depth information.function line3d(a, b, lineColor, gradients) { const aColor = a.color; const bColor = b.color;
a = a.transform(screenMatrix); b = b.transform(screenMatrix);
const aZ = a.pos[Z]; const bZ = a.pos[Z];
a = a.perspectiveDivide(); b = b.perspectiveDivide();
const [x0, y0, z0] = a.pos; const [x1, y1, z1] = b.pos; const points = bresenham(x0, y0, x1, y1);
const saveColor = c.slice();
color(...lineColor); // Set color from lineColor or default to global color. // color(randInt(255), randInt(255), randInt(255)); // Random colors.
points.forEach((p, i) => { const progress = i / points.length; //const z = lerp(z0, z1, progress);
// Lerp from a.color to b.color if gradients are enabled. if (gradients && aColor && bColor) { const R = lerp(aColor[0], bColor[0], progress); const G = lerp(aColor[1], bColor[1], progress); const B = lerp(aColor[2], bColor[2], progress); color(R, G, B); }
const newZ = lerp(aZ, bZ, progress); const stretchedDepth = 1 - min(1, abs(newZ) / 2.5);
/* color( c[0] * stretchedDepth, c[1] * stretchedDepth, c[2] * stretchedDepth, ); // Set color from lineColor or default to global color. */
const depth = newZ * -1; if (depthBuffer) { const index = p.x + p.y * width; if (depth > depthBuffer[index]) { } else { depthBuffer[index] = depth; plot(p.x, p.y); } } else { plot(p.x, p.y); } });
color(...saveColor); // Restore color.}
// TODO: Implement a nice filled option here...// Something pixel-perfect with the outline... like a flood?function circle(x0, y0, radius, filled = false, thickness, precision) { if (filled || thickness > 1) { oval(x0, y0, radius, radius, filled, thickness, precision); return; }
// Circle x0 = floor(x0); y0 = floor(y0); radius = floor(radius);
let f = 1 - radius, ddF_x = 0, ddF_y = -2 * radius, x = 0, y = radius;
point(x0, y0 + radius); point(x0, y0 - radius); point(x0 + radius, y0); point(x0 - radius, y0);
while (x < y) { if (f >= 0) { y -= 1; ddF_y += 2; f += ddF_y; } x += 1; ddF_x += 2; f += ddF_x + 1; point(x0 + x, y0 + y); point(x0 - x, y0 + y); point(x0 + x, y0 - y); point(x0 - x, y0 - y); point(x0 + y, y0 + x); point(x0 - y, y0 + x); point(x0 + y, y0 - x); point(x0 - y, y0 - x); }}
// Draw a filled pie slice / wedge (for pie charts, progress indicators, etc.)// startAngle and endAngle are in radians, 0 = right, PI/2 = down, etc.function pie(x0, y0, radius, startAngle, endAngle, precision = 3) { const points = [[x0, y0]]; // Start from center // Generate arc points const angleSpan = endAngle - startAngle; const steps = max(floor(abs(angleSpan) * radius / precision), 8); for (let i = 0; i <= steps; i++) { const angle = startAngle + (angleSpan * i) / steps; const x = x0 + radius * cos(angle); const y = y0 + radius * sin(angle); points.push([x, y]); } // Close back to center points.push([x0, y0]); shape({ points, filled: true });}
// TODO: Generate sampled points around a circle then usefunction oval( x0, y0, radiusX, radiusY, filled = false, thickness = 1, precision,) { const points = generateEllipsePoints(x0, y0, radiusX, radiusY, precision); shape({ points, filled, thickness });}
// TODO: How can I relate precision to the circumference and avoid this little// sharp point on the top?// TODO: This function can have radians etc. / be more terse. 23.02.13.19.42function generateEllipsePoints(x0, y0, radiusX, radiusY, precision = 20) { const points = []; for (let i = 0; i < 360; i += precision) { const angle = radians(i); const x = x0 + radiusX * cos(angle); const y = y0 + radiusY * sin(angle); points.push([x, y]); } return points;}
// Draws a series of 1px lines without overlapping / overdrawing points.// TODO: Add closed mode? Example: ink(handPalette.w).poly([...w, w[0]]);function poly(coords) { let last = coords[0]; for (let i = 1; i < coords.length; i++) { const cur = coords[i]; line(last, cur); last = cur; }}
// Rasterize an Npx thick poly line with rounded end-caps./* TODO - [😇] Clip coords to inside of the screen. + Later - [] Perhaps if thickness === 1 then this can be combined with `pixelPerfectPolyline` ? - [] Render a third triangle from mid point to last point to next quad point? - [] Rounded half-circle endcaps. - [] Filled circle if coords.length === 1. - [] Texture / special FX. + Done - [x] Triangle rasterization of segment. - [x] Optimize performance. - [x] Run the profiler.*/function pline(coords, thickness, shader) { // 1️⃣ Generate geometry. if (coords.length < 2) return; // Require at least two coordinates.
let points = [], // Raster grids. lines = [], tris = [];
// 🎴 Draw everything from front to back!
let last = coords[coords.length - 1]; // Keep the last drawn element.
let lpar, ldir; // Store last parallel points / prepopulate if supplied.
for (let i = coords.length - 2; i >= 0; i -= 1) { const cur = coords[i];
// 1. Two points on both sides of last and cur via line dir. const lp = [last.x || last[0], last.y || last[1]], cp = [cur.x || cur[0], cur.y || cur[1]]; // Convert last and cur to vec2.
const dir = vec2.normalize([], vec2.subtract([], cp, lp)); // Line direction. if (!ldir) ldir = dir;
const rot = vec2.rotate([], dir, [0, 0], PI / 2); // Rotated by 90d
const offset1 = vec2.scale([], rot, thickness / 2); // Parallel offsets. const offset2 = vec2.scale([], rot, -thickness / 2);
let c1, c2; if (!lpar) { c1 = vec2.add([], lp, offset1); // Compute both sets of points. c2 = vec2.add([], lp, offset2); lpar = [c1, c2]; } else { [c1, c2] = lpar; }
[c1, c2, lp, cp].forEach((v) => vec2.floor(v, v)); // Floor everything.
// 2. Plotting
const dot = vec2.dot(dir, ldir); // Get the dot product of cur and last dir.
let trig; // Triangle geometry.
if (dot > 0) { // Vertex order for forward direction. trig = [ [c1, c2, lp], [c2, lp, cp], ]; lines.push(...bresenham(...c1, ...lp)); // Par line 1 lines.push(...bresenham(...c2, ...cp)); // Par line 2 } else { // Vertex order for backward direction. trig = [ [c2, lp, c1], [c1, cp, lp], ]; lines.push(...bresenham(...c2, ...lp)); // Par line 1 lines.push(...bresenham(...c1, ...cp)); // Par line 2 }
// Partial outside clipping. // const clippedTris = trig.filter((triangle) => // triangle.every( // (v) => v[0] >= 0 && v[0] < width && v[1] >= 0 && v[1] < height // ) // );
// Full outside clipping. // Clip triangles that are *fully* offscreen. // Take into account panning here... const clippedTris = trig.filter((triangle) => triangle.some((v) => { const tv = v.slice(); tv[0] += panTranslation.x; tv[1] += panTranslation.y; return tv[0] >= 0 && tv[0] < width && tv[1] >= 0 && tv[1] < height; }), );
clippedTris.forEach((tri) => fillTri(tri, tris)); // Fill quad. //trig.forEach((t) => fillTri(t, tris)); // Fill quad.
ldir = dir; lines.push(...bresenham(...lp, ...cp));
if (i === coords.length - 2) points.push({ x: c1[0], y: c1[1] }, { x: c2[0], y: c2[1] });
points.push({ x: lp[0], y: lp[1] }, { x: cp[0], y: cp[1] }); // Add points.
// Paint each triangle. if (cur.color === "rainbow") color(...rainbow()); else if (cur.color) color(...cur.color);
if (shader) { const progress = 1 - i / (coords.length - 2); shadePixels(tris, shader, [progress]); } else { tris.forEach((p) => point(p)); }
tris.length = 0;
last = cur; // Update the last point. lpar = [c1, c2]; // ... and last parallel points. }
// 3️⃣ Painting
// color(0, 255, 0); // Paint vertex points. // points.forEach((p) => point(p));
// color(0, 0, 255); // Paint wireframe lines. // lines.forEach((p) => point(p));
return lpar;}
// 🔺 Rasterizes a tri. See also: https://www.youtube.com/watch?v=SbB5taqJsS4function fillTri(v3, pix) { const scan = []; // Scan buffer of left->right down Y axis. const [min, mid, max] = v3.sort((va, vb) => va[1] - vb[1]); // Sort Y.
const v1 = [max[0] - min[0], max[1] - min[1]], // Area / cross-product. v2 = [mid[0] - min[0], mid[1] - min[1]], cp = v1[0] * v2[1] - v2[0] * v1[1];
const handedness = cp > 0 ? 1 : cp < 0 ? 0 : -1; // O->L, 1->R, -1->️🚫
// Scan across each edge. [ [min, max, 0 + handedness], // Min and Max for each edge with handedness. [min, mid, 1 - handedness], [mid, max, 1 - handedness], ].forEach(function scanEdge(v) { const yStart = v[0][Y], yEnd = v[1][Y], xStart = v[0][X], xEnd = v[1][X];
const yDist = yEnd - yStart; const xDist = xEnd - xStart; if (yDist <= 0) return; // Don't convert if we have no columns. const xStep = xDist / yDist;
let x = xStart; for (let y = yStart; y < yEnd; y += 1) { scan[y * 2 + v[2]] = floor(x); x += xStep; } });
// Rasterize all by scanning horizontally. for (let y = min[Y]; y < max[Y]; y += 1) { for (let x = scan[y * 2]; x < scan[y * 2 + 1]; x += 1) { pix.push({ x, y }); } }}
/** * Bresenham's Line Algorithm * @description - Returns an array of integer points that make up an aliased line from {x0, y0} to {x1, y1}. * - This function is "abstract" and does not render anything... but outputs points. * @param x0 * @param y0 * @param x1 * @param y1 * @returns {*[]} */function bresenham(x0, y0, x1, y1) { const points = [];
// Make sure everything is floor'd. x0 = floor(x0) || 0; y0 = floor(y0) || 0; x1 = floor(x1) || 0; y1 = floor(y1) || 0; // Bresenham's Algorithm const dx = abs(x1 - x0); const dy = -abs(y1 - y0); const sx = x0 < x1 ? 1 : -1; const sy = y0 < y1 ? 1 : -1; let err = dx + dy;
while (true) { points.push({ x: x0, y: y0 });
if (x0 === x1 && y0 === y1) break; const e2 = 2 * err; if (e2 >= dy) { err += dy; x0 += sx; } if (e2 <= dx) { err += dx; y0 += sy; } } return points;}
// Takes in x, y, width and height and draws an// outline, inline (1px) or filled rectangle, optionally// from the center by inputting eg: "inline*center" in mode.const BOX_CENTER = "center";// Parameters// (1) box (any object with {x, y, w, h} properties) (1)// (2) box, mode (2)// (3) x, y, size (3)// (4) x, y, w, h (4)// (4) x, y, size, mode:string (4)// (5) x, y, w, h, mode (5)// TODO: ♾️ If width or height (size) is infinity then make sure the box covers// the entire pixel buffer width. 25.05.11.16.30function box() { const boxStart = performance.now(); // 🚨 PERFORMANCE TRACKING let x, y, w, h, mode = "fill";
// Apply the TODO: If any argument is NaN then just make it 'undefined' for (let i = 0; i < arguments.length; i++) { if (Number.isNaN(arguments[i])) { arguments[i] = undefined; } }
if (arguments.length === 1 || arguments.length === 2) { // Array(4) if (Array.isArray(arguments[0])) { x = arguments[0][0]; y = arguments[0][1]; w = arguments[0][2]; h = arguments[0][3]; } else if (arguments[0]) { // Object {x, y, w, h} // Note: Also works with anything that has width and height properties.
// Allow long names. if (!isNaN(arguments[0].width)) arguments[0].w = arguments[0].width; if (!isNaN(arguments[0].height)) arguments[0].h = arguments[0].height;
x = arguments[0].x || 0; y = arguments[0].y || 0; w = arguments[0].w || 0;
if (isNaN(arguments[0].h)) { h = w; } else { h = arguments[0].h; }
if (x === undefined || y === undefined || w === undefined) { return console.error( "Could not make a box {x,y,w,h} from:", arguments[0], ); } } if (arguments[1]) mode = arguments[1]; } else if (arguments.length === 3) { // x, y, size x = arguments[0]; y = arguments[1]; w = arguments[2]; h = arguments[2]; } else if (arguments.length === 4) { if (typeof arguments[3] === "number") { // x, y, w, h x = arguments[0]; y = arguments[1]; w = arguments[2]; h = arguments[3]; } else { // x, y, size, mode x = arguments[0]; y = arguments[1]; w = arguments[2]; h = arguments[2]; mode = arguments[3]; } } else if (arguments.length === 5) { // x, y, w, h, mode x = arguments[0]; y = arguments[1]; w = arguments[2]; h = arguments[3]; mode = arguments[4]; } else { //return console.error("Invalid box call."); }
if (w === 0 || h === 0 || isNaN(w) || isNaN(h)) return; // Silently quit if the box has no volume. if (w === Infinity) w = width; if (h === Infinity) h = height;
// Random parameters if undefined, null, or NaN. if (nonvalue(x)) x = randInt(width); if (nonvalue(y)) y = randInt(height); if (nonvalue(w)) w = randInt(width); if (nonvalue(h)) h = randInt(height);
// Abs / normalize the parameters. if (mode === undefined || mode === "") mode = "fill";
// Check for "Center" at the end of mode. if (mode.endsWith(BOX_CENTER)) { x -= w / 2; y -= h / 2; mode = mode.slice(0, -BOX_CENTER.length - 1); // Clear through separator: * }
x = floor(x); y = floor(y); w = signedCeil(w); h = signedCeil(h);
({ x, y, w, h } = Box.from([x, y, w, h]).abs);
// Apply any global pan translations. // x += panTranslation.x; // Note: Already processed in `line`. // y += panTranslation.y;
const thickness = parseInt(mode.split(":")[1]) || 1; mode = mode.split(":")[0];
if (mode === "outline" || mode === "out") { if (thickness === 1) { line(x - 1, y - 1, x + w, y - 1); // Top line(x - 1, y + h, x + w, y + h); // Bottom line(x - 1, y, x - 1, y + h - 1); // Left line(x + w, y, x + w, y + h - 1); // Right } else { const leftX = x - thickness; const topY = y - thickness; const rightX = x + w + thickness; const bottomY = y + h + thickness; const boxHeight = h + thickness * 2; const boxWidth = w + thickness * 2; box(leftX, topY, boxWidth, thickness); // Top box box(leftX, bottomY - thickness, boxWidth, thickness); // Bottom box box(leftX, topY + thickness, thickness, boxHeight - thickness * 2); // Left box box( rightX - thickness, topY + thickness, thickness, boxHeight - thickness * 2, ); // Right box } } else if (mode === "inline" || mode === "in") { if (thickness === 1) { line(x, y, x + w - 1, y); // Top line(x, y + h - 1, x + w - 1, y + h - 1); // Bottom line(x, y + 1, x, y + h - 2); // Left line(x + w - 1, y + 1, x + w - 1, y + h - 2); // Right } else { if (thickness * 2 <= w && thickness * 2 <= h) { box(x, y, w, thickness); // Top box(x, y + h - thickness, w, thickness); // Bottom box(x, y + thickness, thickness, h - thickness * 2); // Left box(x + w - thickness, y + thickness, thickness, h - thickness * 2); // Right } else { box(x, y, w, h); // Just fill the box if the inline is too big. } } } else if (mode === "fill" || mode === "") { // TODO: The boxes could be cropped to always fit inside the screen here. w -= 1;
// Guard: resolve stale fade strings in c to prevent rendering artifacts if (typeof c[0] === 'string' && !c[0].startsWith('fade:')) { setColor(0, 0, 0, c[3] || 255); }
const cachedInk = c.slice(0);
// 🎨 NEW LOCAL FADE HANDLING: Check if current color is a fade const fadeInfo = parseFadeColor(c); const isLocalFade = fadeInfo !== null; // console.log("📦 BOX DEBUG: Local fade detection", { // "current color c": c, // "c[0]": c[0], // "typeof c[0]": typeof c[0], // "c[0] startsWith fade": typeof c[0] === 'string' ? c[0].startsWith('fade:') : 'not string', // "fadeInfo": fadeInfo, // "isLocalFade": isLocalFade, // "global fadeMode": fadeMode, // "global fadeColors": fadeColors.length > 0 ? fadeColors : "empty" // }); if (mathSign(height) === 1) { for (let row = 0; row < h; row += 1) { if (isLocalFade) { // Use local fade info instead of global fade state const numericAngle = parseFloat(fadeInfo.direction); if (!isNaN(numericAngle)) { // For numeric angles, we need to calculate fade per pixel // Draw line pixel by pixel with angle-based fade for (let col = 0; col <= w; col++) { const pixelX = x + col; const pixelY = y + row; // Use the same calculateAngleFadePosition function as clear() const t = calculateAngleFadePosition(pixelX, pixelY, x, y, x + w, y + h - 1, numericAngle); // Calculate fade color locally without setting global state const fadeColor = getLocalFadeColor(t, pixelX, pixelY, fadeInfo); setColor(...fadeColor); plot(pixelX, pixelY); } } else { // Use named direction let t; if (fadeInfo.direction === "vertical") { t = h <= 1 ? 0 : row / (h - 1); } else if (fadeInfo.direction === "vertical-reverse") { t = h <= 1 ? 0 : 1 - (row / (h - 1)); // Reverse the direction } else if (fadeInfo.direction === "horizontal") { t = 0; // Will be calculated per pixel in line drawing } else if (fadeInfo.direction === "horizontal-reverse") { t = 0; // Will be calculated per pixel in line drawing } else { // diagonal, etc. t = h <= 1 ? 0 : row / (h - 1); } if (fadeInfo.direction.startsWith("horizontal")) { // For horizontal fades, draw pixels individually with local fade colors const lineY = y + row; for (let px = x; px < x + w; px++) { // Prevent division by zero for single-pixel width const horizontalT = w <= 1 ? 0 : ( fadeInfo.direction === "horizontal-reverse" ? 1 - ((px - x) / (w - 1)) : (px - x) / (w - 1) ); const fadeColor = getLocalFadeColor(horizontalT, px, lineY, fadeInfo); setColor(...fadeColor); plot(px, lineY); } } else { // For vertical fades, set color once per row const fadeColor = getLocalFadeColor(t, x, y + row, fadeInfo); setColor(...fadeColor); line(x, y + row, x + w, y + row); } } } else { line(x, y + row, x + w, y + row); } } } else { for (let row = 0; row > h; row -= 1) { if (isLocalFade) { // Use local fade info instead of global fade state const numericAngle = parseFloat(fadeInfo.direction); if (!isNaN(numericAngle)) { // For numeric angles, we need to calculate fade per pixel // Draw line pixel by pixel with angle-based fade for (let col = 0; col <= w; col++) { const pixelX = x + col; const pixelY = y + row; // Use the same calculateAngleFadePosition function as clear() const t = calculateAngleFadePosition(pixelX, pixelY, x, y, x + w, y + h - 1, numericAngle); const fadeColor = getLocalFadeColor(t, pixelX, pixelY, fadeInfo); setColor(...fadeColor); plot(pixelX, pixelY); } } else { // Use named direction let t; if (fadeInfo.direction === "vertical") { t = Math.abs(row) / Math.abs(h - 1); } else if (fadeInfo.direction === "vertical-reverse") { t = 1 - (Math.abs(row) / Math.abs(h - 1)); } else if (fadeInfo.direction === "horizontal") { t = 0; // Will be calculated per pixel in line drawing } else if (fadeInfo.direction === "horizontal-reverse") { t = 0; // Will be calculated per pixel in line drawing } else { // diagonal, etc. t = Math.abs(row) / Math.abs(h - 1); } if (fadeInfo.direction.startsWith("horizontal")) { // For horizontal fades, we need to temporarily set up fade state for line() // TODO: Update line() function to also use local fade handling const savedFadeMode = fadeMode; const savedFadeColors = fadeColors.slice(); const savedFadeDirection = fadeDirection; const savedFadeNeat = fadeNeat; fadeMode = true; fadeColors = fadeInfo.colors; fadeDirection = fadeInfo.direction; fadeNeat = fadeInfo.neat; line(x, y + row, x + w, y + row); // Restore previous fade state fadeMode = savedFadeMode; fadeColors = savedFadeColors; fadeDirection = savedFadeDirection; fadeNeat = savedFadeNeat; } else { // For vertical fades, set color once per row const fadeColor = getLocalFadeColor(t, x, y + row, fadeInfo); setColor(...fadeColor); line(x, y + row, x + w, y + row); } } } else { line(x, y + row, x + w, y + row); } } } } graphPerf.track('box', performance.now() - boxStart); // 🚨 PERFORMANCE TRACKING}
// Rasterizes an outlined or filled shape from pairs of points.// Accepts: (x, y, x, y, x, y, ...)// Or: ([[x, y], [x, y], ...])// Or: { points: ", filled: false }function shape() { const shapeStart = performance.now(); // 🚨 PERFORMANCE TRACKING let argPoints; let points; let filled = true; let thickness = 1; // Used if unfilled.
if (arguments.length === 1 && !Array.isArray(arguments[0])) { // Assume an object {points, filled} argPoints = arguments[0].points; filled = arguments[0].filled; thickness = arguments[0].thickness || thickness; } else { argPoints = arguments[0]; }
if (!Array.isArray(argPoints[0])) { // Assume a flat list of coordinates to convert into pairs. points = [];
for (let p = 0; p < argPoints.length; p += 2) { points.push([argPoints[p], argPoints[p + 1]]); } } else { points = argPoints; // Assume array of pairs was passed. }
if (filled) { // 🚀 MASSIVE OPTIMIZATION: Use fast fillTri for triangles instead of slow fillShape if (points.length === 3) { // Triangle - use the optimized fillTri function const triPixels = []; fillTri(points, triPixels); triPixels.forEach((p) => point(p)); } else { // For other polygons, fall back to the general fillShape algorithm fillShape(points); } } else { // Make lines from 1->2->3->...->1 if (thickness === 1) { points.forEach((p, i) => { const lastPoint = i < points.length - 1 ? points[i + 1] : points[0]; line(...p, ...lastPoint); }); } else { // Thicker outline using pline. points.push(points[0]); pline( points.map((p) => p2.of(...p)), thickness, ); } } graphPerf.track('shape', performance.now() - shapeStart); // 🚨 PERFORMANCE TRACKING}
// Note: This may not be very fast. It was written by ChatGPT. 23.02.11.12.51// Note: The most efficient algorithm in C I could find as an alternate:// https://gist.github.com/ideasman42/983738130f754ef58ffa66bcdbbab892// Fills a shape using the scan line algorithm, hitting every pixel via `point`.function fillShape(points) { // Find the minimum and maximum y-coordinates of the points let minY = Infinity; let maxY = -Infinity; for (let i = 0; i < points.length; i++) { minY = min(minY, points[i][1]); maxY = max(maxY, points[i][1]); }
// For each scan line from minY to maxY for (let y = minY; y <= maxY; y++) { // Find the intersections of the scan line with the edges of the polygon let intersections = []; for (let i = 0; i < points.length; i++) { let p1 = points[i]; let p2 = points[(i + 1) % points.length]; if ((p1[1] <= y && y < p2[1]) || (p2[1] <= y && y < p1[1])) { let x = ((y - p1[1]) * (p2[0] - p1[0])) / (p2[1] - p1[1]) + p1[0]; intersections.push(x); } }
// Sort the intersections in increasing order intersections.sort((a, b) => a - b);
// Fill in the pixels between each pair of intersections for (let i = 0; i < intersections.length; i += 2) { let x1 = floor(intersections[i]); let x2 = ceil(intersections[i + 1]); for (let x = x1; x < x2; x++) { point(x, y); } } }}
// Draws a filled triangle with gradient colors using barycentric interpolation// Each vertex has its own color that smoothly blends across the triangle// Draws a filled triangle with vertex colors using barycentric interpolation for smooth gradients// Now includes Z-depth testing for proper occlusionfunction drawGradientTriangle(x1, y1, color1, z1, x2, y2, color2, z2, x3, y3, color3, z3) { // Skip triangles with any vertex having extreme coordinates (clipping failure). // After screen-space clipping all vertices should be within [0, width]×[0, height]; // anything >2× screen is a clipping artifact (e.g. a near-plane shoot-off). const maxCoord = max(width, height) * 2; if (abs(x1) > maxCoord || abs(y1) > maxCoord || abs(x2) > maxCoord || abs(y2) > maxCoord || abs(x3) > maxCoord || abs(y3) > maxCoord) { return; // Skip triangles with extreme coordinates }
// Calculate bounding box const minX = floor(min(x1, x2, x3)); const maxX = ceil(max(x1, x2, x3)); const minY = floor(min(y1, y2, y3)); const maxY = ceil(max(y1, y2, y3)); // Clip to screen bounds const screenMinX = max(0, minX); const screenMaxX = min(width, maxX); const screenMinY = max(0, minY); const screenMaxY = min(height, maxY); // Skip if triangle is completely off-screen if (screenMinX >= screenMaxX || screenMinY >= screenMaxY) return;
// Triangle area (signed). We use edge functions (half-plane tests) so the // inside test and barycentric weights share work: u = E1/areaABC where // E1(x,y) = areaPBC(x,y). Each E is linear in (x, y), so we can increment // it by constant deltas per pixel instead of recomputing. const areaABC = (x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1); if (abs(areaABC) < 0.5) return; // degenerate
const invArea = 1 / areaABC;
// Partial derivatives of each edge function (constant per step). const dE1dx = y2 - y3, dE1dy = x3 - x2; // E1 = areaPBC → weight V1 const dE2dx = y3 - y1, dE2dy = x1 - x3; // E2 = areaPCA → weight V2 const dE3dx = y1 - y2, dE3dy = x2 - x1; // E3 = areaPAB → weight V3
// Evaluate each edge function at the top-left pixel centre (minX+0.5, minY+0.5). const px0 = screenMinX + 0.5; const py0 = screenMinY + 0.5; let e1Row = (x2 - px0) * (y3 - py0) - (x3 - px0) * (y2 - py0); let e2Row = (x3 - px0) * (y1 - py0) - (x1 - px0) * (y3 - py0); let e3Row = (x1 - px0) * (y2 - py0) - (x2 - px0) * (y1 - py0);
// Colour components are passed in pre-scaled 0-255 space (the caller in // Form.graph does the `* 255` multiply). We just alias them for readability. const c1r = color1[0], c1g = color1[1], c1b = color1[2], c1a = color1[3]; const c2r = color2[0], c2g = color2[1], c2b = color2[2], c2a = color2[3]; const c3r = color3[0], c3g = color3[1], c3b = color3[2], c3a = color3[3]; // If every vertex is fully opaque we can skip the per-pixel alpha path. const allOpaque = c1a >= 254 && c2a >= 254 && c3a >= 254;
const hasDepth = depthBuffer.length > 0; const pix = pixels; const bufW = width; let pixCount = 0;
for (let y = screenMinY; y < screenMaxY; y++) { let e1 = e1Row, e2 = e2Row, e3 = e3Row; let rowPix = (screenMinX + y * bufW) * 4; let rowDep = screenMinX + y * bufW; let wasInside = false; for (let x = screenMinX; x < screenMaxX; x++) { // Inside test via barycentric: all three weights ≥ 0. Division by // invArea flips sign correctly for either winding order. const u = e1 * invArea; const v = e2 * invArea; const w = 1 - u - v; if (u >= 0 && v >= 0 && w >= 0) { wasInside = true; const depth = u * z1 + v * z2 + w * z3; if (!hasDepth || depth <= depthBuffer[rowDep]) { const r = (u * c1r + v * c2r + w * c3r) | 0; const g = (u * c1g + v * c2g + w * c3g) | 0; const b = (u * c1b + v * c2b + w * c3b) | 0; if (allOpaque) { pix[rowPix] = r; pix[rowPix + 1] = g; pix[rowPix + 2] = b; pix[rowPix + 3] = 255; } else { const a = (u * c1a + v * c2a + w * c3a) | 0; if (a >= 254) { pix[rowPix] = r; pix[rowPix + 1] = g; pix[rowPix + 2] = b; pix[rowPix + 3] = 255; } else if (a > 0) { // Integer alpha blend: src*a/256 + dst*(256-a)/256. const aa = a + 1; const inv = 256 - a; pix[rowPix] = (r * aa + pix[rowPix] * inv) >> 8; pix[rowPix + 1] = (g * aa + pix[rowPix + 1] * inv) >> 8; pix[rowPix + 2] = (b * aa + pix[rowPix + 2] * inv) >> 8; const dstA = pix[rowPix + 3]; pix[rowPix + 3] = dstA > a ? dstA : a; } } if (hasDepth) depthBuffer[rowDep] = depth; pixCount++; } } else if (wasInside) { // Convex triangle — once we've entered & left on this scanline we // won't re-enter. Skip to next row. break; } e1 += dE1dx; e2 += dE2dx; e3 += dE3dx; rowPix += 4; rowDep += 1; } e1Row += dE1dy; e2Row += dE2dy; e3Row += dE3dy; } renderStats.pixelsDrawn += pixCount;}
// Helper: Subdivide a triangle if it's too large in screen space// Returns an array of smaller triangles (or the original if small enough)function subdivideTriangleIfNeeded(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, maxScreenSize = 300, depth = 0) { // Prevent infinite recursion - max depth of 4 (16 triangles max) if (depth > 4) { return [[x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3]]; } const minX = min(x1, x2, x3); const maxX = max(x1, x2, x3); const minY = min(y1, y2, y3); const maxY = max(y1, y2, y3); const screenWidth = maxX - minX; const screenHeight = maxY - minY; // Clamp to actual screen bounds to get real on-screen size const clampedMinX = max(0, minX); const clampedMaxX = min(width, maxX); const clampedMinY = max(0, minY); const clampedMaxY = min(height, maxY); const onScreenWidth = clampedMaxX - clampedMinX; const onScreenHeight = clampedMaxY - clampedMinY; // Use ON-SCREEN dimensions for subdivision decision // If the on-screen portion is small enough, don't subdivide if (onScreenWidth <= maxScreenSize && onScreenHeight <= maxScreenSize) { return [[x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3]]; } // Also skip if triangle is completely off-screen if (onScreenWidth <= 0 || onScreenHeight <= 0) { return [[x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3]]; } // Subdivide into 4 smaller triangles by finding midpoints const mx12 = (x1 + x2) / 2, my12 = (y1 + y2) / 2; const mx23 = (x2 + x3) / 2, my23 = (y2 + y3) / 2; const mx31 = (x3 + x1) / 2, my31 = (y3 + y1) / 2; const mz12 = (z1 + z2) / 2, mz23 = (z2 + z3) / 2, mz31 = (z3 + z1) / 2; // Perspective-correct UV interpolation // Interpolate u/w, v/w, and 1/w, then recover u and v const invW1 = 1.0 / w1, invW2 = 1.0 / w2, invW3 = 1.0 / w3; const u1w = uv1[0] * invW1, v1w = uv1[1] * invW1; const u2w = uv2[0] * invW2, v2w = uv2[1] * invW2; const u3w = uv3[0] * invW3, v3w = uv3[1] * invW3; // Interpolate 1/w linearly in screen space (1/w is linear after perspective divide) const mInvW12 = (invW1 + invW2) / 2, mInvW23 = (invW2 + invW3) / 2, mInvW31 = (invW3 + invW1) / 2; const mw12 = 1 / mInvW12, mw23 = 1 / mInvW23, mw31 = 1 / mInvW31;
// Interpolate u/w and v/w linearly in screen space const mu12w = (u1w + u2w) / 2, mv12w = (v1w + v2w) / 2; const mu23w = (u2w + u3w) / 2, mv23w = (v2w + v3w) / 2; const mu31w = (u3w + u1w) / 2, mv31w = (v3w + v1w) / 2;
// Recover UV coordinates: u = (u/w) / (1/w) const muv12 = [mu12w / mInvW12, mv12w / mInvW12]; const muv23 = [mu23w / mInvW23, mv23w / mInvW23]; const muv31 = [mu31w / mInvW31, mv31w / mInvW31]; // Recursively subdivide the 4 sub-triangles const result = []; result.push(...subdivideTriangleIfNeeded(x1, y1, uv1, z1, w1, mx12, my12, muv12, mz12, mw12, mx31, my31, muv31, mz31, mw31, maxScreenSize, depth + 1)); result.push(...subdivideTriangleIfNeeded(mx12, my12, muv12, mz12, mw12, x2, y2, uv2, z2, w2, mx23, my23, muv23, mz23, mw23, maxScreenSize, depth + 1)); result.push(...subdivideTriangleIfNeeded(mx31, my31, muv31, mz31, mw31, mx23, my23, muv23, mz23, mw23, x3, y3, uv3, z3, w3, maxScreenSize, depth + 1)); result.push(...subdivideTriangleIfNeeded(mx12, my12, muv12, mz12, mw12, mx23, my23, muv23, mz23, mw23, mx31, my31, muv31, mz31, mw31, maxScreenSize, depth + 1)); return result;}
// Draws a filled triangle with texture mapping using barycentric interpolation// Each vertex has UV coordinates that map to a texture buffer// Uses perspective-correct interpolation with clip-space W values (not NDC Z!)// Also interpolates Z values for depth bufferingfunction drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { // Safety check: avoid division by zero or near-zero W values. This is stricter // than "finite math only" but much looser than the clip-space near plane so // near-camera floor geometry can survive clipping without exploding. if (w1 < MIN_PERSPECTIVE_W || w2 < MIN_PERSPECTIVE_W || w3 < MIN_PERSPECTIVE_W) { return; // Skip triangles with invalid depth }
// Skip triangles with any vertex having extreme coordinates (clipping failure). const maxCoord = max(width, height) * 2; if (abs(x1) > maxCoord || abs(y1) > maxCoord || abs(x2) > maxCoord || abs(y2) > maxCoord || abs(x3) > maxCoord || abs(y3) > maxCoord) { return; // Skip triangles with extreme coordinates }
// Calculate bounding box const minX = floor(min(x1, x2, x3)); const maxX = ceil(max(x1, x2, x3)); const minY = floor(min(y1, y2, y3)); const maxY = ceil(max(y1, y2, y3)); // Clip to screen bounds const screenMinX = max(0, minX); const screenMaxX = min(width, maxX); const screenMinY = max(0, minY); const screenMaxY = min(height, maxY); // Skip if triangle is completely off-screen if (screenMinX >= screenMaxX || screenMinY >= screenMaxY) return; // Store bounding box info for debug visualization if (showBoundingBoxes) { boundingBoxes.push({ minX, maxX, minY, maxY, screenMinX, screenMaxX, screenMinY, screenMaxY, rejected: false }); }
// Calculate area of the whole triangle (for barycentric coordinates) const areaABC = (x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1);
// Avoid degenerate triangles if (abs(areaABC) < 0.5) return;
const texWidth = texture.width; const texHeight = texture.height; // Perspective-correct texture mapping: interpolate 1/w, u/w, v/w // Use clip-space W (not NDC Z) for proper perspective correction! const invW1 = 1 / w1; const invW2 = 1 / w2; const invW3 = 1 / w3; const u1OverW = uv1[0] * invW1; const v1OverW = uv1[1] * invW1; const u2OverW = uv2[0] * invW2; const v2OverW = uv2[1] * invW2; const u3OverW = uv3[0] * invW3; const v3OverW = uv3[1] * invW3;
// Iterate over bounding box and check if each pixel is inside the triangle for (let y = screenMinY; y < screenMaxY; y++) { for (let x = screenMinX; x < screenMaxX; x++) { // Calculate barycentric coordinates using signed area method const areaPBC = (x2 - x) * (y3 - y) - (x3 - x) * (y2 - y); const areaPCA = (x3 - x) * (y1 - y) - (x1 - x) * (y3 - y); const u = areaPBC / areaABC; // Weight for vertex 1 const v = areaPCA / areaABC; // Weight for vertex 2 const w = 1 - u - v; // Weight for vertex 3
// Check if point is inside triangle if (u >= 0 && v >= 0 && w >= 0) { // Interpolate NDC Z for depth testing. Convention: lower Z = nearer // (post-perspective-divide Z is in [-1, +1]; near plane → -1). const depth = u * z1 + v * z2 + w * z3;
// Z-test: skip this pixel if a nearer fragment is already there. const bufferIndex = x + y * width; if (depthBuffer.length > 0) { if (depth > depthBuffer[bufferIndex]) { continue; // existing pixel is closer to the camera } }
renderStats.pixelsDrawn++; // Interpolate 1/w and uv/w using barycentric weights const interpInvW = u * invW1 + v * invW2 + w * invW3; // Guard against very small interpInvW which causes UV explosion if (interpInvW < 0.0001) continue; const interpUOverW = u * u1OverW + v * u2OverW + w * u3OverW; const interpVOverW = u * v1OverW + v * v2OverW + w * v3OverW; // Recover perspective-correct UV by dividing by interpolated 1/w const texU = interpUOverW / interpInvW; const texV = interpVOverW / interpInvW; // Skip pixels with extreme UV values (clipping artifacts) if (abs(texU) > 1000 || abs(texV) > 1000) continue;
// Sample texture at UV coordinates (with wrapping) // Use proper modulo for negative values let texX = ((floor(texU * texWidth) % texWidth) + texWidth) % texWidth; let texY = ((floor(texV * texHeight) % texHeight) + texHeight) % texHeight; // Get pixel from texture buffer const pixelIndex = (texY * texWidth + texX) * 4; const r = texture.pixels[pixelIndex]; const g = texture.pixels[pixelIndex + 1]; const b = texture.pixels[pixelIndex + 2]; const a = floor(texture.pixels[pixelIndex + 3] * alphaMultiplier);
// Update depth buffer and draw pixel if (depthBuffer.length > 0) { depthBuffer[bufferIndex] = depth; } // Draw the textured pixel with alpha fade color(r, g, b, a); point(x, y); } } }}
// Solid opaque triangle fill that writes whole spans into the pixel buffer.// Covers exactly the pixels fillShape + point + plot would (same half-open// scanline rule, same pan, same mask clip) but skips the per-pixel call// chain. Returns false when the current ink needs plot's general path// (alpha, erase, fade, skips), so the caller falls back.let span32 = null, span32Pixels = null;const littleEndian = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1;function fillTriSolid(x1, y1, x2, y2, x3, y3) { if (!pixels || pixels.byteLength === 0 || skips.length > 0) return false; if (typeof c[0] !== "number" || c[0] === -1 || c[3] !== 255) return false; if (span32Pixels !== pixels) { if (pixels.byteOffset % 4 !== 0) return false; span32 = new Uint32Array(pixels.buffer, pixels.byteOffset, pixels.byteLength >> 2); span32Pixels = pixels; } const r = c[0], g = c[1], b = c[2]; const packed = littleEndian ? ((255 << 24) | (b << 16) | (g << 8) | r) >>> 0 : ((r << 24) | (g << 16) | (b << 8) | 255) >>> 0; const px = panTranslation.x, py = panTranslation.y; let left = 0, top = 0, right = width, bottom = height; if (activeMask) { left = max(left, activeMask.x); top = max(top, activeMask.y); right = min(right, activeMask.x + activeMask.width); bottom = min(bottom, activeMask.y + activeMask.height); } let minY = y1 < y2 ? y1 : y2; if (y3 < minY) minY = y3; let maxY = y1 > y2 ? y1 : y2; if (y3 > maxY) maxY = y3; if (minY + py < top) minY = top - py; if (maxY + py >= bottom) maxY = bottom - 1 - py; for (let y = minY; y <= maxY; y++) { // Half-open edge rule, one test per edge, no per-row arrays. Most // triangles from a shaded mesh are a few pixels wide, so short spans // are stored in a plain loop; fill() only pays off on wide ones. let a = Infinity, bx = -Infinity, hits = 0, x; if ((y1 <= y && y < y2) || (y2 <= y && y < y1)) { x = ((y - y1) * (x2 - x1)) / (y2 - y1) + x1; if (x < a) a = x; if (x > bx) bx = x; hits++; } if ((y2 <= y && y < y3) || (y3 <= y && y < y2)) { x = ((y - y2) * (x3 - x2)) / (y3 - y2) + x2; if (x < a) a = x; if (x > bx) bx = x; hits++; } if ((y3 <= y && y < y1) || (y1 <= y && y < y3)) { x = ((y - y3) * (x1 - x3)) / (y1 - y3) + x3; if (x < a) a = x; if (x > bx) bx = x; hits++; } if (hits < 2) continue; let sx0 = floor(a) + px, sx1 = ceil(bx) + px; if (sx0 < left) sx0 = left; if (sx1 > right) sx1 = right; if (sx0 >= sx1) continue; const row = (y + py) * width; if (sx1 - sx0 < 32) { for (let i = row + sx0, e = row + sx1; i < e; i++) span32[i] = packed; } else { span32.fill(packed, row + sx0, row + sx1); } } return true;}
// Draws a triangle from three points, with optional fill mode.// Usage: tri(x1, y1, x2, y2, x3, y3, mode = "fill")// mode can be "fill", "outline", "out", or "inline", "in"function tri() { let x1, y1, x2, y2, x3, y3; let mode = "fill";
// Fast path: six finite numbers, filled. Skips the argument juggling below. if ( arguments.length === 6 && Number.isFinite(arguments[0]) && Number.isFinite(arguments[1]) && Number.isFinite(arguments[2]) && Number.isFinite(arguments[3]) && Number.isFinite(arguments[4]) && Number.isFinite(arguments[5]) && fillTriSolid( floor(arguments[0]), floor(arguments[1]), floor(arguments[2]), floor(arguments[3]), floor(arguments[4]), floor(arguments[5]), ) ) return;
// Apply the TODO: If any argument is NaN then just make it 'undefined' for (let i = 0; i < arguments.length; i++) { if (Number.isNaN(arguments[i])) { arguments[i] = undefined; } }
if (arguments.length === 6) { // tri(x1, y1, x2, y2, x3, y3) x1 = arguments[0]; y1 = arguments[1]; x2 = arguments[2]; y2 = arguments[3]; x3 = arguments[4]; y3 = arguments[5]; } else if (arguments.length === 7) { // tri(x1, y1, x2, y2, x3, y3, mode) x1 = arguments[0]; y1 = arguments[1]; x2 = arguments[2]; y2 = arguments[3]; x3 = arguments[4]; y3 = arguments[5]; mode = arguments[6]; } else if (arguments.length === 1) { // tri([x1, y1, x2, y2, x3, y3]) or tri({points: [...], mode: "..."}) if (Array.isArray(arguments[0])) { const coords = arguments[0]; if (coords.length >= 6) { x1 = coords[0]; y1 = coords[1]; x2 = coords[2]; y2 = coords[3]; x3 = coords[4]; y3 = coords[5]; } } else if (arguments[0] && arguments[0].points) { const obj = arguments[0]; const coords = obj.points; if (Array.isArray(coords) && coords.length >= 6) { x1 = coords[0]; y1 = coords[1]; x2 = coords[2]; y2 = coords[3]; x3 = coords[4]; y3 = coords[5]; } if (obj.mode) mode = obj.mode; } } else if (arguments.length === 2) { // tri([x1, y1, x2, y2, x3, y3], mode) if (Array.isArray(arguments[0])) { const coords = arguments[0]; if (coords.length >= 6) { x1 = coords[0]; y1 = coords[1]; x2 = coords[2]; y2 = coords[3]; x3 = coords[4]; y3 = coords[5]; } mode = arguments[1]; } } else { return console.error("Invalid triangle call. Expected tri(x1, y1, x2, y2, x3, y3) or tri(x1, y1, x2, y2, x3, y3, mode)"); }
// Validate coordinates if ([x1, y1, x2, y2, x3, y3].some(coord => coord === undefined || isNaN(coord))) { return console.error("Invalid triangle coordinates:", { x1, y1, x2, y2, x3, y3 }); }
// Random parameters if undefined, null, or NaN. if (nonvalue(x1)) x1 = randInt(width); if (nonvalue(y1)) y1 = randInt(height); if (nonvalue(x2)) x2 = randInt(width); if (nonvalue(y2)) y2 = randInt(height); if (nonvalue(x3)) x3 = randInt(width); if (nonvalue(y3)) y3 = randInt(height);
// Floor the coordinates x1 = floor(x1); y1 = floor(y1); x2 = floor(x2); y2 = floor(y2); x3 = floor(x3); y3 = floor(y3);
if (mode === "fill" || mode === "") { if (fillTriSolid(x1, y1, x2, y2, x3, y3)) return; // Use the existing fillShape function const points = [[x1, y1], [x2, y2], [x3, y3]]; fillShape(points); } else { // Draw outline with specified mode const thickness = parseInt(mode.split(":")[1]) || 1; const outlineMode = mode.split(":")[0]; if (outlineMode === "outline" || outlineMode === "out") { // Draw three lines forming the triangle outline line(x1, y1, x2, y2); line(x2, y2, x3, y3); line(x3, y3, x1, y1); } else if (outlineMode === "inline" || outlineMode === "in") { // For inline mode, we would need to shrink the triangle inward // For now, just draw the outline (this could be enhanced later) line(x1, y1, x2, y2); line(x2, y2, x3, y3); line(x3, y3, x1, y1); } else { // Default to fill if mode is unrecognized const points = [[x1, y1], [x2, y2], [x3, y3]]; fillShape(points); } }}
// Renders a square grid at x, y given cols, rows, and scale.// Buffer is optional, and if present will render the pixels at scale starting// from the top left corner of the buffer, repeating if needed to fill the grid.
function grid( { box: { x, y, w: cols, h: rows }, transform: { scale, angle, width: twidth, height: theight, anchor }, centers = [], }, buffer,) { const gridStart = performance.now(); // 🚨 PERFORMANCE TRACKING // 🚀 PERFORMANCE OPTIMIZATION: Use lower precision when running slowly const isSlowFrame = graphPerf && graphPerf.lastFPS && graphPerf.lastFPS < 15; const oc = c.slice(); // Remember the original color.
let w, h;
// Note: `transform` is a bit overloaded here and looks for the presence // of certain properties to determine the behavior. // See `function paste` here in `graph` for an example usage. if (scale !== undefined) { if (number(scale)) scale = { x: scale, y: scale }; w = cols * scale.x; h = rows * scale.y;
// ❤️🔥 // TODO: Allow for scaleX and scaleY values on transform / let scale // be an input array. } else if (twidth !== undefined && theight !== undefined) { w = twidth; h = theight; scale = { x: w / cols, y: h / rows }; // ^ Give scale a separate width and height. }
const colPix = w / cols, rowPix = h / rows;
// A reversed axis runs from the far edge back, so shift the anchor by the // (negative) span to keep a flip inside the same box as an unflipped draw. if (scale.x < 0) x -= w; if (scale.y < 0) y -= h;
angle = wrap(angle, 360); // Keep angle positive.
// Always make sure we are at the mid-point of the pixel we rotate around. // given the image resolution's even / oddness on each axis. // (Make some off by 1 adjustments for specific angles.)
let xmod = 0, ymod = 0;
if (angle) { // Odd width. if (w % 2 !== 0 && h % 2 === 0) { if (x % 1 !== 0 && angle === 90) xmod += 0.5;
// if (angle === 90 || angle === 270) ymod += 1; if (angle === 270) { xmod += x % 1 !== 0 ? 1.0 : 0.5; ymod += 0.5; }
if (angle === 180) { ymod += 0.5; if (x % 1 === 0) xmod += 0.5; } } if (h % 2 !== 0 && w % 2 === 0) { // Odd height. if (y % 1 !== 0 && angle === 90) ymod += 0.5;
if (angle === 270) { xmod += 0.5; ymod += y % 1 !== 0 ? 1.0 : 0.5; }
if (angle === 180) { xmod += 0.5; if (y % 1 === 0) ymod += 0.5; } } else if (w % 2 === 0 && h % 2 === 0) { // Both even... xmod += 0.5; ymod += 0.5; } else if (w % 2 !== 0 && h % 2 !== 0) { // Both odd... xmod += 0.5; ymod += 0.5; } }
// Make off by 1 adjustments for specific scale inverstions. // (This is kind of hacky. 23.07.20.13.44) if (scale.x < 0 && scale.y > 0) { if (angle >= 90 && angle < 270) ymod += 1 * mathSign(-scale.y); if (angle >= 180 && angle <= 270) xmod += 1 * mathSign(-scale.x); } else if (scale.x > 0 && scale.y > 0) { if (angle >= 90 && angle < 270) xmod += 1 * mathSign(-scale.x); if (angle >= 180 && angle <= 270) ymod += 1 * mathSign(-scale.y); } else if (scale.y < 0 && scale.x > 0) { if (angle >= 90 && angle < 270) ymod += 1 * mathSign(-scale.y); if (angle >= 180 && angle <= 270) xmod += 1 * mathSign(-scale.x); } else if (scale.y < 0 && scale.x < 0) { if (angle >= 90 && angle < 270) xmod += 1 * mathSign(-scale.x); if (angle >= 180 && angle <= 270) ymod += 1 * mathSign(-scale.y); }
x += xmod; y += ymod;
// Rotate around the anchor, or center. let centerX, centerY; if (anchor) { centerX = anchor.x + x; centerY = anchor.y + y; } else { centerX = x + w / 2; centerY = y + h / 2; }
angle = radians(angle); // Sets angle to 0 if it was undefined. // Draw a scaled image if the buffer is present. if (buffer) { const bufWidth = buffer.width; const bufHeight = buffer.height; let bufPixels = buffer.pixels;
if (!bufPixels) { bufPixels = ensureBufferPixels(buffer); }
if (!bufPixels) { console.warn("🎨 grid: buffer missing pixel data, skipping render", buffer); return; }
// Use fast integer conversions and pre-calculate values const scaleXAbs = ~~abs(scale.x); // Fast float-to-int conversion const scaleYAbs = ~~abs(scale.y); const isAngleZero = angle === 0;
// Pre-calculate trigonometric values only if needed let cosValue, sinValue; if (!isAngleZero) { cosValue = cos(angle); sinValue = sin(angle); }
// Pre-calculate integer boundaries and scales const bufferWidth = scaleXAbs; const bufferHeight = scaleYAbs; const halfBoxWidth = bufferWidth >> 1; // Fast division by 2 const halfBoxHeight = bufferHeight >> 1; const adjustedBufferWidth = bufferWidth + (halfBoxWidth << 1); // Fast multiplication by 2 const adjustedBufferHeight = bufferHeight + (halfBoxHeight << 1);
// Pre-calculate row and column pixel increments const colPixInt = ~~(w / cols); const rowPixInt = ~~(h / rows);
// Fast path for simple scaling (no rotation, integer scales) if ( isAngleZero && scale.x === scaleXAbs && scale.y === scaleYAbs && scale.x > 0 && scale.y > 0 ) { // Ultra-fast nearest-neighbor scaling with direct buffer operations for (let j = 0; j < rows; j += 1) { const srcY = j % bufHeight; const destStartY = ~~(y + j * rowPixInt); const destEndY = ~~(y + (j + 1) * rowPixInt); const pixelHeight = destEndY - destStartY;
if (pixelHeight > 0 && destStartY >= 0 && destEndY <= height) { for (let i = 0; i < cols; i += 1) { const srcX = i % bufWidth; const srcIndex = (srcX + srcY * bufWidth) << 2; // Fast * 4 if (srcIndex < bufPixels.length) { // Extract color data directly const r = bufPixels[srcIndex]; const g = bufPixels[srcIndex + 1]; const b = bufPixels[srcIndex + 2]; const a = bufPixels[srcIndex + 3];
const destStartX = ~~(x + i * colPixInt); const destEndX = ~~(x + (i + 1) * colPixInt); const pixelWidth = destEndX - destStartX;
if (pixelWidth > 0 && destStartX >= 0 && destEndX <= width) { // Set color once and draw block efficiently color(r, g, b, a);
// Use box for rectangular fills when possible (faster than multiple lineh calls) if (pixelWidth > 1 && pixelHeight > 1) { box(destStartX, destStartY, pixelWidth, pixelHeight, "fill"); } else if (pixelHeight === 1) { // Single horizontal line lineh(destStartX, destEndX - 1, destStartY); } else { // Vertical line or single pixel for (let dy = 0; dy < pixelHeight; dy += 1) { lineh(destStartX, destEndX - 1, destStartY + dy); } } } } } } } } else { // Complex path for rotation and non-integer scaling (preserve existing functionality) // 🚀 PERFORMANCE OPTIMIZATION: Reduce resolution when running slowly const skipStep = isSlowFrame ? 2 : 1; // Skip every other pixel when slow for (let j = 0; j < rows; j += skipStep) { const plotY = y + rowPix * j; const repeatY = j % bufHeight;
for (let i = 0; i < cols; i += skipStep) { const plotX = x + colPix * i;
let finalX, finalY;
if (isAngleZero) { finalX = plotX; finalY = plotY; } else { // Rotate the plot coordinates around the center of the grid const dx = plotX - centerX; const dy = plotY - centerY; finalX = dx * cosValue - dy * sinValue + centerX; finalY = dx * sinValue + dy * cosValue + centerY; }
// Adjusted boundary checks if ( finalX < -adjustedBufferWidth || finalX > width + adjustedBufferWidth || finalY < -adjustedBufferHeight || finalY > height + adjustedBufferHeight ) { continue; // Skip drawing this box }
// Find the proper color const repeatX = i % bufWidth; const pixIndex = (repeatX + bufWidth * repeatY) << 2; // Fast multiplication by 4
if (pixIndex < bufPixels.length) { const colorData = [ bufPixels[pixIndex], bufPixels[pixIndex + 1], bufPixels[pixIndex + 2], bufPixels[pixIndex + 3], ]; color(...colorData);
// Calculate destination pixel ranges ensuring no gaps // Use colPix/rowPix which are the actual pixel sizes (w/cols, h/rows) // A negative scale runs its axis backwards, so the cell's two // edges arrive swapped; order them or the cell measures negative // and never draws. Walking the source forwards across a reversed // axis is what makes the mirror. const edgeX = Math.round(x + i * colPix); // Round to nearest pixel for better centering const edgeX2 = Math.round(x + (i + 1) * colPix); const edgeY = Math.round(y + j * rowPix); const edgeY2 = Math.round(y + (j + 1) * rowPix); const destStartX = Math.min(edgeX, edgeX2); const destEndX = Math.max(edgeX, edgeX2); const destStartY = Math.min(edgeY, edgeY2); const destEndY = Math.max(edgeY, edgeY2);
const pixelWidth = destEndX - destStartX; const pixelHeight = destEndY - destStartY;
// Only draw if there's actually area to fill if (pixelWidth > 0 && pixelHeight > 0) { // Apply rotation if needed if (isAngleZero) { // Optimized no-rotation path if (pixelWidth > 1 && pixelHeight > 1) { // Use box for efficiency when drawing rectangles box(destStartX, destStartY, pixelWidth, pixelHeight, "fill"); } else { // Fall back to line drawing for thin regions for (let dy = 0; dy < pixelHeight; dy += 1) { lineh(destStartX, destEndX - 1, destStartY + dy); } } } else { // Rotation path - draw larger boxes to fill gaps during rotation // The gaps occur because rotated pixels don't align perfectly on the grid const boxSize = Math.max(2, Math.ceil(Math.max(pixelWidth, pixelHeight) * 1.2)); for (let dy = 0; dy < pixelHeight; dy += 1) { for (let dx = 0; dx < pixelWidth; dx += 1) { const px = destStartX + dx; const py = destStartY + dy;
// Rotate around center const relX = px - centerX; const relY = py - centerY; const rotX = relX * cosValue - relY * sinValue + centerX; const rotY = relX * sinValue + relY * cosValue + centerY;
// Draw overlapping boxes to prevent gaps box(~~rotX, ~~rotY, boxSize, boxSize, "fill"); } } } } } } } } } else { // Draw a debug / blueprint grid if no buffer is present.
// Plot a point in each of the four corners. const right = x + w - 1, bottom = y + h - 1;
color(64, 64, 64); plot(x, y); plot(right, y); plot(x, bottom); plot(right, bottom); color(...oc);
// Draw each grid square, with optional center points. for (let i = 0; i < cols; i += 1) { const plotX = x + colPix * i; for (let j = 0; j < rows; j += 1) { const plotY = y + rowPix * j;
// Lightly shade this grid square, alternating tint on evens and odds. const alphaMod = oc[3] / 255; color(oc[0], oc[1], oc[2], even(i + j) ? 50 * alphaMod : 75 * alphaMod); box(plotX, plotY, scale.x, scale.y);
// Color in the centers of each grid square. centers.forEach((p) => { color(oc[0], oc[1], oc[2], 100); plot(plotX + p.x, plotY + p.y); }); } }
color(...oc); // Restore color. } // 🚨 PERFORMANCE TRACKING const gridEnd = performance.now(); graphPerf.track('grid', gridEnd - gridStart);}
// Rendering stored drawings.// Silently fails if `drawing` is left `undefined`.// Params:// drawing, x, y, scale = 1, angle = 0, thickness = 1// drawing, position, scale = 1, angle = 0, thickness = 1function draw() { const args = arguments; let drawing = args[0], x, y, scale = 1, angle = 0, thickness = 1; if (typeof args[1] === "number") { x = args[1]; y = args[2]; scale = args[3] || scale; angle = args[4] || angle; thickness = args[5] || thickness; } else if (typeof args[1] === "object") { drawing = args[0]; if (Array.isArray(args[1])) { x = args[1][0]; y = args[1][1]; } else { x = args[1].x; y = args[1].y; } scale = args[2] || scale; angle = args[3] || angle; thickness = args[4] || thickness; }
if (drawing === undefined || drawing === null) return;
// Store offset for later application (after rotation setup) let xOffset = 0; let yOffset = 0; if (drawing.offset) { xOffset = drawing.offset[0] * scale; // Use baseline-corrected offset if available, otherwise fall back to original yOffset = drawing.baselineOffset ? drawing.baselineOffset[1] * scale : drawing.offset[1] * scale; }
// 🖼️ Handle BDF pixel-based fonts (MatrixChunky8, etc.) if (drawing.pixels && drawing.resolution && !drawing.commands) { const [charWidth, charHeight] = drawing.resolution; // Apply rotation if needed const angleRad = radians(angle); const sinA = sin(angleRad); const cosA = cos(angleRad); const hasRotation = angle !== 0; // Calculate glyph center for rotation origin const centerX = charWidth / 2; const centerY = charHeight / 2; // Apply offset (for BDF fonts, apply directly without rotation) x += xOffset; y += yOffset; // Apply pan like vector fonts do x = floor(x); y = floor(y); pan(x, y); // 🐛 Debug BDF rendering // Convert BDF pixel data to screen coordinates and render for (let row = 0; row < drawing.pixels.length; row++) { const pixelRow = drawing.pixels[row]; if (!Array.isArray(pixelRow)) continue; for (let col = 0; col < pixelRow.length; col++) { if (pixelRow[col] === 1) { // Only render "on" pixels // Start with pixel position relative to glyph center let pixelX = col - centerX; let pixelY = row - centerY; // Apply rotation around center if (hasRotation) { const rotatedX = pixelX * cosA - pixelY * sinA; const rotatedY = pixelX * sinA + pixelY * cosA; pixelX = rotatedX; pixelY = rotatedY; } // Translate back from center pixelX += centerX; pixelY += centerY; // Apply scale pixelX *= scale; pixelY *= scale; // Round for final pixel position (relative to pan origin) const finalX = Math.round(pixelX); const finalY = Math.round(pixelY); const isMatrixChunky = drawing?.fontName === "MatrixChunky8";
const matrixDebugActive = matrixDebugEnabled(); if (!matrixDebugActive && matrixChunkyDebugCount !== 0) matrixChunkyDebugCount = 0;
if ( isMatrixChunky && matrixDebugActive && row < 8 && col < 8 ) { console.log("🧱 MatrixChunky8 draw", { char: drawing?.char || drawing?.code || "?", row, col, finalX, finalY, scale, currentColor: c.slice(0, 4), skip: skips.length, mask: activeMask ? { x: activeMask.x, y: activeMask.y, width: activeMask.width, height: activeMask.height, } : null, }); }
let beforePixel; let logged = false; if ( isMatrixChunky && matrixDebugActive && matrixChunkyDebugCount < 20 ) { beforePixel = pixel(finalX, finalY); logged = true; }
// Render pixel (or scaled block if scale > 1) if (scale === 1) { point(finalX, finalY); } else { // For scaled pixels, fill in the entire scaled block for (let sy = 0; sy < scale; sy++) { for (let sx = 0; sx < scale; sx++) { point(finalX + sx, finalY + sy); } } }
if (logged) { const afterPixel = pixel(finalX, finalY); matrixChunkyDebugCount += 1; console.log("🪄 MatrixChunky8 point write", { char: drawing?.char || drawing?.code || "?", row, col, finalX, finalY, color: c.slice(0, 4), before: beforePixel, after: afterPixel, }); } } } } pan(-x, -y); // Restore pan offset like vector fonts return; // Exit early for BDF fonts }
// TODO: Eventually make this the call: rotatePoint(args[0], args[1], 0, 0); angle = radians(angle); const s = sin(angle); const c = cos(angle);
// Apply rotated offset for vector fonts // When rotated, offset needs to be transformed by the rotation matrix if (xOffset !== 0 || yOffset !== 0) { const rotatedXOffset = xOffset * c - yOffset * s; const rotatedYOffset = xOffset * s + yOffset * c; // Round the offset to avoid fractional positioning that causes glyph corruption x += Math.round(rotatedXOffset); y += Math.round(rotatedYOffset); }
x = floor(x); y = floor(y);
pan(x, y);
// 🍎 Build a poly line out of subsequent points sharing start & end points. // 🧠 And plot other commands. const gesture = []; // Keep track of continuous lines.
function paintGesture() { // Draw each gesture path and then kill it. thickness === 1 ? poly(gesture) : pline(gesture, thickness); gesture.length = 0; }
drawing?.commands?.forEach(({ name, args }, i) => { args = args.map((a) => a * scale);
// console.log(name, i, drawing.commands.length, drawing);
if (name === "line") { let x1 = args[0]; // x1 let y1 = args[1]; // y1 let x2 = args[2]; // x2 let y2 = args[3]; // y2 // Round rotated coordinates to avoid sub-pixel positioning artifacts let nx1 = Math.round(x1 * c - y1 * s); let ny1 = Math.round(x1 * s + y1 * c); let nx2 = Math.round(x2 * c - y2 * s); let ny2 = Math.round(x2 * s + y2 * c);
if (nx1 !== nx2 || ny1 !== ny2) { if (thickness === 1) { gesture.push([nx1, ny1], [nx2, ny2]); } else { gesture.push({ x: nx1, y: ny1 }, { x: nx2, y: ny2 }); } }
const nextCommand = drawing.commands[i + 1]; if (nextCommand && nextCommand.name === "line") { const nextArgs = nextCommand.args.map((a) => a * scale); if (args[2] !== nextArgs[0] || args[3] !== nextArgs[1]) { // If the last point of cur line and 1st point of next line are diff. // Then we can paint! paintGesture(); } else { // Otherwise we should pop off the last point to avoid repeats. gesture.pop(); } } else { paintGesture(); } } else if (name === "point") { // Apply scale and rotation to point coordinates let px = args[0]; let py = args[1]; // Round rotated coordinates to avoid sub-pixel positioning artifacts let npx = Math.round(px * c - py * s); let npy = Math.round(px * s + py * c);
thickness === 1 ? point(npx, npy) : circle(npx, npy, thickness / 2, true); } });
pan(-x, -y);}
// Write out a line of text.// TODO: Add directionality using a bresenham algorithm.// - Must know about height.// TODO: Abstract this to another level, similar to 'draw' above.// - I would need to get the final drawing API and pass that to// a module that builds on it, then also has functions that// get added back to it. This would be *graph: layer 2*.function printLine( text, font, startX, startY, blockWidth = 6, scale = 1, xOffset = 0, thickness = 1, rotation = 0, fontMetadata = null, fallbackFont = null,) { if (!text) return;
// Early culling for mask bounds if (activeMask) { const lineHeight = font?.A?.box?.height || 10; // Estimate line height const scaledHeight = lineHeight * scale;
// Check if the entire line is outside the mask bounds if ( startY >= activeMask.y + activeMask.height || startY + scaledHeight <= activeMask.y || startX >= activeMask.x + activeMask.width || startX + text.length * blockWidth * scale <= activeMask.x ) { return; // Don't render if completely outside mask } }
// Check if font supports proportional spacing using passed metadata instead of glyph object // This avoids triggering the BDF proxy when checking font properties const isProportional = fontMetadata?.proportional === true || fontMetadata?.bdfFont === "MatrixChunky8" || fontMetadata?.name === "MatrixChunky8";
const getGlyphForChar = (char) => { // Whitespace has no glyph — advance width handles spacing. // Never fall back to "?" for spaces. if (char.trim() === "") return null;
let primaryGlyph = null; let primaryQuestion = null; let fallbackGlyph = null; let fallbackQuestion = null;
try { primaryGlyph = font?.[char] || null; primaryQuestion = font?.["?"] || null; } catch (err) { primaryGlyph = null; primaryQuestion = null; }
// Prefer real glyphs over placeholders whenever possible. if (primaryGlyph && !primaryGlyph.isPlaceholder) return primaryGlyph;
if (fallbackFont) { try { fallbackGlyph = fallbackFont?.[char] || null; fallbackQuestion = fallbackFont?.["?"] || null; } catch (err) { fallbackGlyph = null; fallbackQuestion = null; }
if (fallbackGlyph && !fallbackGlyph.isPlaceholder) return fallbackGlyph; if (fallbackQuestion) return fallbackQuestion; }
if (primaryGlyph) return primaryGlyph; if (primaryQuestion) return primaryQuestion; return null; };
if (isProportional) { // Use character advance widths from font metadata const advances = fontMetadata?.advances || {}; const bdfOverrides = fontMetadata?.bdfOverrides || {};
// Calculate character positions for proportional spacing let currentX = startX + xOffset; [...text.toString()].forEach((char, i) => { let charX = currentX; let charY = startY; // Apply BDF overrides if they exist for this character if (bdfOverrides[char]) { const override = bdfOverrides[char]; if (override.x !== undefined) { charX += override.x * scale; } if (override.y !== undefined) { charY += override.y * scale; } } // Get character advance width from font data, fallback to blockWidth const charAdvance = advances[char] || blockWidth; draw( getGlyphForChar(char), charX, charY, scale, rotation, thickness, ); // Advance to next character position currentX += charAdvance * scale; }); } else { // Use original monospace logic for fonts without proportional flag [...text.toString()].forEach((char, i) => { draw( getGlyphForChar(char), startX + blockWidth * scale * i + xOffset, startY, scale, rotation, thickness, ); }); }}
function noise16() { // Determine the area to process (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // Don't apply pan translation to mask bounds - mask is already set at current pan position minX = Math.max(0, activeMask.x); minY = Math.max(0, activeMask.y); maxX = Math.min(width, activeMask.x + activeMask.width); maxY = Math.min(height, activeMask.y + activeMask.height); }
for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const i = (y * width + x) * 4; pixels[i] = byteInterval17(randInt(16)); // r pixels[i + 1] = byteInterval17(randInt(16)); // g pixels[i + 2] = byteInterval17(randInt(16)); // b pixels[i + 3] = 255; // a } }}
function noise16DIGITPAIN() { // Determine the area to process (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // Don't apply pan translation to mask bounds - mask is already set at current pan position minX = Math.max(0, activeMask.x); minY = Math.max(0, activeMask.y); maxX = Math.min(width, activeMask.x + activeMask.width); maxY = Math.min(height, activeMask.y + activeMask.height); }
for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const i = (y * width + x) * 4; pixels[i] = byteInterval17(randInt(16)) * 0.6; // r pixels[i + 1] = byteInterval17(randInt(16)) * 0.15; // g pixels[i + 2] = byteInterval17(randInt(16)) * 0.55; // b pixels[i + 3] = 255; // a } }}
function noise16Aesthetic() { // Determine the area to process (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // Don't apply pan translation to mask bounds - mask is already set at current pan position minX = Math.max(0, activeMask.x); minY = Math.max(0, activeMask.y); maxX = Math.min(width, activeMask.x + activeMask.width); maxY = Math.min(height, activeMask.y + activeMask.height); }
for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const i = (y * width + x) * 4; pixels[i] = byteInterval17(randInt(16)) * 0.4; // r pixels[i + 1] = byteInterval17(randInt(16)) * 0.15; // g pixels[i + 2] = byteInterval17(randInt(16)) * 0.8; // b pixels[i + 3] = 255; // a } }}
function noise16Sotce() { // Determine the area to process (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // Don't apply pan translation to mask bounds - mask is already set at current pan position minX = Math.max(0, activeMask.x); minY = Math.max(0, activeMask.y); maxX = Math.min(width, activeMask.x + activeMask.width); maxY = Math.min(height, activeMask.y + activeMask.height); }
for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const i = (y * width + x) * 4; if (randomFlip()) pixels[i] = byteInterval17(14 + randInt(2)); // r if (randomFlip()) pixels[i + 1] = byteInterval17(8 + randInt(2)) * 0.9; // g if (randomFlip()) pixels[i + 2] = byteInterval17(8 + randInt(2)) * 0.9; // b pixels[i + 3] = 255; // a } }}
function noiseTinted(tint, amount, saturation) { // console.log("Tinting:", tint, amount, saturation); tint = findColor(tint); for (let i = 0; i < pixels.length; i += 4) { const grayscale = randInt(255); pixels[i] = lerp( lerp(grayscale, randInt(255), saturation), tint[0], amount, ); // r pixels[i + 1] = lerp( lerp(grayscale, randInt(255), saturation), tint[1], amount, ); // g pixels[i + 2] = lerp( lerp(grayscale, randInt(255), saturation), tint[2], amount, ); // b pixels[i + 3] = 255; // a }}
// Accumulated fractional steps for smooth fractional spinninglet spinAccumulator = 0;
// Accumulated fractional zoom for smooth zooming (additive like scroll/spin)let zoomAccumulator = 0.0;let zoomTimeOffset = Math.random() * 1000; // Random time offset for organic variation
// Accumulated fractional scroll for smooth scrollinglet scrollAccumulatorX = 0;let scrollAccumulatorY = 0;
// Reset scroll accumulators - called when pieces changefunction resetScrollState() { scrollAccumulatorX = 0; scrollAccumulatorY = 0;}
// Accumulated fractional shear for smooth shearinglet shearAccumulatorX = 0;let shearAccumulatorY = 0;
// Accumulated radial displacement for smooth suckinglet suckAccumulator = 0.0;
// Per-pixel shear accumulation for ensuring all pixels eventually movelet pixelShearAccumX = null;let pixelShearAccumY = null;
// Scroll the entire pixel buffer by x and/or y pixels with wrappingfunction scroll(dx = 0, dy = 0) { if (dx === 0 && dy === 0) return; // No change needed
// Accumulate fractional scroll amounts scrollAccumulatorX += dx; scrollAccumulatorY += dy;
// Extract integer parts for actual scrolling // Use trunc (not floor) for symmetric negative handling: // floor(-0.3) = -1 (overshoots), trunc(-0.3) = 0 (correct) const integerDx = Math.trunc(scrollAccumulatorX); const integerDy = Math.trunc(scrollAccumulatorY);
// Keep fractional remainders scrollAccumulatorX -= integerDx; scrollAccumulatorY -= integerDy;
// Only proceed if we have integer pixels to scroll if (integerDx === 0 && integerDy === 0) { return; }
// Determine the area to scroll (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // 🔧 FIX: Use floor for all boundaries to avoid 1-pixel artifacts // When ceil is mixed with floor, floating-point arithmetic can cause the // effective bounds to be 1 pixel off, creating scroll skip artifacts. // Using floor consistently gives us the pixel-aligned inner bounds. const maskMinX = Math.floor(activeMask.x); const maskMinY = Math.floor(activeMask.y); const maskMaxX = Math.floor(activeMask.x + activeMask.width); const maskMaxY = Math.floor(activeMask.y + activeMask.height);
minX = Math.max(0, Math.min(width, maskMinX)); maxX = Math.max(0, Math.min(width, maskMaxX)); minY = Math.max(0, Math.min(height, maskMinY)); maxY = Math.max(0, Math.min(height, maskMaxY)); }
const boundsWidth = maxX - minX; const boundsHeight = maxY - minY;
// Early exit if bounds are invalid if (boundsWidth <= 0 || boundsHeight <= 0) { return; }
// Use integer scroll amounts let finalDx = ((integerDx % boundsWidth) + boundsWidth) % boundsWidth; let finalDy = ((integerDy % boundsHeight) + boundsHeight) % boundsHeight;
if (finalDx === 0 && finalDy === 0) { return; // No effective shift after normalization }
// 🚀 TRY GPU SCROLL FIRST if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("scroll") && gpuSpinModule?.gpuScroll && pixels && width && height) { const mask = activeMask ? { x: minX, y: minY, width: boundsWidth, height: boundsHeight } : null;
const success = gpuSpinModule.gpuScroll(pixels, width, height, finalDx, finalDy, mask); if (success) { gpuOk("scroll"); return; } gpuFailed("scroll"); }
// CPU FALLBACK // Create a complete copy of the working area for safe reading // 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it if (pixels.buffer && pixels.buffer.detached) { console.warn('🚨 Pixels buffer detached in scroll, recreating from screen dimensions'); pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); // Fill with transparent black } const tempPixels = new Uint8ClampedArray(pixels);
// 🚀 OPTIMIZED: Bulk row-based copying for maximum performance // Process each row separately with intelligent chunking for (let y = 0; y < boundsHeight; y++) { const srcY = minY + ((y + boundsHeight - finalDy) % boundsHeight); const destY = minY + y; if (finalDx === 0) { // 🚀 FASTEST PATH: No horizontal scroll - single bulk copy per row const srcRowStart = (srcY * width + minX) * 4; const destRowStart = (destY * width + minX) * 4; const rowBytes = boundsWidth * 4; pixels.set(tempPixels.subarray(srcRowStart, srcRowStart + rowBytes), destRowStart); } else { // 🚀 FAST PATH: Horizontal scroll with wrapping - copy in 2 chunks per row // Calculate where the wrap happens const srcStartX = minX + finalDx; // Chunk 1: From scroll offset to end of bounds const chunk1Width = boundsWidth - finalDx; if (chunk1Width > 0) { const srcStart = (srcY * width + srcStartX) * 4; const destStart = (destY * width + minX) * 4; const chunk1Bytes = chunk1Width * 4; pixels.set(tempPixels.subarray(srcStart, srcStart + chunk1Bytes), destStart); } // Chunk 2: From start of bounds to scroll offset (wrapped portion) const chunk2Width = finalDx; if (chunk2Width > 0) { const srcStart = (srcY * width + minX) * 4; const destStart = (destY * width + minX + chunk1Width) * 4; const chunk2Bytes = chunk2Width * 4; pixels.set(tempPixels.subarray(srcStart, srcStart + chunk2Bytes), destStart); } } }}
// Flip the working area vertically (top-to-bottom)function flip() { // Determine the area to flip (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // 🔧 FIX: Use floor for all boundaries to avoid 1-pixel artifacts // When ceil is mixed with floor, floating-point arithmetic can cause the // effective bounds to be 1 pixel off, creating visual skip artifacts. // Using floor consistently gives us the pixel-aligned inner bounds. const maskMinX = Math.floor(activeMask.x); const maskMinY = Math.floor(activeMask.y); const maskMaxX = Math.floor(activeMask.x + activeMask.width); const maskMaxY = Math.floor(activeMask.y + activeMask.height);
minX = Math.max(0, Math.min(width, maskMinX)); maxX = Math.max(0, Math.min(width, maskMaxX)); minY = Math.max(0, Math.min(height, maskMinY)); maxY = Math.max(0, Math.min(height, maskMaxY)); }
const boundsWidth = maxX - minX; const boundsHeight = maxY - minY; if (boundsWidth <= 0 || boundsHeight <= 1) return;
// Try GPU first if (gpuSpinEnabled && gpuSpinAvailable && gpuSpinModule?.gpuComposite && pixels && width && height) { const mask = { x: minX, y: minY, width: boundsWidth, height: boundsHeight }; const success = gpuSpinModule.gpuComposite(pixels, width, height, { zoom: 1.0, zoomAnchorX: 0.5, zoomAnchorY: 0.5, scrollX: 0, scrollY: 0, flipY: true, contrast: 1.0, brightness: 0, mask }); if (success) return; }
// CPU fallback if (pixels.buffer && pixels.buffer.detached) { console.warn("🚨 Pixels buffer detached in flip, recreating from screen dimensions"); pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); }
const tempPixels = new Uint8ClampedArray(pixels);
for (let y = 0; y < boundsHeight; y++) { const srcY = maxY - 1 - y; const destY = minY + y; const srcRowStart = (srcY * width + minX) * 4; const destRowStart = (destY * width + minX) * 4; const rowBytes = boundsWidth * 4; pixels.set(tempPixels.subarray(srcRowStart, srcRowStart + rowBytes), destRowStart); }}
// Rotates pixels in concentric rings around a specified anchor point// steps: positive for clockwise, negative for counterclockwise// anchorX, anchorY: optional anchor point (defaults to center of working area)// 🚀 Pre-allocated buffers for performancelet spinBuffer;let zoomBuffer;
// 🧪 EXPERIMENTAL: Block-based processing for better performance on large imageslet useBlockProcessing = true; // Can be toggled at runtimeconst BLOCK_SIZE = 64; // Process in 64x64 blocks
let spinSkipCounter = 0;
// 🚀 GPU EFFECTS: WebGL2-accelerated effects via OffscreenCanvas// Runtime-configurable: set via setGpuConfig() from kidlisp.com preferences.let gpuEnabled = true; // Master GPU toggle (default on)let gpuSpinModule = null;let gpuSpinEnabled = gpuEnabled;let gpuSpinAvailable = null; // null = not checked yetlet gpuFloodAvailable = null; // null = not checked yet (separate check for float textures)let gpuFloodEnabled = gpuEnabled;let gpuLayerCompositeAvailable = null; // null = not checked yetlet gpuLayerCompositeEnabled = gpuEnabled;let gpuInitPromise = null; // Promise for initialization
// 🎯 Adaptive GPU failure tracking — auto-disable effects that keep failingconst GPU_FAIL_THRESHOLD = 3; // Disable after this many failuresconst GPU_CACHE_KEY = "ac-gpu-disabled-v4"; // v4: Kawase blur + mobileSafeMode blocks all effects on Mali/Adrenoconst gpuFailCounts = { spin: 0, composite: 0, blur: 0, sharpen: 0, shear: 0, suck: 0, flood: 0, zoom: 0, scroll: 0, contrast: 0, brightness: 0, invert: 0, compositeLayers: 0,};const gpuDisabled = {}; // Tracks which effects have been auto-disabledlet gpuFailoverResetCallback = null; // Called when an effect gets disabled
// Load cached disabled effects from localStoragetry { const cached = typeof localStorage !== "undefined" && localStorage.getItem(GPU_CACHE_KEY); if (cached) { const parsed = JSON.parse(cached); for (const key of Object.keys(parsed)) { gpuDisabled[key] = true; gpuFailCounts[key] = GPU_FAIL_THRESHOLD; // Mark as already failed } console.log(`🎮 GPU: Loaded cached disabled effects: ${Object.keys(parsed).join(", ")}`); }} catch {}
function gpuFailed(effectName) { gpuFrameLog.failed.add(effectName); gpuFailCounts[effectName] = (gpuFailCounts[effectName] || 0) + 1; if (gpuFailCounts[effectName] >= GPU_FAIL_THRESHOLD && !gpuDisabled[effectName]) { gpuDisabled[effectName] = true; console.warn(`🎮 GPU ${effectName}: Auto-disabled after ${GPU_FAIL_THRESHOLD} failures — using CPU fallback`); gpuTelemetry.report("gpu-disabled", effectName); // Persist to localStorage so we skip detection on next load try { if (typeof localStorage !== "undefined") { localStorage.setItem(GPU_CACHE_KEY, JSON.stringify(gpuDisabled)); } } catch {} // Signal that a GPU failover happened — KidLisp checks this to reset if (typeof self !== "undefined") self.__gpuFailoverOccurred = true; if (gpuFailoverResetCallback) gpuFailoverResetCallback(); } return false;}
// Register a callback to reset the piece when GPU effects fail overfunction onGpuFailover(callback) { gpuFailoverResetCallback = callback;}
function gpuOk(effectName) { if (gpuFailCounts[effectName] > 0) gpuFailCounts[effectName] = 0; gpuFrameLog.ok.add(effectName);}
function gpuAllowed(effectName) { return !gpuDisabled[effectName];}
function getGpuStatus() { return { failCounts: { ...gpuFailCounts }, disabled: { ...gpuDisabled } };}
// 📊 GPU frame logging — tracks effect usage every 8 framesconst gpuFrameLog = { frameCount: 0, ok: new Set(), // effects that succeeded this period failed: new Set(), // effects that failed this period disabled: new Set(), // effects skipped (auto-disabled) rendererCached: null, // GPU renderer string (cached on first query)};
function gpuLogTick() { gpuFrameLog.frameCount++; if (gpuFrameLog.frameCount % 8 !== 0) return;
// Capture renderer on first tick if (!gpuFrameLog.rendererCached) { try { if (typeof OffscreenCanvas !== "undefined") { const c = new OffscreenCanvas(1, 1); const g = c.getContext("webgl2"); if (g) { const dbg = g.getExtension("WEBGL_debug_renderer_info"); gpuFrameLog.rendererCached = dbg ? g.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : "unknown"; g.getExtension("WEBGL_lose_context")?.loseContext(); } else { gpuFrameLog.rendererCached = "no-webgl2"; } } else { gpuFrameLog.rendererCached = "no-offscreen"; } } catch { gpuFrameLog.rendererCached = "error"; } }
const ok = [...gpuFrameLog.ok]; const failed = [...gpuFrameLog.failed]; const dis = Object.keys(gpuDisabled).filter(k => gpuDisabled[k]);
// Log once on frame 8 (first status), then only on failures if (gpuFrameLog.frameCount === 8) { console.log(`🎮 GPU renderer=${gpuFrameLog.rendererCached} ok=[${ok}] fail=[${failed}] disabled=[${dis}]`); }
// Report to telemetry on frame 8 (first status report) and when failures occur if (gpuFrameLog.frameCount === 8 || failed.length > 0) { gpuTelemetry.report("gpu-status", null, { frame: gpuFrameLog.frameCount, renderer: gpuFrameLog.rendererCached, ok, failed, disabled: dis, }); }
// Reset per-period sets gpuFrameLog.ok.clear(); gpuFrameLog.failed.clear();}
// 📡 GPU telemetry — batches failure reports and sends to /api/kidlisp-logconst gpuTelemetry = (() => { const queue = []; let flushTimer = null; let deviceInfo = null;
function getDevice() { if (deviceInfo) return deviceInfo; const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; deviceInfo = { userAgent: ua, mobile: /Mobi|Android|iPhone|iPad/i.test(ua), screen: typeof self !== "undefined" && self.screen ? { w: self.screen.width, h: self.screen.height } : null, gpu: null, // filled on first report }; return deviceInfo; }
function report(type, effect, detail) { const device = getDevice(); // Try to capture WebGL renderer info on first report if (!device.gpu && typeof OffscreenCanvas !== "undefined") { try { const c = new OffscreenCanvas(1, 1); const g = c.getContext("webgl2"); if (g) { const dbg = g.getExtension("WEBGL_debug_renderer_info"); if (dbg) { device.gpu = { vendor: g.getParameter(dbg.UNMASKED_VENDOR_WEBGL), renderer: g.getParameter(dbg.UNMASKED_RENDERER_WEBGL), }; } g.getExtension("WEBGL_lose_context")?.loseContext(); } } catch { /* ignore */ } }
queue.push({ type, effect, detail: detail || null, device, gpuStatus: getGpuStatus(), });
// Flush after short delay (batch nearby events) if (!flushTimer) { flushTimer = setTimeout(flush, 2000); } }
async function flush() { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } if (!queue.length) return; const events = queue.splice(0, queue.length); try { await fetch("/api/kidlisp-log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ events }), keepalive: true, }); } catch { /* telemetry failures are silent */ } }
return { report, flush };})();
// 🚀 Initialize GPU effects module eagerly (call this at startup)async function initGpuEffects() { if (gpuInitPromise) return gpuInitPromise; gpuInitPromise = (async () => { try { gpuSpinModule = await import('./gpu-effects.mjs'); gpuSpinAvailable = gpuSpinModule.isGpuEffectsAvailable(); gpuFloodAvailable = gpuSpinModule.isGpuFloodAvailable?.() ?? false; gpuLayerCompositeAvailable = gpuSpinModule.isGpuLayerCompositeAvailable?.() ?? false; // GPU init status logs (enable with window.acGpuDebug = true) // if (gpuSpinAvailable) console.log('🎮 GPU Effects: Available and enabled'); // else console.log('🎮 GPU Effects: Not available, using CPU fallback'); // if (gpuFloodAvailable) console.log('🎮 GPU Flood: Available (JFA algorithm)'); // else console.log('🎮 GPU Flood: Not available (no float texture support), using CPU fallback'); // if (gpuLayerCompositeAvailable) console.log('🎮 GPU Layer Composite: Available (up to 8 layers)'); // else console.log('🎮 GPU Layer Composite: Not available, using CPU fallback'); } catch (e) { console.warn('🎮 GPU Effects: Module load failed, using CPU fallback', e); gpuSpinAvailable = false; gpuFloodAvailable = false; gpuLayerCompositeAvailable = false; } return gpuSpinAvailable; })(); return gpuInitPromise;}
// 🧪 EXPERIMENTAL: Toggle GPU effects for performance testingfunction setGpuSpin(enabled) { gpuSpinEnabled = enabled; console.log(`🎮 GPU Effects ${enabled ? 'ENABLED' : 'DISABLED'}`);}
// 🧪 EXPERIMENTAL: Toggle GPU flood fill for performance testingfunction setGpuFlood(enabled) { gpuFloodEnabled = enabled; console.log(`🎮 GPU Flood ${enabled ? 'ENABLED' : 'DISABLED'}`);}
// 🧪 EXPERIMENTAL: Toggle GPU contrast for performance testinglet gpuContrastEnabled = gpuEnabled;function setGpuContrast(enabled) { gpuContrastEnabled = enabled; console.log(`🎮 GPU Contrast ${enabled ? 'ENABLED' : 'DISABLED'}`);}
// 🧪 EXPERIMENTAL: Toggle GPU layer compositing for performance testingfunction setGpuLayerComposite(enabled) { gpuLayerCompositeEnabled = enabled; console.log(`🎮 GPU Layer Composite ${enabled ? 'ENABLED' : 'DISABLED'}`);}
// 🎛️ Unified GPU config — called from kidlisp.com preferences via postMessage// config: { gpu: bool, effects: { spin: bool, flood: bool, contrast: bool, composite: bool } }function setGpuConfig(config) { gpuEnabled = !!config.gpu; const effects = config.effects || {}; // When master GPU is off, all effects are off regardless of individual settings gpuSpinEnabled = gpuEnabled && (effects.spin !== false); gpuFloodEnabled = gpuEnabled && (effects.flood !== false); gpuContrastEnabled = gpuEnabled && (effects.contrast !== false); gpuLayerCompositeEnabled = gpuEnabled && (effects.composite !== false); console.log(`🎮 GPU Config: master=${gpuEnabled}, spin=${gpuSpinEnabled}, flood=${gpuFloodEnabled}, contrast=${gpuContrastEnabled}, composite=${gpuLayerCompositeEnabled}`); // Re-init GPU module if enabling for the first time if (gpuEnabled && !gpuSpinModule) initGpuEffects();}
/** * GPU-accelerated multi-layer compositing * Composites multiple layers onto the current pixel buffer in a single GPU pass * * @param {Array<{pixels: Uint8ClampedArray, x: number, y: number, width: number, height: number, alpha: number}>} layers * @returns {boolean} - true if GPU was used, false if fell back to CPU */function compositeLayers(layers) { if (!layers || layers.length === 0) return true; const compositeStart = performance.now(); // 🚀 TRY GPU COMPOSITE FIRST if (gpuLayerCompositeEnabled && gpuLayerCompositeAvailable && gpuAllowed("compositeLayers") && gpuSpinModule && pixels && width && height) { const result = gpuSpinModule.gpuCompositeLayers(pixels, width, height, layers);
if (result.success) { gpuOk("compositeLayers"); const compositeTime = performance.now() - compositeStart; graphPerf.track("composite-layers-gpu", compositeTime); return true; } gpuFailed("compositeLayers"); } // 💻 CPU FALLBACK: Composite each layer individually const cpuStart = performance.now(); for (const layer of layers) { if (!layer.pixels) continue; const alpha = layer.alpha !== undefined ? layer.alpha : 255; const destX = Math.floor(layer.x || 0); const destY = Math.floor(layer.y || 0); const srcW = layer.width; const srcH = layer.height; const src = layer.pixels; // Clamp to screen bounds const startX = Math.max(0, destX); const startY = Math.max(0, destY); const endX = Math.min(width, destX + srcW); const endY = Math.min(height, destY + srcH); if (startX >= endX || startY >= endY) continue; const alphaFactor = alpha / 255.0; for (let dy = startY; dy < endY; dy++) { const srcY = dy - destY; const srcRowStart = srcY * srcW * 4; const dstRowStart = dy * width * 4; for (let dx = startX; dx < endX; dx++) { const srcX = dx - destX; const srcIdx = srcRowStart + srcX * 4; const dstIdx = dstRowStart + dx * 4; const sA = src[srcIdx + 3] * alphaFactor; if (sA < 1) continue; // Skip nearly transparent const sR = src[srcIdx]; const sG = src[srcIdx + 1]; const sB = src[srcIdx + 2]; if (sA >= 254) { // Opaque - direct copy pixels[dstIdx] = sR; pixels[dstIdx + 1] = sG; pixels[dstIdx + 2] = sB; pixels[dstIdx + 3] = 255; } else { // Alpha blend const invAlpha = 255 - sA; pixels[dstIdx] = (sA * sR + invAlpha * pixels[dstIdx]) >> 8; pixels[dstIdx + 1] = (sA * sG + invAlpha * pixels[dstIdx + 1]) >> 8; pixels[dstIdx + 2] = (sA * sB + invAlpha * pixels[dstIdx + 2]) >> 8; pixels[dstIdx + 3] = Math.min(255, pixels[dstIdx + 3] + (sA >> 1)); } } } } const cpuTime = performance.now() - cpuStart; graphPerf.track("composite-layers-cpu", cpuTime); return false;}
/** * GPU-accelerated batched effects - applies zoom, scroll, contrast, brightness in ONE pass * This is much faster than calling each effect separately on slower hardware * * @param {Object} options - Effect parameters * @param {number} options.zoom - Zoom scale (1.0 = no change) * @param {number} options.zoomAnchorX - Zoom anchor X (0-1, default 0.5) * @param {number} options.zoomAnchorY - Zoom anchor Y (0-1, default 0.5) * @param {number} options.scrollX - Horizontal scroll (pixels) * @param {number} options.scrollY - Vertical scroll (pixels) * @param {number} options.contrast - Contrast level (1.0 = no change) * @param {number} options.brightness - Brightness adjustment (-255 to +255, 0 = no change) * @returns {boolean} - true if GPU was used, false if fell back to CPU */function batchedEffects(options = {}) { const { zoom = 1.0, zoomAnchorX = 0.5, zoomAnchorY = 0.5, scrollX = 0, scrollY = 0, contrast: contrastLevel = 1.0, brightness: brightnessLevel = 0 } = options; // Early exit if nothing to do const hasZoom = zoom !== 1.0; const hasScroll = scrollX !== 0 || scrollY !== 0; const hasContrast = contrastLevel !== 1.0; const hasBrightness = brightnessLevel !== 0; if (!hasZoom && !hasScroll && !hasContrast && !hasBrightness) { return true; } const batchStart = performance.now(); // Build mask if active let mask = null; if (activeMask) { const maskX = activeMask.x + panTranslation.x; const maskY = activeMask.y + panTranslation.y; mask = { x: Math.max(0, Math.floor(maskX)), y: Math.max(0, Math.floor(maskY)), width: Math.min(width, Math.floor(maskX + activeMask.width)) - Math.max(0, Math.floor(maskX)), height: Math.min(height, Math.floor(maskY + activeMask.height)) - Math.max(0, Math.floor(maskY)) }; } // 🚀 TRY GPU BATCHED EFFECTS if (gpuSpinAvailable && gpuAllowed("composite") && gpuSpinModule && pixels && width && height) { const result = gpuSpinModule.gpuComposite?.(pixels, width, height, { zoom, zoomAnchorX, zoomAnchorY, scrollX, scrollY, contrast: contrastLevel, brightness: brightnessLevel, mask });
if (result) { gpuOk("composite"); const batchTime = performance.now() - batchStart; graphPerf.track("batched-effects-gpu", batchTime); return true; } gpuFailed("composite"); } // 💻 CPU FALLBACK - Apply effects individually const cpuStart = performance.now(); if (hasZoom) zoom_cpu(zoom, zoomAnchorX, zoomAnchorY); if (hasScroll) scroll_cpu(scrollX, scrollY); if (hasContrast) contrast(contrastLevel); if (hasBrightness) brightness(brightnessLevel); const cpuTime = performance.now() - cpuStart; graphPerf.track("batched-effects-cpu", cpuTime); return false;}
// Internal CPU-only zoom (for fallback)function zoom_cpu(scale, anchorX = 0.5, anchorY = 0.5) { // CPU fallback for batched zoom - call the full zoom function // which will skip its own GPU attempt and fall through to CPU path zoom(scale, anchorX, anchorY);}
// Internal CPU-only scroll (for fallback)function scroll_cpu(dx, dy) { // CPU fallback for batched scroll - call the full scroll function // which will skip its own GPU attempt and fall through to CPU path scroll(dx, dy);}
// 🧪 EXPERIMENTAL: Toggle block-based processing for performance testingfunction setBlockProcessing(enabled) { useBlockProcessing = enabled; console.log(`🧪 Block processing ${enabled ? 'ENABLED' : 'DISABLED'} (${BLOCK_SIZE}x${BLOCK_SIZE} blocks)`);}
// Each ring rotates by exactly 'steps' pixels, preserving all datafunction spin(steps = 0, anchorX = null, anchorY = null) { const spinStart = performance.now(); if (steps === 0) { graphPerf.track("spin", 0); return; }
// 🚀 TRY GPU SPIN FIRST if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("spin") && gpuSpinModule && pixels && width && height) { const mask = activeMask ? { x: activeMask.x + panTranslation.x, y: activeMask.y + panTranslation.y, width: activeMask.width, height: activeMask.height } : null;
const success = gpuSpinModule.gpuSpin(pixels, width, height, steps, anchorX, anchorY, mask); if (success) { gpuOk("spin"); const spinEnd = performance.now(); graphPerf.track("spin", spinEnd - spinStart); return; } gpuFailed("spin"); }
// 🚀 CPU FALLBACK: Use block-based processing for better performance (26% faster) return spinBlockBased(steps, anchorX, anchorY, spinStart);}
// 🚀 SIMD Helper: Process individual pixel with wrapping and samplingfunction processSpinPixel(srcX, srcY, destIdx, minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight) { // Fast wrapping (same logic as regular spin) let wrappedSrcX = srcX; let wrappedSrcY = srcY; if (srcX < minX || srcX >= minX + workingWidth) { const normalizedX = srcX - minX; wrappedSrcX = minX + (normalizedX - workingWidth * Math.floor(normalizedX / workingWidth)); if (wrappedSrcX < minX) wrappedSrcX += workingWidth; } if (srcY < minY || srcY >= minY + workingHeight) { const normalizedY = srcY - minY; wrappedSrcY = minY + (normalizedY - workingHeight * Math.floor(normalizedY / workingHeight)); if (wrappedSrcY < minY) wrappedSrcY += workingHeight; } // Fast nearest neighbor with bounds clamping const nearestSrcX = Math.max(minX, Math.min(maxXMinus1, Math.round(wrappedSrcX))); const nearestSrcY = Math.max(minY, Math.min(maxYMinus1, Math.round(wrappedSrcY))); // Direct array access with pre-calculated offsets const srcIdx = (nearestSrcY * width + nearestSrcX) * 4; // Unrolled RGBA copy for maximum speed pixels[destIdx] = spinBuffer[srcIdx]; pixels[destIdx + 1] = spinBuffer[srcIdx + 1]; pixels[destIdx + 2] = spinBuffer[srcIdx + 2]; pixels[destIdx + 3] = spinBuffer[srcIdx + 3];}
// Static buffers for SIMD spin functionspin.coordBuffers = null;spin.destIndices = null;
// 🚀 EXPERIMENTAL: SIMD-optimized spin processing using typed arraysfunction spinSimd(steps, anchorX, anchorY, spinStart) { // Same early exits as regular spin if (graphPerf && graphPerf.lastFPS && graphPerf.lastFPS < 6) { spinSkipCounter++; if (spinSkipCounter % 2 !== 0) { graphPerf.track('spin', 0); return; } } else { spinSkipCounter = 0; }
if (steps === 0) { graphPerf.track('spin', 0); return; }
// Handle fractional steps spinAccumulator += steps; const integerSteps = floor(spinAccumulator); spinAccumulator -= integerSteps; if (integerSteps === 0) { graphPerf.track('spin', 0); return; }
// Determine the area to process let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { minX = activeMask.x + panTranslation.x; minY = activeMask.y + panTranslation.y; maxX = activeMask.x + activeMask.width + panTranslation.x; maxY = activeMask.y + activeMask.height + panTranslation.y; }
const workingWidth = maxX - minX; const workingHeight = maxY - minY; const centerX = anchorX !== null ? anchorX : minX + floor(workingWidth / 2); const centerY = anchorY !== null ? anchorY : minY + floor(workingHeight / 2);
// 🛡️ SAFETY CHECK: Buffer management if (pixels.buffer && pixels.buffer.detached) { console.warn('🚨 Pixels buffer detached in spinSimd, recreating'); pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); } const bufferSize = width * height * 4; if (!spinBuffer || spinBuffer.length !== bufferSize) { spinBuffer = new Uint8ClampedArray(bufferSize); } if (pixels.length !== bufferSize) { pixels = new Uint8ClampedArray(bufferSize); pixels.fill(0); } spinBuffer.set(pixels);
// 🚀 SIMD: Create Float32Array views for vectorized coordinate math const coordCount = (maxY - minY) * (maxX - minX); const maxCoords = Math.ceil(coordCount / 4) * 4; // Round up to multiple of 4 // Reuse coordinate buffers to avoid allocation overhead if (!spinSimd.coordBuffers || spinSimd.coordBuffers.length < maxCoords * 2) { spinSimd.coordBuffers = new Float32Array(maxCoords * 2); // x,y pairs spinSimd.srcIndices = new Uint32Array(maxCoords); spinSimd.destIndices = new Uint32Array(maxCoords); } const coords = spinSimd.coordBuffers; const srcIndices = spinSimd.srcIndices; const destIndices = spinSimd.destIndices;
// Pre-calculate constants for SIMD operations const twoPi = 2 * PI; const maxXMinus1 = maxX - 1; const maxYMinus1 = maxY - 1; let coordIndex = 0; // 🚀 SIMD: Generate all coordinates in vectorized batches for (let destY = minY; destY < maxY; destY++) { const dy = destY - centerY; const dy2 = dy * dy; const destRowOffset = destY * width; for (let destX = minX; destX < maxX; destX++) { const dx = destX - centerX; const distanceSquared = dx * dx + dy2; // Fast path for center pixels (same as regular spin) if (distanceSquared < 1.0) { const idx = (destRowOffset + destX) * 4; pixels[idx] = spinBuffer[idx]; pixels[idx + 1] = spinBuffer[idx + 1]; pixels[idx + 2] = spinBuffer[idx + 2]; pixels[idx + 3] = spinBuffer[idx + 3]; continue; } // Skip very distant pixels if (distanceSquared > 16000000) continue; // Store coordinates for SIMD processing coords[coordIndex * 2] = dx; coords[coordIndex * 2 + 1] = dy; destIndices[coordIndex] = (destRowOffset + destX) * 4; coordIndex++; } } // 🚀 SIMD: Process coordinates in groups of 4 for vectorized operations const groupsOf4 = Math.floor(coordIndex / 4); const remainder = coordIndex % 4; // Process groups of 4 coordinates simultaneously for (let group = 0; group < groupsOf4; group++) { const baseIndex = group * 4; // Load 4 dx,dy pairs into SIMD-friendly arrays const dx0 = coords[baseIndex * 2], dy0 = coords[baseIndex * 2 + 1]; const dx1 = coords[(baseIndex + 1) * 2], dy1 = coords[(baseIndex + 1) * 2 + 1]; const dx2 = coords[(baseIndex + 2) * 2], dy2 = coords[(baseIndex + 2) * 2 + 1]; const dx3 = coords[(baseIndex + 3) * 2], dy3 = coords[(baseIndex + 3) * 2 + 1]; // 🚀 SIMD: Vectorized distance calculations const dist0 = Math.sqrt(dx0 * dx0 + dy0 * dy0); const dist1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); const dist2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); const dist3 = Math.sqrt(dx3 * dx3 + dy3 * dy3); // 🚀 SIMD: Vectorized angle calculations const angle0 = Math.atan2(dy0, dx0); const angle1 = Math.atan2(dy1, dx1); const angle2 = Math.atan2(dy2, dx2); const angle3 = Math.atan2(dy3, dx3); // 🚀 SIMD: Vectorized angle transformations const totalChange0 = integerSteps / dist0; const totalChange1 = integerSteps / dist1; const totalChange2 = integerSteps / dist2; const totalChange3 = integerSteps / dist3; let srcAngle0 = angle0 - totalChange0; let srcAngle1 = angle1 - totalChange1; let srcAngle2 = angle2 - totalChange2; let srcAngle3 = angle3 - totalChange3; // 🚀 SIMD: Vectorized angle normalization srcAngle0 = srcAngle0 - twoPi * Math.floor(srcAngle0 / twoPi); srcAngle1 = srcAngle1 - twoPi * Math.floor(srcAngle1 / twoPi); srcAngle2 = srcAngle2 - twoPi * Math.floor(srcAngle2 / twoPi); srcAngle3 = srcAngle3 - twoPi * Math.floor(srcAngle3 / twoPi); if (srcAngle0 < 0) srcAngle0 += twoPi; if (srcAngle1 < 0) srcAngle1 += twoPi; if (srcAngle2 < 0) srcAngle2 += twoPi; if (srcAngle3 < 0) srcAngle3 += twoPi; // 🚀 SIMD: Vectorized coordinate conversion using fast trig lookup const srcX0 = centerX + dist0 * cos(srcAngle0); const srcY0 = centerY + dist0 * sin(srcAngle0); const srcX1 = centerX + dist1 * cos(srcAngle1); const srcY1 = centerY + dist1 * sin(srcAngle1); const srcX2 = centerX + dist2 * cos(srcAngle2); const srcY2 = centerY + dist2 * sin(srcAngle2); const srcX3 = centerX + dist3 * cos(srcAngle3); const srcY3 = centerY + dist3 * sin(srcAngle3); // Process each of the 4 pixels (wrapping and sampling) processSimdPixel(srcX0, srcY0, destIndices[baseIndex], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); processSimdPixel(srcX1, srcY1, destIndices[baseIndex + 1], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); processSimdPixel(srcX2, srcY2, destIndices[baseIndex + 2], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); processSimdPixel(srcX3, srcY3, destIndices[baseIndex + 3], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); } // Handle remaining pixels (less than 4) for (let i = groupsOf4 * 4; i < coordIndex; i++) { const dx = coords[i * 2]; const dy = coords[i * 2 + 1]; const distance = Math.sqrt(dx * dx + dy * dy); const angle = Math.atan2(dy, dx); const totalAngleChange = integerSteps / distance; let srcAngle = angle - totalAngleChange; srcAngle = srcAngle - twoPi * Math.floor(srcAngle / twoPi); if (srcAngle < 0) srcAngle += twoPi; const srcX = centerX + distance * Math.cos(srcAngle); const srcY = centerY + distance * Math.sin(srcAngle); processSimdPixel(srcX, srcY, destIndices[i], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); } const spinEnd = performance.now(); graphPerf.track('spin', spinEnd - spinStart);}
// 🚀 SIMD Helper: Process individual pixel with wrapping and samplingfunction processSimdPixel(srcX, srcY, destIdx, minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight) { // Fast wrapping (same logic as regular spin) let wrappedSrcX = srcX; let wrappedSrcY = srcY; if (srcX < minX || srcX >= minX + workingWidth) { const normalizedX = srcX - minX; wrappedSrcX = minX + (normalizedX - workingWidth * Math.floor(normalizedX / workingWidth)); if (wrappedSrcX < minX) wrappedSrcX += workingWidth; } if (srcY < minY || srcY >= minY + workingHeight) { const normalizedY = srcY - minY; wrappedSrcY = minY + (normalizedY - workingHeight * Math.floor(normalizedY / workingHeight)); if (wrappedSrcY < minY) wrappedSrcY += workingHeight; } // Fast nearest neighbor with bounds clamping const nearestSrcX = Math.max(minX, Math.min(maxXMinus1, Math.round(wrappedSrcX))); const nearestSrcY = Math.max(minY, Math.min(maxYMinus1, Math.round(wrappedSrcY))); // Direct array access with pre-calculated offsets const srcIdx = (nearestSrcY * width + nearestSrcX) * 4; // Unrolled RGBA copy for maximum speed pixels[destIdx] = spinBuffer[srcIdx]; pixels[destIdx + 1] = spinBuffer[srcIdx + 1]; pixels[destIdx + 2] = spinBuffer[srcIdx + 2]; pixels[destIdx + 3] = spinBuffer[srcIdx + 3];}
// Static buffers for SIMD spin functionspinSimd.coordBuffers = null;spinSimd.srcIndices = null;spinSimd.destIndices = null;
// 🧪 EXPERIMENTAL: Block-based spin processing for better performance on large imagesfunction spinBlockBased(steps, anchorX, anchorY, spinStart) { // Same early exits as regular spin if (graphPerf && graphPerf.lastFPS && graphPerf.lastFPS < 6) { spinSkipCounter++; if (spinSkipCounter % 2 !== 0) { graphPerf.track('spin', 0); return; } } else { spinSkipCounter = 0; }
if (steps === 0) { graphPerf.track('spin', 0); return; }
// Handle fractional steps spinAccumulator += steps; const integerSteps = floor(spinAccumulator); spinAccumulator -= integerSteps; if (integerSteps === 0) { graphPerf.track('spin', 0); return; }
// Determine the area to process let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { minX = activeMask.x + panTranslation.x; minY = activeMask.y + panTranslation.y; maxX = activeMask.x + activeMask.width + panTranslation.x; maxY = activeMask.y + activeMask.height + panTranslation.y; }
const workingWidth = maxX - minX; const workingHeight = maxY - minY; const centerX = anchorX !== null ? anchorX : minX + floor(workingWidth / 2); const centerY = anchorY !== null ? anchorY : minY + floor(workingHeight / 2);
// Setup buffers if (pixels.buffer && pixels.buffer.detached) { console.warn('🚨 Pixels buffer detached in spin, recreating from screen dimensions'); pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); } const bufferSize = width * height * 4; if (!spinBuffer || spinBuffer.length !== bufferSize) { spinBuffer = new Uint8ClampedArray(bufferSize); } if (pixels.length !== bufferSize) { pixels = new Uint8ClampedArray(bufferSize); pixels.fill(0); } spinBuffer.set(pixels);
// Vortex algorithm: angle change = steps / distance from center. // Pixels near the center rotate more than pixels at the edges, // creating the characteristic spiral/swirl pattern. const twoPi = 2 * PI; const maxXMinus1 = maxX - 1; const maxYMinus1 = maxY - 1;
for (let destY = minY; destY < maxY; destY++) { const dy = destY - centerY; const dy2 = dy * dy; const destRowOffset = destY * width;
for (let destX = minX; destX < maxX; destX++) { const dx = destX - centerX; const distanceSquared = dx * dx + dy2;
// Center pixel — no rotation needed if (distanceSquared < 1.0) { const idx = (destRowOffset + destX) * 4; pixels[idx] = spinBuffer[idx]; pixels[idx + 1] = spinBuffer[idx + 1]; pixels[idx + 2] = spinBuffer[idx + 2]; pixels[idx + 3] = spinBuffer[idx + 3]; continue; }
const distance = sqrt(distanceSquared); const angle = Math.atan2(dy, dx); const totalAngleChange = integerSteps / distance;
let sourceAngle = angle - totalAngleChange; sourceAngle = sourceAngle - twoPi * floor(sourceAngle / twoPi); if (sourceAngle < 0) sourceAngle += twoPi;
const srcXf = centerX + distance * cos(sourceAngle); const srcYf = centerY + distance * Math.sin(sourceAngle);
// Wrap into bounds let wrappedSrcX = srcXf; let wrappedSrcY = srcYf;
if (srcXf < minX || srcXf >= maxX) { const normalizedX = srcXf - minX; wrappedSrcX = minX + (normalizedX - workingWidth * floor(normalizedX / workingWidth)); if (wrappedSrcX < minX) wrappedSrcX += workingWidth; } if (srcYf < minY || srcYf >= maxY) { const normalizedY = srcYf - minY; wrappedSrcY = minY + (normalizedY - workingHeight * floor(normalizedY / workingHeight)); if (wrappedSrcY < minY) wrappedSrcY += workingHeight; }
const nearestSrcX = Math.max(minX, Math.min(maxXMinus1, Math.round(wrappedSrcX))); const nearestSrcY = Math.max(minY, Math.min(maxYMinus1, Math.round(wrappedSrcY)));
const srcIdx = (nearestSrcY * width + nearestSrcX) * 4; const destIdx = (destRowOffset + destX) * 4;
pixels[destIdx] = spinBuffer[srcIdx]; pixels[destIdx + 1] = spinBuffer[srcIdx + 1]; pixels[destIdx + 2] = spinBuffer[srcIdx + 2]; pixels[destIdx + 3] = spinBuffer[srcIdx + 3]; } } const spinEnd = performance.now(); graphPerf.track('spin', spinEnd - spinStart);}
// Zoom the entire pixel buffer with 1.0 as neutral (no change)// level < 1.0 zooms out, level > 1.0 zooms in, level = 1.0 does nothing// anchorX, anchorY: 0.0 = top/left, 0.5 = center, 1.0 = bottom/right// Uses bilinear sampling with hard-edge thresholding for smooth scaling with crisp outputfunction zoom(level = 1, anchorX = 0.5, anchorY = 0.5) { if (level === 1.0) return; // No change needed - neutral zoom const zoomStartTime = performance.now(); // For large zoom changes, apply immediately // For small zoom changes, accumulate for aggregative effects let actualZoomLevel; if (Math.abs(level - 1.0) >= 0.1) { // Large zoom change - apply immediately (e.g., zoom(2) or zoom(0.5)) actualZoomLevel = level; // Still update accumulator to maintain state consistency zoomAccumulator = 0; // Reset since we're applying a direct zoom } else { // Small zoom change - accumulate for aggregative effects (e.g., zoom(1.025)) const zoomDelta = level - 1.0; zoomAccumulator += zoomDelta; // Only apply when accumulated change is significant enough const threshold = 0.05; // Increased from 0.01 to 0.05 for better performance if (Math.abs(zoomAccumulator) < threshold) return; // Apply the accumulated zoom change const zoomToApply = Math.sign(zoomAccumulator) * threshold; actualZoomLevel = 1.0 + zoomToApply; // Reduce accumulator by what we applied zoomAccumulator -= zoomToApply; }
// 🚀 TRY GPU ZOOM FIRST if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("zoom") && gpuSpinModule?.gpuZoom && pixels && width && height) { const mask = activeMask ? { x: activeMask.x, y: activeMask.y, width: activeMask.width, height: activeMask.height } : null;
const success = gpuSpinModule.gpuZoom(pixels, width, height, actualZoomLevel, anchorX, anchorY, mask); if (success) { gpuOk("zoom"); const zoomEndTime = performance.now(); graphPerf.track('zoom', zoomEndTime - zoomStartTime); return; } gpuFailed("zoom"); }
// CPU FALLBACK // Determine the area to process (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { minX = activeMask.x; minY = activeMask.y; maxX = activeMask.x + activeMask.width; maxY = activeMask.y + activeMask.height; }
const workingWidth = maxX - minX; const workingHeight = maxY - minY;
// Calculate anchor point in pixel coordinates within working area const anchorPixelX = minX + workingWidth * anchorX; const anchorPixelY = minY + workingHeight * anchorY;
// Create a copy of the current pixels to read from // 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it if (pixels.buffer && pixels.buffer.detached) { console.warn('🚨 Pixels buffer detached in zoom, recreating from screen dimensions'); // This should not happen if setBuffer works correctly, but just in case... pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); // Fill with transparent black } // 🚀 OPTIMIZATION: Reuse zoom buffer instead of allocating new one each call const bufferSize = width * height * 4; if (!zoomBuffer || zoomBuffer.length !== bufferSize) { zoomBuffer = new Uint8ClampedArray(bufferSize); } // 🛡️ SAFETY CHECK: Ensure pixels buffer size matches expected dimensions before copying if (pixels.length !== bufferSize) { console.error(`🚨 ZOOM ERROR: Buffer size mismatch - pixels=${pixels.length}, expected=${bufferSize} (${width}x${height})`); // Defensive handling: only copy what fits to prevent RangeError const copySize = Math.min(pixels.length, bufferSize); zoomBuffer.fill(0); // Clear buffer first if (copySize > 0) { zoomBuffer.set(pixels.subarray(0, copySize)); console.warn(`🚨 ZOOM RECOVERY: Partial copy of ${copySize} bytes to prevent crash`); } } else { zoomBuffer.set(pixels); }
const scale = actualZoomLevel; const invScale = 1.0 / scale; const invWorkingWidth = 1.0 / workingWidth; // 🚀 SIMD: Create typed arrays for vectorized processing const pixelCount = (maxY - minY) * (maxX - minX); const maxPixels = Math.ceil(pixelCount / 4) * 4; // Round up to multiple of 4 // Reuse coordinate buffers to avoid allocation overhead if (!zoom.coordBuffers || zoom.coordBuffers.length < maxPixels * 4) { zoom.srcCoords = new Float32Array(maxPixels * 2); // x,y pairs for source coordinates zoom.destIndices = new Uint32Array(maxPixels); } const srcCoords = zoom.srcCoords; const destIndices = zoom.destIndices; let coordIndex = 0; // 🚀 SIMD: Generate all coordinates in vectorized batches for (let destY = minY; destY < maxY; destY++) { const destRowOffset = destY * width; const srcYBase = (destY - anchorPixelY) * invScale + anchorPixelY; // Pre-calculate Y wrapping once per row let wrappedSrcYBase = ((srcYBase - minY) % workingHeight + workingHeight) % workingHeight + minY; const nearestY = Math.round(wrappedSrcYBase); const finalSrcY = minY + ((nearestY - minY) % workingHeight + workingHeight) % workingHeight; if (finalSrcY >= minY && finalSrcY < maxY) { const srcRowOffset = finalSrcY * width; for (let destX = minX; destX < maxX; destX++) { // Calculate source X coordinate const srcX = (destX - anchorPixelX) * invScale + anchorPixelX; // Store coordinates for SIMD processing srcCoords[coordIndex * 2] = srcX; srcCoords[coordIndex * 2 + 1] = finalSrcY; // Use pre-calculated Y destIndices[coordIndex] = (destRowOffset + destX) * 4; coordIndex++; } } } // 🚀 SIMD: Process coordinates in groups of 4 for vectorized operations const groupsOf4 = Math.floor(coordIndex / 4); for (let group = 0; group < groupsOf4; group++) { const baseIndex = group * 4; // Load 4 source X coordinates for vectorized processing const srcX0 = srcCoords[baseIndex * 2]; const srcX1 = srcCoords[(baseIndex + 1) * 2]; const srcX2 = srcCoords[(baseIndex + 2) * 2]; const srcX3 = srcCoords[(baseIndex + 3) * 2]; // Use same Y coordinate for this row (already calculated) const srcY = srcCoords[baseIndex * 2 + 1]; const srcRowOffset = srcY * width; // 🚀 SIMD: Vectorized X coordinate wrapping const normalizedX0 = (srcX0 - minX) * invWorkingWidth; const normalizedX1 = (srcX1 - minX) * invWorkingWidth; const normalizedX2 = (srcX2 - minX) * invWorkingWidth; const normalizedX3 = (srcX3 - minX) * invWorkingWidth; const wrappedNormX0 = normalizedX0 - Math.floor(normalizedX0); const wrappedNormX1 = normalizedX1 - Math.floor(normalizedX1); const wrappedNormX2 = normalizedX2 - Math.floor(normalizedX2); const wrappedNormX3 = normalizedX3 - Math.floor(normalizedX3); const wrappedSrcX0 = minX + wrappedNormX0 * workingWidth; const wrappedSrcX1 = minX + wrappedNormX1 * workingWidth; const wrappedSrcX2 = minX + wrappedNormX2 * workingWidth; const wrappedSrcX3 = minX + wrappedNormX3 * workingWidth; // 🚀 SIMD: Vectorized nearest neighbor sampling const nearestX0 = Math.round(wrappedSrcX0); const nearestX1 = Math.round(wrappedSrcX1); const nearestX2 = Math.round(wrappedSrcX2); const nearestX3 = Math.round(wrappedSrcX3); const finalSrcX0 = minX + ((nearestX0 - minX) % workingWidth + workingWidth) % workingWidth; const finalSrcX1 = minX + ((nearestX1 - minX) % workingWidth + workingWidth) % workingWidth; const finalSrcX2 = minX + ((nearestX2 - minX) % workingWidth + workingWidth) % workingWidth; const finalSrcX3 = minX + ((nearestX3 - minX) % workingWidth + workingWidth) % workingWidth; // Process each of the 4 pixels if coordinates are valid if (finalSrcX0 >= minX && finalSrcX0 < maxX) { processZoomPixel(srcRowOffset + finalSrcX0, destIndices[baseIndex]); } if (finalSrcX1 >= minX && finalSrcX1 < maxX) { processZoomPixel(srcRowOffset + finalSrcX1, destIndices[baseIndex + 1]); } if (finalSrcX2 >= minX && finalSrcX2 < maxX) { processZoomPixel(srcRowOffset + finalSrcX2, destIndices[baseIndex + 2]); } if (finalSrcX3 >= minX && finalSrcX3 < maxX) { processZoomPixel(srcRowOffset + finalSrcX3, destIndices[baseIndex + 3]); } } // Handle remaining pixels (less than 4) for (let i = groupsOf4 * 4; i < coordIndex; i++) { const srcX = srcCoords[i * 2]; const srcY = srcCoords[i * 2 + 1]; const srcRowOffset = srcY * width; // X coordinate wrapping const normalizedX = (srcX - minX) * invWorkingWidth; const wrappedNormX = normalizedX - Math.floor(normalizedX); const wrappedSrcX = minX + wrappedNormX * workingWidth; const nearestX = Math.round(wrappedSrcX); const finalSrcX = minX + ((nearestX - minX) % workingWidth + workingWidth) % workingWidth; if (finalSrcX >= minX && finalSrcX < maxX) { processZoomPixel(srcRowOffset + finalSrcX, destIndices[i]); } }
// Don't reset zoom accumulator - let it continue accumulating like scroll and spin}
// 🚀 SIMD Helper: Process individual zoom pixel with fast copyingfunction processZoomPixel(srcPixelIndex, destIdx) { const srcIdx = srcPixelIndex * 4; // Unrolled RGBA copy for maximum speed pixels[destIdx] = zoomBuffer[srcIdx]; pixels[destIdx + 1] = zoomBuffer[srcIdx + 1]; pixels[destIdx + 2] = zoomBuffer[srcIdx + 2]; pixels[destIdx + 3] = zoomBuffer[srcIdx + 3];}
// Static buffers for SIMD zoom functionzoom.srcCoords = null;zoom.destIndices = null;
// 🚀 EXPERIMENTAL: SIMD-optimized zoom processing using typed arraysfunction zoomSimd(actualZoomLevel, anchorX, anchorY) { // Determine the area to process (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { minX = activeMask.x; minY = activeMask.y; maxX = activeMask.x + activeMask.width; maxY = activeMask.y + activeMask.height; }
const workingWidth = maxX - minX; const workingHeight = maxY - minY; const anchorPixelX = minX + workingWidth * anchorX; const anchorPixelY = minY + workingHeight * anchorY;
// 🛡️ SAFETY CHECK: Buffer management if (pixels.buffer && pixels.buffer.detached) { console.warn('🚨 Pixels buffer detached in zoomSimd, recreating'); pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); } const bufferSize = width * height * 4; if (!zoomBuffer || zoomBuffer.length !== bufferSize) { zoomBuffer = new Uint8ClampedArray(bufferSize); } zoomBuffer.set(pixels);
const scale = actualZoomLevel; const invScale = 1.0 / scale; // 🚀 SIMD: Create typed arrays for vectorized processing const pixelCount = (maxY - minY) * (maxX - minX); const maxPixels = Math.ceil(pixelCount / 4) * 4; // Round up to multiple of 4 // Reuse coordinate buffers to avoid allocation overhead if (!zoomSimd.coordBuffers || zoomSimd.coordBuffers.length < maxPixels * 4) { zoomSimd.srcCoords = new Float32Array(maxPixels * 2); // x,y pairs for source coordinates zoomSimd.destIndices = new Uint32Array(maxPixels); zoomSimd.srcIndices = new Uint32Array(maxPixels); } const srcCoords = zoomSimd.srcCoords; const destIndices = zoomSimd.destIndices; const srcIndices = zoomSimd.srcIndices;
// Pre-calculate constants for SIMD operations const invWorkingWidth = 1.0 / workingWidth; const invWorkingHeight = 1.0 / workingHeight; let coordIndex = 0; // 🚀 SIMD: Generate all coordinates in vectorized batches for (let destY = minY; destY < maxY; destY++) { const destRowOffset = destY * width; const srcYBase = (destY - anchorPixelY) * invScale + anchorPixelY; // Pre-calculate Y wrapping once per row let wrappedSrcYBase = ((srcYBase - minY) % workingHeight + workingHeight) % workingHeight + minY; const nearestY = Math.round(wrappedSrcYBase); const finalSrcY = minY + ((nearestY - minY) % workingHeight + workingHeight) % workingHeight; if (finalSrcY >= minY && finalSrcY < maxY) { const srcRowOffset = finalSrcY * width; for (let destX = minX; destX < maxX; destX++) { // Calculate source X coordinate const srcX = (destX - anchorPixelX) * invScale + anchorPixelX; // Store coordinates for SIMD processing srcCoords[coordIndex * 2] = srcX; srcCoords[coordIndex * 2 + 1] = finalSrcY; // Use pre-calculated Y destIndices[coordIndex] = (destRowOffset + destX) * 4; coordIndex++; } } } // 🚀 SIMD: Process coordinates in groups of 4 for vectorized operations const groupsOf4 = Math.floor(coordIndex / 4); const remainder = coordIndex % 4; // Process groups of 4 coordinates simultaneously for (let group = 0; group < groupsOf4; group++) { const baseIndex = group * 4; // Load 4 source X coordinates for vectorized processing const srcX0 = srcCoords[baseIndex * 2]; const srcX1 = srcCoords[(baseIndex + 1) * 2]; const srcX2 = srcCoords[(baseIndex + 2) * 2]; const srcX3 = srcCoords[(baseIndex + 3) * 2]; // Use same Y coordinate for this row (already calculated) const srcY = srcCoords[baseIndex * 2 + 1]; const srcRowOffset = srcY * width; // 🚀 SIMD: Vectorized X coordinate wrapping const normalizedX0 = (srcX0 - minX) * invWorkingWidth; const normalizedX1 = (srcX1 - minX) * invWorkingWidth; const normalizedX2 = (srcX2 - minX) * invWorkingWidth; const normalizedX3 = (srcX3 - minX) * invWorkingWidth; const wrappedNormX0 = normalizedX0 - Math.floor(normalizedX0); const wrappedNormX1 = normalizedX1 - Math.floor(normalizedX1); const wrappedNormX2 = normalizedX2 - Math.floor(normalizedX2); const wrappedNormX3 = normalizedX3 - Math.floor(normalizedX3); const wrappedSrcX0 = minX + wrappedNormX0 * workingWidth; const wrappedSrcX1 = minX + wrappedNormX1 * workingWidth; const wrappedSrcX2 = minX + wrappedNormX2 * workingWidth; const wrappedSrcX3 = minX + wrappedNormX3 * workingWidth; // 🚀 SIMD: Vectorized nearest neighbor sampling const nearestX0 = Math.round(wrappedSrcX0); const nearestX1 = Math.round(wrappedSrcX1); const nearestX2 = Math.round(wrappedSrcX2); const nearestX3 = Math.round(wrappedSrcX3); const finalSrcX0 = minX + ((nearestX0 - minX) % workingWidth + workingWidth) % workingWidth; const finalSrcX1 = minX + ((nearestX1 - minX) % workingWidth + workingWidth) % workingWidth; const finalSrcX2 = minX + ((nearestX2 - minX) % workingWidth + workingWidth) % workingWidth; const finalSrcX3 = minX + ((nearestX3 - minX) % workingWidth + workingWidth) % workingWidth; // Process each of the 4 pixels if coordinates are valid if (finalSrcX0 >= minX && finalSrcX0 < maxX) { processZoomSimdPixel(srcRowOffset + finalSrcX0, destIndices[baseIndex]); } if (finalSrcX1 >= minX && finalSrcX1 < maxX) { processZoomSimdPixel(srcRowOffset + finalSrcX1, destIndices[baseIndex + 1]); } if (finalSrcX2 >= minX && finalSrcX2 < maxX) { processZoomSimdPixel(srcRowOffset + finalSrcX2, destIndices[baseIndex + 2]); } if (finalSrcX3 >= minX && finalSrcX3 < maxX) { processZoomSimdPixel(srcRowOffset + finalSrcX3, destIndices[baseIndex + 3]); } } // Handle remaining pixels (less than 4) for (let i = groupsOf4 * 4; i < coordIndex; i++) { const srcX = srcCoords[i * 2]; const srcY = srcCoords[i * 2 + 1]; const srcRowOffset = srcY * width; // X coordinate wrapping const normalizedX = (srcX - minX) * invWorkingWidth; const wrappedNormX = normalizedX - Math.floor(normalizedX); const wrappedSrcX = minX + wrappedNormX * workingWidth; const nearestX = Math.round(wrappedSrcX); const finalSrcX = minX + ((nearestX - minX) % workingWidth + workingWidth) % workingWidth; if (finalSrcX >= minX && finalSrcX < maxX) { processZoomSimdPixel(srcRowOffset + finalSrcX, destIndices[i]); } }}
// 🚀 SIMD Helper: Process individual zoom pixel with fast copyingfunction processZoomSimdPixel(srcPixelIndex, destIdx) { const srcIdx = srcPixelIndex * 4; // Unrolled RGBA copy for maximum speed pixels[destIdx] = zoomBuffer[srcIdx]; pixels[destIdx + 1] = zoomBuffer[srcIdx + 1]; pixels[destIdx + 2] = zoomBuffer[srcIdx + 2]; pixels[destIdx + 3] = zoomBuffer[srcIdx + 3];}
// Static buffers for SIMD zoom functionzoomSimd.srcCoords = null;zoomSimd.destIndices = null;zoomSimd.srcIndices = null;
// Radial displacement transformation with pixel-perfect nearest neighbor sampling// Creates discrete, lossless pixel movement without blur or center holesfunction suck(strength = 1, centerX, centerY) { if (strength === 0.0) return; // No change needed - neutral suck
// Accumulate the suck strength like zoom does suckAccumulator += strength;
// Apply transformation when accumulator reaches threshold const threshold = 0.5; if (Math.abs(suckAccumulator) < threshold) return;
// Determine the area to process (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { minX = activeMask.x; minY = activeMask.y; maxX = activeMask.x + activeMask.width; maxY = activeMask.y + activeMask.height; }
const workingWidth = maxX - minX; const workingHeight = maxY - minY;
// Default center point to middle of working area const centerPixelX = centerX !== undefined ? centerX : minX + workingWidth * 0.5; const centerPixelY = centerY !== undefined ? centerY : minY + workingHeight * 0.5;
const displacementAmount = Math.abs(suckAccumulator); // Positive strength = suck inward (source further out), Negative = blow outward const direction = suckAccumulator > 0 ? 1 : -1;
if (displacementAmount < 0.01) { suckAccumulator = 0.0; return; }
// 🚀 TRY GPU SUCK FIRST if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("suck") && gpuSpinModule?.gpuSuck && pixels && width && height) { const mask = activeMask ? { x: activeMask.x + panTranslation.x, y: activeMask.y + panTranslation.y, width: activeMask.width, height: activeMask.height } : null;
const success = gpuSpinModule.gpuSuck( pixels, width, height, displacementAmount, direction, centerPixelX, centerPixelY, mask ); if (success) { gpuOk("suck"); suckAccumulator = 0.0; return; } gpuFailed("suck"); }
// 🐢 CPU FALLBACK
// 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it if (pixels.buffer && pixels.buffer.detached) { console.warn('🚨 Pixels buffer detached in suck, recreating from screen dimensions'); pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); }
// Create snapshot for pixel-perfect sampling const tempPixels = new Uint8ClampedArray(pixels);
// Apply radial displacement — each dest pixel pulls from further/closer along // the radial line from center. // Suck (direction=1): srcDistance = distance + displacement (pull from further out) // Blow (direction=-1): srcDistance = distance - displacement (pull from closer in) for (let destY = minY; destY < maxY; destY++) { const destRowOffset = destY * width;
for (let destX = minX; destX < maxX; destX++) { const dx = destX - centerPixelX; const dy = destY - centerPixelY; const distance = Math.sqrt(dx * dx + dy * dy);
// Very center pixels stay put to prevent holes if (distance < 1.0) { const destIdx = (destRowOffset + destX) * 4; pixels[destIdx] = tempPixels[destIdx]; pixels[destIdx + 1] = tempPixels[destIdx + 1]; pixels[destIdx + 2] = tempPixels[destIdx + 2]; pixels[destIdx + 3] = tempPixels[destIdx + 3]; continue; }
// Source is along the same radial ray, shifted by displacement const srcDistance = distance + (direction * displacementAmount);
// Compute source position along the same radial direction // Use dx/dy ratio to avoid atan2 (faster, no angle discontinuities = no spiral) const scale = srcDistance / distance; let srcX = centerPixelX + dx * scale; let srcY = centerPixelY + dy * scale;
// Clamp to bounds (no wrapping — pixels at the edge just repeat) srcX = Math.max(minX, Math.min(maxX - 1.0, srcX)); srcY = Math.max(minY, Math.min(maxY - 1.0, srcY));
// Bilinear interpolation for sub-pixel precision const srcX_floor = Math.floor(srcX); const srcY_floor = Math.floor(srcY); const wx = srcX - srcX_floor; const wy = srcY - srcY_floor;
const x1 = Math.max(minX, Math.min(maxX - 1, srcX_floor)); const y1 = Math.max(minY, Math.min(maxY - 1, srcY_floor)); const x2 = Math.max(minX, Math.min(maxX - 1, srcX_floor + 1)); const y2 = Math.max(minY, Math.min(maxY - 1, srcY_floor + 1));
const idx1 = (y1 * width + x1) * 4; const idx2 = (y1 * width + x2) * 4; const idx3 = (y2 * width + x1) * 4; const idx4 = (y2 * width + x2) * 4;
const destIdx = (destRowOffset + destX) * 4;
for (let c = 0; c < 4; c++) { const topInterp = tempPixels[idx1 + c] * (1 - wx) + tempPixels[idx2 + c] * wx; const bottomInterp = tempPixels[idx3 + c] * (1 - wx) + tempPixels[idx4 + c] * wx; pixels[destIdx + c] = Math.round(topInterp * (1 - wy) + bottomInterp * wy); } } }
// Reset accumulator after applying transformation suckAccumulator = 0.0;}
// Accumulated blur strength for smooth progressive blurringlet blurAccumulator = 0.0;
// Reusable temporary buffer for blur operations to avoid memory allocation on each calllet blurTempBuffer = null;let blurTempBufferSize = 0;
// Function to clean up blur buffers and reset statefunction cleanupBlurBuffers() { blurTempBuffer = null; blurTempBufferSize = 0; blurAccumulator = 0.0;}
// Efficient Gaussian blur using separable filtering with linear sampling optimization// Creates smooth blur effect by applying horizontal then vertical Gaussian convolutionfunction blur(strength = 1, quality = "medium") { if (strength <= 0.1) return; // No blur needed - neutral blur
// Start timing for performance monitoring const blurStartTime = performance.now();
// Scale strength to match legacy single-pass blur behavior. // The two-pass separable Gaussian is ~3x stronger than the old // single-pass weighted blur at the same radius value. const scaledStrength = strength / 3;
// Accumulate the blur strength for progressive blurring blurAccumulator += scaledStrength; // Apply blur when accumulator reaches threshold const threshold = 0.5; if (Math.abs(blurAccumulator) < threshold) return; // 🚀 TRY GPU BLUR FIRST if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("blur") && gpuSpinModule?.gpuBlur && pixels && width && height) { const mask = activeMask ? { x: activeMask.x + panTranslation.x, y: activeMask.y + panTranslation.y, width: activeMask.width, height: activeMask.height } : null;
const success = gpuSpinModule.gpuBlur(pixels, width, height, Math.abs(blurAccumulator), mask); if (success) { gpuOk("blur"); blurAccumulator = 0.0; const blurEndTime = performance.now(); graphPerf.track('blur', blurEndTime - blurStartTime); return; } gpuFailed("blur"); } // CPU FALLBACK // Determine the area to process (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // Apply pan translation to mask bounds and ensure they're within screen bounds minX = Math.max(0, Math.min(width, activeMask.x + panTranslation.x)); maxX = Math.max( 0, Math.min(width, activeMask.x + activeMask.width + panTranslation.x), ); minY = Math.max(0, Math.min(height, activeMask.y + panTranslation.y)); maxY = Math.max( 0, Math.min(height, activeMask.y + activeMask.height + panTranslation.y), ); }
const workingWidth = maxX - minX; const workingHeight = maxY - minY;
// Early exit if bounds are invalid if (workingWidth <= 0 || workingHeight <= 0) { blurAccumulator = 0.0; return; }
// 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it if (pixels.buffer && pixels.buffer.detached) { console.warn('🚨 Pixels buffer detached in blur, recreating from screen dimensions'); pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); // Fill with transparent black } // Calculate blur parameters based on strength and quality const blurRadius = Math.max(1, Math.floor(Math.abs(blurAccumulator))); const kernelSize = Math.min(blurRadius * 2 + 1, 15); // Cap at 15 for performance // Generate Gaussian weights using Pascal triangle approximation const weights = generateGaussianWeights(kernelSize, quality); const radius = Math.floor(kernelSize / 2); try { // Reuse temporary buffer to avoid repeated large allocations const requiredSize = pixels.length; if (!blurTempBuffer || blurTempBufferSize !== requiredSize) { // Only allocate new buffer if size changed or buffer doesn't exist blurTempBuffer = new Uint8ClampedArray(requiredSize); blurTempBufferSize = requiredSize; } // Clear the temp buffer to ensure clean state blurTempBuffer.fill(0); // PASS 1: Horizontal blur applyHorizontalBlur(pixels, blurTempBuffer, weights, radius, minX, minY, maxX, maxY); // PASS 2: Vertical blur (from temp back to main buffer) applyVerticalBlur(blurTempBuffer, pixels, weights, radius, minX, minY, maxX, maxY); } catch (error) { console.warn('🚨 Blur operation failed:', error); // Reset temp buffer on error to prevent issues on next call blurTempBuffer = null; blurTempBufferSize = 0; } // Log timing information for performance monitoring const blurEndTime = performance.now(); const blurDuration = blurEndTime - blurStartTime; // console.log(`⚡ blur: ${blurDuration.toFixed(3)}ms`); // Reset accumulator after applying blur blurAccumulator = 0.0;}
// Generate optimized Gaussian weights for blur kernelfunction generateGaussianWeights(kernelSize, quality) { const weights = new Array(kernelSize); const radius = Math.floor(kernelSize / 2); const sigma = radius / 3.0; // Standard deviation for nice falloff if (quality === "fast") { // Simple box blur approximation - all weights equal const weight = 1.0 / kernelSize; for (let i = 0; i < kernelSize; i++) { weights[i] = weight; } } else { // Proper Gaussian weights let sum = 0.0; for (let i = 0; i < kernelSize; i++) { const x = i - radius; const weight = Math.exp(-(x * x) / (2 * sigma * sigma)); weights[i] = weight; sum += weight; } // Normalize weights to sum to 1.0 for (let i = 0; i < kernelSize; i++) { weights[i] /= sum; } } return weights;}
// Apply horizontal Gaussian blur pass with optimized samplingfunction applyHorizontalBlur(sourcePixels, destPixels, weights, radius, minX, minY, maxX, maxY) { // Safety checks if (!sourcePixels || !destPixels || !weights) return; if (radius < 0 || radius >= weights.length / 2) return; for (let y = minY; y < maxY; y++) { const rowOffset = y * width; for (let x = minX; x < maxX; x++) { const destIdx = (rowOffset + x) * 4; // Bounds check for destination index if (destIdx < 0 || destIdx + 3 >= destPixels.length) continue; let r = 0, g = 0, b = 0, a = 0; // Apply convolution kernel horizontally for (let k = -radius; k <= radius; k++) { // Handle boundary conditions with clamping const srcX = Math.max(minX, Math.min(maxX - 1, x + k)); const srcIdx = (rowOffset + srcX) * 4; // Bounds check for source index if (srcIdx < 0 || srcIdx + 3 >= sourcePixels.length) continue; const weightIdx = k + radius; if (weightIdx < 0 || weightIdx >= weights.length) continue; const weight = weights[weightIdx]; r += sourcePixels[srcIdx] * weight; g += sourcePixels[srcIdx + 1] * weight; b += sourcePixels[srcIdx + 2] * weight; a += sourcePixels[srcIdx + 3] * weight; } // Store blurred values with clamping destPixels[destIdx] = Math.round(Math.max(0, Math.min(255, r))); destPixels[destIdx + 1] = Math.round(Math.max(0, Math.min(255, g))); destPixels[destIdx + 2] = Math.round(Math.max(0, Math.min(255, b))); destPixels[destIdx + 3] = Math.round(Math.max(0, Math.min(255, a))); } }}
// Apply vertical Gaussian blur pass with optimized samplingfunction applyVerticalBlur(sourcePixels, destPixels, weights, radius, minX, minY, maxX, maxY) { // Safety checks if (!sourcePixels || !destPixels || !weights) return; if (radius < 0 || radius >= weights.length / 2) return; for (let y = minY; y < maxY; y++) { const rowOffset = y * width; for (let x = minX; x < maxX; x++) { const destIdx = (rowOffset + x) * 4; // Bounds check for destination index if (destIdx < 0 || destIdx + 3 >= destPixels.length) continue; let r = 0, g = 0, b = 0, a = 0; // Apply convolution kernel vertically for (let k = -radius; k <= radius; k++) { // Handle boundary conditions with clamping const srcY = Math.max(minY, Math.min(maxY - 1, y + k)); const srcIdx = (srcY * width + x) * 4; // Bounds check for source index if (srcIdx < 0 || srcIdx + 3 >= sourcePixels.length) continue; const weightIdx = k + radius; if (weightIdx < 0 || weightIdx >= weights.length) continue; const weight = weights[weightIdx]; r += sourcePixels[srcIdx] * weight; g += sourcePixels[srcIdx + 1] * weight; b += sourcePixels[srcIdx + 2] * weight; a += sourcePixels[srcIdx + 3] * weight; } // Store blurred values with clamping destPixels[destIdx] = Math.round(Math.max(0, Math.min(255, r))); destPixels[destIdx + 1] = Math.round(Math.max(0, Math.min(255, g))); destPixels[destIdx + 2] = Math.round(Math.max(0, Math.min(255, b))); destPixels[destIdx + 3] = Math.round(Math.max(0, Math.min(255, a))); } }}
// Apply sharpening filter to enhance edges and details// strength: 0 = no sharpening, 1 = normal sharpening, >1 = aggressive sharpeningfunction sharpen(strength = 1) { if (strength <= 0) return; // No sharpening needed // Start timing for performance monitoring const sharpenStartTime = performance.now(); // 🚀 TRY GPU SHARPEN FIRST if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("sharpen") && gpuSpinModule?.gpuSharpen && pixels && width && height) { const mask = activeMask ? { x: activeMask.x + panTranslation.x, y: activeMask.y + panTranslation.y, width: activeMask.width, height: activeMask.height } : null;
const success = gpuSpinModule.gpuSharpen(pixels, width, height, strength, mask); if (success) { gpuOk("sharpen"); const sharpenEndTime = performance.now(); graphPerf.track('sharpen', sharpenEndTime - sharpenStartTime); return; } gpuFailed("sharpen"); } // CPU FALLBACK // Determine the area to process (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // Apply pan translation to mask bounds and ensure they're within screen bounds minX = Math.max(0, Math.min(width, activeMask.x + panTranslation.x)); maxX = Math.max( 0, Math.min(width, activeMask.x + activeMask.width + panTranslation.x), ); minY = Math.max(0, Math.min(height, activeMask.y + panTranslation.y)); maxY = Math.max( 0, Math.min(height, activeMask.y + activeMask.height + panTranslation.y), ); }
const workingWidth = maxX - minX; const workingHeight = maxY - minY;
// Early exit if bounds are invalid if (workingWidth <= 0 || workingHeight <= 0) return;
// 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it if (pixels.buffer && pixels.buffer.detached) { console.warn('🚨 Pixels buffer detached in sharpen, recreating from screen dimensions'); pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); // Fill with transparent black } try { // Create temporary buffer for sharpen operation const tempBuffer = new Uint8ClampedArray(pixels.length); // Copy original pixels to temp buffer tempBuffer.set(pixels); // Unsharp mask convolution kernel (center weight adjusted by strength) // This is a 3x3 kernel that subtracts a blurred version from the original const centerWeight = 1 + (4 * strength); const edgeWeight = -strength; // Apply sharpening convolution for (let y = minY + 1; y < maxY - 1; y++) { for (let x = minX + 1; x < maxX - 1; x++) { const centerIdx = (y * width + x) * 4; // Skip transparent pixels if (tempBuffer[centerIdx + 3] === 0) continue; // Apply 3x3 unsharp mask kernel for each color channel for (let channel = 0; channel < 3; channel++) { // RGB only, preserve alpha let sum = 0; // Center pixel (enhanced) sum += tempBuffer[centerIdx + channel] * centerWeight; // Surrounding pixels (subtracted) sum += tempBuffer[((y - 1) * width + x) * 4 + channel] * edgeWeight; // top sum += tempBuffer[((y + 1) * width + x) * 4 + channel] * edgeWeight; // bottom sum += tempBuffer[(y * width + (x - 1)) * 4 + channel] * edgeWeight; // left sum += tempBuffer[(y * width + (x + 1)) * 4 + channel] * edgeWeight; // right // Clamp result to valid range pixels[centerIdx + channel] = Math.max(0, Math.min(255, Math.round(sum))); } // Alpha channel stays unchanged } } } catch (error) { console.warn('🚨 Sharpen operation failed:', error); } // Log timing information for performance monitoring const sharpenEndTime = performance.now(); graphPerf.track('sharpen', sharpenEndTime - sharpenStartTime);}
// Sort pixels by color within the masked area (or entire screen if no mask)// Sorts by luminance (brightness) - darker pixels first, lighter pixels lastfunction sort() { // Determine the area to sort (mask or full screen) let minX = 0, minY = 0, maxX = width, maxY = height; if (activeMask) { // Apply pan translation to mask bounds const maskX = activeMask.x + panTranslation.x; const maskY = activeMask.y + panTranslation.y; minX = Math.max(0, Math.floor(maskX)); minY = Math.max(0, Math.floor(maskY)); maxX = Math.min(width, Math.floor(maskX + activeMask.width)); maxY = Math.min(height, Math.floor(maskY + activeMask.height)); }
// Collect all pixels in the area const pixelsToSort = []; for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const index = (y * width + x) * 4; const r = pixels[index]; const g = pixels[index + 1]; const b = pixels[index + 2]; const a = pixels[index + 3];
// Calculate luminance for sorting (standard formula) const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
pixelsToSort.push({ r, g, b, a, luminance, }); } }
// Sort by luminance (darker to lighter) pixelsToSort.sort((a, b) => a.luminance - b.luminance);
// Write sorted pixels back to their positions let sortedIndex = 0; for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const index = (y * width + x) * 4; const sortedPixel = pixelsToSort[sortedIndex];
pixels[index] = sortedPixel.r; pixels[index + 1] = sortedPixel.g; pixels[index + 2] = sortedPixel.b; pixels[index + 3] = sortedPixel.a;
sortedIndex++; } }}
// Copy a region from the current screen buffer into a new buffer// Returns a buffer object that can be used with paste()function copyRegion(x, y, w, h) { x = Math.floor(x); y = Math.floor(y); w = Math.floor(w); h = Math.floor(h);
// Apply pan translation to coordinates x += panTranslation.x; y += panTranslation.y;
// Clamp to screen bounds x = Math.max(0, Math.min(x, width)); y = Math.max(0, Math.min(y, height)); w = Math.max(0, Math.min(w, width - x)); h = Math.max(0, Math.min(h, height - y));
if (w <= 0 || h <= 0) { return null; }
// Create new buffer using ImageData like makeBuffer does const imageData = new ImageData(w, h); const buffer = { pixels: imageData.data, width: imageData.width, height: imageData.height, };
// Copy pixels from screen buffer to new buffer for (let srcY = 0; srcY < h; srcY++) { for (let srcX = 0; srcX < w; srcX++) { const srcIndex = ((y + srcY) * width + (x + srcX)) * 4; const destIndex = (srcY * w + srcX) * 4;
if (srcIndex >= 0 && srcIndex < pixels.length - 3) { buffer.pixels[destIndex] = pixels[srcIndex]; buffer.pixels[destIndex + 1] = pixels[srcIndex + 1]; buffer.pixels[destIndex + 2] = pixels[srcIndex + 2]; buffer.pixels[destIndex + 3] = pixels[srcIndex + 3]; } } }
return buffer;}
let stolen;
// TODO: Steal should run copyregion and keep the buffer in a global 'stolen' variable.function steal(x, y, width, height) { stolen = copyRegion(x, y, width, height); return stolen;}
// Paste the stolen buffer at the specified coordinates with optional scalingfunction putback(x, y, scale = 1) { if (!stolen) return; const result = paste(stolen, x, y, scale); return result;}
// KidPix-style shear function// shearX: horizontal shear factor (positive = right lean, negative = left lean)// shearY: vertical shear factor (positive = down lean, negative = up lean)function shear(shearX = 0, shearY = 0) { if (shearX === 0 && shearY === 0) return;
// Accumulate fractional shear amounts shearAccumulatorX += shearX; shearAccumulatorY += shearY;
const finalShearX = shearAccumulatorX; const finalShearY = shearAccumulatorY;
shearAccumulatorX = 0; shearAccumulatorY = 0;
// 🚀 TRY GPU SHEAR FIRST if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("shear") && gpuSpinModule?.gpuShear && pixels && width && height) { const mask = activeMask ? { x: activeMask.x + panTranslation.x, y: activeMask.y + panTranslation.y, width: activeMask.width, height: activeMask.height } : null;
const success = gpuSpinModule.gpuShear(pixels, width, height, finalShearX, finalShearY, mask); if (success) { gpuOk("shear"); return; } gpuFailed("shear"); }
// 🐢 CPU FALLBACK // Work area bounds let minX = 0, maxX = width, minY = 0, maxY = height; if (activeMask) { minX = Math.max(0, Math.min(width, activeMask.x + panTranslation.x)); maxX = Math.max( 0, Math.min(width, activeMask.x + activeMask.width + panTranslation.x), ); minY = Math.max(0, Math.min(height, activeMask.y + panTranslation.y)); maxY = Math.max( 0, Math.min(height, activeMask.y + activeMask.height + panTranslation.y), ); }
const workingWidth = maxX - minX; const workingHeight = maxY - minY; if (workingWidth <= 0 || workingHeight <= 0) return;
// 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it if (pixels.buffer && pixels.buffer.detached) { console.warn('🚨 Pixels buffer detached in shear, recreating from screen dimensions'); pixels = new Uint8ClampedArray(width * height * 4); pixels.fill(0); // Fill with transparent black }
const tempPixels = new Uint8ClampedArray(pixels); // Horizontal shear: each row shifts more based on distance from center if (finalShearX !== 0) { for (let y = 0; y < workingHeight; y++) { const distFromCenter = y - workingHeight / 2; const rowShift = Math.round(finalShearX * distFromCenter);
for (let x = 0; x < workingWidth; x++) { let srcX = x - rowShift; srcX = ((srcX % workingWidth) + workingWidth) % workingWidth;
const srcOffset = ((minY + y) * width + (minX + srcX)) * 4; const destOffset = ((minY + y) * width + (minX + x)) * 4;
pixels[destOffset] = tempPixels[srcOffset]; pixels[destOffset + 1] = tempPixels[srcOffset + 1]; pixels[destOffset + 2] = tempPixels[srcOffset + 2]; pixels[destOffset + 3] = tempPixels[srcOffset + 3]; } } tempPixels.set(pixels); } // Vertical shear: each column shifts more based on distance from center if (finalShearY !== 0) { for (let x = 0; x < workingWidth; x++) { const distFromCenter = x - workingWidth / 2; const colShift = Math.round(finalShearY * distFromCenter);
for (let y = 0; y < workingHeight; y++) { let srcY = y - colShift; srcY = ((srcY % workingHeight) + workingHeight) % workingHeight;
const srcOffset = ((minY + srcY) * width + (minX + x)) * 4; const destOffset = ((minY + y) * width + (minX + x)) * 4;
pixels[destOffset] = tempPixels[srcOffset]; pixels[destOffset + 1] = tempPixels[srcOffset + 1]; pixels[destOffset + 2] = tempPixels[srcOffset + 2]; pixels[destOffset + 3] = tempPixels[srcOffset + 3]; } } }}
// b. Geometric Abstractions
// For producing a projection matrix.// For matrix & linear algebra help: https://www.youtube.com/playlist?list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_abclass Camera { type = "perspective"; matrix; #x = 0; #rotX = 0; #y = 0; #rotY = 0; #z = 0; #rotZ = 0; fov;
near = 0.01; far = 5000;
position = [0, 0, 0, 1]; rotation = [0, 0, 0]; scale = [1, 1, 1];
// centerCached; // Saved after each call to `center()`.
perspectiveMatrix; #transformMatrix;
// Takes x, y, z position and an optional scale (xyz) array. constructor(fov = 80, { x, y, z, scale } = { x: 0, y: 0, z: 0, scale: 1 }) { this.fov = fov;
this.x = x; this.y = y; this.z = z;
if (scale) this.scale = scale;
this.#perspective(this.fov); this.#transform(); this.matrix = this.#transformMatrix; }
set rotX(n) { this.#rotX = n; this.#perspective(this.fov); this.#transform(); this.matrix = this.#transformMatrix; this.rotation[0] = n; }
get rotX() { return this.#rotX; }
set rotY(n) { this.#rotY = n; this.#perspective(this.fov); this.#transform(); this.matrix = this.#transformMatrix; this.rotation[1] = n; }
get rotY() { return this.#rotY; }
set rotZ(n) { this.#rotZ = n; this.#perspective(this.fov); this.#transform(); this.matrix = this.#transformMatrix; this.rotation[2] = n; }
get rotZ() { return this.#rotZ; }
// Returns the rotation of the camera in radians. get rot() { return [this.#rotX, this.#rotY, this.#rotZ]; }
set x(n = 0) { this.#x = n; this.#perspective(this.fov); this.#transform(); this.matrix = this.#transformMatrix; this.position[0] = n; }
get x() { return this.#x; }
set y(n = 0) { this.#y = n; this.#perspective(this.fov); this.#transform(); this.matrix = this.#transformMatrix; this.position[1] = n; }
get y() { return this.#y; }
set z(n = 0) { this.#z = n; this.#perspective(this.fov); this.#transform(); this.matrix = this.#transformMatrix; this.position[2] = n; }
get z() { return this.#z; }
forward(n) { this.#z -= n; this.#perspective(this.fov); this.#transform(); this.matrix = this.#transformMatrix; }
#perspective(fov) { const zNear = this.near; const zFar = this.far;
this.perspectiveMatrix = mat4.perspective( mat4.create(), radians(fov), width / height, zNear, zFar, );
// See: https://github.com/BennyQBD/3DSoftwareRenderer/blob/641f59125351d9565e744a90ad86256c3970a724/src/Matrix4f.java#L89 // And compare it with: https://glmatrix.net/docs/mat4.js.html#line1508
const zRange = zNear - zFar; const ten = (-zNear - zFar) / zRange; const fourteen = (2 * zFar * zNear) / zRange;
this.perspectiveMatrix[10] = ten; // Zero the Z component. this.perspectiveMatrix[14] = fourteen; this.perspectiveMatrix[11] = 1; // Flip the Y so we see things rightside up. }
get perspective() { return this.perspectiveMatrix; }
// Recalculate the camera matrix for a new display constraint. // TODO: Eventually this should be redundant for custom cameras // that don't hook into the `screen`. 24.02.21.15.26 resize() { this.forward(0); }
// Get an XYZ position on a plane at a given depth, // relative to screen coordinates. ray(X = width / 2, Y = height / 2, depth = 1, flippedY = false) { this.#perspective(this.fov);
// 1. Camera World Space const pos = [...this.position];
if (flippedY) pos[1] *= -1; // TODO: This is a little janky now, both CPU // and GPU should have the same Y.
const rotX = mat4.fromXRotation(mat4.create(), radians(this.#rotX)); const rotY = mat4.fromYRotation(mat4.create(), radians(this.#rotY)); const rotZ = mat4.fromZRotation(mat4.create(), radians(this.#rotZ));
const rotatedX = mat4.multiply(mat4.create(), rotX, mat4.create()); const rotatedY = mat4.multiply(mat4.create(), rotY, rotatedX); const rotatedZ = mat4.multiply(mat4.create(), rotatedY, rotZ);
const scaled = mat4.scale(mat4.create(), rotatedZ, this.scale);
const world = scaled;
// Camera World Space -> Inverted Perspective Projection const invertedProjection = mat4.invert( mat4.create(), this.perspectiveMatrix, ); const invWorldPersProj = mat4.mul(mat4.create(), world, invertedProjection);
// 2. Screen Point -> Inverted World Perspective Projection
// Normalize from screen coordinates. X = 1 - X / width; Y = 1 - Y / height;
// 2. -> Normalized Device Space (NDS) let x = 2.0 * X - 1; let y = 2.0 * Y - 1;
// NDS -> Homogeneous Space // (flipped Z, because we are in a left-handed coordinate system.) const screenPos = vec4.fromValues(x, -y, 1, 1);
// Adjust Z depth of plane... (scale screen position) const shiftedScreenPos = vec4.scale(vec4.create(), screenPos, depth);
// Get near plane. const xyz = vec4.transformMat4( vec4.create(), shiftedScreenPos, invWorldPersProj, );
// Subtract transformed point from camera position. const worldPos = vec4.sub(vec4.create(), pos, xyz);
return worldPos; }
#transform() { // TODO: Why does this and the FPS camera control need to be inverted? // Can't I just somehow invert the matrix to avoid all the swapping? // Maybe it has something to do with rotation order? // The default in three.js is XYZ, but here I'm using YXZ which I // had to override there. 22.10.09.20.31
// Translation. const panned = mat4.translate(mat4.create(), mat4.create(), [ this.#x, this.#y, this.#z, ]);
// Rotation const rotY = mat4.fromYRotation(mat4.create(), radians(-this.#rotY)); // FLIPPED const rotX = mat4.fromXRotation(mat4.create(), radians(this.#rotX)); const rotZ = mat4.fromZRotation(mat4.create(), radians(this.#rotZ)); const rotatedY = mat4.multiply(mat4.create(), rotY, panned); const rotatedX = mat4.multiply(mat4.create(), rotX, rotatedY); const rotatedZ = mat4.multiply(mat4.create(), rotZ, rotatedX);
// Scale // TODO: Add support for camera scaling. const scaled = mat4.scale(mat4.create(), rotatedZ, this.scale);
// Perspective this.#transformMatrix = mat4.multiply( mat4.create(), this.perspectiveMatrix, scaled, ); }}
// For moving a camera round over time.// TODO: Add a track? (Rollercoaster / coast)// TODO: Only supports sideways movement right now.class Dolly { camera;
xVel = 0; yVel = 0; zVel = 0; dec = 0.9;
constructor(camera) { this.camera = camera; }
sim() { this.xVel *= this.dec; this.yVel *= this.dec; this.zVel *= this.dec;
if (abs(this.xVel) > 0) this.camera.x += this.xVel; if (abs(this.yVel) > 0) this.camera.y += this.yVel; if (abs(this.zVel) > 0) this.camera.z += this.zVel; }
push({ x, y, z }) { // Strafe x and z. const xz = vec2.rotate( vec2.create(), vec2.fromValues(x, z), vec2.fromValues(0, 0), radians(-this.camera.rotY), // Take the camera Y axis for strafing. );
this.xVel += xz[0] || 0; this.yVel += y || 0; this.zVel += xz[1] || 0; }}
let formId = 0;
// Debug flag for showing wireframeslet showWireframes = false;let showBoundingBoxes = false;
// Buffer to store wireframe lines for deferred renderinglet wireframeLines = [];
// Buffer to store bounding box debug infolet boundingBoxes = [];
// Stats for tracking rendering infolet renderStats = { originalTriangles: 0, clippedTriangles: 0, subdividedTriangles: 0, wireframeSegmentsTotal: 0, wireframeSegmentsTextured: 0, wireframeSegmentsGradient: 0, wireframeSegmentsClipped: 0, wireframeSegmentsOther: 0, pixelsDrawn: 0, trianglesRejected: 0,};
// Function to toggle wireframe renderingfunction setShowClippedWireframes(enabled) { showWireframes = enabled;}
// Function to clear wireframe buffer (called at start of frame)function clearWireframeBuffer() { wireframeLines = []; boundingBoxes = []; renderStats = { originalTriangles: 0, clippedTriangles: 0, subdividedTriangles: 0, wireframeSegmentsTotal: 0, wireframeSegmentsTextured: 0, wireframeSegmentsGradient: 0, wireframeSegmentsClipped: 0, wireframeSegmentsOther: 0, pixelsDrawn: 0, trianglesRejected: 0, };}
// Function to get current render statsfunction getRenderStats() { return renderStats;}
// Function to toggle bounding box debug visualizationfunction setShowBoundingBoxes(enabled) { showBoundingBoxes = enabled;}
// Function to draw all buffered bounding boxes (called after main rendering)function drawBoundingBoxDebug() { if (!showBoundingBoxes) return; // Draw each bounding box boundingBoxes.forEach(box => { const { minX, maxX, minY, maxY, screenMinX, screenMaxX, screenMinY, screenMaxY, rejected } = box; // Draw original bounding box in blue color(0, 100, 255, 100); line(minX, minY, maxX, minY); line(maxX, minY, maxX, maxY); line(maxX, maxY, minX, maxY); line(minX, maxY, minX, minY); // Draw clipped bounding box in green (accepted) or red (rejected) if (rejected) { color(255, 0, 0, 200); } else { color(0, 255, 0, 200); } line(screenMinX, screenMinY, screenMaxX, screenMinY); line(screenMaxX, screenMinY, screenMaxX, screenMaxY); line(screenMaxX, screenMaxY, screenMinX, screenMaxY); line(screenMinX, screenMaxY, screenMinX, screenMinY); });}
// Cohen-Sutherland line clipping algorithm// Clips a line to the screen bounds [0, 0, width, height]function clipLineToScreen(x1, y1, x2, y2) { // Region codes const INSIDE = 0; // 0000 const LEFT = 1; // 0001 const RIGHT = 2; // 0010 const BOTTOM = 4; // 0100 const TOP = 8; // 1000
// Compute region code for a point function computeOutCode(x, y) { let code = INSIDE; if (x < 0) code |= LEFT; else if (x >= width) code |= RIGHT; if (y < 0) code |= TOP; else if (y >= height) code |= BOTTOM; return code; }
let outcode1 = computeOutCode(x1, y1); let outcode2 = computeOutCode(x2, y2); let accept = false;
while (true) { if (!(outcode1 | outcode2)) { // Both points inside - accept line accept = true; break; } else if (outcode1 & outcode2) { // Both points share an outside region - reject line break; } else { // Line needs clipping let x, y; // Pick point outside rectangle const outcodeOut = outcode1 ? outcode1 : outcode2;
// Find intersection point using line equation if (outcodeOut & TOP) { x = x1 + (x2 - x1) * (0 - y1) / (y2 - y1); y = 0; } else if (outcodeOut & BOTTOM) { x = x1 + (x2 - x1) * (height - 1 - y1) / (y2 - y1); y = height - 1; } else if (outcodeOut & RIGHT) { y = y1 + (y2 - y1) * (width - 1 - x1) / (x2 - x1); x = width - 1; } else if (outcodeOut & LEFT) { y = y1 + (y2 - y1) * (0 - x1) / (x2 - x1); x = 0; }
// Replace point outside rectangle with intersection point if (outcodeOut === outcode1) { x1 = x; y1 = y; outcode1 = computeOutCode(x1, y1); } else { x2 = x; y2 = y; outcode2 = computeOutCode(x2, y2); } } }
if (accept) { return { x1, y1, x2, y2 }; } else { return null; // Line is completely outside }}
// Function to add wireframe line to bufferfunction addWireframeLine(x1, y1, x2, y2, color, category = "other") { // Reject lines with NaN or Infinity first if (!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { return; } // Reject zero-length lines const dx = x2 - x1; const dy = y2 - y1; if (dx === 0 && dy === 0) { return; } // Very strict bounds check: only allow lines where BOTH endpoints are reasonably close to visible area // This prevents perspective-reversed geometry from creating artifacts const minX = -width * 0.1; const maxX = width * 1.1; const minY = -height * 0.1; const maxY = height * 1.1; const p1Valid = x1 >= minX && x1 <= maxX && y1 >= minY && y1 <= maxY; const p2Valid = x2 >= minX && x2 <= maxX && y2 >= minY && y2 <= maxY; if (!p1Valid || !p2Valid) { // At least one endpoint is outside the acceptable range return; }
wireframeLines.push({ x1, y1, x2, y2, color, category });
// Update wireframe stats renderStats.wireframeSegmentsTotal++; switch (category) { case "textured": renderStats.wireframeSegmentsTextured++; break; case "gradient": renderStats.wireframeSegmentsGradient++; break; case "clipped": renderStats.wireframeSegmentsClipped++; break; default: renderStats.wireframeSegmentsOther++; break; }}
// Function to draw all buffered wireframes (called at end of frame)function drawBufferedWireframes() { if (wireframeLines.length > 0) { const savedColor = [c[0], c[1], c[2], c[3]]; withForceReplaceMode(() => { for (const wf of wireframeLines) { // Skip lines that are far off-screen (likely perspective artifacts) const margin = max(width, height) * 0.5; const p1OutOfBounds = abs(wf.x1) > width + margin || abs(wf.y1) > height + margin || wf.x1 < -margin || wf.y1 < -margin; const p2OutOfBounds = abs(wf.x2) > width + margin || abs(wf.y2) > height + margin || wf.x2 < -margin || wf.y2 < -margin; if (p1OutOfBounds || p2OutOfBounds) { continue; } const clipped = clipLineToScreen(wf.x1, wf.y1, wf.x2, wf.y2); if (!clipped) continue; setColor(wf.color[0], wf.color[1], wf.color[2], wf.color[3]); line(clipped.x1, clipped.y1, clipped.x2, clipped.y2); } }); setColor(...savedColor); wireframeLines = []; }}
// 🪧 sign - Create 3D text as a line-based Form// Converts text into line geometry that can be positioned/rotated in 3D space// // Usage:// const label = sign("Player1", { scale: 0.05, color: [0,1,0,1] });// label.position = [x, y, z];// ink(255).form(label);//// Options:// scale: number - Size in world units (default: 0.1)// color: [r,g,b,a] - Normalized RGBA color (default: [1,1,1,1])// align: "left" | "center" | "right" - Text alignment (default: "left")// glyphs: object - Pre-loaded glyph data (required, from Typeface.glyphs)//let signGlyphCache = {}; // Cache for loaded glyphs
function sign(text, options = {}) { const { scale = 0.1, color = [1, 1, 1, 1], align = "left", glyphs = {}, // Pass in typeface.glyphs from the piece } = options; const positions = []; const colors = []; // Normalize color to [r,g,b,a] format with values 0-1 const [r, g, b, a = 1] = Array.isArray(color) ? color.map(c => c > 1 ? c / 255 : c) // Convert 0-255 to 0-1 if needed : [1, 1, 1, 1]; const lineColor = [r, g, b, a]; let cursorX = 0; let totalWidth = 0; const charData = []; // First pass: calculate total width and collect glyph data for (const char of text) { const charCode = char.charCodeAt(0); let glyph = glyphs[char] || glyphs[charCode] || signGlyphCache[charCode]; // Fallback for space if (char === ' ') { charData.push({ char, glyph: null, advance: 3 * scale }); totalWidth += 3 * scale; continue; } if (glyph) { const advance = (glyph.advance || glyph.dwidth?.x || 4) * scale; charData.push({ char, glyph, advance }); totalWidth += advance; } else { // Missing glyph - use a placeholder box charData.push({ char, glyph: null, advance: 4 * scale, missing: true }); totalWidth += 4 * scale; } } // Calculate starting X based on alignment if (align === "center") { cursorX = -totalWidth / 2; } else if (align === "right") { cursorX = -totalWidth; } // Second pass: generate line geometry for (const { char, glyph, advance, missing } of charData) { if (char === ' ') { cursorX += advance; continue; } if (missing) { // Draw a small box for missing glyphs const s = 3 * scale; const h = 6 * scale; positions.push( [cursorX, 0, 0, 1], [cursorX + s, 0, 0, 1], [cursorX + s, 0, 0, 1], [cursorX + s, -h, 0, 1], [cursorX + s, -h, 0, 1], [cursorX, -h, 0, 1], [cursorX, -h, 0, 1], [cursorX, 0, 0, 1] ); colors.push(lineColor, lineColor, lineColor, lineColor, lineColor, lineColor, lineColor, lineColor); cursorX += advance; continue; } // Get glyph offset for positioning const offsetX = (glyph.offset?.[0] || 0) * scale; const offsetY = (glyph.offset?.[1] || 0) * scale; // Process glyph commands if (glyph.commands) { for (const cmd of glyph.commands) { if (cmd.name === "line") { const [x1, y1, x2, y2] = cmd.args; positions.push( [cursorX + offsetX + x1 * scale, -(offsetY + y1 * scale), 0, 1], [cursorX + offsetX + x2 * scale, -(offsetY + y2 * scale), 0, 1] ); colors.push(lineColor, lineColor); } else if (cmd.name === "point") { // Convert points to tiny crosses for visibility in 3D const [x, y] = cmd.args; const px = cursorX + offsetX + x * scale; const py = -(offsetY + y * scale); const ps = scale * 0.3; // Point size positions.push( [px - ps, py, 0, 1], [px + ps, py, 0, 1], [px, py - ps, 0, 1], [px, py + ps, 0, 1] ); colors.push(lineColor, lineColor, lineColor, lineColor); } } } else if (glyph.pixels) { // Fallback for pixel-based glyphs: convert to dot pattern const [charWidth, charHeight] = glyph.resolution || [4, 8]; for (let row = 0; row < glyph.pixels.length; row++) { const pixelRow = glyph.pixels[row]; if (!Array.isArray(pixelRow)) continue; for (let col = 0; col < pixelRow.length; col++) { if (pixelRow[col] === 1) { const px = cursorX + offsetX + col * scale; const py = -(offsetY + row * scale); const ps = scale * 0.25; // Small cross for each pixel positions.push( [px, py - ps, 0, 1], [px, py + ps, 0, 1] ); colors.push(lineColor, lineColor); } } } } cursorX += advance; } // Create and return the Form if (positions.length === 0) { // Empty text - return minimal form positions.push([0, 0, 0, 1], [0, 0, 0, 1]); colors.push([0, 0, 0, 0], [0, 0, 0, 0]); } return new Form( { type: "line", positions, colors }, { pos: [0, 0, 0], scale: 1 } );}
// Helper to cache glyph data for sign() function cacheSignGlyph(charCode, glyphData) { signGlyphCache[charCode] = glyphData;}
function clearSignGlyphCache() { signGlyphCache = {};}
// Meshclass Form { primitive = "triangle"; type = "triangle";
limiter = 0; // Only enabled on CPU rendered `line` at the moment. 22.11.06.18.19
uid; // = nanoid(4); // An id to keep across threads. Takes ~4 milliseconds. 😢
tag; // Gets sent to the GPU as a named / marked tag. // Currently only available on `buffered` types in `3d.mjs` 23.02.07.09.46
// Model vertices = []; indices = [];
// TODO: Texture and color should be optional, and perhaps based on type. // TODO: Should this use a parameter called shader? texture; // = makeBuffer(32, 32); color; colorModifier; noFade = false; // Set to true to disable near-plane alpha fading
// GPU Specific Params & Buffers gpuVerticesSent = 0; gpuReset = false; // Assumes this object is being recreated on the GPU. gpuKeep = true; gpuConvertColors = true; gpuTransformed = false; gpuRecolored = false; MAX_POINTS = 100000; // Some buffered geometry gpu calls may use this hint. uvs = [];
#gradientColors = [ [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], ];
/* I haven't needed support for this yet so it's left commented. 22.10.13.23.12 #texCoords = [ [0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [1.0, 1.0, 0.0, 0.0], ]; */
// Transform position = [0, 0, 0]; rotation = [0, 0, 0]; scale = [1, 1, 1];
gradients = true;
// Blending alpha = 1.0;
constructor( // Model // `type` can be "triangle", or "line" or "line:buffered" // `positions` and colors can be sent and then verticies will be generated { type, vertices, uvs = [], positions, colors, texCoords, gradients, indices, keep = true, }, fill, // Transform transform, ) { this.gradients = gradients; // A flag to decide if we use gradients or not. Only for line3d right now. 22.11.06.02.00
// Give an incremental id per session. this.uid = formId; formId += 1;
// Set the primitive type. this.primitive = type; this.type = type;
// Decide whether to throw this away after being drawn once this.gpuKeep = keep;
// Take into account form -> primitive relationships. if (type === "quad") this.primitive = "triangle"; if (type === "triangle:buffered") this.primitive = "triangle"; if (type === "line:buffered") this.primitive = "line";
this.indices = indices || repeat(positions?.length, (i) => i);
// 🌩️ Ingest positions and turn them into vertices. // ("Import" a model...)
// Switch fill to transform if the was skipped. if (fill?.pos || fill?.rot || fill?.scale) { transform = fill; fill = undefined; }
// Assign texture or color. if (fill?.tex) this.texture = fill.tex; if (fill?.color) this.color = fill.color || c.slice(); if (fill?.alpha) this.alpha = fill.alpha;
// TODO: There is no maxed out notice here. if (positions?.length > 0) this.addPoints({ positions, colors, texCoords }, this.indices);
// Or just set vertices directly. if (vertices?.length > 0) { this.vertices = vertices; this.uvs = uvs; }
this.position = transform?.pos || [0, 0, 0]; this.rotation = transform?.rot || [0, 0, 0];
if (typeof transform.scale === "number") { this.scale = [transform.scale, transform.scale, transform.scale]; } else { this.scale = transform?.scale || [1, 1, 1]; } }
// TODO: This needs to support color (and eventually N vertex attributes).
resetUID() { this.uid = nanoid(4); //this.uid = formId; //formId += 1; }
// Clears vertex and index attributes to prepare for replacement geometry. clear() { this.uvs = []; this.vertices = []; this.indices = []; this.gpuReset = true; this.gpuVerticesSent = 0; }
// How close we are to being beyond the max points allotted by the GPU for // buffer geometries. maxProgress() { return this.vertices.length / (this.MAX_POINTS + 1); }
addPoints(attributes, indices) { const incomingLength = attributes.positions.length; const verticesLength = this.vertices.length; const pointsAvailable = this.MAX_POINTS - verticesLength;
let end = incomingLength; let maxedOut = false;
/* Left for debugging. 22.10.30.18.30 if (this.MAX_POINTS === 256) { console.log( "Incoming:", incomingLength, "Current:", verticesLength, "Max:", this.MAX_POINTS ); } */
if (pointsAvailable < incomingLength) { end = pointsAvailable; maxedOut = true; if (debug) console.warn( "Max. cutoff in GPU form!", this, incomingLength, pointsAvailable, ); }
// Create new vertices from incoming positions. for (let i = 0; i < end; i++) { // Use custom texCoords if provided, otherwise generate from position const texCoord = attributes.texCoords?.[i] || [ attributes.positions[i][X] / 2 + 0.5, attributes.positions[i][Y] / 2 + 0.5, ];
// 🔥 // TODO: // Wrap based on MAX_POINTS.
this.uvs.push(...texCoord); // For sending to the GPU.
// Optionally put color through a special function here. if (attributes.colors?.[i] && typeof this.colorModifier === "function") { attributes.colors[i] = this.colorModifier(attributes.colors[i]); }
this.vertices.push( // For sending to the CPU. new Vertex( attributes.positions[i], attributes.colors?.[i], // this.#gradientColors[i % 3], texCoord, //this.#texCoords[i % 3] // Replace to enable bespoke texture coordinates. attributes.normals?.[i], ), );
// TODO: This may need to be turned back on for the GPU? // What was with the -1 here? 22.11.06.17.42 // if (!indices) this.indices.push(verticesLength - 1 + i); if (!indices) this.indices.push(verticesLength + i); // console.log(indices, !indices, i, verticesLength); }
if (indices) this.indices = indices;
// Create indices from pre-indexed positions or generate // a linear set of indices based on length.
// TODO: How inefficient is this? 22.10.30.17.30 // this.indices = indices || repeat(this.vertices.length, (i) => i);
return maxedOut; }
// Get the world position of this form's local vertex. transformVertex(vertex) { // Build a matrix to represent this form's position, rotation and scale. const panned = mat4.fromTranslation(mat4.create(), [ this.position[X], this.position[Y], this.position[Z], ]);
const rotX = mat4.fromXRotation(mat4.create(), radians(this.rotation[X])); const rotY = mat4.fromYRotation(mat4.create(), radians(this.rotation[Y])); const rotZ = mat4.fromZRotation(mat4.create(), radians(this.rotation[Z]));
const rotatedX = mat4.mul(mat4.create(), panned, rotX); const rotatedY = mat4.mul(mat4.create(), rotatedX, rotY); const rotatedZ = mat4.mul(mat4.create(), rotatedY, rotZ);
const matrix = rotatedZ;
//mat4.translate(matrix, matrix, this.position);
// Apply scale. mat4.scale(matrix, matrix, this.scale);
// Apply the world matrix. //matrix = mat4.mul(mat4.create(), worldMatrix, matrix);
// const transformedVertices = []; // Transform each vertex by the matrix. //this.vertices.forEach((vertex) => { return vertex.transformWorld(matrix); //}); } graph({ matrix: cameraMatrix }) { // Build a matrix to represent this form's position, rotation and scale. // CORRECT ORDER: Scale → Rotate → Translate // This makes objects rotate around their local center, then move to world position // 1. Scale const scaled = mat4.scale(mat4.create(), mat4.create(), this.scale); // 2. Rotate (apply rotations to the scaled matrix) const rotX = mat4.fromXRotation(mat4.create(), radians(this.rotation[X])); const rotY = mat4.fromYRotation(mat4.create(), radians(this.rotation[Y])); const rotZ = mat4.fromZRotation(mat4.create(), radians(this.rotation[Z])); const rotatedX = mat4.multiply(mat4.create(), rotX, scaled); const rotatedY = mat4.multiply(mat4.create(), rotY, rotatedX); const rotatedZ = mat4.multiply(mat4.create(), rotZ, rotatedY);
// 3. Translate (move to world position after rotating) const panned = mat4.fromTranslation(mat4.create(), [ this.position[X] * -1, this.position[Y], this.position[Z] * -1, ]); const transformed = mat4.multiply(mat4.create(), panned, rotatedZ);
// Render wireframe lines for line type forms using untransformed vertices if (this.type === "line" && this.vertices.length > 0) { // Use the full combined matrix for screen-space conversion const fullMatrix = mat4.multiply(mat4.create(), cameraMatrix, transformed);
withForceReplaceMode(() => { const savedColor = [c[0], c[1], c[2], c[3]]; const baseColor = this.color; // Render lines between pairs of vertices using original vertices for (let i = 0; i < this.vertices.length; i += 2) { if (i + 1 < this.vertices.length) { const a = this.vertices[i]; const b = this.vertices[i + 1]; // Create temporary transformed vertices for projection const transformedA = a.transform(fullMatrix); const transformedB = b.transform(fullMatrix);
const clippedLine = clipLineToFrustum(transformedA, transformedB); if (clippedLine.length < 2) continue; const [lineA, lineB] = clippedLine;
// Apply perspective divide and screen space transformation const screenA = toScreenSpace(perspectiveDivide(lineA)); const screenB = toScreenSpace(perspectiveDivide(lineB)); if ( !Number.isFinite(screenA.pos[0]) || !Number.isFinite(screenA.pos[1]) || !Number.isFinite(screenB.pos[0]) || !Number.isFinite(screenB.pos[1]) ) { continue; }
const clipped = clipLineToScreen( screenA.pos[0], screenA.pos[1], screenB.pos[0], screenB.pos[1], ); if (!clipped) continue;
if (baseColor && baseColor.length >= 3) { setColor( baseColor[0], baseColor[1], baseColor[2], baseColor[3] ?? 255, ); } else if (a.color && b.color) { const avg = [ floor((a.color[0] + b.color[0]) / 2 * 255), floor((a.color[1] + b.color[1]) / 2 * 255), floor((a.color[2] + b.color[2]) / 2 * 255), floor((a.color[3] + b.color[3]) / 2 * 255), ]; setColor(...avg); }
// Draw simple 2D line guaranteed to overwrite underlying pixels line(clipped.x1, clipped.y1, clipped.x2, clipped.y2); } } setColor(...savedColor); }); }
// Render filled triangles with gradient colors or textures if (this.type === "triangle" && this.vertices.length >= 3) { const fullMatrix = mat4.multiply(mat4.create(), cameraMatrix, transformed);
// Render triangles (groups of 3 vertices) for (let i = 0; i < this.vertices.length; i += 3) { if (i + 2 < this.vertices.length) { const v0 = this.vertices[i]; const v1 = this.vertices[i + 1]; const v2 = this.vertices[i + 2];
// Transform all three vertices const t0 = v0.transform(fullMatrix); const t1 = v1.transform(fullMatrix); const t2 = v2.transform(fullMatrix);
// Count original triangle renderStats.originalTriangles++;
// Perform full clip-space clipping against all frustum planes // This handles triangles that extend beyond the screen edges let clippedVertices = [t0, t1, t2]; // Clip against all frustum planes in clip-space (before perspective divide) clippedVertices = clipInClipSpace(clippedVertices, ["near", "left", "right", "bottom", "top"]);
// Skip if triangle was completely clipped if (clippedVertices.length < 3) { continue; }
// Belt-and-suspenders: skip any polygon where a vertex has W too small // for a reliable perspective divide. Clip-space clipping should already // enforce this via NEAR_CLIP_Z, but numerical drift (especially with the // projection's W_clip = Z_view convention) can leave a vertex just under. let anyWTooSmall = false; for (let vi = 0; vi < clippedVertices.length; vi++) { if (clippedVertices[vi].pos[W] < MIN_PERSPECTIVE_W) { anyWTooSmall = true; break; } } if (anyWTooSmall) continue;
// Count clipped triangle (only if it resulted in a polygon) if (clippedVertices.length >= 3) { renderStats.clippedTriangles++; } // Calculate fade factor based on closest vertex to camera (use Z from clip space) const nearPlane = NEAR_CLIP_Z; const fadePlane = 0.5; const minZ = min(...clippedVertices.map(v => v.pos[2])); let alphaMultiplier = 1.0; if (!this.noFade && minZ < fadePlane) { // Fade from 1.0 at fadePlane to 0.0 at nearPlane alphaMultiplier = (minZ - nearPlane) / (fadePlane - nearPlane); alphaMultiplier = max(0, min(1, alphaMultiplier)); // Clamp 0-1 }
// Apply perspective divide and screen space transformation to all clipped vertices const screenVertices = clippedVertices.map(vertex => { const persp = perspectiveDivide(vertex); return toScreenSpace(persp); }); // Skip if any vertex has invalid coordinates (NaN from failed perspective divide) const hasInvalidVertex = screenVertices.some(v => !Number.isFinite(v.pos[0]) || !Number.isFinite(v.pos[1]) ); if (hasInvalidVertex) continue; // Apply screen-space clipping to handle edge cases const finalVertices = clipToScreen(screenVertices); // Skip if triangle was completely clipped away if (finalVertices.length < 3) continue;
// Render triangles from clipped vertices (triangulate if we have more than 3 vertices) // After screen-space clipping, we have a convex polygon to triangulate for (let j = 1; j < finalVertices.length - 1; j++) { const s0 = finalVertices[0]; const s1 = finalVertices[j]; const s2 = finalVertices[j + 1];
// Extract screen coordinates const x0 = s0.pos[0], y0 = s0.pos[1]; const x1 = s1.pos[0], y1 = s1.pos[1]; const x2 = s2.pos[0], y2 = s2.pos[1];
// Check if this form has a texture if (this.texture) { // Subdivide triangle if it's too large in screen space // Pass W component for perspective-correct UV interpolation const subdivided = subdivideTriangleIfNeeded( x0, y0, s0.texCoords, s0.pos[2], s0.pos[3], // x, y, uv, z, w x1, y1, s1.texCoords, s1.pos[2], s1.pos[3], x2, y2, s2.texCoords, s2.pos[2], s2.pos[3], 300 // Subdivide triangles larger than 300px to reduce overdraw ); // Count subdivided triangles (only count if more than 1 triangle resulted) if (subdivided.length > 1) { renderStats.subdividedTriangles += subdivided.length; } // Draw each sub-triangle for (const tri of subdivided) { // tri structure: [x0, y0, uv0, z0, w0, x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2] drawTexturedTriangle( tri[0], tri[1], tri[2], tri[3], tri[4], // x0, y0, uv0, z0, w0 tri[5], tri[6], tri[7], tri[8], tri[9], // x1, y1, uv1, z1, w1 tri[10], tri[11], tri[12], tri[13], tri[14], // x2, y2, uv2, z2, w2 this.texture, alphaMultiplier ); // Buffer wireframe overlay for textured triangles if (showWireframes) { // Only add wireframes if all vertices have positive W (in front of camera) const w0 = tri[4]; const w1 = tri[9]; const w2 = tri[14]; if (w0 > 0.01 && w1 > 0.01 && w2 > 0.01) { // Yellow for subdivided, white for non-subdivided const wireColor = subdivided.length > 1 ? [255, 255, 0, 255] : [255, 255, 255, 255]; addWireframeLine(tri[0], tri[1], tri[5], tri[6], wireColor, "textured"); // v0 to v1 addWireframeLine(tri[5], tri[6], tri[10], tri[11], wireColor, "textured"); // v1 to v2 addWireframeLine(tri[10], tri[11], tri[0], tri[1], wireColor, "textured"); // v2 to v0 } } } } else { // Get vertex colors (RGBA 0-1 range from vertex, convert to 0-255) const color0 = [ floor(s0.color[0] * 255), floor(s0.color[1] * 255), floor(s0.color[2] * 255), floor(s0.color[3] * 255 * alphaMultiplier) ]; const color1 = [ floor(s1.color[0] * 255), floor(s1.color[1] * 255), floor(s1.color[2] * 255), floor(s1.color[3] * 255 * alphaMultiplier) ]; const color2 = [ floor(s2.color[0] * 255), floor(s2.color[1] * 255), floor(s2.color[2] * 255), floor(s2.color[3] * 255 * alphaMultiplier) ];
// Draw filled gradient triangle using barycentric interpolation with Z-depth testing drawGradientTriangle( x0, y0, color0, s0.pos[2], // x, y, color, z x1, y1, color1, s1.pos[2], x2, y2, color2, s2.pos[2] ); // Buffer wireframe overlay for gradient triangles if (showWireframes) { // Only add wireframes if all vertices have positive W (in front of camera) const w0 = s0.pos[3]; const w1 = s1.pos[3]; const w2 = s2.pos[3]; if (w0 > 0.01 && w1 > 0.01 && w2 > 0.01) { const green = [0, 255, 0, 255]; // Green for gradient triangles addWireframeLine(x0, y0, x1, y1, green, "gradient"); // v0 to v1 addWireframeLine(x1, y1, x2, y2, green, "gradient"); // v1 to v2 addWireframeLine(x2, y2, x0, y0, green, "gradient"); // v2 to v0 } } } } // Buffer wireframe for the clipped polygon boundary (if clipping created extra vertices) if (showWireframes && finalVertices.length > 3) { const red = [255, 0, 0, 255]; for (let k = 0; k < finalVertices.length; k++) { const vA = finalVertices[k]; const vB = finalVertices[(k + 1) % finalVertices.length]; // Only add wireframe if both vertices are in front of camera if (vA.pos[3] > 0.01 && vB.pos[3] > 0.01) { addWireframeLine(vA.pos[0], vA.pos[1], vB.pos[0], vB.pos[1], red, "clipped"); } } } } } }
// Still return transformed vertices for compatibility const transformedVertices = []; const matrix = mat4.multiply(mat4.create(), cameraMatrix, transformed); this.vertices.forEach((vertex) => { transformedVertices.push(vertex.transform(matrix)); });
return transformedVertices; }}
// Constants for accessing Vector / Position components.const X = 0;const Y = 1;const Z = 2;const W = 3;
// A single point in space with color and texture coordinate information.class Vertex { static X = 0; static Y = 1; static Z = 2; static W = 3;
pos; color; texCoords; normal; constructor( position = [0, 0, 0, 1], color = [1, 1, 1, 1], textureCoordinates = [0, 0], normal = null, ) { this.pos = position; this.color = color; this.texCoords = textureCoordinates;
if (normal !== null) { this.normal = vec3.fromValues(...normal); } else { this.normal = vec3.fromValues(0, 0, 1); // Default normal pointing up } }
// TODO: Optimize this function for large vertex counts. 22.10.13.00.14 transform(matrix) { // Camera const vert = new Vertex( vec4.transformMat4( vec4.create(), [ this.pos[X] * -1, // FLIPPED this.pos[Y], this.pos[Z] * -1, // FLIPPED this.pos[W], ], matrix, ), this.color, this.texCoords, ); // console.log(matrix, vert); return vert; }
transformWorld(matrix) { return new Vertex( vec4.transformMat4(vec4.create(), this.pos, matrix), this.color, this.texCoords, ); }}
// Sutherland-Hodgman clipping algorithm for clip-space (before perspective divide)// This properly handles near-plane clipping by testing against w component// Minimum clip-space Z kept after near-plane clipping. Because the current// projection has W_clip = Z_view, this is effectively the minimum W we allow// post-clip, and must be comfortably above 0 so perspective divide stays stable.// Too small → near-camera triangles "shoot off" the screen.// Too large → noticeable geometry popping right in front of the camera.const NEAR_CLIP_Z = 0.02;const MIN_PERSPECTIVE_W = 0.001;
function clipInClipSpace(vertices, clippingBoundary) { let clipped = [];
function inside(vertex, edge) { const p = vertex.pos; const w = p[W]; switch (edge) { case "left": return p[X] >= -w; case "right": return p[X] <= w; case "bottom": return p[Y] >= -w; case "top": return p[Y] <= w; case "near": // Clip at Z_clip >= NEAR_CLIP_Z. With this projection W_clip = Z_view and // Z_clip ≈ Z_view - tiny, so this also guarantees W >= NEAR_CLIP_Z post-clip, // which keeps the perspective divide numerically stable. A too-small threshold // lets near-camera vertices through with tiny W, producing huge post-divide // screen coordinates ("shooting off" triangles). return p[Z] >= NEAR_CLIP_Z; case "far": return p[Z] <= w; } }
function computeIntersection(v1, v2, edge) { const p1 = v1.pos; const p2 = v2.pos; let t;
switch (edge) { case "left": t = (-p1[W] - p1[X]) / ((p2[X] - p1[X]) + (p2[W] - p1[W])); break; case "right": t = (p1[W] - p1[X]) / ((p2[X] - p1[X]) - (p2[W] - p1[W])); break; case "bottom": t = (-p1[W] - p1[Y]) / ((p2[Y] - p1[Y]) + (p2[W] - p1[W])); break; case "top": t = (p1[W] - p1[Y]) / ((p2[Y] - p1[Y]) - (p2[W] - p1[W])); break; case "near": t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); break; case "far": t = (p1[W] - p1[Z]) / ((p2[Z] - p1[Z]) - (p2[W] - p1[W])); break; }
// Handle NaN/Inf and clamp t to valid range if (!Number.isFinite(t)) t = 0.5; t = max(0, min(1, t)); // Interpolate position (all components linear in clip space) const newPos = vec4.lerp(vec4.create(), p1, p2, t); // Interpolate color linearly const newColor = [ v1.color[0] + (v2.color[0] - v1.color[0]) * t, v1.color[1] + (v2.color[1] - v1.color[1]) * t, v1.color[2] + (v2.color[2] - v1.color[2]) * t, v1.color[3] + (v2.color[3] - v1.color[3]) * t ]; // Interpolate texture coordinates linearly in clip space // Perspective correction will happen during rasterization const newTexCoords = [ v1.texCoords[0] + (v2.texCoords[0] - v1.texCoords[0]) * t, v1.texCoords[1] + (v2.texCoords[1] - v1.texCoords[1]) * t ];
return new Vertex(newPos, newColor, newTexCoords); }
for (const edge of clippingBoundary) { const input = clipped.length > 0 ? clipped : vertices; clipped = [];
if (input.length === 0) break;
let prevVertex = input[input.length - 1];
for (let i = 0; i < input.length; i++) { const curVertex = input[i];
if (inside(curVertex, edge)) { if (!inside(prevVertex, edge)) { const intersection = computeIntersection(prevVertex, curVertex, edge); clipped.push(intersection); } clipped.push(curVertex); } else if (inside(prevVertex, edge)) { const intersection = computeIntersection(prevVertex, curVertex, edge); clipped.push(intersection); }
prevVertex = curVertex; } }
return clipped;}
// Legacy NDC-space clipping function (kept for compatibility)// Sutherland-Hodgman clipping algorithmfunction clip(vertices, clippingBoundary) { let clipped = [];
function inside(p, edge) { switch (edge) { case "left": return p[X] >= -1; case "right": return p[X] <= 1; case "bottom": return p[Y] >= -1; case "top": return p[Y] <= 1; case "near": return p[Z] >= 0; case "far": return p[Z] <= 1; } }
function computeIntersection(p1, p2, edge) { let t; switch (edge) { case "left": t = (-1 - p1[X]) / (p2[X] - p1[X]); break; case "right": t = (1 - p1[X]) / (p2[X] - p1[X]); break; case "bottom": t = (-1 - p1[Y]) / (p2[Y] - p1[Y]); break; case "top": t = (1 - p1[Y]) / (p2[Y] - p1[Y]); break; case "near": t = (0 - p1[Z]) / (p2[Z] - p1[Z]); break; case "far": t = (1 - p1[Z]) / (p2[Z] - p1[Z]); break; }
return vec4.lerp(vec4.create(), p1, p2, t); }
for (const edge of clippingBoundary) { const input = clipped.length > 0 ? clipped : vertices; clipped = [];
if (input.length === 0) break;
let prevVertex = input[input.length - 1];
for (let i = 0; i < input.length; i++) { const curVertex = input[i];
if (inside(curVertex.pos, edge)) { if (!inside(prevVertex.pos, edge)) { const intersection = computeIntersection( prevVertex.pos, curVertex.pos, edge, ); clipped.push(new Vertex(intersection, curVertex.color)); } clipped.push(curVertex); } else if (inside(prevVertex.pos, edge)) { const intersection = computeIntersection( prevVertex.pos, curVertex.pos, edge, ); clipped.push(new Vertex(intersection, curVertex.color)); }
prevVertex = curVertex; } }
return clipped;}
// for 3d line clippingfunction clipLineToFrustum(v1, v2) { const clippingBoundary = ["left", "right", "bottom", "top", "near", "far"];
function inside(p, edge) { switch (edge) { case "left": return p[X] >= -p[W]; case "right": return p[X] <= p[W]; case "bottom": return p[Y] >= -p[W]; case "top": return p[Y] <= p[W]; case "near": return p[Z] >= NEAR_CLIP_Z; case "far": return p[Z] <= p[W]; } }
function computeIntersection(p1, p2, edge) { let t; switch (edge) { case "left": t = (-p1[W] - p1[X]) / (p2[X] - p1[X] + p2[W] - p1[W]); break; case "right": t = (p1[W] - p1[X]) / (p2[X] - p1[X] - p2[W] + p1[W]); break; case "bottom": t = (-p1[W] - p1[Y]) / (p2[Y] - p1[Y] + p2[W] - p1[W]); break; case "top": t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); break; case "near": t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); break; case "far": t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); break; }
if (!Number.isFinite(t)) t = 0.5; t = max(0, min(1, t));
return t; }
let clippedVertices = [v1, v2];
for (const edge of clippingBoundary) { if (clippedVertices.length < 2) break;
const [p1, p2] = clippedVertices; const p1Inside = inside(p1.pos, edge); const p2Inside = inside(p2.pos, edge);
if (p1Inside && p2Inside) { // Both inside, keep both continue; } else if (!p1Inside && !p2Inside) { // Both outside, discard both clippedVertices = []; break; } else { // One inside, one outside const t = computeIntersection(p1.pos, p2.pos, edge); const intersectionVertex = new Vertex( vec4.lerp(vec4.create(), p1.pos, p2.pos, t), [ p1.color[0] + (p2.color[0] - p1.color[0]) * t, p1.color[1] + (p2.color[1] - p1.color[1]) * t, p1.color[2] + (p2.color[2] - p1.color[2]) * t, p1.color[3] + (p2.color[3] - p1.color[3]) * t, ], [ p1.texCoords[0] + (p2.texCoords[0] - p1.texCoords[0]) * t, p1.texCoords[1] + (p2.texCoords[1] - p1.texCoords[1]) * t, ], );
if (p1Inside) { // p1 inside, p2 outside clippedVertices = [p1, intersectionVertex]; } else { // p1 outside, p2 inside clippedVertices = [intersectionVertex, p2]; } } }
return clippedVertices;}
function perspectiveDivide(vertex) { // Safety check: avoid division by very small W values const w = vertex.pos[W]; if (abs(w) < MIN_PERSPECTIVE_W) { // Return a vertex marked as invalid (will be filtered out) const vert = new Vertex([NaN, NaN, NaN, w]); vert.color = vertex.color; vert.texCoords = vertex.texCoords; return vert; } const vert = new Vertex([ vertex.pos[X] / w, vertex.pos[Y] / w, vertex.pos[Z] / w, w, ]);
vert.color = vertex.color; vert.texCoords = vertex.texCoords;
return vert;}
function toScreenSpace(vertex) { // Flip Y. const x = vertex.pos[X]; const y = -vertex.pos[Y]; const z = vertex.pos[Z];
const sX = ((x + 1.0) / 2.0) * width; const sY = ((y + 1.0) / 2.0) * height;
const vert = new Vertex([sX, sY, z, vertex.pos[W]]); vert.color = vertex.color; vert.texCoords = vertex.texCoords; return vert;}
// Sutherland-Hodgman screen-space clipping for triangles// Clips polygon to screen bounds [0, width] x [0, height] and interpolates attributesfunction clipToScreen(vertices) { if (vertices.length < 3) return vertices; const edges = [ { axis: 'x', sign: 1, bound: 0 }, // left: x >= 0 { axis: 'x', sign: -1, bound: width }, // right: x <= width { axis: 'y', sign: 1, bound: 0 }, // top: y >= 0 { axis: 'y', sign: -1, bound: height } // bottom: y <= height ]; let polygon = vertices; for (const edge of edges) { if (polygon.length < 3) break; const clipped = []; const prev = polygon[polygon.length - 1]; for (let i = 0; i < polygon.length; i++) { const curr = polygon[i]; const prevVal = edge.axis === 'x' ? prev.pos[0] : prev.pos[1]; const currVal = edge.axis === 'x' ? curr.pos[0] : curr.pos[1]; const prevInside = edge.sign > 0 ? prevVal >= edge.bound : prevVal <= edge.bound; const currInside = edge.sign > 0 ? currVal >= edge.bound : currVal <= edge.bound; if (currInside) { if (!prevInside) { // Entering: add intersection const t = (edge.bound - prevVal) / (currVal - prevVal); if (Number.isFinite(t) && t >= 0 && t <= 1) { clipped.push(lerpVertex(prev, curr, t)); } } clipped.push(curr); } else if (prevInside) { // Leaving: add intersection const t = (edge.bound - prevVal) / (currVal - prevVal); if (Number.isFinite(t) && t >= 0 && t <= 1) { clipped.push(lerpVertex(prev, curr, t)); } } } polygon = clipped; } return polygon;}
// Interpolation between two vertices in screen space// After perspective divide, 1/w and attribute/w vary linearly — not w or attributes directlyfunction lerpVertex(v1, v2, t) { const w1 = v1.pos[3]; const w2 = v2.pos[3];
// Fall back to simple linear interpolation if W values are degenerate const safeW = w1 > MIN_PERSPECTIVE_W && w2 > MIN_PERSPECTIVE_W && Number.isFinite(w1) && Number.isFinite(w2);
let newW; if (safeW) { // Perspective-correct: interpolate 1/w linearly in screen space const invW1 = 1 / w1; const invW2 = 1 / w2; const newInvW = invW1 + (invW2 - invW1) * t; newW = newInvW > 0.0001 ? 1 / newInvW : w1 + (w2 - w1) * t; } else { newW = w1 + (w2 - w1) * t; }
const vert = new Vertex([ v1.pos[0] + (v2.pos[0] - v1.pos[0]) * t, v1.pos[1] + (v2.pos[1] - v1.pos[1]) * t, v1.pos[2] + (v2.pos[2] - v1.pos[2]) * t, newW ]);
vert.color = [ v1.color[0] + (v2.color[0] - v1.color[0]) * t, v1.color[1] + (v2.color[1] - v1.color[1]) * t, v1.color[2] + (v2.color[2] - v1.color[2]) * t, v1.color[3] + (v2.color[3] - v1.color[3]) * t ];
// Perspective-correct texCoord interpolation if (safeW) { const invW1 = 1 / w1, invW2 = 1 / w2; const newInvW = invW1 + (invW2 - invW1) * t; const tc1OverW = [v1.texCoords[0] * invW1, v1.texCoords[1] * invW1]; const tc2OverW = [v2.texCoords[0] * invW2, v2.texCoords[1] * invW2]; const safeDiv = newInvW > 0.0001 ? newInvW : 1; vert.texCoords = [ (tc1OverW[0] + (tc2OverW[0] - tc1OverW[0]) * t) / safeDiv, (tc1OverW[1] + (tc2OverW[1] - tc1OverW[1]) * t) / safeDiv ]; } else { vert.texCoords = [ v1.texCoords[0] + (v2.texCoords[0] - v1.texCoords[0]) * t, v1.texCoords[1] + (v2.texCoords[1] - v1.texCoords[1]) * t ]; }
return vert;}
function zeroLineClip(vertices) { const clipped = [];
let prevVertex = vertices[vertices.length - 1]; // Start with the last vertex let prevComponent = prevVertex.pos[Z]; let prevInside = prevComponent >= 0;
for (const curVertex of vertices) { const curComponent = curVertex.pos[Z]; const curInside = curComponent >= 0;
if (curInside !== prevInside) { // Edge crosses the z=0 plane, find intersection const t = prevComponent / (prevComponent - curComponent); const intersectionPos = [ prevVertex.pos[X] + t * (curVertex.pos[X] - prevVertex.pos[X]), prevVertex.pos[Y] + t * (curVertex.pos[Y] - prevVertex.pos[Y]), 0, // z = 0 prevVertex.pos[W] + t * (curVertex.pos[W] - prevVertex.pos[W]), ];
// Interpolate color as well const intersectionColor = [ prevVertex.color[0] + t * (curVertex.color[0] - prevVertex.color[0]), prevVertex.color[1] + t * (curVertex.color[1] - prevVertex.color[1]), prevVertex.color[2] + t * (curVertex.color[2] - prevVertex.color[2]), prevVertex.color[3] + t * (curVertex.color[3] - prevVertex.color[3]), ];
clipped.push(new Vertex(intersectionPos, intersectionColor)); }
if (curInside) { clipped.push(curVertex); }
prevVertex = curVertex; prevComponent = curComponent; prevInside = curInside; }}
// Function to set KidLisp evaluation context for dynamic fade directionsfunction setKidLispContext(kidlispInstance, api, env) { currentKidLispContext = { evaluate: kidlispInstance.evaluate.bind(kidlispInstance), api, env, frameCount: kidlispInstance.frameCount };}
// Function to clear KidLisp evaluation contextfunction clearKidLispContext() { currentKidLispContext = null;}
export { clear, point, plot, flood, colorsMatch, pan, unpan, savepan, loadpan, mask, unmask, skip, copy, resize, contrast, brightness, invert, paste, stamp, steal, putback, line, pline, pixelPerfectPolyline, lineAngle, circle, pie, oval, poly, box, tri, shape, grid, draw, noise16, noise16DIGITPAIN, noise16Aesthetic, noise16Sotce, noiseTinted, printLine, blendMode, scroll, flip, resetScrollState, spin, zoom, suck, blur, sharpen, cleanupBlurBuffers, sort, shear, setBlockProcessing, Camera, Form, Dolly, sign, cacheSignGlyph, clearSignGlyphCache, clipInClipSpace, setShowClippedWireframes, clearWireframeBuffer, drawBufferedWireframes, getRenderStats, renderStats, setKidLispContext, clearKidLispContext, setEraseTarget, setGpuSpin, setGpuFlood, setGpuContrast, setGpuLayerComposite, setGpuConfig, compositeLayers, batchedEffects, initGpuEffects, getGpuStatus, onGpuFailover, gpuLogTick,};