app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040
//! ComBackend — the real SOLIDWORKS, over late-bound COM. Windows only.
//!
//! This is the direct port of the PowerShell sidecar it replaces, semantics
//! preserved call for call, because those semantics were learned the hard way:
//!
//! * ATTACH FIRST (`GetActiveObject`), launch only when asked. Silently
//! starting a 90-second application because someone ran a read verb is a
//! hostile thing to do to a user's machine.
//! * `OpenDoc6` with `swOpenDocOptions_Silent` (1) and byref error/warning
//! out-params — an unattended bridge must never park SOLIDWORKS on a dialog
//! nobody will answer. Fall back to legacy `OpenDoc` if the late-bound byref
//! call comes back empty.
//! * `Save3(1, ...)` / `SaveAs3(path, 0, options)` — SaveAs3 keys the format
//! off the destination extension. The OPTIONS BIT IS LOAD-BEARING and is not
//! a detail: `swSaveAsOptions_Silent` (1) ALONE RE-POINTS the open document
//! at the new file, while adding `swSaveAsOptions_Copy` (2) writes the file
//! and leaves the document attached to where it was. The export verbs want
//! Copy — the user keeps working on their own file. The safe-close sweep
//! wants Silent ALONE, because the close that follows it closes the document
//! by the path the document has AT THAT MOMENT, and that path must be the
//! recovery file. Passing the wrong one there would close a document whose
//! content is on disk under a name nothing points at.
//! * Suppressed components (`GetSuppression() == 0`) are excluded from BOMs.
//! * `InterferenceDetectionManager` is always closed with `Done()`, on every
//! path — leaving it open leaves SOLIDWORKS in a modal-ish state.
//!
//! The COM layer cannot be exercised here (the trial serial is pending), so it
//! is written to degrade honestly: every failure carries what SOLIDWORKS
//! actually said, and a panic is caught by the host loop rather than crossing
//! the protocol boundary.
use super::host::{Backend, HostError, COMMANDS};
use super::expr::{parse_chain, Literal};
use super::variant::{Disp, Var};
use serde_json::{json, Map, Value};
use windows::core::{Interface, BSTR, GUID};
use windows::Win32::System::Com::{
CLSIDFromProgID, CoCreateInstance, CoInitializeEx, IDispatch, CLSCTX_LOCAL_SERVER,
COINIT_APARTMENTTHREADED,
};
use windows::Win32::System::Ole::GetActiveObject;
use windows::Win32::System::Variant::VARIANT;
const PROG_ID: &str = "SldWorks.Application";
/// swDocumentTypes_e
const DOC_PART: i32 = 1;
const DOC_ASSEMBLY: i32 = 2;
const DOC_DRAWING: i32 = 3;
fn doc_type_id(path: &str) -> Option<i32> {
match super::mock::doc_type_of(path) {
Some("part") => Some(DOC_PART),
Some("assembly") => Some(DOC_ASSEMBLY),
Some("drawing") => Some(DOC_DRAWING),
_ => None,
}
}
fn doc_type_name(id: i32) -> String {
match id {
DOC_PART => "part".into(),
DOC_ASSEMBLY => "assembly".into(),
DOC_DRAWING => "drawing".into(),
other => format!("unknown({other})"),
}
}
/// The `_hint` `swx_import` carries, keyed on the NET document count. An import
/// leaves ONE open, unsaved, bridge-created document — exactly like
/// `swx_new_document` — and it stays open (and costs memory) until it is closed
/// or saved. The 1342-temp leak was callers importing and never closing.
/// Appended when the document handle had to be recovered from `ActiveDoc`.
const RECOVERED_HANDLE_CAVEAT: &str =
" NOTE: the document handle could not be taken from the import call and was recovered from ActiveDoc. The net count proves a document arrived; if a SECOND import was running concurrently (another caller, or the user importing by hand), the title and path reported here may belong to that one. Confirm with swx_docs before acting on the title.";
fn import_open_hint(net: i32) -> String {
match net {
1 => "Imported. This left ONE new open, unsaved document (like swx_new_document) — it \
stays open and holds memory until you save it (swx_save_as) or close it \
(swx_close_doc). Importing repeatedly without closing is what leaks documents; \
swx_close_import_temps drains any that were left behind."
.to_string(),
0 => "The open-document count did NOT rise, so nothing was actually imported even though \
a call returned — do not treat this as a new document. Check swx_docs."
.to_string(),
n => format!(
"The open-document count rose by {n}, not 1 — more than the single document an import \
should add. Check swx_docs; you may have a duplicate open. swx_close_import_temps \
drains leaked import temporaries."
),
}
}
/// WHY A DOCUMENT WALK STOPPED. Reaching the end of the session and being cut
/// short are different facts, and only one of them means "that is all there was".
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum WalkStop {
/// `GetNext` returned nothing: the session really ended here.
ReachedEnd,
/// We had seen as many documents as we asked for.
HitMax,
/// The time box stopped us.
TimedOut,
/// `GetNext` FAILED — the iteration was invalidated (a document closed under
/// us). Not the end of the session, and never to be reported as one.
IterationFailed,
}
impl WalkStop {
pub fn complete(self) -> bool {
self == WalkStop::ReachedEnd
}
pub fn name(self) -> &'static str {
match self {
WalkStop::ReachedEnd => "reachedEnd",
WalkStop::HitMax => "hitMax",
WalkStop::TimedOut => "timedOut",
WalkStop::IterationFailed => "iterationFailed",
}
}
}
pub struct ComBackend {
sw: Option<Disp>,
was_launched: bool,
/// The com host's OWN record of the documents it created — independent of
/// the server's registry, and the reason this is not just a duplicated
/// check. Rounds 3 and 5 both turned on the server-authorizes / host-acts
/// split: a server-side decision is a claim, and the host acted on it. The
/// host creates every document itself (`new_document`, `import`,
/// `run_script`'s `new_part`), so it can answer "is this mine?" without
/// being told — and it refuses a write the server "authorized" if its own
/// answer differs.
created: Vec<(String, Option<String>)>,
/// The SOLIDWORKS session `created` belongs to. A record from another
/// session is worthless: SOLIDWORKS restarts its Part numbering, so a
/// remembered `Part1` would match the user's next one.
created_session: Option<i64>,
/// The component-insertion call that WORKED on this install, remembered
/// after the first success.
///
/// `IAssemblyDoc::AddComponent5` is documented as THE way to insert a
/// component, and on this bridge's first live target (SOLIDWORKS Connected
/// R2026x) it hands back `Nothing` — which an earlier probe read as "the
/// edition cannot do it". It can: the return value is the lie, not the
/// insertion. So `add_component` walks a ladder of spellings and decides
/// success by COUNTING COMPONENTS rather than by trusting a return, and the
/// rung that worked is cached here — a 150-component assembly must not pay
/// the discovery cost 150 times.
insert_method: Option<String>,
}
impl Default for ComBackend {
fn default() -> Self {
Self::new()
}
}
impl ComBackend {
/// Initializes COM for THIS thread as an STA. The com-host child runs its
/// whole loop on one thread precisely so this holds for every call.
pub fn new() -> Self {
unsafe {
// S_FALSE (already initialized) is fine; only a hard failure matters,
// and it will resurface on the first call with a real message.
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
}
super::host::log("COM initialized (STA)");
// A com-host child is killed and respawned by design — that is the whole
// point of the child-process architecture. Its record of what it created
// has to survive that, or the FIRST thing a respawn does is start
// refusing writes to the bridge's own documents, silently, exactly after
// the recovery path the architecture exists for.
let (created_session, created) = crate::comhost::created_store::load_session();
Self { sw: None, was_launched: false, created, created_session, insert_method: None }
}
fn clsid() -> Result<GUID, HostError> {
// SWX_PROGID (0.10.0): a machine with SEVERAL classic releases picks
// one by its versioned ProgID — "SldWorks.Application.29" is
// SOLIDWORKS 2021. Unset: the version-independent ProgID.
let prog_id = std::env::var("SWX_PROGID").ok().filter(|p| p.trim().starts_with("SldWorks.Application")).unwrap_or_else(|| PROG_ID.to_string());
let wide: Vec<u16> = prog_id.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
CLSIDFromProgID(windows::core::PCWSTR(wide.as_ptr())).map_err(|e| {
HostError::detailed(
"SOLIDWORKS is not registered on this machine (no SldWorks.Application \
ProgID) — it is either not installed or the install is broken",
format!("{e}"),
)
})
}
}
/// A live SldWorks.Application. Attaches to a running instance first;
/// launches one only when `launch` is set.
fn app(&mut self, launch: bool, visible: bool) -> Result<Disp, HostError> {
if let Some(sw) = &self.sw {
// Probe the cached object: a SOLIDWORKS that has since exited leaves
// a stale proxy that fails on first touch.
if sw.call("Visible", &[]).is_ok() {
return Ok(sw.clone());
}
self.sw = None;
}
let clsid = Self::clsid()?;
unsafe {
let mut unk = None;
if GetActiveObject(&clsid, None, &mut unk).is_ok() {
if let Some(unk) = unk {
if let Ok(disp) = unk.cast::<IDispatch>() {
super::host::log("attached to running SOLIDWORKS");
let d = Disp(disp);
self.sw = Some(d.clone());
return Ok(d);
}
}
}
}
if !launch {
return Err(HostError::msg(
"SOLIDWORKS is not running (start it, then retry — swx_start reports whether \
this edition can be launched programmatically at all)",
));
}
super::host::log("launching SOLIDWORKS via COM (cold start can take 30-90s)...");
let disp: IDispatch = unsafe {
CoCreateInstance(&clsid, None, CLSCTX_LOCAL_SERVER).map_err(|e| {
HostError::detailed(
"failed to launch SOLIDWORKS via COM. On SOLIDWORKS Connected / \
3DEXPERIENCE (including Design Professional for Makers) this ALWAYS \
fails: the process it spawns refuses with \"SOLIDWORKS Design must be \
launched from the 3DEXPERIENCE Platform\" and never becomes a drivable \
COM server. The human has to start it from the 3DEXPERIENCE Platform or \
the desktop shortcut it created.",
format!("{e}"),
)
})?
};
let sw = Disp(disp);
sw.try_put("Visible", &Var::boolean(visible));
// Suppress blocking modal dialogs where the API allows it: an unattended
// bridge must never park SOLIDWORKS on a question nobody will answer.
sw.try_put("UserControlBackground", &Var::boolean(true));
self.was_launched = true;
self.sw = Some(sw.clone());
Ok(sw)
}
fn doc_info(doc: &Disp) -> Value {
let type_id = doc.int("GetType", &[]).unwrap_or(0);
let config = doc
.obj("ConfigurationManager", &[])
.ok()
.flatten()
.and_then(|cm| cm.obj("ActiveConfiguration", &[]).ok().flatten())
.and_then(|c| c.string("Name", &[]));
json!({
"title": doc.string("GetTitle", &[]),
"path": doc.string("GetPathName", &[]),
"type": doc_type_name(type_id),
"config": config,
"dirty": doc.flag("GetSaveFlag", &[]),
})
}
/// The target document: explicit `path`, else the active doc.
fn resolve(&mut self, args: &Value) -> Result<Disp, HostError> {
let sw = self.app(false, true)?;
// Resolution order: `path`, then `docName`/`name` (a TITLE), then ActiveDoc.
//
// PATH FIRST, and this ordering is load-bearing. A title is not an
// identity: `C:\users\thing.sldprt` and `C:\bridge\thing.sldprt` are
// both titled `thing.sldprt`, and having two such documents open is
// ordinary — so a save_as authorized against the bridge's copy once
// renamed the user's.
//
// BUT A TITLE IS THE ONLY IDENTITY AN UNSAVED DOCUMENT HAS, and this is
// where the live run broke. `GetOpenDocumentByName` wants a FULL PATH;
// handed the title of a document that has never been saved it returns
// nothing, so `swx_new_document` produced a `Part2` that `swx_docs`
// listed and every other verb reported as "document not open" —
// including with no target at all, because the server now always sends
// the resolved identity. The whole create → model → export flow was dead
// for new documents while 89 mock tests passed, since the mock resolved
// titles from its own map.
//
// So a title is resolved by WALKING THE OPEN DOCUMENTS and comparing
// `GetTitle()`. That works for saved and unsaved alike, and it lets the
// ambiguity rule be enforced here too rather than trusting whichever
// document SOLIDWORKS happens to hand back first.
let path = args
.get("path")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
if let Some(path) = path {
return sw.obj("GetOpenDocumentByName", &[&Var::bstr(path)])?.ok_or_else(|| {
HostError::coded(
"document_not_open",
format!("document not open: {path} (swx_docs lists the open titles and paths)"),
)
});
}
let title = args
.get("docName")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.or_else(|| args.get("name").and_then(|v| v.as_str()).filter(|s| !s.is_empty()));
if let Some(title) = title {
// A failed enumeration must NEVER be read as "that document is not
// open" — `documents()` raises docs_enumeration_failed instead.
let mut hits: Vec<Disp> = Vec::new();
for doc in Self::documents(&sw)? {
// Extension-insensitive (0.10.10): GetTitle follows HideFileExt.
if doc.string("GetTitle", &[]).map(|t| crate::verbs::same_title(&t, title)).unwrap_or(false) {
hits.push(doc);
}
}
return match hits.len() {
1 => Ok(hits.remove(0)),
0 => Err(HostError::coded(
"document_not_open",
format!("document not open: {title} (swx_docs lists the open titles and paths)"),
)),
n => Err(HostError::coded(
"ambiguous_target",
format!(
"{n} open documents are titled '{title}' — name the one you mean by path"
),
)),
};
}
sw.obj("ActiveDoc", &[])?.ok_or_else(|| {
HostError::coded(
"no_target_document",
"no active document (pass path or name, or swx_open one)",
)
})
}
/// Custom properties for one configuration scope ("" = file level).
fn custom_props(doc: &Disp, configuration: &str) -> Value {
let mut out = Map::new();
let mgr = match doc
.obj("Extension", &[])
.ok()
.flatten()
.and_then(|ext| ext.obj("CustomPropertyManager", &[&Var::bstr(configuration)]).ok().flatten())
{
Some(m) => m,
None => return Value::Object(out),
};
let names = match mgr.call("GetNames", &[]) {
Ok(v) => v,
Err(_) => return Value::Object(out),
};
for name_var in names.as_array() {
let name = match name_var.as_string() {
Some(n) if !n.is_empty() => n,
_ => continue,
};
let mut value = BSTR::new();
let mut resolved = BSTR::new();
let call = mgr.call(
"Get4",
&[
&Var::bstr(&name),
&Var::boolean(false),
&Var::byref_bstr(&mut value as *mut BSTR),
&Var::byref_bstr(&mut resolved as *mut BSTR),
],
);
match call {
Ok(_) => {
out.insert(
name,
json!({"value": value.to_string(), "resolved": resolved.to_string()}),
);
}
Err(e) => {
out.insert(name, json!({"value": null, "resolved": null, "error": e.message}));
}
}
}
Value::Object(out)
}
fn cmd_status(&mut self) -> Result<Value, HostError> {
let mut running = false;
let mut version = Value::Null;
// WHICH sldworks.exe COM is attached to. `host_app_health` needs it to
// report about the right process when two are running.
let mut process_id = Value::Null;
// `None` = we could not count. Reported as JSON null, never as 0: the
// difference between "no documents are open" and "I could not ask"
// is the difference the whole 0.3.3 enumeration fix was about.
let mut open_docs: Option<usize> = None;
if let Ok(clsid) = Self::clsid() {
unsafe {
let mut unk = None;
if GetActiveObject(&clsid, None, &mut unk).is_ok() {
if let Some(disp) = unk.and_then(|u| u.cast::<IDispatch>().ok()) {
let sw = Disp(disp);
running = true;
version = sw
.string("RevisionNumber", &[])
.map(Value::from)
.unwrap_or(Value::Null);
// GetDocumentCount is the AUTHORITY here. On 2026-09-03
// it kept saying 51 while GetDocuments returned an empty
// VARIANT, so counting the array meant reporting zero
// open documents with 51 on screen. An unreadable count
// is null — never a comfortable 0.
process_id = sw.int("GetProcessID", &[]).map(Value::from).unwrap_or(Value::Null);
open_docs = sw
.int("GetDocumentCount", &[])
.map(|c| c as usize);
self.sw = Some(sw);
}
}
}
}
Ok(json!({
"running": running,
"version": version,
"openDocs": open_docs,
"processId": process_id,
"attached": self.sw.is_some(),
}))
}
/// Did this call fail because the COM SERVER WENT AWAY? For a quit rung
/// that is the SUCCESS case, not an error: any rung that works tears the
/// server down underneath the very call that asked for it.
///
/// ONLY the specific HRESULTs that mean "the object we were calling has gone
/// away". An earlier version matched any message containing "server" or
/// "rpc", which would report a quit for a completely unrelated failure with
/// SOLIDWORKS still on screen.
fn is_rpc_disconnect(e: &HostError) -> bool {
let d = e.detail.as_deref().unwrap_or("");
[
"0x800706BA", // RPC_S_SERVER_UNAVAILABLE
"0x80010108", // RPC_E_DISCONNECTED
"0x800706BE", // RPC_S_CALL_FAILED
"0x800706BF", // RPC_S_CALL_FAILED_DNE
"0x80010012", // CO_E_OBJNOTCONNECTED
"0x80010105", // RPC_E_SERVERFAULT
]
.iter()
.any(|code| d.contains(code))
}
/// `GetDocumentCount` — ONE call, always answers. `None` only when the
/// call itself failed.
fn document_count(sw: &Disp) -> Option<i32> {
sw.int("GetDocumentCount", &[])
}
/// `swx_docs`, and it MUST NOT WEDGE on a huge count.
///
/// The authoritative count is read first. Under the cap the full list is
/// enumerated as before; over it, only the first `DOC_SAMPLE` handles are
/// turned into `doc_info` and the rest are reported by number — because a
/// session with 1342 leaked import temps cannot be marshalled in the
/// budget, and blocking the whole 30s is what got the com host killed.
fn cmd_docs(&mut self) -> Result<Value, HostError> {
let sw = self.app(false, true)?;
let active =
sw.obj("ActiveDoc", &[])?.map(|d| Self::doc_info(&d)).unwrap_or(Value::Null);
let count = Self::document_count(&sw);
// AN UNREADABLE COUNT IS UNKNOWN, AND UNKNOWN MUST NOT ENUMERATE. The
// first version fell through to the full marshal when the count came back
// `None` — which is exactly the 30s wedge this cap exists to prevent, and
// most likely precisely when the machine is under the memory pressure
// that made the count unreadable in the first place.
if count.is_none() {
return Ok(json!({
"docs": [],
"count": Value::Null,
"documentCount": Value::Null,
"truncated": true,
"sample": [],
"active": active,
"_hint": "SOLIDWORKS would not say how many documents are open, so this bridge did NOT try to enumerate them — an unbounded marshal is how the com host gets killed, and an unreadable count usually means the session is under pressure. `count:null` means UNKNOWN, not zero. Retry; if it keeps failing, swx_close_import_temps drains leaked import temporaries and swx_status reports the process's memory.",
}));
}
if let Some(c) = count {
if c as usize > super::host::doc_enum_cap() {
// NEVER `GetDocuments` HERE. Walk exactly DOC_SAMPLE documents,
// one at a time, time-boxed — the cost of this branch must be a
// function of the SAMPLE, never of how flooded the session is.
let (walked, stop) = Self::walk_documents(
&sw,
super::host::DOC_SAMPLE,
std::time::Duration::from_secs(super::host::DOC_SAMPLE_BUDGET_SECS),
)?;
let timed_out = stop == WalkStop::TimedOut;
let sample: Vec<Value> = walked.iter().map(Self::doc_info).collect();
// COUNTED OVER THE SAMPLE ONLY, which is what the field has always
// been called. Counting it over all 2144 was 2144 `GetPathName`
// round trips for a number nobody could act on.
// Roots resolved ONCE, not per document: on Windows resolving
// them reads the registry, and this loop is the one place that
// would have paid for it ten times over.
let roots = super::host::temp_roots();
let temps = walked
.iter()
.filter(|d| {
d.string("GetPathName", &[])
.map(|p| super::host::is_import_temp_under_any(&p, &roots))
.unwrap_or(false)
})
.count();
return Ok(json!({
"docs": [],
"count": c,
"documentCount": c,
"truncated": true,
"sample": sample,
"active": active,
"importTempsInSample": temps,
// ...OUT OF HOW MANY. A bare count against an unstated
// denominator reads as a count of the session.
"importTempsInSampleOf": sample.len(),
"sampleTruncatedByTime": timed_out,
"sampleWalkStopped": stop.name(),
"_hint": format!(
"{c} documents are open — too many to enumerate in the budget, so this is the count plus the first {} as a sample, NOT the whole list (and NOT a timeout). A count this large is almost always leaked STEP/IGES import temporaries in the bridge\'s own temp dir (…\\swx<pid>\\IC~~\\…); run \
swx_close_import_temps to drain them. It never touches a saved user document.",
sample.len()
),
}));
}
}
let docs: Vec<Value> = Self::documents(&sw)?.iter().map(Self::doc_info).collect();
Ok(json!({
"docs": docs,
"count": count.map(Value::from).unwrap_or_else(|| Value::from(docs.len())),
"truncated": false,
"active": active,
}))
}
/// Drain leaked STEP/IGES-import temporaries — and NOTHING else.
///
/// A document qualifies ONLY by [`super::host::is_import_temp`] on its own
/// `GetPathName`: a path under the bridge's temp dir with an `IC~~`
/// conversion segment. No user document can match that, so this is safe in
/// the DEFAULT posture — the path is the ownership proof, and a stronger one
/// than the registry (it survives the pid change that made the 1342 leaked
/// temps un-ownable). `CloseDoc` discards without a save prompt, which is
/// correct: these were never saved to a real location.
///
/// BOUNDED per call: it closes at most `max` documents or runs for at most
/// ~40s, then reports `done:false` with `remaining` so the server can call
/// it again. Closing 1342 documents in one unbounded call is exactly the
/// kind of thing that gets the com host killed mid-work.
fn cmd_close_import_temps(&mut self, args: &Value) -> Result<Value, HostError> {
let sw = self.app(false, true)?;
let before = Self::document_count(&sw);
let max = args.get("max").and_then(|v| v.as_u64()).unwrap_or(200).max(1) as usize;
// THE ANCHOR IS THE WHOLE LOCATION PROOF, so without a resolved `%TEMP%`
// nothing can be identified as a temp. Say so — `closed:0, done:true`
// would read as "there was nothing to drain" on a session holding
// hundreds of them.
let temp_roots = super::host::temp_roots();
if temp_roots.is_empty() {
return Ok(json!({
"closed": 0,
"closedDocs": [],
"documentCountBefore": before,
"documentCountAfter": before,
"done": false,
"batchLimit": max,
"tempRoot": Value::Null,
"tempRoots": [],
}));
}
// WALK, DO NOT MARSHAL. `GetDocuments` returns every handle in one call
// and did not answer within 30s on Drew's 2144-document session — the
// exact session this verb exists to drain, so the drain would have wedged
// the com host on the one machine state it was written for.
//
// Two phases, deliberately: COLLECT up to `max` victims by walking one
// document at a time (reading each path as we pass it), then CLOSE them.
// Closing while walking would invalidate the very iteration we are in the
// middle of.
let (walked, walk_stop) = Self::walk_documents(
&sw,
// Enough to find `max` temps on a session where they are not all
// temps, without becoming an unbounded scan.
max.saturating_mul(4).max(max + 50),
std::time::Duration::from_secs(super::host::DRAIN_WALK_BUDGET_SECS),
)?;
let walk_timed_out = walk_stop == WalkStop::TimedOut;
let scanned = walked.len() as u64;
let mut victims: Vec<(String, String)> = Vec::new();
// EVERY document the walk passed, by (title, path). The title is read for
// all of them, not just the victims, because it is what the collision
// check below needs — and one extra call per scanned document inside a
// bounded window is the price of not closing the wrong file.
let mut seen: Vec<(String, String)> = Vec::new();
let mut hit_limit = walk_timed_out;
let mut elsewhere = 0u64;
let mut elsewhere_sample: Option<String> = None;
for doc in &walked {
let Some(path) = doc.string("GetPathName", &[]) else { continue };
let title = doc.string("GetTitle", &[]).unwrap_or_default();
seen.push((title.clone(), path.clone()));
// NOTE: the scan does NOT stop at `max` — only the victim list does.
// Stopping the scan would blind the collision check to a document
// later in the window that shares a victim's title.
if !super::host::is_import_temp_under_any(&path, &temp_roots) {
// A NEAR MISS is worth counting: the launcher's own
// `swx<digits>\IC~~\<file>` tail, under a directory that is not
// one of the temp roots this process could resolve. Almost
// certainly a leaked temp whose `%TEMP%` we do not know — and the
// one thing the report must not do is call that "nothing to
// drain".
if super::host::has_import_temp_tail(&path) {
elsewhere += 1;
if elsewhere_sample.is_none() {
elsewhere_sample = Some(path.clone());
}
}
continue;
}
if victims.len() >= max {
hit_limit = true;
continue;
}
victims.push((title, path));
}
// ── A TITLE IS NOT AN IDENTITY, and this is the one place that rule was
// missing. `ISldWorks::CloseDoc` resolves a document BY NAME and does
// not prompt. The victim is chosen by PATH — the whole location proof
// — and was then closed by TITLE, so on Drew's live session (leaked
// temps titled `First.step-1.SLDPRT` beside his SAVED
// `…\validation\First.step-1.SLDPRT`) SOLIDWORKS would have chosen
// which one died, and this response would have recorded the temp's
// path as closed either way.
//
// Two guards, because one of them is about a call whose by-path
// behaviour this bridge has not yet proven on a live machine:
// 1. CLOSE BY THE PATH THE VICTIM WAS CHOSEN BY.
// 2. REFUSE any victim whose title another open document shares —
// and say which paths, so the user can act on it.
// WHAT THIS GUARD ACTUALLY PROTECTS, measured live 2026-09-17.
//
// `tools/drain-gate.py --fixture new` copied a bridge-created
// document's own file to a second directory under the SAME basename
// and called `swx_open` on the copy. SOLIDWORKS REFUSED it:
// `errors = 65536` (`swFileWithSameTitleAlreadyOpen`).
//
// So on this edition two SAVED documents CANNOT share a title, and
// this guard can only ever fire for PATHLESS ones — which is exactly
// the state identity-at-birth removes by giving every bridge-created
// document a file the moment it exists. That is not a reason to delete
// the guard: a document the bridge did not create can still be
// pathless, and an older bridge's leftovers still are. It is a reason
// to know which case it is for, so nobody "simplifies" it on the
// assumption that saved collisions are the common case.
let mut collisions: Vec<Value> = Vec::new();
let victims: Vec<(String, String)> = victims
.into_iter()
.filter(|(title, path)| {
let others: Vec<String> = seen
.iter()
.filter(|(t, p)| crate::verbs::same_title(t, title) && !crate::verbs::same_path(p, path))
.map(|(_, p)| p.clone())
.collect();
if others.is_empty() {
return true;
}
let mut paths = vec![path.clone()];
paths.extend(others);
collisions.push(json!({"title": title, "paths": paths}));
false
})
.collect();
// ...THEN CLOSE, by path, verifying each one.
let mut closed: Vec<Value> = Vec::new();
let mut wrong_document_closed = false;
let mut close_ignored_path = false;
let mut close_outcome_unknown = false;
let mut close_failed: Vec<Value> = Vec::new();
for (title, path) in victims {
let asked = sw.call("CloseDoc", &[&Var::bstr(&path)]).is_ok();
// VERIFY, by the same identity we asked with. `GetOpenDocumentByName`
// is one call.
let still_open = Self::open_document_by_name(&sw, &path).is_some();
if asked && still_open {
// ── TWO VERY DIFFERENT FACTS, and only a COUNT separates them.
//
// The documented signature of `CloseDoc` is `CloseDoc(name)` and
// the help text says only "name of the document to close" — that
// a FULL PATH is accepted is this bridge's premise, and it has
// not been proven on a live machine. So:
//
// * the count did NOT move -> the call did nothing at all.
// That is a CAPABILITY problem (the path form is not
// understood), not a wrong close, and it is not a reason to
// retry with a title: that is exactly the ambiguity this
// round removed. Refuse, and say which premise failed.
// * the count DROPPED while our victim is still open -> some
// OTHER document was closed. That must be unreachable, and
// this is the check that proves it rather than hoping.
//
// One extra count, only in the anomaly case.
let expected = before.map(|b| b - closed.len() as i32);
let now = Self::document_count(&sw);
let nothing_happened = match (expected, now) {
(Some(e), Some(n)) => n >= e,
// ── THE THIRD CASE, and it is not the benign one. An
// unreadable count cannot distinguish "nothing happened"
// from "something else was closed" — and the count can
// become unreadable BECAUSE something large just changed:
// closing a big assembly is exactly the kind of event that
// makes GetDocumentCount stop answering. Taking the
// reassuring arm here would report a capability problem and
// accuse nobody while a user's document may have gone.
// Everywhere else in this bridge an unknown refuses AND
// SAYS UNKNOWN; so does this.
_ => {
close_outcome_unknown = true;
close_failed.push(json!({
"title": title,
"path": path,
"outcome": "undetermined",
"documentCountBeforeClose": expected,
"why": "CloseDoc returned success, the document is still open, and the \
open-document count could not be read afterwards — so it \
cannot be told whether NOTHING closed or ANOTHER document did. \
A human must check whether this path, or some other document, \
closed. The drain stopped here.",
}));
break;
}
};
if nothing_happened {
close_ignored_path = true;
close_failed.push(json!({
"title": title,
"path": path,
"why": "CloseDoc(path) returned success, the document is still open, and \
the open-document count did not move — the call did nothing. This \
bridge will NOT retry by title: a title is not an identity. The \
drain stopped here.",
}));
break;
}
wrong_document_closed = true;
close_failed.push(json!({
"title": title,
"path": path,
"why": "CloseDoc reported success, the document is still open, and the count \
DROPPED — a DIFFERENT document was closed. The drain stopped here.",
}));
break;
}
if !asked || still_open {
close_failed.push(json!({
"title": title,
"path": path,
"why": "SOLIDWORKS declined to close it; nothing else was touched",
}));
continue;
}
// It is no longer ours to remember, on either veto.
self.created.retain(|(t, p)| {
t != &title && p.as_deref() != Some(path.as_str())
});
closed.push(json!({"title": title, "path": path}));
}
let remaining_after = Self::document_count(&sw);
// DID THE WALK SEE EVERY OPEN DOCUMENT? The two numbers were already in
// the response and nothing compared them.
let scanned_all = before.map(|b| scanned >= b.max(0) as u64).unwrap_or(false);
// More may remain either because we hit the batch limit, or because the
// count still shows import-image documents we did not reach this pass.
let done = !hit_limit;
Ok(json!({
"closed": closed.len(),
"closedDocs": closed,
"documentCountBefore": before,
"documentCountAfter": remaining_after,
// NOT DONE while documents carry the launcher's tail and we closed
// nothing, nor when the walk did not reach the end of the session,
// nor when a victim was skipped: "done" would be a claim about the
// user's session that the evidence does not support.
// DONE MEANS THE SESSION IS DRAINED, and every one of these is part
// of that claim. `hit_limit` alone was seeded from the TIME box only,
// so a walk that stopped at its scan cap (hitMax) or on an
// invalidated iteration (iterationFailed) reported a finished drain
// with temporaries still open past the window.
"done": done
&& walk_stop.complete()
&& scanned_all
&& collisions.is_empty()
&& close_failed.is_empty()
&& !(elsewhere > 0 && closed.is_empty()),
"batchLimit": max,
"tempRoot": temp_roots.first(),
"tempRoots": temp_roots,
"importTempsElsewhere": elsewhere,
"importTempsElsewhereSample": elsewhere_sample,
// HOW FAR THE WALK GOT. `importTempsElsewhere` is a count over the
// documents SCANNED, not over the session — on a flooded session this
// verb deliberately looks at a bounded window per call.
"documentsScanned": scanned,
"walkTruncatedByTime": walk_timed_out,
// DID THE WALK REACH THE END OF THE SESSION? A `GetNext` that fails
// mid-walk (a document closed under us, invalidating the iteration)
// is not the end of the session and must never be reported as one.
"walkComplete": walk_stop.complete(),
// reachedEnd | hitMax | timedOut | iterationFailed. Published because
// `titleCollisions: []` and `done` cannot be interpreted without it: a
// collision check over 55 of 60 documents is not the same claim as one
// over the session.
"walkStop": walk_stop.name(),
"titleCollisions": collisions,
// THE COLLISION CHECK'S REACH. It compares each victim's title against
// the documents the walk actually PASSED, so a same-titled document
// outside that window is invisible to it. Harmless for the close (the
// path is the key) and load-bearing for the report.
"titleCollisionsCheckedOver": scanned,
"closeFailed": close_failed,
"wrongDocumentClosed": wrong_document_closed,
"closeIgnoredPath": close_ignored_path,
"closeOutcomeUnknown": close_outcome_unknown,
}))
}
/// PRESERVE EVERY OPEN DOCUMENT, THEN CLOSE IT. Drew's standing
/// authorization, 2026-09-11: *"I despise every time you ask for help
/// closing. You MUST find/build a way to safely close the docs/program."*
///
/// The authorization is to CLOSE. It is not an authorization to LOSE
/// anything, and those are different permissions — so the safety property
/// here is not ownership (irrelevant: the user said yes) but a sequence:
///
/// **nothing is closed until it is on disk.**
///
/// Every document, saved or not, bridge-created or the user's, is made CLEAN
/// AND PATHED before anything closes. A dirty document is SAVED TO RECOVERY:
/// `SaveAs3` with Silent ALONE (never Copy), so the OPEN DOCUMENT NOW POINTS
/// AT THE RECOVERY FILE — which is exactly what makes the close key right,
/// because the close is by the path the document has at that moment. The
/// user's original file on disk is never written to; it is simply no longer
/// what this in-memory document is attached to. A pathless document gets a
/// file of its own for the first time. Only then does the close happen, by
/// that path, and a save prompt cannot appear because there is nothing left
/// unsaved to prompt about.
///
/// ONE DOCUMENT PER CALL, because the server must look for a dialog between
/// every save and every close.
///
/// 0.7.1 did a whole batch inside one host call and the server censused once
/// per batch. That was wrong for a reason the design itself implies: a SAVE
/// can raise a prompt (a missing external reference, a rebuild question), and
/// a dialog raised by the first save was invisible until every other document
/// in the batch had already been closed. So `phase` splits the work into the
/// two steps the server interleaves its census with, and `max`/`batch` go
/// back to being what they always should have been — a bound on round trips
/// and wall time, not the unit of safety.
fn cmd_preserve_and_close(&mut self, args: &Value) -> Result<Value, HostError> {
match args.get("phase").and_then(|v| v.as_str()).unwrap_or("preserve") {
"preserve" => self.preserve_one(args),
"close" => self.close_one(args),
other => Err(HostError::msg(format!(
"unknown preserve_and_close phase {other:?} — expected \"preserve\" or \"close\""
))),
}
}
/// PRESERVE ONE DOCUMENT. Drew's standing authorization, 2026-09-11: *"I
/// despise every time you ask for help closing. You MUST find/build a way to
/// safely close the docs/program."*
///
/// The authorization is to CLOSE. It is not an authorization to LOSE
/// anything, and those are different permissions — so the safety property
/// here is not ownership (irrelevant: the user said yes) but a sequence:
///
/// **nothing is closed until it is on disk.**
///
/// The document at `index` in the walk is made CLEAN AND PATHED. A dirty one
/// is SAVED TO RECOVERY: `SaveAs3` with Silent ALONE (never Copy), so the
/// OPEN DOCUMENT NOW POINTS AT THE RECOVERY FILE — which is what makes the
/// close key right, because the close is by the path the document has at that
/// moment. The user's original file on disk is never written to; it is simply
/// no longer what this in-memory document is attached to. A pathless document
/// gets a file of its own for the first time.
///
/// The index is a position in the walk, not an identity, and that is safe
/// only because NOTHING CLOSES during this phase: the walk order is stable
/// while the server steps through it.
fn preserve_one(&mut self, args: &Value) -> Result<Value, HostError> {
let sw = self.app(false, true)?;
let recovery = args
.get("recoveryDir")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| HostError::msg("recoveryDir is required"))?
.to_string();
let index = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let count = Self::document_count(&sw);
// Walk live pointers, never a title. Only as far as the document asked
// for — the walk is bounded and the server pays for each step.
let (walked, _stop) = Self::walk_documents(
&sw,
index + 1,
std::time::Duration::from_secs(super::host::DRAIN_WALK_BUDGET_SECS),
)?;
let Some(doc) = walked.get(index) else {
// THE WALK IS EXHAUSTED. Not an error: it is how the server learns
// there is nothing further to preserve.
return Ok(json!({
"phase": "preserve", "index": index, "walked": 0,
"documentCount": count, "record": Value::Null, "ready": Value::Null,
}));
};
let title = doc.string("GetTitle", &[]).unwrap_or_default();
let path = doc.string("GetPathName", &[]).unwrap_or_default();
let dirty = doc.flag("GetSaveFlag", &[]).unwrap_or(true);
// GETTYPE CAN FAIL, AND AN UNREADABLE TYPE IS NOT A PART. 0.7.4 wrote
// `.unwrap_or(1)`, which turned every failed COM read into "part" — the
// LOWEST recovery floor of the three — and then named the recovery file
// `.SLDPRT`, from which the verdict read the kind back. One failed read
// therefore closed a truncated assembly against a part-sized floor. This
// is a COM call on a document being closed under memory pressure, which
// is the condition this sweep exists for.
let kind = match doc.int("GetType", &[]) {
Some(2) => Some(crate::disk::DocKind::Assembly),
Some(3) => Some(crate::disk::DocKind::Drawing),
Some(1) => Some(crate::disk::DocKind::Part),
// Unreadable, or a type this bridge does not know: UNKNOWN, carried
// as such all the way to the verdict.
_ => None,
};
// WHEN GETTYPE FAILS, ASK THE EVIDENCE ALREADY IN HAND.
//
// The document's OWN PATH is the strongest of it: the user's file, named
// by SOLIDWORKS, on disk, read from this same document a moment ago. Its
// title comes next, and is weaker than it looks — SOLIDWORKS returns the
// title as the shell shows it, so with Explorer's default "hide
// extensions for known file types" a saved assembly's title is `bracket`,
// not `bracket.SLDASM`.
//
// THIS IS NOT THE CIRCULARITY 0.7.5 REMOVED, and the difference is
// provenance: `path` and `title` come from SOLIDWORKS and describe the
// document. The recovery file's name is chosen BY THIS SWEEP from the
// kind, so reading a kind back out of THAT can only repeat whatever it
// already assumed.
let kind = kind
.or_else(|| crate::disk::DocKind::from_extension(&path))
.or_else(|| crate::disk::DocKind::from_extension(&title));
let kind_name = kind.map(|k| json!(k.type_name())).unwrap_or(Value::Null);
let pathless = path.trim().is_empty();
let mut ready = Value::Null;
let record = if !dirty && !pathless {
// Already on disk and unchanged: nothing to preserve.
ready = json!({
"path": path, "docType": kind_name, "writtenHere": false,
"originalBytes": Value::Null,
});
// CLOSED IN PLACE. The user's own file is this document's
// preservation, so its size and modification time are measured here —
// read-only — and recorded: that is what lets swx_recover_list later
// say "closed at its own path, untouched" and mean it, or say that
// something has written to it since. Measured live 2026-09-30: the
// record had nothing for such a document at all.
let original_bytes = std::fs::metadata(&path).ok().map(|m| m.len());
let original_modified_ms = crate::scratch::modified_ms(&path);
json!({
"title": title, "path": path, "recoveryPath": Value::Null,
// Nothing was written for this one: it was already its own file
// on disk and unchanged, so there is no size and no signature of
// ours to report.
"recoveryBytes": Value::Null, "recoverySignatureOk": Value::Null,
"docKind": kind_name, "originalBytes": original_bytes,
"originalModifiedMs": original_modified_ms,
"wasDirty": false, "wasPathless": false, "preserved": true,
})
} else {
// UNIQUE BY CONSTRUCTION, and never an overwrite: if the name is
// taken, take the next one.
let stem = if title.trim().is_empty() { "document".to_string() } else { title.clone() };
// THE ORIGINAL'S SIZE, read before anything is written: when the kind
// cannot be established this is the other half of the floor — a
// rescue of a document that had a 4 MB file must not be 30 KB.
let original_bytes = if pathless {
None
} else {
std::fs::metadata(&path).ok().map(|m| m.len())
};
let out = Self::unique_recovery_path(&recovery, &stem, kind);
// A NAME SOLIDWORKS MAY REFUSE TO WRITE.
//
// `SaveAs3` dispatches its output format on the extension, and
// `.sldrecover` matches no writer. Whether it fails or silently falls
// back to some default is not something this bridge knows, and
// guessing it from memory is how a format claim went wrong two passes
// ago. So it is not guessed: the save is ATTEMPTED, and if it fails
// the document is re-saved under `.SLDPRT` and the reason recorded.
//
// A guessed NAME is tolerable now in a way it was not in 0.7.4,
// because nothing downstream reads the kind back out of it: the
// verification still applies the UNKNOWN-kind rules (the strictest
// floor, and at least the original file's size). The name is a label;
// the floor is the check.
let mut out = out;
// The name is a guess whenever the kind is unknown — whether it was
// guessed up front (this install) or after a refused neutral name.
let mut named_as_part = kind.is_none() && !Self::NEUTRAL_EXTENSION_ACCEPTED;
let first = Self::save_as3(doc, &out, false);
let failed = match &first {
Err(_) => true,
Ok(code) => *code != 0,
};
let attempt = if failed && kind.is_none() && out.to_lowercase().ends_with(".sldrecover")
{
named_as_part = true;
out = Self::unique_recovery_path(
&recovery,
&stem,
Some(crate::disk::DocKind::Part),
);
Self::save_as3(doc, &out, false)
} else {
first
};
match attempt {
Err(e) => json!({
"title": title, "path": path, "recoveryPath": Value::Null,
"recoveryBytes": 0, "recoverySignatureOk": false,
"docKind": kind_name, "originalBytes": original_bytes,
"namingFallback": if named_as_part { json!("unknownKindNamedAsPart") } else { Value::Null },
"wasDirty": dirty, "wasPathless": pathless, "preserved": false,
"why": format!("could not be saved, so it was NOT closed: {}", e.message),
}),
Ok(code) => {
// ── VERIFY THE FILE STRUCTURALLY, NOT STATISTICALLY. A file
// can EXIST and hold nothing, and it can hold plenty and
// still be a fragment: a partial write on a full disk
// stops wherever it stops. This laptop's host disk has
// been at 100% during this very release series, so it is
// not hypothetical — and the cost of getting it wrong is
// the document being closed against a file with none, or
// half, of its content in it. `recovery_file_verdict`
// reads the OLE compound-document signature and the size,
// and fails closed on anything it cannot vouch for.
let now_path = doc.string("GetPathName", &[]).unwrap_or_default();
let v = super::host::recovery_file_verdict(&now_path, kind, original_bytes);
if code != 0 || now_path.trim().is_empty() || !v.ok {
json!({
"title": title, "path": path, "recoveryPath": out,
"recoveryBytes": v.bytes, "recoverySignatureOk": v.signature_ok,
"docKind": kind_name, "originalBytes": original_bytes,
"wasDirty": dirty, "wasPathless": pathless, "preserved": false,
"why": format!(
"SaveAs3 returned {code} and {} — closing this document would have \
discarded the only copy",
v.why.unwrap_or_else(|| "the recovery file could not be verified".into())
),
})
} else if !crate::verbs::same_path(&now_path, &out) {
// THE OPEN DOCUMENT MUST NOW BE THE RECOVERY FILE.
// SaveAs3 is called with Silent ALONE (no Copy), so it
// RE-POINTS the document — and that is what makes the
// close key correct. If anyone ever passes Copy here the
// document would still be dirty and pathless while this
// code closed a file, so the mismatch is checked rather
// than assumed.
json!({
"title": title, "path": path, "recoveryPath": out,
"recoveryBytes": v.bytes, "recoverySignatureOk": v.signature_ok,
"docKind": kind_name, "originalBytes": original_bytes,
"wasDirty": dirty, "wasPathless": pathless, "preserved": false,
"why": format!(
"the file was written but the open document still reports \
{now_path:?} — it did not re-point to the recovery file, so \
closing by that path would close something else, or nothing. \
(SaveAs3 must be Silent ALONE here, never Copy.)"
),
})
} else {
ready = json!({
"path": now_path, "docType": kind_name, "writtenHere": true,
"originalBytes": original_bytes,
});
json!({
"title": title, "path": path, "recoveryPath": now_path,
"recoveryBytes": v.bytes, "recoverySignatureOk": true,
"docKind": kind_name, "originalBytes": original_bytes,
// The kind is still UNKNOWN: only the NAME fell back,
// and the verification above used the unknown rules.
"namingFallback": if named_as_part { json!("unknownKindNamedAsPart") } else { Value::Null },
"wasDirty": dirty, "wasPathless": pathless, "preserved": true,
})
}
}
}
};
Ok(json!({
"phase": "preserve", "index": index, "walked": 1,
"documentCount": count, "record": record, "ready": ready,
}))
}
/// CLOSE ONE DOCUMENT, by the path it reports NOW.
///
/// Reached only after the server has re-censused for a dialog since the save
/// that made this path, and only for a document `preserve_one` vouched for.
/// A document this bridge wrote is re-verified here as well: the file must
/// still be a real document at the moment of the close, not merely at the
/// moment of the save.
fn close_one(&mut self, args: &Value) -> Result<Value, HostError> {
let sw = self.app(false, true)?;
let path = args
.get("path")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| HostError::msg("path is required"))?
.to_string();
let written_here = args.get("writtenHere").and_then(|v| v.as_bool()).unwrap_or(false);
if written_here {
// THE KIND TRAVELS WITH THE DOCUMENT. Re-deriving it from the file
// name here would re-introduce exactly the circularity this release
// removed: the name came from the kind.
let kind = args
.get("docKind")
.and_then(|v| v.as_str())
.and_then(|t| crate::disk::DocKind::from_type_name(t));
let original_bytes = args.get("originalBytes").and_then(|v| v.as_u64());
let v = super::host::recovery_file_verdict(&path, kind, original_bytes);
if !v.ok {
return Ok(json!({
"phase": "close", "path": path, "closed": false,
"documentCount": Self::document_count(&sw),
"why": v.why.unwrap_or_else(|| "the recovery file could not be verified".into()),
}));
}
}
// ALREADY GONE WITH ITS PARENT (0.9.12): closing an assembly unloads
// the components it held; one of them is then no longer open, and that
// is a close (after its own recovery write), reported as such.
if Self::open_document_by_name(&sw, &path).is_none() {
self.created.retain(|(_, p)| p.as_deref() != Some(path.as_str()));
return Ok(json!({
"phase": "close", "path": path, "closed": true, "alreadyClosed": true,
"documentCount": Self::document_count(&sw), "why": Value::Null,
}));
}
let _ = sw.call("CloseDoc", &[&Var::bstr(&path)]);
let still_open = Self::open_document_by_name(&sw, &path).is_some();
if !still_open {
self.created.retain(|(_, p)| p.as_deref() != Some(path.as_str()));
}
Ok(json!({
"phase": "close", "path": path, "closed": !still_open, "alreadyClosed": false,
"documentCount": Self::document_count(&sw),
"why": if still_open { json!("still open after CloseDoc") } else { Value::Null },
}))
}
/// `<recovery>\<stem>[-n].<ext>`, never overwriting. Several open documents
/// can share one title (that is half of what makes this bridge's life hard),
/// so the name is made unique against the FILESYSTEM.
/// DOES THIS INSTALL ACCEPT A NEUTRAL EXTENSION? **Measured, 2026-09-17, on
/// the live SOLIDWORKS Connected install: NO.**
///
/// A scripted `SaveAs3` to `.sldrecover` on a throwaway document returned
/// *"format not supported for …"* and wrote nothing. So the first attempt was
/// not merely likely to fail — it was certain to, and a certain failure is not
/// something to spend a COM round trip and a user's wait on during a close.
///
/// The unknown-kind document therefore goes straight to `.SLDPRT`, with
/// `namingFallback: "unknownKindNamedAsPart"` recorded so nothing downstream
/// reads that extension as a statement of fact, and with the UNKNOWN-kind
/// verification unchanged (the strictest floor, and at least the original
/// file's size). The name is a label; the floor is the check.
///
/// The runtime retry is deliberately KEPT rather than deleted: flip this to
/// `true` if a future version accepts the neutral name, and the honest name
/// comes back with the retry already covering the other outcome. What is not
/// kept is an attempt this bridge has measured to be futile.
const NEUTRAL_EXTENSION_ACCEPTED: bool = false;
fn unique_recovery_path(dir: &str, title: &str, kind: Option<crate::disk::DocKind>) -> String {
// AN UNKNOWN KIND IS NOT GIVEN A GUESSED EXTENSION. Naming a document
// `.SLDPRT` because its type could not be read states something this
// bridge does not know, in the one place downstream code was reading it
// back from. The title's own extension is real evidence — the user or
// SOLIDWORKS wrote it — so it is used when present; otherwise the file
// gets a neutral name that claims nothing.
let ext = match kind {
Some(k) => k.extension(),
// NOTHING ANYWHERE SAID WHAT THIS IS — no type, no path extension, no
// title extension: a pathless document whose type could not be read.
// A neutral name would be the honest label, and this install refuses
// to write one (see NEUTRAL_EXTENSION_ACCEPTED), so the file is named
// as a part and the RECORD carries the fact that the name is a guess.
None if Self::NEUTRAL_EXTENSION_ACCEPTED => "sldrecover",
None => crate::disk::DocKind::Part.extension(),
};
let stem_raw = title.rsplit_once('.').map(|(a, _)| a).unwrap_or(title);
let stem: String = stem_raw
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
let stem = stem.trim_matches('_');
let stem = if stem.is_empty() { "document" } else { stem };
let sep = if dir.ends_with('\\') || dir.ends_with('/') { "" } else { "\\" };
let first = format!("{dir}{sep}{stem}.{ext}");
if !std::path::Path::new(&first).exists() {
return first;
}
for n in 1..10_000 {
let p = format!("{dir}{sep}{stem}-{n}.{ext}");
if !std::path::Path::new(&p).exists() {
return p;
}
}
format!("{dir}{sep}{stem}-{}.{ext}", crate::launchos::epoch_ms())
}
/// ONE RUNG of the quit ladder (docs/QUIT-INVESTIGATION.md §5.3).
///
/// Every rung ENDS with every COM reference released, and rungs (b)-(f)
/// therefore START from a released state — the com host re-attaches only to
/// act, never to measure. A `WM_CLOSE` sent while we still hold an
/// `IDispatch` is the most direct way to manufacture the half-closed
/// `SLDWORKS.exe -Embedding` orphan this design exists to avoid.
///
/// The result is what the rung DID, not whether it worked: the server
/// decides that from a process census. A rung that tears the COM server down
/// underneath us comes back as an RPC disconnect, which is the SUCCESS case
/// and must not be reported as an error.
/// IS THIS STILL THE WINDOW WE MEASURED? Asked immediately before rung (c)
/// or (d) sends anything, because the handle was captured before rung (a) ran
/// and rung (a)'s whole purpose is to make that window go away.
///
/// Both facts have to answer, and an unreadable one is a NO: the owning pid
/// (`GetWindowThreadProcessId`) and that pid's creation time (the census). A
/// pid alone would pass a recycled pid.
#[cfg(windows)]
fn hwnd_still_ours(hwnd: i64, pid: u32, created: Option<u64>, detail: &mut Vec<String>) -> bool {
let Some(created) = created else {
detail.push("no creation time was supplied for the target process".into());
return false;
};
let actual = crate::quit::win::window_pid(hwnd);
let actual_created = crate::launchos::os()
.processes()
.ok()
.and_then(|c| actual.and_then(|p| c.iter().find(|r| r.pid == p).and_then(|r| r.created_at)));
let id = crate::quit::Identity { pid, created_at: created, main_hwnd: Some(hwnd) };
let ok = crate::quit::hwnd_still_owned(actual, actual_created, &id);
detail.push(format!(
"hwnd {hwnd} re-verified before sending: owner pid {actual:?} (created \
{actual_created:?}) vs expected {pid} (created {created}) -> {}",
if ok { "same window" } else { "NOT ours, nothing sent" }
));
ok
}
#[cfg(not(windows))]
fn hwnd_still_ours(_hwnd: i64, _pid: u32, _created: Option<u64>, detail: &mut Vec<String>) -> bool {
detail.push("window ownership cannot be re-verified off Windows".into());
false
}
fn cmd_quit_rung(&mut self, args: &Value) -> Result<Value, HostError> {
let rung = args.get("rung").and_then(|v| v.as_str()).unwrap_or("");
if !crate::quit::is_rung(rung) {
return Err(HostError::coded(
"unknown_rung",
format!("'{rung}' is not a rung of the quit ladder"),
));
}
let hwnd = args.get("mainHwnd").and_then(|v| v.as_i64()).unwrap_or(0);
let pid = args.get("processId").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let created = args.get("processCreatedAt").and_then(|v| v.as_u64());
let mut detail: Vec<String> = Vec::new();
let mut result = "declined";
match rung {
"a" => {
let sw = self.app(false, true)?;
// UserControl is THE flag that decides whether SOLIDWORKS
// outlives its automation client. Remember it so a decline can
// be undone.
let was = sw.flag("UserControl", &[]);
detail.push(format!("UserControl before = {was:?}"));
sw.try_put("UserControl", &Var::boolean(false));
detail.push("UserControl=false attempted".into());
let exit = sw.call("ExitApp", &[]);
match &exit {
Ok(_) => detail.push("ExitApp returned".into()),
Err(e) => {
// The disconnect HRESULTs mean the server went away —
// which IS the success case for this call.
if Self::is_rpc_disconnect(e) {
detail.push(format!("ExitApp: RPC disconnect ({}) — the COM server went away", e.message));
result = "exited";
} else {
detail.push(format!("ExitApp failed: {}", e.message));
}
}
}
// RELEASE EVERYTHING, unconditionally, before returning.
drop(sw);
self.sw = None;
detail.push("released every COM reference".into());
// Restore UserControl only if it plainly declined AND we can
// still reach the app — never leave the user's session with a
// flag we changed.
if result != "exited" {
if let (Some(prev), Ok(sw2)) = (was, self.app(false, true)) {
if prev {
sw2.try_put("UserControl", &Var::boolean(true));
detail.push("UserControl restored to true".into());
}
drop(sw2);
self.sw = None;
}
}
}
"b" => {
let sw = self.app(false, true)?;
// THE COMMAND ID IS READ, NOT ASSUMED (0.11.1). RunCommand runs
// whatever command the number names; a wrong one could raise any
// dialog (a File ▸ Open blocked every quit on 2026-10-15, origin
// under investigation). swCommands_e.swCommands_App_Exit is read
// from the installed interop; unreadable or different from 2825,
// the rung does not send it.
let exe = sw.string("GetExecutablePath", &[]).unwrap_or_default();
let verified = swconst_ids(&exe, &[("swCommands_e", "swCommands_App_Exit")])
.ok()
.and_then(|m| m.get("swCommands_App_Exit").copied());
if verified != Some(crate::quit::SW_COMMANDS_APP_EXIT) {
detail.push(format!(
"RunCommand NOT sent: swCommands_App_Exit reads {verified:?} from the installed interop, not {} — \
a command id that cannot be verified is never run",
crate::quit::SW_COMMANDS_APP_EXIT
));
drop(sw);
self.sw = None;
detail.push("released every COM reference".into());
return Ok(json!({"rung": rung, "result": "declined", "detail": detail.join("; "), "comReferencesOutstanding": 0}));
}
let called = sw.call(
"RunCommand",
&[&Var::i4(crate::quit::SW_COMMANDS_APP_EXIT), &Var::bstr("")],
);
match &called {
Ok(_) => detail.push(format!("RunCommand({}) returned", crate::quit::SW_COMMANDS_APP_EXIT)),
Err(e) => {
if Self::is_rpc_disconnect(e) {
detail.push("RunCommand: RPC disconnect — the COM server went away".into());
result = "exited";
} else {
detail.push(format!("RunCommand failed: {}", e.message));
}
}
}
drop(sw);
self.sw = None;
detail.push("released every COM reference".into());
}
"c" | "d" => {
// MESSAGE RUNGS: no COM at all, and they must not run while we
// hold a reference.
self.sw = None;
if hwnd == 0 {
detail.push("no main-frame hwnd was supplied".into());
result = "skipped";
} else if !Self::hwnd_still_ours(hwnd, pid, created, &mut detail) {
// NOT OUR WINDOW ANY MORE. Nothing is sent — a WM_CLOSE to a
// recycled handle is a close nobody asked for.
result = "skipped";
} else {
#[cfg(windows)]
{
let (sent, blocked, note) = if rung == "c" {
crate::quit::win::send_wm_close(hwnd)
} else {
crate::quit::win::post_sc_close(hwnd)
};
detail.push(note);
if blocked {
result = "uipiBlocked";
} else if !sent {
result = "declined";
}
}
#[cfg(not(windows))]
{
detail.push("window messages are Windows-only".into());
result = "skipped";
}
}
}
"e" => {
// The in-process exit. ONLY the shipped, content-hashed macro.
match crate::quit::exit_macro_path() {
None => {
detail.push(
"the exit macro is not shipped in resources/macros/ (a .swp is a \
binary VBA project and cannot be authored without SOLIDWORKS), so \
this rung is unavailable"
.into(),
);
result = "skipped";
}
Some(path) => {
let sw = self.app(false, true)?;
let mut err_code = 0i32;
let called = sw.call(
"RunMacro2",
&[
&Var::bstr(&path),
&Var::bstr("main"),
&Var::bstr("main"),
&Var::i4(0),
&Var::byref_i4(&mut err_code as *mut i32),
],
);
match &called {
Ok(_) => detail.push(format!("RunMacro2({path}) returned, err={err_code}")),
Err(e) => {
if Self::is_rpc_disconnect(e) {
detail.push("RunMacro2: RPC disconnect — the COM server went away".into());
result = "exited";
} else {
detail.push(format!("RunMacro2 failed: {}", e.message));
}
}
}
drop(sw);
self.sw = None;
detail.push("released every COM reference".into());
}
}
}
"f" => {
self.sw = None;
match created {
None => {
detail.push("no process creation time — the Restart Manager needs the pid AND its creation time".into());
result = "skipped";
}
Some(ct) => {
#[cfg(windows)]
{
let (ok, note) = crate::quit::win::restart_manager_shutdown(pid, ct);
detail.push(note);
if !ok {
result = "declined";
}
}
#[cfg(not(windows))]
{
let _ = (pid, ct);
detail.push("the Restart Manager is Windows-only".into());
result = "skipped";
}
}
}
}
_ => unreachable!("guarded by is_rung"),
}
Ok(json!({
"rung": rung,
"result": result,
"detail": detail.join("; "),
"comReferencesOutstanding": usize::from(self.sw.is_some()),
}))
}
fn cmd_open(&mut self, args: &Value) -> Result<Value, HostError> {
let path = args
.get("path")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| HostError::msg("path is required"))?;
if !std::path::Path::new(path).exists() {
return Err(HostError::msg(format!("file not found: {path}")));
}
let type_id = doc_type_id(path).ok_or_else(|| {
HostError::msg(format!(
"not a SOLIDWORKS document: {} (want .sldprt/.sldasm/.slddrw)",
std::path::Path::new(path)
.extension()
.map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
.unwrap_or_default()
))
})?;
let configuration =
args.get("configuration").and_then(|v| v.as_str()).unwrap_or("").to_string();
// LAUNCH IS THE SERVER'S DECISION, NOT THIS FUNCTION'S. `swx_open` used
// to pass launch:true unconditionally, which made it a second road to
// CoCreateInstance that walked straight past the edition check — on a
// Connected install that puts the "must be launched from the
// 3DEXPERIENCE Platform" modal on the user's screen and leaves a dead
// process behind. The server decides with `may_launch` and says so here.
let launch = args.get("launch").and_then(|v| v.as_bool()).unwrap_or(false);
let sw = self.app(launch, true)?;
let mut errors = 0i32;
let mut warnings = 0i32;
// swOpenDocOptions_Silent = 1, swOpenDocOptions_ReadOnly = 2.
//
// Silent alone takes a WRITE LOCK on the user's file — nobody else can
// open it for editing while the bridge holds it — and an older-version
// document is converted in memory, which marks it dirty and makes
// SOLIDWORKS offer to save on close. Since the default posture refuses
// to write to a document the bridge did not create anyway, holding a
// write lock on it buys nothing; `readOnly` is therefore the default
// here, and a caller that means to edit says so.
let read_only = args.get("readOnly").and_then(|v| v.as_bool()).unwrap_or(true);
let open_options = if read_only { 1 | 2 } else { 1 };
// NO fallback to the legacy OpenDoc: it has
// no silent option and would let SOLIDWORKS raise the interactive open
// dialog on an unattended machine — the exact thing this whole path
// exists to prevent. Our byref marshalling is explicit here (unlike the
// late-bound PowerShell it replaces), so the fallback bought nothing but
// that risk. If OpenDoc6 fails, report it.
let doc = sw
.call(
"OpenDoc6",
&[
&Var::bstr(path),
&Var::i4(type_id),
&Var::i4(open_options),
&Var::bstr(&configuration),
&Var::byref_i4(&mut errors as *mut i32),
&Var::byref_i4(&mut warnings as *mut i32),
],
)?
.as_dispatch()
.map(Disp)
.ok_or_else(|| {
HostError::msg(format!(
"SOLIDWORKS failed to open {path} (errors={errors} warnings={warnings})"
))
})?;
Ok(json!({
"opened": Self::doc_info(&doc),
"readOnly": read_only,
"errors": errors,
"warnings": warnings,
}))
}
/// `SaveAs3` — the one call behind both export and save-as.
///
/// TWO facts about it were learned the hard way on a live install, and both
/// contradicted the port's original reading of the API docs:
///
/// 1. It returns **swFileSaveError_e (a long), not a bool**: `0` means
/// SUCCESS. v0.2 read the return as a bool, so every successful export
/// reported failure while writing perfectly good bytes to disk.
/// 2. The options flag matters. `swSaveAsOptions_Silent` (1) alone RENAMES
/// the open document to the export path — the user's `bracket.sldprt`
/// silently becomes `bracket.step` in memory. Exports therefore pass
/// `Silent|Copy` (1|2 = 3), which leaves the open document untouched;
/// only a deliberate `save_as` uses plain Silent.
fn save_as3(doc: &Disp, out: &str, copy: bool) -> Result<i32, HostError> {
// LAST LINE OF DEFENCE. The server checks this too, but every road to a
// SaveAs3 — the export verbs, save_as, and the modelling DSL — passes
// through here, and the DSL road skipped the server's check once
// already. SaveAs3 reads the format off the extension, so this is the
// one place that sees what SOLIDWORKS will actually be asked to do.
if let Some(bad) = crate::verbs3::blocked_by_extension(out) {
return Err(HostError::msg(format!(
"refusing to write {out}: .{bad} (VRML) raises a modal option sheet that hangs \
SOLIDWORKS until the com-host watchdog kills it, and leaves the dialog on the \
user's screen"
)));
}
if let Some(parent) = std::path::Path::new(out).parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
// swSaveAsOptions_Silent = 1, swSaveAsOptions_Copy = 2.
//
// `copy: false` is SILENT ALONE, and that RE-POINTS the open document at
// `out`. The safe-close sweep depends on exactly that (see
// `cmd_preserve_and_close`): it reads the close key back off the document
// after this call, and the key must be the recovery file. The export
// verbs pass `copy: true` for the opposite reason — the user must keep
// working on the document they had.
let options = if copy { 1 | 2 } else { 1 };
let r = doc.call("SaveAs3", &[&Var::bstr(out), &Var::i4(0), &Var::i4(options)])?;
// Belt and braces: a build that ever DOES return a bool here still reads
// correctly (true -> 0 = clean, false -> a generic save error).
Ok(match r.as_i32() {
Some(code) => code,
None => {
if r.as_bool() == Some(true) {
0
} else {
1
}
}
})
}
/// swFileSaveError_e, spelled out. Reported verbatim so a caller can act on
/// the difference between "this format is not available" and "the file is
/// locked".
fn save_error_text(code: i32) -> String {
if code == 0 {
return "ok".into();
}
let bits: &[(i32, &str)] = &[
(1, "generic save error"),
(2, "read-only save error"),
(4, "file name empty"),
(8, "file name contains an @ sign"),
(16, "file lock error"),
(32, "file save format not available"),
(128, "invalid file extension"),
(256, "format not supported for this document type"),
(512, "file save as bad selection"),
(1024, "file save as bad file name"),
];
let named: Vec<&str> =
bits.iter().filter(|(b, _)| code & b != 0).map(|(_, t)| *t).collect();
if named.is_empty() {
format!("swFileSaveError_e {code}")
} else {
format!("{} (swFileSaveError_e {code})", named.join(", "))
}
}
fn cmd_export(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
let out = args
.get("out")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| HostError::msg("out (destination path) is required"))?;
let title_before = doc.string("GetTitle", &[]);
let code = Self::save_as3(&doc, out, true)?;
if code != 0 {
return Err(HostError::msg(format!(
"SOLIDWORKS refused to write {out}: {}. Formats verified on this install for a PART are STEP/STP, IGES, STL, X_T, X_B, SAT, 3MF, GLB, PLY, AMF, 3DXML, PNG, JPG, TIF and PDF; OBJ, DXF, DWG and U3D are refused for a part (DXF/DWG are drawing formats). VRML (.wrl) HANGS on a modal option sheet and is blocked by this bridge.",
Self::save_error_text(code)
)));
}
let bytes = std::fs::metadata(out).map(|m| m.len()).ok();
// Copy mode should leave the document's identity alone; assert it, because
// silently renaming the user's open document is the worst failure this
// verb has.
let title_after = doc.string("GetTitle", &[]);
let renamed = title_before.is_some() && title_before != title_after;
Ok(json!({
"exported": out,
"bytes": bytes,
"fileSizeKB": bytes.map(|b| (b as f64 / 1024.0).round() as u64),
"saveErrorCode": code,
"documentRenamed": renamed,
"doc": Self::doc_info(&doc),
}))
}
fn cmd_bom(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
if doc.int("GetType", &[]).unwrap_or(0) != DOC_ASSEMBLY {
return Err(HostError::msg("BOM needs an assembly (.sldasm) as the target document"));
}
let top_only = args.get("topLevelOnly").and_then(|v| v.as_bool()).unwrap_or(false);
let comps = doc.call("GetComponents", &[&Var::boolean(top_only)])?;
// Aggregate per (file, configuration): that pair IS the BOM line item.
let mut keys: Vec<String> = Vec::new();
let mut rows: Vec<Map<String, Value>> = Vec::new();
let mut skipped = 0usize;
for comp in comps.as_array() {
let comp = match comp.as_dispatch().map(Disp) {
Some(c) => c,
None => continue,
};
// swComponentSuppressionState_e: 0 = fully suppressed
if comp.int("GetSuppression", &[]) == Some(0) {
skipped += 1;
continue;
}
let file = comp.string("GetPathName", &[]).unwrap_or_default();
let configuration = comp.string("ReferencedConfiguration", &[]).unwrap_or_default();
let key = format!("{file}|{configuration}");
if let Some(i) = keys.iter().position(|k| *k == key) {
let q = rows[i].get("quantity").and_then(|v| v.as_i64()).unwrap_or(0) + 1;
rows[i].insert("quantity".into(), Value::from(q));
continue;
}
let props = comp
.obj("GetModelDoc2", &[])
.ok()
.flatten()
.map(|d| Self::custom_props(&d, ""))
.unwrap_or_else(|| json!({}));
let mut row = Map::new();
row.insert("file".into(), Value::from(file.clone()));
row.insert("name".into(), Value::from(super::mock::file_stem(&file).to_string()));
row.insert("configuration".into(), Value::from(configuration));
row.insert("quantity".into(), Value::from(1));
row.insert("suppressedInstances".into(), Value::from(0));
row.insert("properties".into(), props);
keys.push(key);
rows.push(row);
}
if skipped > 0 {
// One line, after the loop — never one per component.
super::host::log(&format!("bom: {skipped} suppressed component(s) excluded"));
}
let rows: Vec<Value> = rows.into_iter().map(Value::Object).collect();
Ok(json!({
"doc": Self::doc_info(&doc),
"topLevelOnly": top_only,
"rowCount": rows.len(),
"rows": rows,
}))
}
fn cmd_interference(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
if doc.int("GetType", &[]).unwrap_or(0) != DOC_ASSEMBLY {
return Err(HostError::msg("interference detection needs an assembly (.sldasm)"));
}
// LIVE FINDING (2026-09-02): `InterferenceDetectionManager` is a member of
// IAssemblyDoc, NOT of IModelDocExtension. v0.2 looked for it on the
// Extension and every interference check failed with "unavailable".
let mgr = doc
.obj("InterferenceDetectionManager", &[])?
.ok_or_else(|| HostError::msg(
"InterferenceDetectionManager unavailable (it is a member of the assembly \
document; make sure the target really is an .sldasm)",
))?;
let coincidence =
args.get("coincidenceCounts").and_then(|v| v.as_bool()).unwrap_or(false);
// A drop guard, not a bare trailing statement: Done() must run on EVERY
// exit — an early `?`, AND an unwinding panic. Leaving the interference
// session open parks SOLIDWORKS in a modal-ish state, so this is exactly
// the invariant that must survive the unexpected.
struct DoneGuard(Disp);
impl Drop for DoneGuard {
fn drop(&mut self) {
let _ = self.0.call("Done", &[]);
}
}
let _guard = DoneGuard(mgr.clone());
// The puts come AFTER the guard is armed. They were before it, so a
// panic inside one would have leaked the interference session — which is
// the exact state the guard exists to prevent.
mgr.try_put("UseTransform", &Var::boolean(true));
mgr.try_put("TreatCoincidenceAsInterference", &Var::boolean(coincidence));
mgr.try_put("MakeInterferingPartsTransparent", &Var::boolean(false));
let found = mgr.call("GetInterferences", &[])?;
// `null`, not 0. "Zero interferences" is a CLEARANCE REPORT somebody
// builds a physical thing from; manufacturing an all-clear out of a
// count SOLIDWORKS declined to give is the most expensive empty-value
// collapse in this file.
let count = mgr.int("GetInterferenceCount", &[]);
let mut out = Vec::new();
for item in found.as_array() {
let item = match item.as_dispatch().map(Disp) {
Some(i) => i,
None => continue,
};
let names: Vec<Value> = item
.call("Components", &[])
.map(|c| {
c.as_array()
.iter()
.filter_map(|v| v.as_dispatch().map(Disp))
.filter_map(|c| c.string("Name2", &[]))
.map(Value::from)
.collect()
})
.unwrap_or_default();
out.push(json!({"components": names, "volumeM3": item.float("Volume", &[])}));
}
// A count that disagrees with the list is not an answer either.
let listed = out.len();
if let Some(c) = count {
if c as usize != listed && listed == 0 {
return Err(HostError::coded(
"interference_enumeration_failed",
format!(
"SOLIDWORKS reports {c} interference(s) but returned none of them. This \
is NOT a clean result — nothing was concluded from it; retry the check."
),
));
}
}
Ok(json!({
"doc": Self::doc_info(&doc),
"count": count,
"countKnown": count.is_some(),
"interferences": out,
"listed": listed,
}))
}
fn cmd_rebuild_check(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
// Top-level rebuild, not just the active configuration.
let rebuilt = doc.call("ForceRebuild3", &[&Var::boolean(false)])?.as_bool().unwrap_or(false);
let mut bad = Vec::new();
let mut unreadable = 0usize;
let mut feature = doc.obj("FirstFeature", &[]).ok().flatten();
// A feature tree is finite, but a broken GetNextFeature must not spin
// this process forever.
let mut guard = 0;
while let Some(f) = feature {
guard += 1;
if guard > 20_000 {
super::host::log("rebuild_check: feature walk hit the 20000 guard");
break;
}
// LIVE FINDING (2026-09-02): `GetErrorCode2` is present on the
// dispatch interface but rejects every arity we can express
// late-bound (it wants a single BYREF BSTR out-param), so v0.2's
// two-argument call failed for EVERY feature — which made a clean,
// empty document report 16 "unreadable" errors. The plain
// `GetErrorCode()` takes no arguments and answers correctly.
match f.call("GetErrorCode", &[]) {
Ok(code) => {
if code.as_i32().unwrap_or(0) != 0 {
bad.push(json!({
"feature": f.string("Name", &[]),
"type": f.string("GetTypeName2", &[]),
"code": code.as_i32(),
"status": Value::Null,
}));
}
}
Err(_) => {
unreadable += 1;
bad.push(json!({
"feature": f.string("Name", &[]),
"type": f.string("GetTypeName2", &[]),
"code": Value::Null,
"status": "error code could not be read",
}));
}
}
feature = f.obj("GetNextFeature", &[]).ok().flatten();
}
// The app's own "what's wrong" census, as a cross-check on the walk.
let whats_wrong = doc
.obj("Extension", &[])
.ok()
.flatten()
.and_then(|e| e.int("GetWhatsWrongCount", &[]));
Ok(json!({
"doc": Self::doc_info(&doc),
"rebuilt": rebuilt,
"featureErrors": bad,
"errorCount": bad.len(),
"unreadableFeatures": unreadable,
"whatsWrongCount": whats_wrong,
"featuresWalked": guard,
}))
}
fn cmd_pack_and_go(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
let dest = args
.get("out")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| HostError::msg("out (destination directory) is required"))?;
// AFTER authorization, never before: creating the directory first left
// one on the user's disk even when the write was refused.
std::fs::create_dir_all(dest)
.map_err(|e| HostError::msg(format!("cannot create {dest}: {e}")))?;
let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;
let png = ext
.obj("GetPackAndGo", &[])?
.ok_or_else(|| HostError::msg("GetPackAndGo returned nothing"))?;
png.try_put(
"IncludeDrawings",
&Var::boolean(args.get("includeDrawings").and_then(|v| v.as_bool()).unwrap_or(false)),
);
png.call("SetSaveToName", &[&Var::boolean(true), &Var::bstr(dest)])?;
let mut files_v = VARIANT::default();
let mut flags_v = VARIANT::default();
let _ = png.call(
"GetDocumentSaveToNames",
&[
&Var::byref_variant(&mut files_v as *mut VARIANT),
&Var::byref_variant(&mut flags_v as *mut VARIANT),
],
);
let files: Vec<Value> =
Var(files_v).as_array().iter().map(|v| v.to_json()).collect();
let statuses: Vec<Value> =
ext.call("SavePackAndGo", &[&Var::dispatch(&png.0)])?.as_array().iter().map(|v| v.to_json()).collect();
Ok(json!({
"doc": Self::doc_info(&doc),
"out": dest,
"files": files,
"statuses": statuses,
}))
}
fn cmd_mass_properties(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;
let mp = ext.obj("CreateMassProperty", &[])?.ok_or_else(|| {
HostError::msg("CreateMassProperty returned nothing (drawings have no mass properties)")
})?;
if let Some(system_units) = args.get("useSystemUnits").and_then(|v| v.as_bool()) {
mp.try_put("UseSystemUnits", &Var::boolean(system_units));
}
let center: Vec<Value> =
mp.call("CenterOfMass", &[]).map(|v| v.as_array().iter().map(|e| e.to_json()).collect())
.unwrap_or_default();
// Parts only; assemblies throw — best-effort, never fatal.
let bbox: Value = doc
.call("GetPartBox", &[&Var::boolean(true)])
.map(|v| Value::Array(v.as_array().iter().map(|e| e.to_json()).collect()))
.unwrap_or(Value::Null);
// THE CSWA FIELDS (0.11.0): what an exam answer is stated in — grams,
// mm³, mm², millimetres in the DOCUMENT's frame (IMassProperty reports
// in the part's own coordinate system, metres).
let mass = mp.float("Mass", &[]);
let volume = mp.float("Volume", &[]);
let area = mp.float("SurfaceArea", &[]);
let com_mm: Option<Vec<f64>> = (center.len() >= 3)
.then(|| center.iter().take(3).filter_map(|v| v.as_f64()).map(|m| (m * 1.0e9).round() / 1.0e6).collect::<Vec<f64>>())
.filter(|v| v.len() == 3);
let material = Self::material_of(&doc, "");
Ok(json!({
"doc": Self::doc_info(&doc),
"massKg": mass,
"massG": mass.map(|m| m * 1000.0),
"volumeM3": volume,
"volumeMm3": volume.map(|v| v * 1.0e9),
"surfaceAreaM2": area,
"surfaceAreaMm2": area.map(|a| a * 1.0e6),
"density": mp.float("Density", &[]),
"densityKgM3": mp.float("Density", &[]),
"materialName": material.get("name").cloned().unwrap_or(Value::Null),
"centerOfMass": center,
"centerOfMassMm": com_mm,
"boundingBox": bbox,
}))
}
/// The escape hatch: a dotted call chain rooted at `$sw` or `$doc`.
///
/// NOT an arbitrary script host — there is no PowerShell in this bridge any
/// more, and there is no eval in Rust. What it supports is exactly what
/// prototyping a new verb needs: `$sw.Member.Member(args...)` with string,
/// number and boolean literals.
fn cmd_eval(&mut self, args: &Value) -> Result<Value, HostError> {
let expr = args.get("expr").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
let (root_name, segments) = parse_chain(&expr)?;
let mut current = match root_name.as_str() {
"sw" => self.app(false, true)?,
"doc" => self.resolve(&json!({}))?,
other => {
return Err(HostError::msg(format!(
"unknown root '${other}' — use $sw (the application) or $doc (the active document)"
)))
}
};
let mut last: Option<Var> = None;
for (i, seg) in segments.iter().enumerate() {
let owned: Vec<Var> = seg.args.iter().map(literal_var).collect();
let refs: Vec<&Var> = owned.iter().collect();
let value = current.call(&seg.name, &refs)?;
if i + 1 < segments.len() {
current = value.as_dispatch().map(Disp).ok_or_else(|| {
HostError::msg(format!(
"'{}' did not return an object, so '.{}' has nothing to run on",
seg.name,
segments[i + 1].name
))
})?;
} else {
last = Some(value);
}
}
let value = last.unwrap_or_else(Var::empty);
Ok(json!({
"result": value.as_string().unwrap_or_default(),
"value": value.to_json(),
"expr": expr,
"supports": "$sw / $doc rooted call chains with string, number and boolean literals",
}))
}
}
/// A parsed literal as a COM argument.
fn literal_var(l: &Literal) -> Var {
match l {
Literal::Str(s) => Var::bstr(s),
Literal::Int(i) => Var::i4(*i),
Literal::Num(f) => Var::r8(*f),
Literal::Bool(b) => Var::boolean(*b),
// VT_EMPTY is how "argument omitted" reaches a late-bound optional
// [in] object parameter — SelectByID2's callout, LoadFile4's import data.
// NOTHING, not "omitted" — see `Var::null_dispatch`. SelectByID2's
// callout parameter is the documented use of this literal, and it is an
// `[in]` object like LoadFile4's importData, which answered
// `Type mismatch. (0x80020005)` to VT_EMPTY on the live install.
Literal::Null => Var::null_dispatch(),
}
}
impl ComBackend {
/// Remember a document THIS HOST created, and persist it.
fn remember_created(&mut self, doc: &Disp) {
let title = doc.string("GetTitle", &[]).unwrap_or_default();
let path = doc.string("GetPathName", &[]).filter(|s| !s.is_empty());
self.remember_created_parts(title, path);
}
/// The same, from the `{title, path}` a result carries — the form the
/// dispatch loop can apply to any command without knowing what it does.
fn remember_created_parts(&mut self, title: String, path: Option<String>) {
if title.is_empty() {
return;
}
// A record from a previous SOLIDWORKS session is discarded the moment we
// learn the current one, rather than being inherited.
let session = self.current_session();
if self.created_session != session {
self.created.clear();
self.created_session = session;
}
// Evict by IDENTITY, not by title. Keying this on the title meant an
// imported document displaced the record of a SAVED one that happened to
// share its name — which is exactly what `swx_import` of a STEP beside
// the suite's own part produced — and the bridge then disowned the
// document it had created.
match &path {
Some(p) => self.created.retain(|(t, cp)| {
!cp.as_deref().map(|q| crate::verbs::same_path(q, p)).unwrap_or(false)
&& !(cp.is_none() && crate::verbs::same_title(t, &title))
}),
None => self.created.retain(|(t, cp)| !(cp.is_none() && crate::verbs::same_title(t, &title))),
}
self.created.push((title, path));
crate::comhost::created_store::save_session(self.created_session, &self.created);
}
/// SOLIDWORKS' own process id, or None when it cannot be asked.
fn current_session(&self) -> Option<i64> {
self.sw.as_ref().and_then(|sw| sw.int("GetProcessID", &[])).map(i64::from)
}
/// Does this host believe it created `doc`? Path where there is one, title
/// otherwise — the same identity rule the server uses, reached independently.
fn host_owns(&self, doc: &Disp) -> bool {
// FAIL CLOSED across sessions: a stored record whose session does not
// match the running SOLIDWORKS (a crash, a kill, the user quitting) is
// not evidence of anything.
let now = self.current_session();
if now.is_none() || now != self.created_session {
return false;
}
let title = doc.string("GetTitle", &[]).unwrap_or_default();
let path = doc.string("GetPathName", &[]).filter(|s| !s.is_empty());
match &path {
Some(p) => self
.created
.iter()
.any(|(_, cp)| cp.as_deref().map(|q| crate::verbs::same_path(q, p)).unwrap_or(false)),
None => self.created.iter().any(|(t, cp)| cp.is_none() && crate::verbs::same_title(t, &title)),
}
}
/// Which document a mutating command is aimed at, resolved the way the
/// command itself will resolve it.
fn authorize_target(&mut self, cmd: &str, args: &Value) -> Option<Disp> {
// `run_script` names its document with `targetDocument`; everything else
// uses the docName/path/name keys `resolve` already understands.
if cmd == "run_script" {
if let Some(t) = args.get("targetDocument").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {
return self.resolve(&json!({"docName": t})).ok();
}
// A script that starts by creating its own document touches nothing
// that exists yet.
if let Ok(ops) = crate::script::parse(&args.get("script").cloned().unwrap_or(Value::Null)) {
if matches!(ops.first(), Some(crate::script::Op::NewPart { .. } | crate::script::Op::NewAssembly { .. })) {
return None;
}
}
}
self.resolve(args).ok()
}
/// THE HOST'S OWN VETO. Called before every mutating COM call.
///
/// It re-decides the same two questions the server did — is the document
/// ours, and does the destination already exist — from its OWN records and
/// its OWN view of the filesystem, and it reads the process-level opt-in
/// from the ENVIRONMENT it inherited rather than from anything the server
/// sent. So the server cannot fabricate the escape: `allowUserWrites` in
/// the request is necessary but never sufficient.
/// Run the Platform's `.lnk` — by ONE named route.
///
/// THE MEASUREMENT THIS EXISTS FOR (2026-09-04, Drew's machine): five of
/// five launches through `cmdStart` produced a `SWXDesktopLauncher` log that
/// stopped at `SecurityContext:` and never logged `Run Begin`. Every launch
/// of the same shortcut with the same arguments from an INTERACTIVE context
/// reached `Run Begin` ~2s later and started SOLIDWORKS. The suspected
/// difference is the startup context — `DETACHED_PROCESS |
/// CREATE_NO_WINDOW` and the show state that rides with it — so the three
/// routes above `cmdStart` all hand the link to the SHELL with a normal show
/// state, by three independent mechanisms. The bridge does not have to know
/// which one is right: `crate::launch` tries them in order and keeps the one
/// that works.
///
/// `raw_arg` on the `cmd` route rather than `arg`: `cmd.exe` does its own
/// command-line parsing, and Rust's escaper is written for the CRT
/// convention, so a path with a space in it (which every one of these has)
/// arrives at `start` mis-split.
/// What an invoke was able to say about what it started.
///
/// `Some((pid, created_at))` is AUTHORITY TO TERMINATE, and the only thing
/// that is. `None` means the route ran but cannot say — which is a first-
/// class answer: the bridge then never adopts a chain from that route and
/// never kills anything on its behalf. See `launch::AttributionRule`.
#[cfg(windows)]
fn spawn_shortcut(shortcut: &str, route: &str) -> Result<Option<(u32, Option<u64>)>, HostError> {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
if shortcut.contains('"') {
return Err(HostError::coded(
"host_app_launch_blocked",
"the shortcut path contains a quote character and will not be run",
));
}
if !crate::launch::is_route(route) {
return Err(HostError::coded(
"host_app_launch_blocked",
format!("'{route}' is not a launch route this bridge knows"),
));
}
let blocked = |e: std::io::Error| {
HostError::coded(
"host_app_launch_blocked",
format!("could not invoke the shortcut via '{route}': {e}"),
)
};
match route {
// ShellExecuteExW with an EXPLICIT SW_SHOWNORMAL, and
// SEE_MASK_NOCLOSEPROCESS so the shell hands back the process it
// created. Runs here, on the com host's thread, because the shell's
// link resolver needs an STA apartment and this is the only thread
// in the bridge that has one.
"shellExecute" => crate::launchos::shell_execute_lnk(shortcut).map_err(|e| {
HostError::coded("host_app_launch_blocked", format!("route 'shellExecute': {e}"))
}),
// Start-Process -PassThru PRINTS the pid, and the PowerShell child
// is waited on, so a policy block or a bad path is reported as
// "this route started nothing" instead of being mistaken later for
// a stall in a launcher that never ran.
"powershell" => crate::launchos::powershell_start_lnk(shortcut).map_err(|e| {
HostError::coded("host_app_launch_blocked", format!("route 'powershell': {e}"))
}),
// NOT ATTRIBUTABLE. The shell forwards the link to an existing
// explorer process and returns nothing, so there is no handle and
// no pid — and therefore, later, no kill.
"explorer" => std::process::Command::new("explorer.exe")
.arg(shortcut)
.creation_flags(DETACHED_PROCESS)
.spawn()
.map(|_| None)
.map_err(blocked),
// NOT ATTRIBUTABLE, and the route that stalled five times out of
// five. Kept last rather than deleted: it is the one route proven
// to at least START the chain on this machine. `raw_arg` because
// cmd.exe does its own parsing and Rust's escaper is written for
// the CRT convention, so a path with a space arrives mis-split.
_ => std::process::Command::new("cmd.exe")
.raw_arg(format!("/c start \"\" \"{shortcut}\""))
.creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)
.spawn()
.map(|_| None)
.map_err(blocked),
}
}
fn authorize(
&self,
args: &Value,
what: &str,
doc: Option<&Disp>,
destination: Option<&str>,
) -> Result<(), HostError> {
self.authorize_inner(args, what, doc, destination, false)
}
/// `always` forces the check even with no document and no destination — for
/// the commands whose harm IS the state change (quitting, closing everything).
fn authorize_inner(
&self,
args: &Value,
what: &str,
doc: Option<&Disp>,
destination: Option<&str>,
always: bool,
) -> Result<(), HostError> {
// THE SAME THREE-STATE RULE THE SERVER USES, from the same function —
// this is the INDEPENDENT veto, and two vetoes that disagree about what
// "the destination is free" means are one veto with extra steps. An
// unverifiable destination refuses here too, and the com host is the layer
// most likely to hit it: it is a child of ab, and it may not hold the
// user's network token at all.
let clobbers = match crate::writes::destination_state(destination) {
crate::writes::Destination::Occupied => true,
crate::writes::Destination::Free => false,
crate::writes::Destination::Unverifiable(io) => {
return Err(HostError::coded(
"destination_unverifiable",
crate::writes::unverifiable_message(destination.unwrap_or(""), &io),
));
}
};
let foreign = doc.map(|d| !self.host_owns(d)).unwrap_or(false);
if !clobbers && !foreign && !always {
return Ok(());
}
// THE FULL OPT-IN CHAIN, re-checked here from the SAME function the
// server uses — env, plus allowUserWrites AND confirm AND a reason.
//
// This used to test `allowUserWrites` alone, which had two consequences,
// both live-proven on 2026-09-03. It made the host's veto a WEAKER test
// than the server's, so "two independent vetoes" was not true in the
// direction that mattered; and because a `Grant` only carried that one
// boolean, a verb that authorized a CLOSE and then sent the host a
// payload without it was refused by the host even though the human had
// authorized it in full. An escape hatch nobody can open is not a safety
// property, it is a bug — Drew authorized closing his own documents and
// the bridge told him no.
let env_on = crate::writes::user_writes_enabled();
// DREW'S STANDING AUTHORIZATION for ENDING THE SESSION, honoured by the
// host's own veto so the two layers agree. `userAuthorized:true` with a
// reason grants the quit and nothing else — see writes::authorize. The
// host still checks it for itself rather than trusting the server's word,
// which is the point of being an independent veto.
let authorized_quit = always
&& args.get("userAuthorized").and_then(|v| v.as_bool()).unwrap_or(false)
&& args
.get("reason")
.and_then(|v| v.as_str())
.map(|r| r.trim().len() >= 8)
.unwrap_or(false);
if authorized_quit {
return Ok(());
}
let missing = crate::writes::missing_opt_in(args);
if env_on && missing.is_empty() {
return Ok(());
}
Err(HostError::detailed(
format!(
"the com host refuses to {what}: {}{}",
if foreign { "this document was not created by this bridge" } else { "" },
if clobbers {
format!(
"{}the destination already exists ({})",
if foreign { ", and " } else { "" },
destination.unwrap_or("")
)
} else {
String::new()
}
),
format!(
"This is the com host's OWN check, not the server's — it keeps its own record of \
the documents it created and reads {} from the environment it was started with. \
A server-side decision alone is a claim; two independent vetoes is the \
guarantee — and the host applies the IDENTICAL test, so what the user \
authorizes in full the host allows in full. envOptIn={env_on} missing=[{}]",
crate::writes::ENV_ALLOW,
missing.join(", ")
),
))
}
}
impl Backend for ComBackend {
fn commands(&self) -> &'static [&'static str] {
COMMANDS
}
fn register_created(&mut self, doc: &Value) {
let title = doc.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
let path = doc
.get("path")
.and_then(|p| p.as_str())
.filter(|s| !s.is_empty())
.map(String::from);
self.remember_created_parts(title, path);
}
fn authorize_command(&mut self, cmd: &str, args: &Value) -> Result<(), HostError> {
// `eval` can reach ANY COM member, so the read allowlist is re-checked
// HERE as well as on the server. It was server-only, which made the one
// verb that can call anything the one with no host-side guard.
if cmd == "eval" {
let expr = args.get("expr").and_then(|v| v.as_str()).unwrap_or("");
if let Some((member, why)) = super::expr::refused_member(expr) {
return Err(HostError::detailed(
format!("the com host refuses to evaluate '{member}': {why}"),
"This is the host's own re-check of the read allowlist, not the server's. \
swx_com_eval evaluates reads only."
.to_string(),
));
}
return Ok(());
}
// Quitting, and closing everything, are harmful BY THEMSELVES — there is
// no document to be foreign and no destination to clobber, so they are
// gated unconditionally.
if matches!(cmd, "stop" | "close_all_documents") {
return self.authorize_inner(args, "quit or close the user's SOLIDWORKS session", None, None, true);
}
// An EXPORT (and a save_as in Copy mode) writes a NEW file and leaves
// the document alone, so reading the user's part to produce one is not a
// write to their data — only the destination has to be clear. Applying
// the ownership rule here as well would refuse the single most useful
// thing this bridge does.
let copy_only = cmd == "export"
|| (cmd == "save_as" && args.get("copy").and_then(|v| v.as_bool()).unwrap_or(false));
// `open` names a file that is not open yet, so there is no Disp to
// resolve — ownership is decided on the PATH against the host's own
// record, the same identity rule everywhere else uses.
if cmd == "open" {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
let ours = self
.created
.iter()
.any(|(_, p)| p.as_deref().map(|q| crate::verbs::same_path(q, path)).unwrap_or(false));
if !ours {
return self.authorize_inner(args, "open this document FOR WRITING", None, None, true);
}
return Ok(());
}
let doc: Option<Disp> = if copy_only { None } else { self.authorize_target(cmd, args) };
let destination = args
.get("out")
.or_else(|| args.get("outputPath"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
self.authorize(args, cmd, doc.as_ref(), destination)
}
fn call(&mut self, cmd: &str, args: &Value) -> Result<Value, HostError> {
match cmd {
"ping" => Ok(json!({"pong": true, "pid": std::process::id()})),
"status" => self.cmd_status(),
"start" => {
let visible = args.get("visible").and_then(|v| v.as_bool()).unwrap_or(true);
// `launch` is decided by the SERVER, which knows the edition. See
// `app()` and the swx_start handler: SOLIDWORKS Connected cannot
// be launched programmatically at all.
let launch = args.get("launch").and_then(|v| v.as_bool()).unwrap_or(false);
let sw = self.app(launch, visible)?;
Ok(json!({
"started": true,
"version": sw.string("RevisionNumber", &[]),
"wasLaunched": self.was_launched,
"mainHwnd": Self::main_hwnd(&sw),
"processId": sw.int("GetProcessID", &[]),
}))
}
// THE SANCTIONED LAUNCH for SOLIDWORKS Connected.
//
// Invoke the shortcut the 3DEXPERIENCE Platform installed, which
// runs CATSTART.exe -> SWXDesktopLauncher.exe with the tenant and
// registry arguments baked into it. We do NOT assemble that command
// line ourselves: the tenant is the user's, and a hand-built
// launcher invocation would be a guess. `cmd /c start` is used
// rather than CreateProcess because a `.lnk` is a shell artefact —
// only the shell resolves it.
//
// Fire and forget, then a SHORT attach poll. A Connected cold start
// runs one to three minutes and the com host must not be held for
// that long: it is the only channel the server has, so blocking it
// would freeze swx_progress and every other verb. The server polls
// instead, and this call answers with what is true right now.
// SOLIDWORKS VISUALIZE (0.9.19): ALWAYS its platform shortcut —
// MEASURED 2026-10-05, starting SLDWORKSVisualize.exe directly raises
// "SOLIDWORKS Visualize must be opened from the 3DEXPERIENCE Platform
// or from the desktop shortcut…". No SOLIDWORKS-attached check: the two
// applications run independently.
"launch_visualize" => {
let shortcut = args.get("shortcut").and_then(|v| v.as_str()).unwrap_or("");
if shortcut.is_empty() || !shortcut.to_ascii_lowercase().ends_with(".lnk") {
return Err(HostError::coded("visualize_shortcut_missing", "Visualize is started only through its platform shortcut (.lnk)"));
}
let root = Self::spawn_shortcut(shortcut, "shellExecute")?;
Ok(json!({"invoked": true, "shortcut": shortcut, "route": "shellExecute", "rootPid": root.map(|(p, _)| p)}))
}
"launch_shortcut" => {
let shortcut = args.get("shortcut").and_then(|v| v.as_str()).unwrap_or("");
if shortcut.is_empty() {
return Err(HostError::coded(
"host_app_launch_blocked",
"no shortcut was given to invoke",
));
}
// Already up: invoking the shortcut again would raise a second
// window (or the Platform's "already running" notice) on the
// user's screen for no reason.
if let Ok(sw) = self.app(false, true) {
return Ok(json!({
"invoked": false,
"attached": true,
"alreadyRunning": true,
"version": sw.string("RevisionNumber", &[]),
}));
}
// The SERVER chooses the route (it owns the state machine and
// the memory of what worked). An absent route means the caller
// is an older server: fall back to the first route rather than
// refusing, so a version skew cannot make the bridge unable to
// start SOLIDWORKS at all.
let route = args
.get("route")
.and_then(|v| v.as_str())
.filter(|r| crate::launch::is_route(r))
.unwrap_or(crate::launch::ROUTES[0]);
let root = Self::spawn_shortcut(shortcut, route)?;
// One brief look, so a warm start reports attached immediately
// instead of making the caller poll for something already true.
let attached = self.app(false, true).is_ok();
Ok(json!({
"invoked": true,
"route": route,
// THE ATTRIBUTION, or an honest absence of one.
"rootPid": root.map(|(p, _)| p),
"rootCreatedAt": root.and_then(|(_, c)| c),
"attributionSource": match route {
"shellExecute" => "shellExecuteHandle",
"powershell" => "startProcessPassThru",
_ => "none",
},
"attributionNote": if root.is_some() {
Value::Null
} else {
Value::from(format!(
"route '{route}' cannot say what it started, so this bridge will not terminate anything on its behalf"
))
},
"attached": attached,
"shortcut": shortcut,
"version": if attached {
self.app(false, true).ok().and_then(|sw| sw.string("RevisionNumber", &[])).map(Value::from).unwrap_or(Value::Null)
} else {
Value::Null
},
}))
}
"stop" => {
let sw = match self.app(false, true) {
Ok(sw) => sw,
Err(_) => return Ok(json!({"stopped": false, "reason": "not running"})),
};
// ExitApp tears down the very COM server we are calling it on, so
// the outgoing call often comes back as an RPC disconnect
// (RPC_E_SERVER_DIED / "server is not available"). That IS the
// success case — the app quit — so it must not be reported as a
// failure. A genuinely different error still surfaces.
let mut disconnect: Option<String> = None;
if let Err(e) = sw.call("ExitApp", &[]) {
let d = e.detail.as_deref().unwrap_or("");
// ONLY the specific HRESULTs that mean "the object we were
// calling has gone away". The first version also matched any
// message containing "server" or "rpc", which would report
// stopped:true for a completely unrelated failure with
// SOLIDWORKS still on screen — a lie the caller then acts on.
let disconnected = [
"0x800706BA", // RPC_S_SERVER_UNAVAILABLE
"0x80010108", // RPC_E_DISCONNECTED
"0x800706BE", // RPC_S_CALL_FAILED
"0x800706BF", // RPC_S_CALL_FAILED_DNE
"0x80010012", // CO_E_OBJNOTCONNECTED
"0x80010105", // RPC_E_SERVERFAULT
]
.iter()
.any(|code| d.contains(code));
if !disconnected {
return Err(e);
}
disconnect = Some(d.to_string());
}
self.sw = None;
self.was_launched = false;
// The session is over: SOLIDWORKS restarts its Part numbering, so
// no remembered title can be trusted into the next one.
self.created.clear();
crate::comhost::created_store::clear();
// VERIFY. ExitApp returning cleanly is not proof the application
// quit; a re-attach that still succeeds means it did not.
let still_up = Self::clsid()
.ok()
.map(|clsid| unsafe {
let mut unk = None;
GetActiveObject(&clsid, None, &mut unk).is_ok() && unk.is_some()
})
.unwrap_or(false);
Ok(json!({
"stopped": !still_up,
"stillRunning": still_up,
"rpcDisconnect": disconnect,
}))
}
"docs" => self.cmd_docs(),
"open" => self.cmd_open(args),
"close_doc" => {
let sw = self.app(false, true)?;
let doc = self.resolve(args)?;
let title = doc.string("GetTitle", &[]).unwrap_or_default();
if args.get("save").and_then(|v| v.as_bool()).unwrap_or(false) {
// close_doc{save:true} is a WRITE, and it is the same Save3
// the gated `save` command makes.
let (mut e, mut w) = (0i32, 0i32);
doc.call(
"Save3",
&[
&Var::i4(1),
&Var::byref_i4(&mut e as *mut i32),
&Var::byref_i4(&mut w as *mut i32),
],
)?;
}
// BY PATH when the document has one — a title is not an
// identity, and `CloseDoc` resolves by name without prompting.
// `resolve` has already refused an ambiguous title
// (`ambiguous_target`), so this is the second guard rather than
// the only one.
let path = doc.string("GetPathName", &[]).unwrap_or_default();
let key = if path.trim().is_empty() { title.clone() } else { path.clone() };
sw.call("CloseDoc", &[&Var::bstr(&key)])?;
// A closed document is no longer ours: the server prunes its
// registry the same way, and the two vetoes must not disagree
// about who owns what.
self.created.retain(|(t, _)| *t != title);
crate::comhost::created_store::save_session(self.created_session, &self.created);
Ok(json!({"closed": title}))
}
"save" => {
let doc = self.resolve(args)?;
let (mut errors, mut warnings) = (0i32, 0i32);
// swSaveAsOptions_Silent = 1
let saved = doc
.call(
"Save3",
&[
&Var::i4(1),
&Var::byref_i4(&mut errors as *mut i32),
&Var::byref_i4(&mut warnings as *mut i32),
],
)?
.as_bool()
.unwrap_or(false);
Ok(json!({
"saved": saved,
"errors": errors,
"warnings": warnings,
"doc": Self::doc_info(&doc),
}))
}
"export" => self.cmd_export(args),
"props_get" => {
let doc = self.resolve(args)?;
let configuration =
args.get("configuration").and_then(|v| v.as_str()).unwrap_or("");
let configurations: Vec<Value> = doc
.call("GetConfigurationNames", &[])
.map(|v| v.as_array().iter().map(|e| e.to_json()).collect())
.unwrap_or_default();
Ok(json!({
"doc": Self::doc_info(&doc),
"configuration": configuration,
"properties": Self::custom_props(&doc, configuration),
"configurations": configurations,
}))
}
"props_set" => {
let doc = self.resolve(args)?;
let configuration =
args.get("configuration").and_then(|v| v.as_str()).unwrap_or("");
let mgr = doc
.obj("Extension", &[])?
.and_then(|e| e.obj("CustomPropertyManager", &[&Var::bstr(configuration)]).ok().flatten())
.ok_or_else(|| HostError::msg("CustomPropertyManager unavailable"))?;
let mut set = Map::new();
let props = args.get("properties").and_then(|p| p.as_object()).cloned().unwrap_or_default();
for (name, value) in props {
let text = match &value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
// swCustomInfoText = 30; swCustomPropertyReplaceValue = 2
let r = mgr.call(
"Add3",
&[&Var::bstr(&name), &Var::i4(30), &Var::bstr(&text), &Var::i4(2)],
)?;
set.insert(name, r.as_i32().map(Value::from).unwrap_or(Value::Null));
}
Ok(json!({"doc": Self::doc_info(&doc), "set": Value::Object(set)}))
}
"mass_properties" => self.cmd_mass_properties(args),
"bom" => self.cmd_bom(args),
"interference" => self.cmd_interference(args),
"rebuild_check" => self.cmd_rebuild_check(args),
"pack_and_go" => self.cmd_pack_and_go(args),
"eval" => self.cmd_eval(args),
// v0.3
"new_document" => self.cmd_new_document(args),
"restore_document_visible" => self.cmd_restore_document_visible(args),
"activate_document" => self.cmd_activate_document(args),
"close_all_documents" => self.cmd_close_all_documents(args),
"document_info" => self.cmd_document_info(args),
"design_info" => self.cmd_design_info(args),
"get_parameters" => self.cmd_get_parameters(args),
"set_parameter" => self.cmd_set_parameter(args),
"inspect_bodies" => self.cmd_inspect_bodies(args),
"import" => self.cmd_import(args),
"close_import_temps" => self.cmd_close_import_temps(args),
"preserve_and_close" => self.cmd_preserve_and_close(args),
// THE QUIT LADDER. One rung per call; the SERVER orchestrates and
// measures, because the measurement must not be a COM call.
"quit_rung" => self.cmd_quit_rung(args),
// Drop every IDispatch this process holds. Called before any rung
// and before any measurement — see `crate::quit`'s refcount note.
"release_com" => {
let had = self.sw.is_some();
self.sw = None;
Ok(json!({
"released": had,
// `Disp` is a refcounted IDispatch (Clone = AddRef, Drop =
// Release) and `self.sw` is the only reference this host
// caches, so with it dropped and no local in scope our
// outstanding count is zero.
"outstanding": 0,
"note": "every IDispatch this com host held has been released",
}))
}
// Read-only: how many references we are holding right now. The
// ladder asserts zero before it measures.
"com_refs" => Ok(json!({"outstanding": usize::from(self.sw.is_some())})),
"save_as" => self.cmd_save_as(args),
"run_script" => self.cmd_run_script(args),
"add_component" => self.cmd_add_component(args),
"run_macro" => self.cmd_run_macro(args),
"get_preferences" => self.cmd_get_preferences(args),
"set_preference" => self.cmd_set_preference(args),
"show" => self.cmd_show(args),
"render_image" => self.cmd_render_image(args),
"check_recovery" => self.cmd_check_recovery(),
"app_state" => self.cmd_app_state(),
"startup_state" => self.cmd_startup_state(),
"cloud_status" => self.cmd_cloud_status(),
other => Err(HostError::msg(format!("unknown command: {other}"))),
}
}
}
// ── v0.3: the Fusion-parity surface ─────────────────────────────────────────
//
// Every call below was probed against a LIVE SOLIDWORKS Design Professional for
// Makers 2026 (3DEXPERIENCE R2026x, sldworks.exe 34.3.0) before it was written.
// Where a signature could NOT be proven live, the comment says so — that
// distinction is the whole point of the exercise, and it is reproduced verbatim
// in docs/FUSION-PARITY-REPORT.md.
impl ComBackend {
/// The stock template for a document type. `GetDocumentTemplate` is the
/// reliable route (proven live for all three types); the
/// `GetUserPreferenceStringValue` defaults are the fallback.
fn template_for(sw: &Disp, type_id: i32) -> Option<String> {
let via_template = sw
.call(
"GetDocumentTemplate",
&[&Var::i4(type_id), &Var::bstr(""), &Var::i4(0), &Var::r8(0.0), &Var::r8(0.0)],
)
.ok()
.and_then(|v| v.as_string())
.filter(|s| !s.is_empty());
if via_template.is_some() {
return via_template;
}
// swDefaultPartTemplate = 8, swDefaultAssemblyTemplate = 9,
// swDefaultDrawingTemplate = 10 (all three verified live).
let pref = match type_id {
DOC_PART => 8,
DOC_ASSEMBLY => 9,
_ => 10,
};
sw.call("GetUserPreferenceStringValue", &[&Var::i4(pref)])
.ok()
.and_then(|v| v.as_string())
.filter(|s| !s.is_empty())
}
fn type_id_from_name(name: &str) -> Result<i32, HostError> {
match name.trim().to_lowercase().as_str() {
"part" | "prt" | "sldprt" => Ok(DOC_PART),
"assembly" | "asm" | "sldasm" => Ok(DOC_ASSEMBLY),
"drawing" | "drw" | "slddrw" => Ok(DOC_DRAWING),
other => Err(HostError::msg(format!(
"unknown document type '{other}' — use part, assembly or drawing"
))),
}
}
/// `swx_new_document`. LIVE-PROVEN recipe.
///
/// `ISldWorks::NewPart()` HANGS on this install (>110s, killed by the
/// watchdog), which is why this goes through `NewDocument(template,0,0,0)`
/// with a template resolved at runtime instead of the convenience wrapper.
fn cmd_new_document(&mut self, args: &Value) -> Result<Value, HostError> {
let kind = args.get("type").and_then(|v| v.as_str()).unwrap_or("part");
let type_id = Self::type_id_from_name(kind)?;
let sw = self.app(false, true)?;
let template = match args.get("template").and_then(|v| v.as_str()).filter(|s| !s.is_empty())
{
Some(t) => t.to_string(),
None => Self::template_for(&sw, type_id).ok_or_else(|| {
HostError::msg(format!(
"no {kind} template is configured in SOLIDWORKS (Tools > Options > File \
Locations > Document Templates) — pass `template` with an explicit \
.prtdot/.asmdot/.drwdot path"
))
})?,
};
if !std::path::Path::new(&template).exists() {
return Err(HostError::msg(format!("template not found on disk: {template}")));
}
let visible = args.get("visible").and_then(|v| v.as_bool()).unwrap_or(true);
if visible {
let doc = Self::new_document_from(&sw, &template)?;
return Ok(json!({"created": Self::doc_info(&doc), "template": template, "type": kind}));
}
// HIDDEN (0.10.6). MEASURED 2026-10-14: every created-and-closed
// document cost SOLIDWORKS ~83 MB of commit and ~125 handles for good,
// with GetDocumentCount back at 0 — the leading hypothesis is the
// window and graphics context each visible document gets.
// ISldWorks::DocumentVisible(Visible, Type) "allows the application to
// control the display of a document in a window upon creation or
// retrieval" (help.solidworks.com, ISldWorks::DocumentVisible), and
// IModelDoc2::Visible's own remarks say hiding a document does NOT
// release its user-interface resources — "open the document invisibly
// using ISldWorks::DocumentVisible" instead. It is a SESSION setting:
// it is put back to true immediately after NewDocument, on every path,
// the error path included, and the reply says whether that worked.
let b = crate::memory::hidden_bracket(
|visible| {
let r = sw.call("DocumentVisible", &[&Var::boolean(visible), &Var::i4(type_id)]).map(|_| ());
if r.is_err() && visible {
std::thread::sleep(std::time::Duration::from_millis(300));
}
r
},
|| Self::new_document_from(&sw, &template),
3,
);
let mut visibility = json!({
"requested": false,
"hiddenWith": "ISldWorks::DocumentVisible",
"documentVisibleSet": b.set_ok,
"documentVisibleRestored": b.restored,
});
match b.made {
Ok(doc) => {
visibility["visibleReadBack"] = json!(doc.flag("Visible", &[]));
// ITS FILE, BY THE OBJECT WE HOLD. A hidden document is not
// necessarily the ACTIVE one, so the server's usual "save the
// active document into the scratch folder" could name somebody
// else's: the scratch file is written here, through this Disp.
if let Some(path) = args.get("scratchPath").and_then(|v| v.as_str()).filter(|p| !p.is_empty()) {
let code = Self::save_as3(&doc, path, false);
visibility["scratchSaved"] = json!(matches!(code, Ok(0)));
}
Ok(json!({"created": Self::doc_info(&doc), "template": template, "type": kind, "visibility": visibility}))
}
Err(e) => Err(e.with_data(json!({"visibility": visibility}))),
}
}
fn new_document_from(sw: &Disp, template: &str) -> Result<Disp, HostError> {
sw.call("NewDocument", &[&Var::bstr(template), &Var::i4(0), &Var::r8(0.0), &Var::r8(0.0)])?
.as_dispatch()
.map(Disp)
.ok_or_else(|| HostError::msg(format!("SOLIDWORKS created no document from {template} (is the template valid?)")))
}
/// PUT THE SESSION SETTING BACK: DocumentVisible(true, type), retried —
/// left false, every document the USER opens next would be invisible.
fn restore_document_visible(sw: &Disp, type_id: i32) -> bool {
for attempt in 0..3u64 {
if sw.call("DocumentVisible", &[&Var::boolean(true), &Var::i4(type_id)]).is_ok() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(200 * (attempt + 1)));
}
false
}
/// `restore_document_visible` — the server's second chance when a hidden
/// creation reported the setting NOT restored. Only ever sets it TRUE.
fn cmd_restore_document_visible(&mut self, args: &Value) -> Result<Value, HostError> {
let kind = args.get("type").and_then(|v| v.as_str()).unwrap_or("part");
let type_id = Self::type_id_from_name(kind)?;
let sw = self.app(false, true)?;
let restored = Self::restore_document_visible(&sw, type_id);
if !restored {
return Err(HostError::msg(format!(
"ISldWorks::DocumentVisible(true, {kind}) failed three times — documents of that type \
may open INVISIBLE in this session until SOLIDWORKS restarts"
)));
}
Ok(json!({"documentVisibleRestored": true, "type": kind}))
}
/// `swx_activate_document`. Not provable live in this session (SOLIDWORKS
/// Connected was down when this was written); the byref out-param is
/// marshalled explicitly, and a failure is reported with what SOLIDWORKS
/// said.
fn cmd_activate_document(&mut self, args: &Value) -> Result<Value, HostError> {
let name = args
.get("name")
.or_else(|| args.get("path"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| HostError::msg("name (the document title, as swx_docs reports it) is required"))?;
let sw = self.app(false, true)?;
// `visible:true` (0.10.6): a hidden document is made visible first —
// attempted and read back (UNMEASURED; see make_visible).
let visibility = if args.get("visible").and_then(|v| v.as_bool()) == Some(true) {
self.resolve(&json!({"docName": name})).ok().map(|d| Self::make_visible(&d))
} else {
None
};
let mut errors = 0i32;
// THE NAME SOLIDWORKS ITSELF SHOWS (0.10.10): the caller's title is
// matched extension-insensitively (HideFileExt decides whether GetTitle
// carries ".SLDPRT"), and ActivateDoc3 is handed the document's own.
let shown = self.resolve(&json!({"docName": name})).ok().and_then(|d| d.string("GetTitle", &[])).unwrap_or_else(|| name.to_string());
// ActivateDoc3(Name, UseUserPreferences, Option, byref Errors).
// swRebuildOnActivation_e: 1 = swDontRebuildActiveDoc. 0 is
// swUserDecision — it PROMPTS the user (help.solidworks.com swconst;
// 0.10.2: this said "0 = swDontRebuildActiveDoc" and passed 0).
let doc = sw
.call(
"ActivateDoc3",
&[
&Var::bstr(&shown),
&Var::boolean(false),
&Var::i4(1),
&Var::byref_i4(&mut errors as *mut i32),
],
)?
.as_dispatch()
.map(Disp)
.ok_or_else(|| {
HostError::msg(format!(
"SOLIDWORKS could not activate '{name}' (errors={errors}) — swx_docs lists \
the titles that are actually open"
))
})?;
Ok(json!({"activeDocument": Self::doc_info(&doc), "errors": errors, "visibility": visibility}))
}
/// Enumerate the open documents, and NEVER return an empty list the
/// application itself contradicts.
///
/// `GetDocuments` on a busy SOLIDWORKS hands back a null/empty VARIANT
/// rather than an error, and `as_array()` turns that into an empty Vec. So
/// `swx_docs` answered `success:true` with ZERO documents while 51 were
/// open — twice, three minutes apart, with `GetDocumentCount` and
/// `swx_state` both still saying 51. Everything downstream then drew a
/// confident wrong conclusion from it: resolve-by-title said "no open
/// document is titled 'Assem24'" and the ownership gate said "this bridge
/// did not create it", for an assembly created seconds earlier.
///
/// An empty list is only believed when the count agrees it is empty.
/// WALK the open documents one at a time, stopping at `max` or at `budget`.
///
/// `GetDocuments` marshals EVERY handle in one call, and that is what killed
/// the com host on Drew's 2144-document session: `GetDocumentCount` answered
/// in 0.7 s, `GetDocuments` did not answer in 30 s, and the code above the cap
/// then called `GetPathName` on all 2144 of them — 2144 more round trips — to
/// fill in a field named `importTempsInSample`. The mock's `GetDocuments` was
/// instant, so the whole degrade path was mock-proven and false live.
///
/// `GetFirstDocument()` / `GetNext()` is the per-document road, and it was
/// measured live on the 1342-document session: `$sw.GetFirstDocument()
/// .GetNext().GetTitle()` answered immediately. So the cost is now O(what we
/// actually look at) instead of O(everything open).
///
/// Returns the documents visited and WHY the walk stopped — because "we
/// stopped early" must never be reported as "that is all there was".
///
/// The reason matters: a `GetNext` that fails mid-walk (the user closed
/// something, and the iteration is invalidated) used to be indistinguishable
/// from reaching the end, so a walk cut short after four of 2144 documents
/// was reported as a completed drain.
fn walk_documents(
sw: &Disp,
max: usize,
budget: std::time::Duration,
) -> Result<(Vec<Disp>, WalkStop), HostError> {
let deadline = std::time::Instant::now() + budget;
let mut out: Vec<Disp> = Vec::new();
let mut cur = sw.obj("GetFirstDocument", &[])?;
let mut stop = WalkStop::ReachedEnd;
while let Some(doc) = cur {
if out.len() >= max {
stop = WalkStop::HitMax;
break;
}
if std::time::Instant::now() >= deadline {
stop = WalkStop::TimedOut;
break;
}
let next = doc.obj("GetNext", &[]);
let failed = next.is_err();
let next = next.ok().flatten();
out.push(doc);
if failed {
// The ITERATION broke, which is not the same as the session
// ending. Say so.
stop = WalkStop::IterationFailed;
break;
}
cur = next;
}
Ok((out, stop))
}
/// Is a document with this exact name still open? `GetOpenDocumentByName`
/// takes the full path for a document that has one, and it is ONE call — the
/// cheap, exact way to verify a close did what it was asked to.
fn open_document_by_name(sw: &Disp, name: &str) -> Option<Disp> {
sw.obj("GetOpenDocumentByName", &[&Var::bstr(name)]).ok().flatten()
}
fn documents(sw: &Disp) -> Result<Vec<Disp>, HostError> {
// NEVER MARSHAL A FLOODED SESSION. Above the cap this call cannot be
// completed in any budget a caller would wait for, and its failure mode is
// a 30s wedge and a killed com host — so it refuses, in ONE place, for
// every caller that would otherwise have discovered it the hard way.
// Callers that can work from a count and a sample do that instead.
if let Some(c) = Self::document_count(sw) {
if c as usize > super::host::doc_enum_cap() {
return Err(HostError::coded(
"docs_enumeration_capped",
format!(
"{c} documents are open — more than this bridge will marshal in one call \
({}). GetDocuments returns every handle at once and did not answer within \
30s on a 2144-document session, which kills the com host; the count and a \
sample come back from swx_docs instead. This many open documents is \
almost always leaked STEP/IGES import temporaries: run \
swx_close_import_temps to drain them.",
super::host::doc_enum_cap()
),
));
}
}
// THE COUNT IS AN Option, and that is the whole point of this round's
// fix. It used to be `unwrap_or(-1)`, and `-1 <= 0` made an UNREADABLE
// count believe an empty enumeration — the exact road to bug #5 that
// this function was written to close, left open inside the closure.
let mut count: Option<i32> = None;
for attempt in 0..4u64 {
let docs: Vec<Disp> = sw
.call("GetDocuments", &[])?
.as_array()
.iter()
.filter_map(|v| v.as_dispatch().map(Disp))
.collect();
if !docs.is_empty() {
return Ok(docs);
}
count = sw.int("GetDocumentCount", &[]);
// An empty list is believed ONLY when the count AGREES it is empty.
// A count we could not read agrees with nothing.
if count == Some(0) {
return Ok(docs);
}
std::thread::sleep(std::time::Duration::from_millis(150 * (attempt + 1)));
}
Err(HostError::coded(
"docs_enumeration_failed",
match count {
Some(c) => format!(
"SOLIDWORKS reports {c} open document(s) but returned none — the enumeration \
failed, most likely because it was busy. NOTHING was concluded from this \
read; retry the call."
),
None => "SOLIDWORKS returned no documents AND would not say how many are open, so \
this bridge cannot tell an empty session from a failed enumeration. \
NOTHING was concluded from this read; retry the call."
.to_string(),
},
))
}
/// `swx_close_all_documents`. DESTRUCTIVE, so the server gates it; here we
/// only report what was open before and what survived.
fn cmd_close_all_documents(&mut self, args: &Value) -> Result<Value, HostError> {
let sw = self.app(false, true)?;
// ABOVE THE CAP the lists are not affordable — `GetDocuments` on a
// 2144-document session does not answer in 30s — but the ACT is one COM
// call and the user may well want it. So the flooded session gets counts
// instead of lists, and says so; it never gets a list that is silently a
// sample, and it never gets a wedge.
let count_before = Self::document_count(&sw);
let capped = count_before
.map(|c| c as usize > super::host::doc_enum_cap())
.unwrap_or(false);
// `documents()`, so a contradicted empty enumeration cannot make a
// DESTRUCTIVE verb report that it closed nothing when it closed
// everything the user had open.
let before: Vec<Value> = if capped {
Vec::new()
} else {
Self::documents(&sw)?.iter().map(Self::doc_info).collect()
};
let include_unsaved =
args.get("closeUnsaved").and_then(|v| v.as_bool()).unwrap_or(false);
let would_prompt: Vec<Value> = before
.iter()
.filter(|d| d.get("dirty").and_then(|x| x.as_bool()).unwrap_or(false))
.cloned()
.collect();
let ok = sw
.call("CloseAllDocuments", &[&Var::boolean(include_unsaved)])?
.as_bool()
.unwrap_or(false);
// Empty here is EXPECTED and believable — `documents()` accepts an
// empty list the count agrees with, and only refuses one it contradicts.
let count_after = Self::document_count(&sw);
let still_capped =
count_after.map(|c| c as usize > super::host::doc_enum_cap()).unwrap_or(false);
let remaining: Option<Vec<Value>> = if still_capped {
None
} else {
Some(Self::documents(&sw)?.iter().map(Self::doc_info).collect())
};
Ok(json!({
"closedAll": ok,
// NULL, NOT AN EMPTY LIST, when the session was too flooded to
// enumerate. An empty list here would read as "nothing was open".
"closed": if capped { Value::Null } else { json!(before) },
"remaining": remaining.as_ref().map(|r| json!(r)).unwrap_or(Value::Null),
"documentCountBefore": count_before,
"documentCountAfter": count_after,
"listsOmittedTooManyDocuments": capped || still_capped,
"wouldPrompt": if capped { Value::Null } else { json!(would_prompt) },
"closeUnsaved": include_unsaved,
}))
}
/// `ISldWorks::RunMacro2(path, module, proc, options, [out] error) -> bool`
///
/// SCAFFOLDING — written from the documented signature, NOT yet run against
/// a live SOLIDWORKS. Everything else in this file carries a note saying
/// whether it was proven live, and this one has not been, so it says so.
///
/// Two details that are easy to get wrong and expensive to debug:
/// * the error is an OUT parameter (`swRunMacroError_e`), not the return
/// value — the return is a plain bool, and a macro that fails to compile
/// comes back `false` with the reason only in the out param;
/// * `swRunMacroUnloadAfterRun = 1` is passed deliberately. Without it the
/// VBA project stays loaded and an edited macro keeps running its OLD
/// code, which looks exactly like the edit not having been saved.
fn cmd_run_macro(&mut self, args: &Value) -> Result<Value, HostError> {
let sw = self.app(false, true)?;
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
let module = args.get("module").and_then(|v| v.as_str()).unwrap_or("main");
let procedure = args.get("procedure").and_then(|v| v.as_str()).unwrap_or("main");
// swRunMacroOption_e: swRunMacroUnloadAfterRun = 1
let mut run_error: i32 = 0;
let r = sw.call(
"RunMacro2",
&[
&Var::bstr(path),
&Var::bstr(module),
&Var::bstr(procedure),
&Var::i4(1),
&Var::byref_i4(&mut run_error as *mut i32),
],
)?;
let ok = r.as_bool().unwrap_or(run_error == 0);
Ok(json!({
"ran": ok,
"path": path,
"module": module,
"procedure": procedure,
"runError": run_error,
"runErrorText": Self::macro_error_text(run_error),
"unloadedAfterRun": true,
"provenLive": false,
}))
}
/// `swRunMacroError_e`, spelled out — the difference between "no such file"
/// and "your VBA does not compile" is the whole content of a failed run.
fn macro_error_text(code: i32) -> &'static str {
match code {
0 => "ok",
1 => "the macro or its module/procedure could not be found",
2 => "the macro could not be opened (missing file, or not a macro)",
4 => "the macro failed to compile",
8 => "the macro raised a runtime error",
_ => "unrecognised swRunMacroError_e — reported verbatim rather than guessed at",
}
}
fn cmd_document_info(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
let info = Self::doc_info(&doc);
let configurations: Vec<Value> = doc
.call("GetConfigurationNames", &[])
.map(|v| v.as_array().iter().map(|e| e.to_json()).collect())
.unwrap_or_default();
let path = info.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string();
let props = Self::custom_props(&doc, "");
Ok(json!({
"name": info.get("title").cloned().unwrap_or(Value::Null),
"productType": info.get("type").cloned().unwrap_or(Value::Null),
"path": info.get("path").cloned().unwrap_or(Value::Null),
// An unsaved scratch document has no path at all — that is the ONE
// reliable "never been saved" signal SOLIDWORKS gives us.
"isSaved": !path.is_empty(),
"dirty": info.get("dirty").cloned().unwrap_or(Value::Null),
"configuration": info.get("config").cloned().unwrap_or(Value::Null),
"configurations": configurations,
"customPropertyCount": props.as_object().map(|m| m.len()).unwrap_or(0),
"needsRebuild": doc
.obj("Extension", &[])
.ok()
.flatten()
.and_then(|e| e.int("NeedsRebuild2", &[])),
"doc": info,
}))
}
/// `swx_design_info` — the design-level summary Fusion answers with.
fn cmd_design_info(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
let type_id = doc.int("GetType", &[]).unwrap_or(0);
let mut features = 0usize;
let mut sketches = 0usize;
let mut errors = 0usize;
let mut feature = doc.obj("FirstFeature", &[]).ok().flatten();
let mut guard = 0;
while let Some(f) = feature {
guard += 1;
if guard > 20_000 {
break;
}
features += 1;
if f.string("GetTypeName2", &[]).as_deref() == Some("ProfileFeature") {
sketches += 1;
}
if f.call("GetErrorCode", &[]).ok().and_then(|c| c.as_i32()).unwrap_or(0) != 0 {
errors += 1;
}
feature = f.obj("GetNextFeature", &[]).ok().flatten();
}
let bodies = if type_id == DOC_PART {
doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])
.map(|v| v.as_array().len())
.unwrap_or(0)
} else {
0
};
let components = if type_id == DOC_ASSEMBLY {
doc.call("GetComponents", &[&Var::boolean(false)])
.map(|v| v.as_array().len())
.unwrap_or(0)
} else {
0
};
let configurations: Vec<Value> = doc
.call("GetConfigurationNames", &[])
.map(|v| v.as_array().iter().map(|e| e.to_json()).collect())
.unwrap_or_default();
Ok(json!({
"doc": Self::doc_info(&doc),
"features": features,
"sketches": sketches,
"bodies": bodies,
"components": components,
"featureErrors": errors,
"configurations": configurations,
"material": Self::material_of(&doc, ""),
"units": Self::units_of(&doc),
"needsRebuild": doc
.obj("Extension", &[])
.ok()
.flatten()
.and_then(|e| e.int("NeedsRebuild2", &[])),
}))
}
/// `GetMaterialPropertyName2(config, byref database) -> name`. The
/// two-plain-string form v0.2 would have used is rejected by SOLIDWORKS —
/// the second parameter is a BYREF out-param, which only the explicit
/// marshalling here can supply.
fn material_of(doc: &Disp, configuration: &str) -> Value {
let mut database = BSTR::new();
match doc.call(
"GetMaterialPropertyName2",
&[&Var::bstr(configuration), &Var::byref_bstr(&mut database as *mut BSTR)],
) {
Ok(v) => {
let name = v.as_string().unwrap_or_default();
if name.is_empty() {
Value::Null
} else {
json!({"name": name, "database": database.to_string()})
}
}
Err(_) => Value::Null,
}
}
/// Document units, read through `IModelDocExtension::GetUserPreferenceInteger`.
///
/// HONEST CAVEAT: the raw enum id is reported alongside the decoded name,
/// because the `swUserPreferenceIntegerValue_e` ids are not published in a
/// form this bridge can verify against the installed build. The round trip
/// (`swx_set_preference` then `swx_get_preferences`) IS proven; the LABEL is
/// best-effort and says so.
fn units_of(doc: &Disp) -> Value {
let read = |id: i32| {
doc.obj("Extension", &[])
.ok()
.flatten()
.and_then(|e| e.int("GetUserPreferenceInteger", &[&Var::i4(id), &Var::i4(0)]))
};
json!({
"linearRaw": read(53),
"linear": read(53).map(Self::length_unit_name).unwrap_or(Value::Null),
"systemRaw": read(48),
"note": "raw swUserPreferenceIntegerValue_e values; the decoded names are \
best-effort. Every geometry number this bridge reports is in SI \
(metres/kg) or, where it says so, millimetres — never in document units.",
})
}
fn length_unit_name(v: i32) -> Value {
// swLengthUnit_e
Value::from(match v {
0 => "mm",
1 => "cm",
2 => "m",
3 => "in",
4 => "ft",
5 => "ft-in",
6 => "angstrom",
7 => "nm",
8 => "micron",
9 => "mil",
10 => "uin",
_ => "unknown",
})
}
/// `swx_get_parameters` — global variables (the equation manager) plus the
/// named dimensions Fusion would call user parameters.
fn cmd_get_parameters(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
let mut out: Vec<Value> = Vec::new();
if let Ok(Some(eq)) = doc.obj("GetEquationMgr", &[]) {
let count = eq.int("GetCount", &[]).unwrap_or(0);
for i in 0..count.max(0) {
let text = eq.string("Equation", &[&Var::i4(i)]).unwrap_or_default();
let value = eq.float("Value", &[&Var::i4(i)]);
// `"name" = expression` is the equation-manager syntax.
let (name, expression) = match text.split_once('=') {
Some((n, e)) => (n.trim().trim_matches('"').to_string(), e.trim().to_string()),
None => (format!("equation{i}"), text.clone()),
};
out.push(json!({
"name": name,
"expression": expression,
"value": value,
"unit": Value::Null,
"source": "equation",
"index": i,
}));
}
}
// Named dimensions: walk the feature tree and ask each feature for its
// display dimensions. `IDimension::SystemValue` is metres, which is what
// `Parameter("D1@Boss-Extrude1").SystemValue` returned live.
// BOUNDED: each feature costs at least one COM round trip, so a
// thousand-feature model would otherwise turn a "read the parameters"
// verb into a minute of blocked CAD application. 500 features is well
// past any hand-built part and the cut is reported, not hidden.
const FEATURE_BUDGET: usize = 500;
// Each probe is a COM round trip on a single-threaded apartment that
// nothing else can use meanwhile, so the COST is what needs bounding,
// not just the feature count. 400 probes is a fraction of a second on a
// warm host and covers any hand-built part.
const PROBE_BUDGET: usize = 400;
let mut probes = 0usize;
let mut feature = doc.obj("FirstFeature", &[]).ok().flatten();
let mut guard = 0usize;
let mut truncated = false;
while let Some(f) = feature {
guard += 1;
if guard > FEATURE_BUDGET {
truncated = true;
break;
}
let fname = f.string("Name", &[]).unwrap_or_default();
if !fname.is_empty() {
for n in 1..=8 {
if probes >= PROBE_BUDGET {
truncated = true;
break;
}
probes += 1;
let key = format!("D{n}@{fname}");
let dim = doc.obj("Parameter", &[&Var::bstr(&key)]).ok().flatten();
let Some(dim) = dim else { break };
let value = dim.float("SystemValue", &[]);
out.push(json!({
"name": key,
"expression": Value::Null,
"value": value,
"valueMm": value.map(|v| (v * 1000.0 * 1000.0).round() / 1000.0),
"unit": "m",
"source": "dimension",
}));
}
}
if truncated {
break;
}
feature = f.obj("GetNextFeature", &[]).ok().flatten();
}
Ok(json!({
"doc": Self::doc_info(&doc),
"parameters": out,
"count": out.len(),
"featuresWalked": guard,
"dimensionProbes": probes,
"truncated": truncated,
}))
}
/// `swx_set_parameter` — drive one named dimension or global variable.
fn cmd_set_parameter(&mut self, args: &Value) -> Result<Value, HostError> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| HostError::msg("name is required, e.g. \"D1@Boss-Extrude1\""))?;
// `name` here is the DIMENSION, not the document. The document comes
// from the server's authorized `docName`/`path`.
let doc = self.resolve(&json!({
"docName": args.get("docName").cloned(),
"path": args.get("path").cloned(),
}))?;
// Millimetres if the caller says so (the unit an engineer speaks), metres
// otherwise — SystemValue is metres and the ambiguity has to die here.
let (value, unit) = match (args.get("valueMm").and_then(|v| v.as_f64()), args.get("value").and_then(|v| v.as_f64())) {
(Some(mm), _) => (mm * 0.001, "mm"),
(None, Some(m)) => (m, "m"),
_ => return Err(HostError::msg("value (metres) or valueMm (millimetres) is required")),
};
if !value.is_finite() {
return Err(HostError::msg("value must be a finite number"));
}
let dim = doc
.obj("Parameter", &[&Var::bstr(name)])?
.ok_or_else(|| {
HostError::msg(format!(
"no dimension named '{name}' — swx_get_parameters lists what this document \
has (dimension names look like D1@Boss-Extrude1)"
))
})?;
let before = dim.float("SystemValue", &[]);
dim.put("SystemValue", &Var::r8(value))?;
let rebuilt = doc.call("EditRebuild3", &[]).ok().and_then(|v| v.as_bool());
let after = dim.float("SystemValue", &[]);
Ok(json!({
"doc": Self::doc_info(&doc),
"name": name,
"requested": value,
"unitGiven": unit,
"before": before,
"value": after,
"valueMm": after.map(|v| (v * 1000.0 * 1000.0).round() / 1000.0),
"rebuilt": rebuilt,
}))
}
/// `swx_inspect_bodies` — geometry read-back in MILLIMETRES, the Fusion
/// shape. `GetBodies2(0, true)` (solid bodies, visible only) is proven live.
/// A MaterialPropertyValues array's colour as 0..255 RGB; None when the
/// holder has no colour of its own (an empty array or negative entries).
fn rgb_of(holder: &Disp, prop: &str) -> Option<Value> {
let v: Vec<f64> = holder.call(prop, &[]).ok()?.as_array().iter().filter_map(|e| e.as_f64()).collect();
(v.len() >= 3 && v[..3].iter().all(|c| (0.0..=1.0).contains(c)))
.then(|| json!([(v[0] * 255.0).round() as i64, (v[1] * 255.0).round() as i64, (v[2] * 255.0).round() as i64]))
}
/// ONE BODY'S DENSITY (kg/m³): an IMassProperty restricted to that body
/// with `AddBodies` (help: "Uses only the specified bodies when calculating
/// the mass properties"; a multibody part can give each body its own
/// material). `None` when any step is unavailable.
fn body_density(doc: &Disp, body: &Disp) -> Option<f64> {
let mp = doc.obj("Extension", &[]).ok().flatten()?.obj("CreateMassProperty", &[]).ok().flatten()?;
let list = Var::dispatch_array(&[body.0.clone()]);
if mp.call("AddBodies", &[&list]).ok()?.as_bool() != Some(true) {
return None;
}
// Mass / Volume of the BODY-restricted mass property (review #11:
// IMassProperty::Density is documented "for this model").
let (m, v) = (mp.float("Mass", &[])?, mp.float("Volume", &[])?);
(v > 0.0).then(|| m / v).filter(|d| d.is_finite() && *d > 0.0)
}
fn cmd_inspect_bodies(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
if doc.int("GetType", &[]).unwrap_or(0) != DOC_PART {
return Err(HostError::msg(
"inspect_bodies needs a part (.sldprt); for an assembly use swx_bom or \
swx_mass_properties",
));
}
let bodies = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?;
let filter = args.get("nameContains").and_then(|v| v.as_str()).unwrap_or("");
// The PART's density: what swx_mass_properties reports.
let part_density = doc
.obj("Extension", &[])
.ok()
.flatten()
.and_then(|ext| ext.obj("CreateMassProperty", &[]).ok().flatten())
.and_then(|mp| mp.float("Density", &[]))
.filter(|d| d.is_finite() && *d > 0.0);
let mut out = Vec::new();
for b in bodies.as_array() {
let Some(body) = b.as_dispatch().map(Disp) else { continue };
let name = body.string("Name", &[]).unwrap_or_default();
if !filter.is_empty() && !name.to_lowercase().contains(&filter.to_lowercase()) {
continue;
}
// GetBodyBox -> [xMin,yMin,zMin,xMax,yMax,zMax] in metres.
let bb: Vec<f64> =
body.call("GetBodyBox", &[]).map(|v| v.as_array().iter().filter_map(|e| e.as_f64()).collect()).unwrap_or_default();
let mm = |v: f64| (v * 1000.0 * 1000.0).round() / 1000.0;
let (bbox, size) = if bb.len() >= 6 {
(
json!({"minMm": [mm(bb[0]), mm(bb[1]), mm(bb[2])], "maxMm": [mm(bb[3]), mm(bb[4]), mm(bb[5])]}),
json!({"x": mm(bb[3] - bb[0]), "y": mm(bb[4] - bb[1]), "z": mm(bb[5] - bb[2])}),
)
} else {
(Value::Null, Value::Null)
};
// THE DENSITY (issue #2, Michael 2026-10-13: massKg came back
// ~1000x small — exactly volume x 1). IBody2::GetMassProperties'
// one argument is the DENSITY to use, not an accuracy: "Density to
// use for the mass property calculations on this body … Mass
// (Volume*density)" (help.solidworks.com/2021/english/api/
// sldworksapi/solidworks.interop.sldworks~solidworks.interop.
// sldworks.ibody2~getmassproperties.html; Availability 2001Plus).
// The bridge passed 0.0. The density is the body's material's,
// read through an IMassProperty restricted to THIS body
// (IMassProperty::AddBodies), else the part's (the IMassProperty
// swx_mass_properties reads), else unknown — and then no mass.
let (density, density_source) = match Self::body_density(&doc, &body) {
Some(d) => (Some(d), "bodyMassProperty"),
None => match part_density {
Some(d) => (Some(d), "partMassProperty"),
None => (None, "unreadable"),
},
};
// IBody2::GetMassProperties(density) -> [cx,cy,cz, volume, area, mass, ...]
let mp: Vec<f64> = body
.call("GetMassProperties", &[&Var::r8(density.unwrap_or(1.0))])
.map(|v| v.as_array().iter().filter_map(|e| e.as_f64()).collect())
.unwrap_or_default();
let get = |i: usize| mp.get(i).copied();
out.push(json!({
"name": name,
"bbox": bbox,
"sizeMm": size,
"volumeMm3": get(3).map(|v| (v * 1.0e9 * 1000.0).round() / 1000.0),
"areaMm2": get(4).map(|v| (v * 1.0e6 * 1000.0).round() / 1000.0),
"massKg": density.and(get(5)),
"densityKgM3": density,
"densitySource": density_source,
// THE APPEARANCE (0.10.0, issue #1): the body's own colour
// (IBody2::MaterialPropertyValues2) or, if it has none, the
// part's (IModelDoc2::MaterialPropertyValues), as 0..255 RGB.
"appearanceRgb": Self::rgb_of(&body, "MaterialPropertyValues2").or_else(|| Self::rgb_of(&doc, "MaterialPropertyValues")),
"centerOfMassMm": (mp.len() >= 3).then(|| json!([mm(mp[0]), mm(mp[1]), mm(mp[2])])),
"faceCount": body.int("GetFaceCount", &[]),
"visible": body.flag("Visible", &[]),
"isSolid": true,
}));
}
Ok(json!({
"documentName": doc.string("GetTitle", &[]),
"doc": Self::doc_info(&doc),
"units": "mm",
"bodyCount": out.len(),
"bodies": out,
}))
}
/// OPEN A NEUTRAL FILE AS A LINKED PART — the 3D Interconnect route.
///
/// `OpenDoc6(path, swDocPART, options, "", byref errors, byref warnings)`.
/// The options value is `swOpenDocOptions_Silent` (1), the same one every
/// other open in this bridge uses and for the same reason: an unattended
/// bridge must never park SOLIDWORKS on a dialog nobody will answer. **That
/// choice is not yet measured for THIS route** — the live run that proved the
/// route exists did not vary the options — so it is reported in the reply
/// rather than assumed silently.
///
/// The result is a NEW PART holding a linked 3D Interconnect feature that
/// points back at the source file. It is `linked: true`, and the reply says
/// so: a caller who thinks it has a self-contained part would be wrong about
/// what moving or deleting the source file does to it.
fn import_via_opendoc6(
&mut self,
sw: &Disp,
path: &str,
before: i32,
interconnect: Option<bool>,
diagnostics: Option<bool>,
args: &Value,
) -> Result<Value, HostError> {
// (`dissolve` is refused in `cmd_import`, above the route choice, so it
// fails closed on every route rather than only on this one.)
let mut errors = 0i32;
let mut warnings = 0i32;
// MEASURED ENUM VALUES, for whoever runs the matrix next.
//
// `swOpenDocOptions_e`: Silent=1, ReadOnly=2, ViewOnly=4, RapidDraft=8,
// LoadModel=16, AutoMissingConfig=32, OverrideDefaultLoadLightweight=64,
// LoadLightweight=128, DontLoadHiddenComponents=256,
// LoadExternalReferencesInMemory=512, OpenDetailingMode=1024,
// LDR_EditAssembly=2048, SpeedPak=4096, AdvancedConfig=8192.
//
// `swFileLoadError_e`: swGenericError=1, swFileNotFoundError=2,
// swInvalidFileTypeError=1024, swFileRequiresRepairError=2097152,
// swApplicationBusy=8388608, swConnectedIsOffline=16777216.
//
// Live 2026-09-17 with options=1 and docType=1 on two real STEP files:
// no document, errors=2097152 (swFileRequiresRepairError), warnings=0.
// So this invocation is MEASURED WRONG, not measured right — the
// defaults stay only until the matrix says what to change them to.
let options = args.get("openOptions").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
let doc_type =
args.get("docType").and_then(|v| v.as_i64()).unwrap_or(Self::DOC_TYPE_PART as i64) as i32;
let invocation = json!({
"method": "OpenDoc6",
"args": {
"path": path,
"docType": doc_type,
"options": options,
"configuration": "",
"errors": "byref i4",
"warnings": "byref i4",
},
});
let opened_call = sw
.call(
"OpenDoc6",
&[
&Var::bstr(path),
&Var::i4(doc_type),
&Var::i4(options),
&Var::bstr(""),
&Var::byref_i4(&mut errors as *mut i32),
&Var::byref_i4(&mut warnings as *mut i32),
],
)
.map_err(|e| {
let detail = e.detail.clone();
let data = e.data.clone();
let mut err = HostError::detailed(
format!(
"SOLIDWORKS could not open {path} as a 3D Interconnect part \
(errors={errors} warnings={warnings}). 3D Interconnect is ON, so a \
neutral file is opened rather than translated."
),
detail.unwrap_or_else(|| e.message.clone()),
);
err.data = Some(json!({
"route": "OpenDoc6",
"invocation": invocation.clone(),
"interconnect": interconnect,
"importDiagnosticsAutoRun": diagnostics,
"openDoc6": data,
"errors": errors,
"warnings": warnings,
"path": path,
}));
err
});
// A CALL THAT THREW MAY STILL HAVE OPENED SOMETHING. Ask, before
// reporting a failure that would leave it open and untracked.
let opened = match opened_call {
Ok(v) => v.as_dispatch().map(Disp),
Err(mut err) => {
let recount = self.claim_document_born_from_a_failure(sw, before, &mut err);
if let Some(Value::Object(m)) = err.data.as_mut().map(|d| d) {
m.insert("afterFailure".into(), recount);
}
return Err(err);
}
};
// OWN IT THE MOMENT IT EXISTS — the same rule the translator route
// learned the hard way. The document this produces is PATHLESS (its title
// comes from the source file), so without registration at birth the drain
// could not identify it as the bridge's own.
let born = opened.as_ref().map(Self::doc_info);
if let Some(doc) = born.clone() {
self.register_created(&doc);
}
let after = match Self::document_count(sw) {
Some(c) => c,
None => {
let mut e = HostError::detailed(
"the import may or may not have completed: SOLIDWORKS stopped reporting how \
many documents are open. Check swx_docs before retrying — retrying blind \
could import the file twice."
.to_string(),
"GetDocumentCount returned nothing".to_string(),
);
e.created = born;
return Err(e);
}
};
let net = after - before;
let Some(doc) = opened else {
// F4 — NET > 0 WITH NO HANDLE. `OpenDoc6` returned nothing, and the
// count says a document appeared anyway. The translator route has
// recovered the handle from `ActiveDoc` since 0.5.x for exactly this
// shape; this route returned an error and owned nothing, so whatever
// had opened was unowned, uncounted and unmentioned — the leak
// `register_created` exists to prevent, one route later.
if after > before {
if let Some(recovered) = sw.obj("ActiveDoc", &[]).ok().flatten() {
let info = Self::doc_info(&recovered);
self.register_created(&info);
return Ok(json!({
"imported": info,
"via": "OpenDoc6 (handle recovered from ActiveDoc)",
"invocation": invocation,
"interconnect": interconnect,
"interconnectSettingReadable": interconnect.is_some(),
"linked": true,
"sourcePath": path,
"errors": errors,
"warnings": warnings,
"documentsOpened": after - before,
"netCountOk": after - before == 1,
"documentCount": after,
"_hint": format!(
"OpenDoc6 returned no handle but a document appeared, so the handle was \
recovered from ActiveDoc and the document is OWNED by this bridge — \
it can be closed and drained. {}",
import_open_hint(after - before)
),
}));
}
}
return Err(HostError::detailed(
format!(
"SOLIDWORKS opened nothing for {path} (errors={errors} warnings={warnings}). \
3D Interconnect is ON, so this file was opened as a part rather than \
translated."
),
format!("OpenDoc6 returned no document; open-document count went {before} -> {after}"),
)
.with_data(json!({
"route": "OpenDoc6",
"invocation": invocation.clone(),
"interconnect": interconnect,
"importDiagnosticsAutoRun": diagnostics,
"errors": errors,
"warnings": warnings,
"path": path,
})));
};
Ok(json!({
"imported": Self::doc_info(&doc),
"via": "OpenDoc6 (3D Interconnect)",
"invocation": invocation,
// WHAT THE USER'S SETTING DECIDED, reported rather than implied.
"interconnect": interconnect,
"interconnectSettingReadable": interconnect.is_some(),
// AND WHAT THAT MEANS FOR THE DOCUMENT: it is not self-contained.
"linked": true,
"sourcePath": path,
"openOptions": options,
"docType": doc_type,
"openOptionsMeasured": false,
"importDiagnosticsAutoRun": diagnostics,
"errors": errors,
"warnings": warnings,
"documentsOpened": net,
"netCountOk": net == 1,
"documentCount": after,
"_hint": format!(
"{}3D Interconnect is ON in this user's SOLIDWORKS, so {path} was OPENED as a part \
holding a LINKED feature rather than translated — the part is NOT self-contained, \
and moving or deleting the source file changes it. This bridge never alters that \
preference. `dissolve:true` is refused until the breaking API is measured on this \
install.",
import_open_hint(net)
),
}))
}
/// A CALL THREW — DID IT LEAVE A DOCUMENT BEHIND ANYWAY?
///
/// The import counts ONCE, before it starts, and every success path re-counts
/// afterwards. The THROWING paths did not: a `LoadFile4` or `OpenDoc6` that
/// raised a COM exception went straight to an error without asking whether a
/// document had appeared regardless. If one had, it was open, untracked, and
/// this host would later refuse to close it — because its own record said it
/// had never made one, which is the exact leak `register_created` exists to
/// prevent.
///
/// So the error path re-counts, and if the session grew it takes ownership of
/// the active document and reports it. Ownership at birth is not a
/// happy-path rule.
fn claim_document_born_from_a_failure(
&mut self,
sw: &Disp,
before: i32,
err: &mut HostError,
) -> Value {
let after = match Self::document_count(sw) {
Some(c) => c,
None => return Value::Null,
};
if after <= before {
return json!({"documentCountBefore": before, "documentCountAfter": after, "appeared": false});
}
let born = sw.obj("ActiveDoc", &[]).ok().flatten().map(|d| Self::doc_info(&d));
if let Some(info) = born.clone() {
self.register_created(&info);
err.created = Some(info);
}
json!({
"documentCountBefore": before,
"documentCountAfter": after,
// A DOCUMENT APPEARED DESPITE THE FAILURE, and it is now owned — so
// the drain and the safe close can both reach it.
"appeared": true,
"claimed": born,
})
}
/// `LoadFile2(path, "")` ALONE — for the matrix.
///
/// In `auto` this only ever runs as a fallback after `LoadFile4`, so its
/// answer has always been entangled with whatever the first call did to the
/// session. Asked for by name it runs first and by itself, which is the only
/// way its `false` — unexplained since 2026-09-17 — can be attributed to
/// anything.
fn import_via_loadfile2(
&mut self,
sw: &Disp,
path: &str,
before: i32,
interconnect: Option<bool>,
diagnostics: Option<bool>,
) -> Result<Value, HostError> {
let invocation = json!({
"method": "LoadFile2",
"args": {"path": path, "arg": ""},
});
let call = sw.call("LoadFile2", &[&Var::bstr(path), &Var::bstr("")]);
let failure = call.as_ref().err().map(|e| {
json!({"message": e.message, "detail": e.detail, "code": e.code})
});
let returned = call.as_ref().ok().and_then(|v| v.as_bool());
if returned != Some(true) {
return Err(HostError::detailed(
format!(
"SOLIDWORKS could not import {path} by LoadFile2 alone (returned {returned:?})"
),
failure
.as_ref()
.and_then(|f| f.get("detail").and_then(|d| d.as_str()).map(|s| s.to_string()))
.unwrap_or_else(|| "LoadFile2 returned false".into()),
)
.with_data(json!({
"route": "LoadFile2",
"invocation": invocation,
"interconnect": interconnect,
"importDiagnosticsAutoRun": diagnostics,
"loadFile2": {"returned": returned, "failure": failure},
"path": path,
})));
}
let doc = sw
.obj("ActiveDoc", &[])?
.ok_or_else(|| HostError::msg("LoadFile2 reported success but no document became active"))?;
let info = Self::doc_info(&doc);
// OWN IT THE MOMENT IT EXISTS, exactly as the other routes do.
self.register_created(&info);
let after = Self::document_count(sw);
let net = after.map(|a| a - before);
Ok(json!({
"imported": info,
"via": "LoadFile2",
"invocation": invocation,
"interconnect": interconnect,
"interconnectSettingReadable": interconnect.is_some(),
"importDiagnosticsAutoRun": diagnostics,
"linked": false,
"errors": 0,
"documentsOpened": net,
"netCountOk": net == Some(1),
"documentCount": after,
"_hint": "Imported by LoadFile2 asked for BY NAME — a measurement route, not the \
default. It left one new open, unsaved document.",
}))
}
/// WHY `dissolve:true` IS REFUSED RATHER THAN GUESSED. /// WHY `dissolve:true` IS REFUSED RATHER THAN GUESSED.
///
/// Breaking a 3D Interconnect link is a WRITE to the user's document, and the
/// call that does it has not been measured on this install. This project has
/// already shipped one confident claim about somebody else's format from
/// memory — the OLE compound-file signature of pass 23, which was wrong for
/// every modern SOLIDWORKS file — and that one only cost a refusal. A wrong
/// guess here modifies a document.
///
/// So the refusal names exactly what has to be measured, and the safe route
/// that needs no new API at all.
fn dissolve_refusal() -> HostError {
let mut e = HostError::coded(
"dissolve_not_measured",
"this bridge will not break a 3D Interconnect link with an API it has not verified on \
this install. The import itself went ahead as a LINKED part (that is what 3D \
Interconnect does); nothing was changed.",
);
e.detail = Some(
"To settle it, measure which call actually breaks the link on this edition — the \
candidates are ModelDocExtension::BreakAllExternalFileReferences2 and the 3D \
Interconnect feature's own Dissolve — and verify the EFFECT (the linked feature is \
gone and the body survives), not the return value. Until then: import linked, then \
dissolve in the SOLIDWORKS UI, or turn 3D Interconnect off in Options and import \
again, which this bridge will route through LoadFile4."
.into(),
);
e
}
/// `swMultiCAD_Enable3DInterconnect` — **measured live, 2026-09-17: TRUE.**
///
/// The id and the neighbouring diagnostics toggles were read from the
/// installed `SolidWorks.Interop.swconst.dll` at
/// `C:\Program Files\Dassault Systemes\SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS\`
/// and then read back off the running application with
/// `GetUserPreferenceToggle`:
///
/// ```text
/// 691 swMultiCAD_Enable3DInterconnect TRUE
/// 291 swImportAutoRunImportDiagnostics TRUE
/// 690 swImportNeutralRunDiagnostics TRUE
/// 639 swForceEnableImportDiagnosis FALSE
/// ```
const PREF_3D_INTERCONNECT: i32 = 691;
/// `swImportAutoRunImportDiagnostics` — TRUE on this install, which is why a
/// STEP with faulty faces raises a MODAL Import Diagnostics dialog.
const PREF_AUTO_IMPORT_DIAGNOSTICS: i32 = 291;
/// `swDocPART` — the document type an interconnect open produces.
const DOC_TYPE_PART: i32 = 1;
/// Read a user preference toggle. `None` when SOLIDWORKS would not answer —
/// an unreadable preference is UNKNOWN, and the import reports it as such
/// rather than assuming the default.
fn pref_toggle(sw: &Disp, id: i32) -> Option<bool> {
sw.call("GetUserPreferenceToggle", &[&Var::i4(id)]).ok().and_then(|v| v.as_bool())
}
/// `swx_import` — bring a neutral CAD file in as a new document.
///
/// **WHAT IS MEASURED, AND WHAT IS NOT.** This distinction is the whole
/// state of this verb, and two releases in a row have gone wrong by blurring
/// it.
///
/// MEASURED on this install (2026-09-17):
///
/// * `swMultiCAD_Enable3DInterconnect` (691) is **TRUE**.
/// * `LoadFile4` with the correct variant encoding EXECUTES and returns no
/// document, `errors = 1` (`swGenericError`).
/// * `LoadFile2` returns **false**. Still unexplained.
/// * `OpenDoc6(path, swDocPART, 1, "", …)` on a real `.step` returns no
/// document, `errors = 2097152` (`swFileRequiresRepairError`), warnings 0,
/// open-document count unchanged — for a 25 KB McMaster AP203 file and a
/// 4.6 MB one alike.
///
/// **NOT MEASURED:** what the 691 toggle actually implies about the route. I
/// wrote in 0.8.0 that it meant "OpenDoc6 with swDocPART", from recollection,
/// and the live run says that is wrong. No route is known to work on this
/// edition today.
///
/// So this verb carries a MEASUREMENT SURFACE (`route`, `openOptions`,
/// `docType`, `withImportData`, `argString`) and echoes the exact
/// `invocation` it made. `route:"auto"` keeps the shipped behaviour; the rest
/// exist so the matrix can be run against the real application, which is the
/// only thing that can answer this — PowerShell cannot drive SOLIDWORKS
/// Connected at all (every member call fails `TYPE_E_ELEMENTNOTFOUND`
/// 0x8002802B, because Connected does not expose the type info the COM
/// adapter needs), so raw IDispatch through this bridge is the only
/// instrument there is.
///
/// **THE TOGGLE IS NEVER CHANGED.** Not set, and not flipped-and-restored: a
/// crash between the two halves would leave the user's SOLIDWORKS configured
/// differently than they left it, and a CAD setting silently altered by a
/// tool is exactly the kind of harm this bridge exists not to do. The
/// preference is READ, reported, and obeyed.
///
/// `LoadFile2` returning `false` on this install is still UNEXPLAINED. It is
/// not the interconnect story — it was the route that appeared to work from
/// 09-05 to 09-11 — and it has not been diagnosed.
fn cmd_import(&mut self, args: &Value) -> Result<Value, HostError> {
let path = args
.get("filePath")
.or_else(|| args.get("path"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| HostError::msg("filePath is required"))?;
if !std::path::Path::new(path).exists() {
return Err(HostError::msg(format!("file not found: {path}")));
}
let sw = self.app(false, true)?;
// THE USER'S SETTING, READ AND OBEYED — never written. `None` means
// SOLIDWORKS would not answer, and an unreadable preference is UNKNOWN:
// the translator route is attempted and the reply says the toggle could
// not be read, rather than a default being assumed.
let interconnect = Self::pref_toggle(&sw, Self::PREF_3D_INTERCONNECT);
let diagnostics = Self::pref_toggle(&sw, Self::PREF_AUTO_IMPORT_DIAGNOSTICS);
// ── THE MEASUREMENT SURFACE. Named routes and exact arguments, so the
// matrix can be run against the live application instead of recalled.
// `auto` is the shipped behaviour and the default; an unknown route is
// REFUSED rather than silently treated as auto, because a typo that
// quietly measures something else is worse than no measurement.
let route = args.get("route").and_then(|v| v.as_str()).unwrap_or("auto").to_ascii_lowercase();
if !matches!(route.as_str(), "auto" | "loadfile4" | "loadfile2" | "opendoc6") {
return Err(HostError::coded(
"unknown_import_route",
format!(
"unknown import route {route:?} — expected \"auto\", \"loadfile4\", \
\"loadfile2\" or \"opendoc6\""
),
));
}
// F2 — DISSOLVE IS REFUSED BEFORE ANY ROUTE IS CHOSEN. It used to live
// inside the interconnect route only, so asking for it while the toggle
// was off (or unreadable) SILENTLY DROPPED it: the caller asked for an
// unlinked import, got a linked-or-not import with no answer either way,
// and nothing said so. A request this bridge will not honour must fail
// closed on every route.
if args.get("dissolve").and_then(|v| v.as_bool()).unwrap_or(false) {
return Err(Self::dissolve_refusal());
}
let mut errors = 0i32;
// Count first. LoadFile4 can IMPORT SUCCESSFULLY and still hand back
// something that is not an IDispatch we can use — and the first version
// of this fell through to LoadFile2 on exactly that, importing the file
// a SECOND time and leaving two documents open, one of them untracked.
// So the fallback is decided by whether a document actually appeared,
// not by the shape of the return value.
// FAIL CLOSED. If we cannot count the open documents we cannot tell
// whether LoadFile4 already imported the file, and guessing wrong runs
// the import a second time — two documents, one of them untracked.
// The COUNT, not the enumeration: `GetDocumentCount` is one call and
// answers even in a session holding hundreds of leaked temps, whereas
// marshalling that many document handles is what times out. Import must
// not become impossible just because earlier imports were not drained.
let before = match Self::document_count(&sw) {
Some(c) => c,
None => {
return Err(HostError::detailed(
"cannot import: SOLIDWORKS would not report how many documents are open, so \
there is no way to tell afterwards whether the import already happened"
.to_string(),
"GetDocumentCount returned nothing".to_string(),
))
}
};
// ── OPENDOC6 ONLY WHEN ASKED FOR BY NAME. `auto` no longer prefers it
// under 3D Interconnect: the matrix measured that route returning
// `errors=2097152` (swFileRequiresRepairError) on every option value
// tried, so preferring it would be preferring a known failure.
if route == "opendoc6" {
return self.import_via_opendoc6(&sw, path, before, interconnect, diagnostics, args);
}
// ── LOADFILE2 ALONE, for the matrix: no LoadFile4 first, so its answer
// is its own rather than a fallback's.
if route == "loadfile2" {
return self.import_via_loadfile2(&sw, path, before, interconnect, diagnostics);
}
// ── THE TRANSLATOR ROUTE.
// ONE CALL, WITH THE ENCODING THE LIVE FAILURE NAMED. `importData` is an
// `[in]` OBJECT parameter, and "no import data" is a NULL DISPATCH, not
// VT_EMPTY — measured 2026-09-17 as
// `Type mismatch. (0x80020005) (argument index 1)`, which with the
// reversed `rgvarg` packing is this third parameter exactly.
//
// Deliberately NOT retried with the old encoding on failure: a silent
// second attempt would make the next live run ambiguous about which
// spelling SOLIDWORKS accepted, and this bridge has spent two releases
// learning that a guess nobody can see is worse than a failure somebody
// can read.
// THE SECOND ARGUMENT, overridable for the matrix. "r" is what every
// published example passes and what this bridge has always used; nothing
// has measured whether it is right on this edition.
let arg_string = args.get("argString").and_then(|v| v.as_str()).unwrap_or("r").to_string();
// AND THE IMPORT DATA. `null_dispatch` is Nothing — correct as of 0.7.9,
// and still not enough to make the call work here. `withImportData:true`
// asks SOLIDWORKS for a real `ImportFileData` object with
// `GetImportFileData(path)` and passes THAT, which is the other thing the
// signature will take. What that call returns is reported rather than
// assumed: an object, nothing, or a failure.
let want_data = args.get("withImportData").and_then(|v| v.as_bool()).unwrap_or(false);
let mut import_data_report = Value::Null;
let import_data = if want_data {
match sw.call("GetImportFileData", &[&Var::bstr(path)]) {
Ok(v) => {
let d = v.as_dispatch().map(Disp);
import_data_report = json!({
"returnedObject": d.is_some(),
// The type name where SOLIDWORKS will give one. It is a
// read, so a failure here is reported as null rather than
// failing the import.
"typeName": d.as_ref().and_then(|o| o.string("GetType", &[]))
.map(Value::from).unwrap_or(Value::Null),
});
d
}
Err(e) => {
import_data_report = json!({
"returnedObject": false,
"failure": {"message": e.message, "detail": e.detail},
});
None
}
}
} else {
None
};
let data_arg = match &import_data {
Some(d) => Var::dispatch(&d.0),
None => Var::null_dispatch(),
};
let load4 = sw.call(
"LoadFile4",
&[
&Var::bstr(path),
&Var::bstr(&arg_string),
&data_arg,
&Var::byref_i4(&mut errors as *mut i32),
],
);
// THE EXACT CALL THAT WAS MADE, echoed on every reply. A matrix of
// results is only readable if each row states its own invocation.
let invocation = json!({
"method": "LoadFile4",
"args": {
"path": path,
"argString": arg_string,
"importData": if import_data.is_some() { "GetImportFileData(path)" } else { "Nothing (null VT_DISPATCH)" },
"errors": "byref i4",
},
"importFileData": import_data_report,
});
// KEEP THE WHOLE FAILURE, not its first sentence. `variant.rs` puts the
// HRESULT, the EXCEPINFO source and description and the failing argument
// index onto `detail`; 0.7.7 kept only `message`, so a live run where
// EVERY STEP import began failing reported "SOLIDWORKS rejected
// 'LoadFile4'" and nothing a person could act on. The translator's own
// number is the entire diagnosis.
let load4_fail: Option<Value> = load4.as_ref().err().map(|e| {
json!({
"message": e.message,
"detail": e.detail,
"code": e.code,
})
});
let load4_err = load4.as_ref().err().map(|e| match &e.detail {
Some(d) if !d.trim().is_empty() => format!("{}: {}", e.message, d),
_ => e.message.clone(),
});
let imported = load4.ok().and_then(|v| v.as_dispatch()).map(Disp);
// OWN IT THE MOMENT IT EXISTS. Registration used to happen in the
// dispatcher, on the way out — so any failure AFTER the document was
// created (a count that stopped answering, a timeout, a kill) left a
// document open that this host would then refuse to close, because its
// own record said it had never made one. `register_created` persists, so
// this survives a respawn as well as a failure.
let born = imported.as_ref().map(Self::doc_info);
if let Some(doc) = born.clone() {
self.register_created(&doc);
}
let after = match Self::document_count(&sw) {
Some(c) => c,
None => {
let mut e = HostError::detailed(
"the import may or may not have completed: SOLIDWORKS stopped reporting how \
many documents are open. Check swx_docs before retrying — retrying blind \
could import the file twice."
.to_string(),
"GetDocumentCount returned nothing".to_string(),
);
e.created = born;
return Err(e);
}
};
// THE NET-COUNT ASSERTION. A clean import raises the open-document count
// by exactly one; anything else is reported so a caller is never told
// "imported" for a no-op or a double.
let net = after - before;
if let Some(doc) = imported {
return Ok(json!({
"imported": Self::doc_info(&doc),
"via": "LoadFile4",
"invocation": invocation.clone(),
"interconnect": interconnect,
"interconnectSettingReadable": interconnect.is_some(),
"linked": false,
"errors": errors,
"documentsOpened": net,
"netCountOk": net == 1,
"documentCount": after,
"_hint": import_open_hint(net),
}));
}
if after > before {
// It DID import; we just could not take the handle. Use the active
// document rather than importing it again.
//
// CAVEAT, and it is reported rather than silently assumed: under a
// CONCURRENT second import (another caller, or a user importing by
// hand at the same moment) `ActiveDoc` is whichever document became
// active last, which may be the OTHER import. The net count still
// proves that A document arrived; the title/path we report may
// belong to the other one. The bridge serialises its own COM calls,
// so this needs a second actor outside the bridge — and the honest
// move is to say so in the hint, not to pretend the handle was ours.
let doc = sw
.obj("ActiveDoc", &[])?
.ok_or_else(|| HostError::msg("import added a document but none became active"))?;
return Ok(json!({
"imported": Self::doc_info(&doc),
"via": "LoadFile4 (handle recovered from ActiveDoc)",
"invocation": invocation.clone(),
"titleMayBeAnotherConcurrentImport": true,
"errors": errors,
"documentsOpened": net,
"netCountOk": net == 1,
"documentCount": after,
"_hint": format!("{}{}", import_open_hint(net), RECOVERED_HANDLE_CAVEAT),
}));
}
// THE SECOND ROUTE'S OWN ANSWER IS ALSO REPORTED. "LoadFile2 refused" and
// "LoadFile2 threw" are different failures, and the reply used to show
// neither.
let load2 = sw.call("LoadFile2", &[&Var::bstr(path), &Var::bstr("")]);
let load2_fail: Option<Value> = load2.as_ref().err().map(|e| {
json!({"message": e.message, "detail": e.detail, "code": e.code})
});
let ok = load2.as_ref().ok().and_then(|v| v.as_bool()).unwrap_or(false);
// ── THE MEASURED DEAD END. On SOLIDWORKS Connected 2026 with
// `swMultiCAD_Enable3DInterconnect` TRUE there is NO programmatic
// neutral-file import route: the matrix of 2026-09-17 tried LoadFile4
// with Nothing and with a real ImportFileData object (errors=1 both),
// LoadFile2 (false), and OpenDoc6 across four option values and two
// document types (2097152 = requires repair, 1024 = invalid file
// type). The imports that worked from 09-05 to 09-11 must have run
// with the toggle OFF.
//
// So `auto` still makes ONE LoadFile4 attempt — a future edition that
// accepts it will simply work — and then says what is actually wrong,
// naming the setting, rather than reporting a translator failure that
// would send the reader looking for a missing translator.
//
// THE SETTING IS THE USER'S. This bridge does not change it and will
// not offer to.
// F3 — AN UNREADABLE SETTING IS NOT "OFF". `None` means SOLIDWORKS would
// not answer; `Some(false)` means the user turned it off. They take the
// same route and mean opposite things about how much to trust the result,
// so a failure under an unreadable toggle is reported as ITS OWN
// outcome rather than as a translator that is missing.
if !ok && route == "auto" && interconnect.is_none() {
let mut e = HostError::coded(
"interconnect_setting_unreadable",
format!(
"SOLIDWORKS could not import {path}, and this bridge could not read whether 3D \
Interconnect is on — `GetUserPreferenceToggle(691)` gave no answer. With that \
setting ON, no programmatic import route works on this edition (measured \
2026-09-17), so the most likely cause of this failure cannot be confirmed or \
ruled out. Nothing was opened and nothing was changed."
),
);
e.detail = Some(
"Check Tools > Options > System Options > Import > \"Enable 3D Interconnect\". If \
it is on, turn it off and import again. This bridge does not change user \
settings. swx_status shows whether COM is answering at all."
.into(),
);
e.data = Some(json!({
"interconnect": Value::Null,
"interconnectSettingReadable": false,
"invocation": invocation.clone(),
"loadFile4": load4_fail,
"loadFile2": {"returned": load2.as_ref().ok().and_then(|v| v.as_bool()), "failure": load2_fail},
"errors": errors,
// WHAT THE COUNT DID: whether a document was left behind is the
// fact ownership turns on. Named as the SUCCESS reply names them,
// so a caller reads one vocabulary either way.
"documentCountBefore": before,
"documentCount": Self::document_count(&sw),
"documentsOpened": Self::document_count(&sw).map(|a| a - before),
"path": path,
}));
return Err(e);
}
if !ok && route == "auto" && interconnect == Some(true) {
// "NOTHING WAS OPENED" MUST BE MEASURED AFTER THE LAST CALL THAT
// COULD OPEN SOMETHING. The count behind this claim was taken before
// LoadFile2 ran, so a LoadFile2 that returned false while still
// leaving a document behind would be reported as a no-op — and the
// document would be open, unowned and unmentioned.
// Measured FIRST, so the sentence can be true either way.
let mut e = HostError::coded("interconnect_blocks_import", String::new());
let appeared = self.claim_document_born_from_a_failure(&sw, before, &mut e);
let opened_anyway = appeared.get("appeared").and_then(|v| v.as_bool()).unwrap_or(false);
e.message = format!(
"SOLIDWORKS cannot import {path} while 3D Interconnect is on. Measured on this \
edition 2026-09-17: with `swMultiCAD_Enable3DInterconnect` TRUE, no programmatic \
route imports a neutral file — LoadFile4 returns nothing (errors=1) with or \
without ImportFileData, LoadFile2 returns false, and OpenDoc6 answers \
swFileRequiresRepairError. {}",
if opened_anyway {
"A document appeared ANYWAY and this bridge has taken ownership of it — see \
`afterFailure.claimed`; it can be closed and drained."
} else {
"Nothing was opened and nothing was changed."
}
);
// ASK, DO NOT INSTRUCT. This used to read "Turn it off in
// SOLIDWORKS: Tools > Options > …", which is a runbook — and an agent
// holding a desktop bridge can execute a runbook. The setting belongs
// to the user; the bridge's job is to say what would unblock the
// import and let them decide, not to describe the keystrokes as
// though somebody should perform them.
e.detail = Some(format!(
"ASK THE USER to turn 3D Interconnect off if they want this import (it lives at \
Tools > Options > System Options > Import > \"Enable 3D Interconnect\"), then \
try again — this bridge will route through LoadFile4 and say so. THIS BRIDGE \
DOES NOT CHANGE USER SETTINGS, not even temporarily: a crash between changing \
and restoring would leave SOLIDWORKS configured differently than they left it. \
(To measure the matrix again, swx_import takes \
route/openOptions/docType/withImportData/argString and echoes the exact \
invocation.)"
));
e.data = Some(json!({
"interconnect": true,
"importDiagnosticsAutoRun": diagnostics,
"afterFailure": appeared,
"invocation": invocation.clone(),
"loadFile4": load4_fail,
"loadFile2": {"returned": load2.as_ref().ok().and_then(|v| v.as_bool()), "failure": load2_fail},
"errors": errors,
// WHAT THE COUNT DID: whether a document was left behind is the
// fact ownership turns on. Named as the SUCCESS reply names them,
// so a caller reads one vocabulary either way.
"documentCountBefore": before,
"documentCount": Self::document_count(&sw),
"documentsOpened": Self::document_count(&sw).map(|a| a - before),
"path": path,
"measured": "2026-09-17: LoadFile4 errors=1 (Nothing and ImportFileData), LoadFile2 false, OpenDoc6 docType 1 options 0/1/2/16 errors=2097152, docType 0 options 1 errors=1024",
"userSettingPath": "Tools > Options > System Options > Import > Enable 3D Interconnect",
}));
return Err(e);
}
if !ok {
return Err(HostError::detailed(
format!(
"SOLIDWORKS could not import {path} (LoadFile4 opened nothing, errors={errors}; \
LoadFile2 {}). Importable formats depend on the installed translators.",
if load2_fail.is_some() { "failed" } else { "refused" }
),
load4_err.unwrap_or_else(|| "LoadFile4 returned no document".into()),
)
.with_data(json!({
"invocation": invocation.clone(),
"interconnect": interconnect,
"importDiagnosticsAutoRun": diagnostics,
"loadFile4": load4_fail,
"loadFile2": {
"returned": load2.as_ref().ok().and_then(|v| v.as_bool()),
"failure": load2_fail,
},
"errors": errors,
"path": path,
})));
}
let doc = sw
.obj("ActiveDoc", &[])?
.ok_or_else(|| HostError::msg("import reported success but no document became active"))?;
Ok(json!({
"imported": Self::doc_info(&doc),
"via": "LoadFile2 (after LoadFile4)",
"invocation": invocation.clone(),
"interconnect": interconnect,
"interconnectSettingReadable": interconnect.is_some(),
"linked": false,
"errors": errors,
"documentsOpened": 1,
}))
}
/// `swx_save_as` — a DELIBERATE rename/move of the open document (plain
/// Silent, no Copy), as opposed to `swx_export`'s copy.
fn cmd_save_as(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
let out = args
.get("outputPath")
.or_else(|| args.get("out"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| HostError::msg("outputPath is required"))?;
let copy = args.get("copy").and_then(|v| v.as_bool()).unwrap_or(false);
// A rename touches the DOCUMENT; a copy only writes a file.
let was_ours = self.host_owns(&doc);
let code = Self::save_as3(&doc, out, copy)?;
if code != 0 {
return Err(HostError::msg(format!(
"SOLIDWORKS refused to save {out}: {}",
Self::save_error_text(code)
)));
}
// OWNERSHIP FOLLOWS THE DOCUMENT, not the string that used to name it.
// A non-copy save_as retitles and re-paths the document in place; if the
// host's record is not moved with it, the bridge disowns the scratch
// document it just saved and can no longer close or clean it up.
if !copy && was_ours {
let old_title = doc.string("GetTitle", &[]).unwrap_or_default();
self.created.retain(|(t, _)| *t != old_title);
self.remember_created(&doc);
}
Ok(json!({
"savedAs": out,
"copy": copy,
"bytes": std::fs::metadata(out).map(|m| m.len()).ok(),
"doc": Self::doc_info(&doc),
}))
}
/// `swx_show_*` — activate the document and fit it in the view, in the
/// BACKGROUND. `ViewZoomtofit2` is proven live.
fn cmd_show(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args)?;
let title = doc.string("GetTitle", &[]).unwrap_or_default();
// A HIDDEN document (0.10.6) is made visible first — attempted and read
// back, never assumed: the help page for ISldWorks::DocumentVisible
// says a document created invisibly "cannot" be made visible with
// IModelDoc2::Visible (close it and reopen it instead). UNMEASURED.
let visibility = Self::make_visible(&doc);
let mut errors = 0i32;
let activated = if args.get("activate").and_then(|v| v.as_bool()).unwrap_or(true) {
let sw = self.app(false, true)?;
// swRebuildOnActivation_e 1 = swDontRebuildActiveDoc (0 is
// swUserDecision, which PROMPTS — fixed here in 0.10.6 as it was in
// activate in 0.10.2).
sw.call(
"ActivateDoc3",
&[
&Var::bstr(&title),
&Var::boolean(false),
&Var::i4(1),
&Var::byref_i4(&mut errors as *mut i32),
],
)
.is_ok()
} else {
false
};
let zoomed = doc.call("ViewZoomtofit2", &[]).is_ok();
Ok(json!({
"document": title,
"doc": Self::doc_info(&doc),
"activated": activated,
"zoomedToFit": zoomed,
"activateErrors": errors,
"visibility": visibility,
}))
}
/// IModelDoc2::Visible: read; if false, put true and read again.
fn make_visible(doc: &Disp) -> Value {
let before = doc.flag("Visible", &[]);
if before != Some(false) {
return json!({"wasVisible": before, "madeVisible": false});
}
let put = doc.put("Visible", &Var::boolean(true)).is_ok();
let after = doc.flag("Visible", &[]);
json!({
"wasVisible": false,
"putVisible": put,
"visibleAfter": after,
"madeVisible": after == Some(true),
"note": if after == Some(true) { Value::Null } else {
json!("SOLIDWORKS kept this document hidden: a document created invisibly cannot be made \
visible (help.solidworks.com, ISldWorks::DocumentVisible). Save it and reopen it \
(swx_save_as, then swx_open) to see it.")
},
})
}
/// `swx_render_image` (0.9.18) — MEASURED 2026-10-05 on the laptop (window
/// minimized): 4K in 11.6 s, every setting read back restored, the four
/// swconst ids resolved. `shadows:true` did not appear in applied/restored —
/// already on, or unreadable; `settings` now says which. The calls, each
/// from the help pages cited in src/render.rs and docs/RENDERING.md:
///
/// * `IModelDoc2::ActiveView` → `IModelView`;
/// * `IModelView::DisplayMode` (get/put, `swViewDisplayMode_e`);
/// * `IModelView::HasPerspective` / `AddPerspective` / `RemovePerspective`;
/// * `IModelView::Orientation3`, `Translation3` (MathTransform /
/// MathVector, get/put) and `Scale2` (double) — the camera, captured and
/// put back;
/// * `IModelDocExtension::ViewDisplayRealView` (get/put bool);
/// * `ISldWorks::Get/SetUserPreferenceToggle(swDisplayShadowsInShadedMode)`
/// — a SYSTEM setting per the "Display Shadow" example;
/// `…(swDisplayAmbientOcclusionShadows)` — the same call, its scope
/// UNMEASURED;
/// * `ISldWorks::Get/SetUserPreferenceIntegerValue(swColorsBackgroundAppearance,
/// swSystemColorsViewportBackground)` for a plain white/black background;
/// * `IModelDoc2::ShowNamedView2("", swStandardViews_e)`,
/// `ViewZoomtofit2`, `GraphicsRedraw2`;
/// * `IModelDoc2::SaveBMP(path, width, height)` — the export.
///
/// The `swUserPreference*_e` ids are NOT published on the help pages, and
/// this bridge never guesses one: they are read from the installed
/// `SolidWorks.Interop.swconst.dll` (beside SLDWORKS.exe, measured
/// 2026-09-17), and a render that needs one it cannot read refuses
/// `render_enum_unresolved` before changing anything.
fn cmd_render_image(&mut self, args: &Value) -> Result<Value, HostError> {
let sw = self.app(false, true)?;
let doc = sw
.obj("ActiveDoc", &[])?
.ok_or_else(|| HostError::coded("no_active_document", "no document is active in SOLIDWORKS to render"))?;
let view = doc
.obj("ActiveView", &[])?
.ok_or_else(|| HostError::coded("render_failed", "the active document has no active view"))?;
let ext = doc.obj("Extension", &[])?;
let title = doc.string("GetTitle", &[]).unwrap_or_default();
let want_b = |k: &str| args.get(k).and_then(|v| v.as_bool());
// THE ENUM IDS this request needs, read from the installed swconst.dll.
let mut need: Vec<(&str, &str)> = Vec::new();
if want_b("shadows").is_some() {
need.push(("swUserPreferenceToggle_e", "swDisplayShadowsInShadedMode"));
}
if want_b("ambientOcclusion").is_some() {
need.push(("swUserPreferenceToggle_e", "swDisplayAmbientOcclusionShadows"));
}
let bg = args.get("backgroundColor").and_then(|v| v.as_i64());
if bg.is_some() {
need.push(("swUserPreferenceIntegerValue_e", "swColorsBackgroundAppearance"));
need.push(("swUserPreferenceIntegerValue_e", "swSystemColorsViewportBackground"));
}
let ids = if need.is_empty() {
std::collections::HashMap::new()
} else {
let exe_dir = sw.string("GetExecutablePath", &[]).unwrap_or_default();
swconst_ids(&exe_dir, &need).map_err(|e| HostError::coded("render_enum_unresolved", e))?
};
let id = |m: &str| ids.get(m).copied();
// ── CAPTURE ─────────────────────────────────────────────────────────
enum Was {
Mode(i32),
Persp(bool),
Real(bool),
Toggle(i32, bool),
Int(i32, i32),
}
let mut undo: Vec<(&'static str, Was)> = Vec::new();
let mut applied = Map::new();
let camera = (
view.obj("Orientation3", &[]).ok().flatten(),
view.obj("Translation3", &[]).ok().flatten(),
view.float("Scale2", &[]),
);
let mut steps_failed: Vec<String> = Vec::new();
// EVERY REQUESTED SETTING, accounted for (0.9.19): name -> (requested,
// before, after-apply, why). Filled as each is applied.
let mut acct: Vec<(&'static str, Value, Value, Value, &'static str)> = Vec::new();
// ── APPLY ───────────────────────────────────────────────────────────
if let Some(m) = args.get("displayModeId").and_then(|v| v.as_i64()).map(|v| v as i32) {
let was = view.int("DisplayMode", &[]);
let mut why = if was.is_none() { "unreadable" } else { "alreadyAsRequested" };
if let Some(was) = was {
if was != m {
if view.put("DisplayMode", &Var::i4(m)).is_ok() {
// A SET IS ONLY "APPLIED" IF IT READS BACK (0.9.20).
if view.int("DisplayMode", &[]) == Some(m) {
undo.push(("displayMode", Was::Mode(was)));
applied.insert("displayMode".into(), json!({"from": was, "to": m}));
why = "applied";
} else {
why = "setIgnored";
}
} else {
why = "setFailed";
}
}
}
acct.push(("displayStyle", args.get("displayStyle").cloned().unwrap_or(json!(m)), json!(was), json!(view.int("DisplayMode", &[])), why));
}
if let Some(p) = want_b("perspective") {
let was = view.flag("HasPerspective", &[]);
let mut why = if was.is_none() { "unreadable" } else { "alreadyAsRequested" };
if let Some(was) = was {
if was != p {
let r = view.call(if p { "AddPerspective" } else { "RemovePerspective" }, &[]);
if r.is_ok() && view.flag("HasPerspective", &[]) == Some(p) {
undo.push(("perspective", Was::Persp(was)));
applied.insert("perspective".into(), json!({"from": was, "to": p}));
why = "applied";
} else if r.is_ok() {
why = "setIgnored";
} else {
steps_failed.push("perspective".into());
why = "setFailed";
}
}
}
acct.push(("perspective", json!(p), json!(was), json!(view.flag("HasPerspective", &[])), why));
}
if let Some(rv) = want_b("realView") {
let was = ext.as_ref().and_then(|e| e.flag("ViewDisplayRealView", &[]));
let mut why = if was.is_none() { "unreadable" } else { "alreadyAsRequested" };
if let (Some(was), Some(ext)) = (was, ext.as_ref()) {
if was != rv {
if ext.put("ViewDisplayRealView", &Var::boolean(rv)).is_ok() {
// MEASURED 2026-10-05: the put returned OK and the read-back
// right after was still FALSE — RealView needs a certified
// graphics card (and may be unavailable while minimized).
// That is "setIgnored", never "applied".
if ext.flag("ViewDisplayRealView", &[]) == Some(rv) {
undo.push(("realView", Was::Real(was)));
applied.insert("realView".into(), json!({"from": was, "to": rv}));
why = "applied";
} else {
why = "setIgnored";
}
} else {
why = "setFailed";
}
}
}
acct.push(("realView", json!(rv), json!(was), json!(ext.as_ref().and_then(|e| e.flag("ViewDisplayRealView", &[]))), why));
}
for (key, member) in [("shadows", "swDisplayShadowsInShadedMode"), ("ambientOcclusion", "swDisplayAmbientOcclusionShadows")] {
if let Some(v) = want_b(key) {
let pid = id(member);
let was = pid.and_then(|pid| Self::pref_toggle(&sw, pid));
let mut why = if pid.is_none() { "idUnresolved" } else if was.is_none() { "unreadable" } else { "alreadyAsRequested" };
if let (Some(was), Some(pid)) = (was, pid) {
if was != v {
if sw.call("SetUserPreferenceToggle", &[&Var::i4(pid), &Var::boolean(v)]).is_ok() {
if Self::pref_toggle(&sw, pid) == Some(v) {
undo.push((if key == "shadows" { "shadows" } else { "ambientOcclusion" }, Was::Toggle(pid, was)));
applied.insert(key.into(), json!({"from": was, "to": v}));
why = "applied";
} else {
why = "setIgnored";
}
} else {
why = "setFailed";
}
}
}
let now = pid.and_then(|pid| Self::pref_toggle(&sw, pid));
acct.push((if key == "shadows" { "shadows" } else { "ambientOcclusion" }, json!(v), json!(was), json!(now), why));
}
}
if let (Some(color), Some(app_id), Some(col_id)) =
(bg, id("swColorsBackgroundAppearance"), id("swSystemColorsViewportBackground"))
{
let get = |pid: i32| sw.int("GetUserPreferenceIntegerValue", &[&Var::i4(pid)]);
if let (Some(was_app), Some(was_col)) = (get(app_id), get(col_id)) {
if sw.call("SetUserPreferenceIntegerValue", &[&Var::i4(app_id), &Var::i4(0)]).is_ok() {
undo.push(("backgroundAppearance", Was::Int(app_id, was_app)));
applied.insert("backgroundAppearance".into(), json!({"from": was_app, "to": 0}));
}
if sw.call("SetUserPreferenceIntegerValue", &[&Var::i4(col_id), &Var::i4(color as i32)]).is_ok() {
undo.push(("backgroundColor", Was::Int(col_id, was_col)));
applied.insert("backgroundColor".into(), json!({"from": was_col, "to": color}));
}
}
}
if let Some(color) = bg {
let now = id("swSystemColorsViewportBackground").and_then(|c| sw.int("GetUserPreferenceIntegerValue", &[&Var::i4(c)]));
let why = if applied.contains_key("backgroundColor") { "applied" } else if now.is_none() { "unreadable" } else { "alreadyAsRequested" };
let before = applied.get("backgroundColor").and_then(|a| a.get("from").cloned()).unwrap_or(json!(now));
acct.push(("background", args.get("background").cloned().unwrap_or(json!(color)), before, json!(now), why));
}
if let Some(vid) = args.get("viewId").and_then(|v| v.as_i64()) {
let ok = doc.call("ShowNamedView2", &[&Var::bstr(""), &Var::i4(vid as i32)]).is_ok();
if ok {
applied.insert("orientation".into(), json!({"to": args.get("view")}));
}
acct.push(("view", args.get("view").cloned().unwrap_or(Value::Null), json!("captured camera"), json!(args.get("view")), if ok { "applied" } else { "setFailed" }));
}
if args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false) {
let ok = doc.call("ViewZoomtofit2", &[]).is_ok();
if ok {
applied.insert("zoom".into(), json!({"to": "fit"}));
}
acct.push(("zoomToFit", json!(true), json!(camera.2), json!(view.float("Scale2", &[])), if ok { "applied" } else { "setFailed" }));
}
// FRAMING (0.9.19): multiply the view scale AFTER any fit. Its direction
// (a larger Scale2 = closer?) is UNMEASURED.
if let Some(z) = args.get("zoomFactor").and_then(|v| v.as_f64()).filter(|z| (*z - 1.0).abs() > 1e-9) {
let was = view.float("Scale2", &[]);
let ok = was.map(|w| view.put("Scale2", &Var::r8(w * z)).is_ok()).unwrap_or(false);
if ok {
applied.insert("zoom".into(), json!({"to": "factor", "factor": z}));
}
acct.push(("zoomFactor", json!(z), json!(was), json!(view.float("Scale2", &[])), if was.is_none() { "unreadable" } else if ok { "applied" } else { "setFailed" }));
}
let _ = doc.call("GraphicsRedraw2", &[]);
// ── EXPORT ──────────────────────────────────────────────────────────
let bmp = args.get("bmpPath").and_then(|v| v.as_str()).unwrap_or("").to_string();
let w = args.get("width").and_then(|v| v.as_i64()).unwrap_or(1920) as i32;
let h = args.get("height").and_then(|v| v.as_i64()).unwrap_or(1080) as i32;
let saved = doc
.call("SaveBMP", &[&Var::bstr(&bmp), &Var::i4(w), &Var::i4(h)])
.map(|v| v.as_bool().unwrap_or(false));
// ── RESTORE, on every path, newest first, each READ BACK ────────────
let mut restored = Map::new();
let mut failures: Vec<Value> = Vec::new();
for (key, was) in undo.into_iter().rev() {
let (ok, value, back) = match was {
Was::Mode(m) => {
let _ = view.put("DisplayMode", &Var::i4(m));
let b = view.int("DisplayMode", &[]);
(b == Some(m), json!(m), json!(b))
}
Was::Persp(p) => {
let _ = view.call(if p { "AddPerspective" } else { "RemovePerspective" }, &[]);
let b = view.flag("HasPerspective", &[]);
(b == Some(p), json!(p), json!(b))
}
Was::Real(r) => {
let e = ext.as_ref();
let _ = e.map(|e| e.put("ViewDisplayRealView", &Var::boolean(r)));
let b = e.and_then(|e| e.flag("ViewDisplayRealView", &[]));
(b == Some(r), json!(r), json!(b))
}
Was::Toggle(pid, v) => {
let _ = sw.call("SetUserPreferenceToggle", &[&Var::i4(pid), &Var::boolean(v)]);
let b = Self::pref_toggle(&sw, pid);
(b == Some(v), json!(v), json!(b))
}
Was::Int(pid, v) => {
let _ = sw.call("SetUserPreferenceIntegerValue", &[&Var::i4(pid), &Var::i4(v)]);
let b = sw.int("GetUserPreferenceIntegerValue", &[&Var::i4(pid)]);
(b == Some(v), json!(v), json!(b))
}
};
restored.insert(key.into(), json!({"value": value, "readBack": back, "restored": ok}));
if !ok {
failures.push(json!(key));
}
}
// The camera: orientation, translation and scale put back as captured.
if applied.contains_key("orientation") || applied.contains_key("zoom") || applied.contains_key("perspective") {
let mut cam_ok = true;
if let Some(o) = &camera.0 {
cam_ok &= view.put("Orientation3", &Var::dispatch(&o.0)).is_ok();
}
if let Some(t) = &camera.1 {
cam_ok &= view.put("Translation3", &Var::dispatch(&t.0)).is_ok();
}
if let Some(sc) = camera.2 {
cam_ok &= view.put("Scale2", &Var::r8(sc)).is_ok();
}
restored.insert("camera".into(), json!({"restored": cam_ok && camera.0.is_some(), "captured": camera.0.is_some()}));
if !(cam_ok && camera.0.is_some()) {
failures.push(json!("camera"));
}
}
let _ = doc.call("GraphicsRedraw2", &[]);
// THE ACCOUNT: one row per requested setting.
let mut settings = Map::new();
for (name, requested, before, after, why) in acct {
let changed = why == "applied";
let key = match name {
"displayStyle" => "displayMode",
"background" => "backgroundColor",
"view" | "zoomToFit" | "zoomFactor" => "camera",
other => other,
};
let restored_now = if changed {
restored.get(key).and_then(|r| r.get("restored").cloned()).unwrap_or(Value::Null)
} else {
Value::Null
};
settings.insert(name.into(), json!({"requested": requested, "before": before, "after": after,
"changed": changed, "restored": restored_now, "why": why}));
}
let data = json!({"appliedSettings": applied, "restoredSettings": restored, "restoreFailures": failures,
"stepsFailed": steps_failed, "settings": settings,
"enumIds": Value::Object(ids.iter().map(|(k, v)| (k.clone(), json!(v))).collect())});
match saved {
Ok(true) => {
let mut out = data;
out["document"] = json!(title);
out["saveBmp"] = json!(true);
Ok(out)
}
Ok(false) => Err(HostError::coded("render_failed", "SaveBMP returned false").with_data(data)),
Err(e) => Err(HostError::coded("render_failed", format!("SaveBMP failed: {}", e.message)).with_data(data)),
}
}
/// `swx_get_app_state` — the Fusion-shaped snapshot, from COM only (the
/// server adds the window/dialog half through ab).
/// HAS SOLIDWORKS FINISHED STARTING? `ISldWorks::StartupProcessCompleted`, a
/// read. Cheap on purpose — no document enumeration — because the readiness
/// gate asks it before every document command until the answer is yes.
/// MEASURED 2026-10-01: COM attach is not readiness; document commands sent
/// 8 s after attach crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still
/// initialising. Whether this property turns true at the right moment is the
/// next measurement; `null` means SOLIDWORKS would not answer it.
fn cmd_startup_state(&mut self) -> Result<Value, HostError> {
let sw = match self.app(false, true) {
Ok(sw) => sw,
Err(_) => {
return Ok(json!({"running": false, "startupCompleted": Value::Null, "processId": Value::Null}))
}
};
Ok(json!({
"running": true,
"startupCompleted": sw.flag("StartupProcessCompleted", &[]),
"processId": sw.int("GetProcessID", &[]),
// One call that always answers; a non-zero count at any poll records
// the session as having held a document (session_history).
"documentCount": Self::document_count(&sw),
}))
}
fn cmd_app_state(&mut self) -> Result<Value, HostError> {
let sw = match self.app(false, true) {
Ok(sw) => sw,
Err(_) => {
return Ok(json!({
"running": false,
"activeDocument": Value::Null,
"documents": [],
"busy": Value::Null,
"mainHwnd": Value::Null,
}))
}
};
// DEGRADE ABOVE THE CAP, exactly as `cmd_docs` does — this is called by
// `ownership_context` on nearly every verb, so a 1342-document session
// would wedge the whole bridge here, not just `swx_docs`. Over the cap
// we report the COUNT and set `documentsTruncated`, and the SERVER must
// then NOT prune its ownership registry against an empty list (an empty
// list here means "too many to enumerate", not "nothing is open").
let count = Self::document_count(&sw);
// `None` (unreadable) is UNKNOWN-and-truncated, never "small enough to
// enumerate": the server must not prune ownership against it and must not
// pay the unbounded marshal. See cmd_docs.
let truncated = count.map(|c| c as usize > super::host::doc_enum_cap()).unwrap_or(true);
let docs: Vec<Value> = if truncated {
Vec::new()
} else {
Self::documents(&sw)?.iter().map(Self::doc_info).collect()
};
let active = sw.obj("ActiveDoc", &[])?.map(|d| Self::doc_info(&d));
let workspace = active
.as_ref()
.and_then(|a| a.get("type").and_then(|t| t.as_str()))
.unwrap_or("none")
.to_string();
let unsaved = docs
.iter()
.filter(|d| d.get("dirty").and_then(|x| x.as_bool()).unwrap_or(false))
.count();
Ok(json!({
"running": true,
"version": sw.string("RevisionNumber", &[]),
"activeDocument": active.clone().map(|a| a.get("title").cloned().unwrap_or(Value::Null)).unwrap_or(Value::Null),
"activeDoc": active,
"documents": docs,
"documentsTruncated": truncated,
"documentCount": count,
"unsavedDocuments": unsaved,
"workspace": workspace,
"busy": sw.flag("CommandInProgress", &[]),
"visible": sw.flag("Visible", &[]),
"processId": sw.int("GetProcessID", &[]),
"mainHwnd": Self::main_hwnd(&sw),
}))
}
/// The application window handle, straight from COM — the only way to
/// attribute a desktop window to SOLIDWORKS with certainty. Proven live
/// (`$sw.Frame().GetHWnd()` -> 1836650).
fn main_hwnd(sw: &Disp) -> Value {
sw.obj("Frame", &[])
.ok()
.flatten()
.and_then(|f| f.int("GetHWnd", &[]))
.map(Value::from)
.unwrap_or(Value::Null)
}
/// `swx_check_recovery` — SOLIDWORKS exposes no "is the recovery pane up"
/// API, so this reports the two facts it CAN prove and leaves the dialog
/// half to the server's window census.
fn cmd_check_recovery(&mut self) -> Result<Value, HostError> {
let sw = self.app(false, true)?;
// The COUNT, for the same reason cmd_status uses it: a contradicted
// empty enumeration would report "no documents open" to a human asking
// whether SOLIDWORKS is showing a recovery pane.
// `null`, not 0, when SOLIDWORKS will not say. A human asking whether
// the Document Recovery pane is up is asking about a session that has
// just come back from a crash; "0 documents open" is the most
// misleading possible answer to get wrong there.
let docs = sw.int("GetDocumentCount", &[]).map(|c| c as usize);
Ok(json!({
"openDocuments": docs,
"comApiHasRecoveryCheck": false,
"note": "SOLIDWORKS exposes no COM member for the Document Recovery pane; the \
server pairs this with the window census, which is where a recovery \
dialog actually shows up.",
}))
}
/// `swx_get_preferences` / `swx_set_preference`. Deliberately a SMALL,
/// named table: a preference this bridge cannot round-trip is a preference
/// it has no business claiming to support.
fn cmd_get_preferences(&mut self, args: &Value) -> Result<Value, HostError> {
let doc = self.resolve(args).ok();
let sw = self.app(false, true)?;
let mut current = Map::new();
for (name, id) in PREFERENCES {
let value = doc
.as_ref()
.and_then(|d| d.obj("Extension", &[]).ok().flatten())
.and_then(|e| e.int("GetUserPreferenceInteger", &[&Var::i4(*id), &Var::i4(0)]));
current.insert(
(*name).to_string(),
json!({"id": id, "value": value, "scope": "document"}),
);
}
current.insert(
"lengthUnit".into(),
json!(doc
.as_ref()
.and_then(|d| d.obj("Extension", &[]).ok().flatten())
.and_then(|e| e.int("GetUserPreferenceInteger", &[&Var::i4(53), &Var::i4(0)]))
.map(Self::length_unit_name)
.unwrap_or(Value::Null)),
);
Ok(json!({
"current": Value::Object(current),
"documentScoped": doc.is_some(),
"app": {
"visible": sw.flag("Visible", &[]),
"licenseType": sw.int("GetCurrentLicenseType", &[]),
"version": sw.string("RevisionNumber", &[]),
},
}))
}
fn cmd_set_preference(&mut self, args: &Value) -> Result<Value, HostError> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| HostError::msg("name is required"))?;
let id = PREFERENCES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, id)| *id)
.ok_or_else(|| {
HostError::msg(format!(
"unknown preference '{name}' — this bridge only exposes preferences it can \
round-trip: {}",
PREFERENCES.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
))
})?;
let value = args
.get("value")
.and_then(|v| v.as_i64())
.ok_or_else(|| HostError::msg("value (an integer) is required"))?
as i32;
// `name` here is the PREFERENCE, not the document.
let doc = self.resolve(&json!({
"docName": args.get("docName").cloned(),
"path": args.get("path").cloned(),
}))?;
let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;
let before = ext.int("GetUserPreferenceInteger", &[&Var::i4(id), &Var::i4(0)]);
ext.call(
"SetUserPreferenceInteger",
&[&Var::i4(id), &Var::i4(0), &Var::i4(value)],
)?;
let after = ext.int("GetUserPreferenceInteger", &[&Var::i4(id), &Var::i4(0)]);
// Verify by READING BACK — a preference setter that reports success
// without checking is how a bridge starts lying.
Ok(json!({
"applied": {name: after},
"name": name,
"id": id,
"requested": value,
"before": before,
"value": after,
"verified": after == Some(value),
}))
}
/// `swx_cloud_status` — what this install can tell us about 3DEXPERIENCE.
///
/// LIVE FINDING: the SOLIDWORKS COM API exposes NO PLM/3DSpace member on
/// this build (`IsPLMConnected`, `GetPLMConnection`, `GetSearchFolders` and
/// `EnumDocuments2` are all absent or rejected). So there is no COM route to
/// the cloud file store at all, and this verb says so precisely rather than
/// pretending.
fn cmd_cloud_status(&mut self) -> Result<Value, HostError> {
let sw = self.app(false, true)?;
let mut probed = Map::new();
for member in ["IsPLMConnected", "GetPLMConnection", "GetSearchFolders", "EnumDocuments2"] {
probed.insert(
member.to_string(),
Value::from(sw.call(member, &[]).is_ok()),
);
}
Ok(json!({
"connected": Value::Null,
"comApiExposesCloud": false,
"probedMembers": Value::Object(probed),
"licenseType": sw.int("GetCurrentLicenseType", &[]),
"version": sw.string("RevisionNumber", &[]),
}))
}
/// `swx_run_script` — the modeling DSL, executed with the exact call
/// sequence proven live on this install.
/// `swx_add_component` — put component instances into an assembly.
///
/// THE FINDING THIS VERB EXISTS FOR. `IAssemblyDoc::AddComponent5` is the
/// documented way to insert a component, and on this bridge's first live
/// target (SOLIDWORKS Connected R2026x, Design Professional for Makers) it
/// returns `Nothing`. An earlier probe read that as "this edition cannot
/// insert components at all" and the whole assembly surface — `swx_bom`,
/// `swx_interference`, per-component mass properties — was written off as
/// mock-only.
///
/// The return value is the lie, not the insertion. So this command does not
/// trust it: it COUNTS the assembly's components before and after the call
/// and treats a rise as success. On top of that it walks a ladder of
/// spellings (`AddComponent5` with each config option, then `AddComponent4`,
/// `AddComponent3`, `AddComponent`) and, if every one fails, loads the
/// component document read-only with `OpenDoc6` — a documented precondition
/// — and walks the ladder again. Whatever rung works is remembered in
/// `insert_method`, because an assembly of 150 fasteners must not pay the
/// discovery cost 150 times.
fn cmd_add_component(&mut self, args: &Value) -> Result<Value, HostError> {
let asm = self.resolve(args)?;
if asm.int("GetType", &[]).unwrap_or(0) != DOC_ASSEMBLY {
return Err(HostError::coded(
"not_an_assembly",
"add_component needs an assembly (.sldasm) as the target document — \
swx_new_document {\"type\":\"assembly\"} makes one",
));
}
let reqs = Self::component_requests(args)?;
let fix = args.get("fix").and_then(|v| v.as_bool()).unwrap_or(false);
let sw = self.app(false, true)?;
// AddComponent5 inserts into the ACTIVE assembly, so the target has to
// be the one in front. This switches which DOCUMENT is current inside
// SOLIDWORKS; it does not raise the application over anything.
let title = asm.string("GetTitle", &[]).unwrap_or_default();
let mut activate_errors = 0i32;
let _ = sw.call(
"ActivateDoc3",
&[
&Var::bstr(&title),
&Var::boolean(false),
&Var::i4(0),
&Var::byref_i4(&mut activate_errors as *mut i32),
],
);
// WHICH components did this call insert? Matching the request against
// GetComponents by FILE PATH cannot answer that once the assembly already
// holds an instance of the same file: `same_path` matches ANY instance, so
// the transform for a new component can be written onto an OLDER one -
// overwriting a good placement and leaving the new component wherever
// AddComponent5 dropped it. Measured on the crate's side panel: R2
// instances 1-2 landed exactly, 3-5 carried the "centred on the requested
// point" signature. Names are identities (SOLIDWORKS never reissues one in
// a document), so snapshot them and diff.
let before: std::collections::HashSet<String> = asm
.call("GetComponents", &[&Var::boolean(true)])
.map(|v| v.as_array())
.unwrap_or_default()
.iter()
.filter_map(|c| c.as_dispatch().map(Disp))
.filter_map(|c| c.string("Name2", &[]))
.collect();
let mut inserted: Vec<Value> = Vec::new();
let mut failed: Vec<Value> = Vec::new();
let mut fixed = 0usize;
for r in &reqs {
if !std::path::Path::new(&r.file).exists() {
failed.push(json!({
"filePath": r.file,
"error": "file not found on the DESKTOP filesystem",
}));
continue;
}
let mut used: Option<&'static str> = None;
let mut attempts: Vec<Value> = Vec::new();
let mut preloaded: Option<Value> = None;
'passes: for pass in 0..2 {
if pass == 1 {
// Documented precondition, paid for only when the direct
// route failed: the component document must be in memory.
// READ-ONLY — inserting a component reads its geometry and
// never writes to the part file, and a write lock on it
// would buy nothing.
let (e, w) = Self::preload_component(&sw, &r.file, &r.config);
preloaded = Some(json!({"openErrors": e, "openWarnings": w}));
}
let before = Self::component_count(&asm);
for method in Self::insert_ladder(self.insert_method.as_deref()) {
let called = Self::invoke_insert(&asm, method, r);
let after = Self::component_count(&asm);
match (before, after) {
(Some(b), Some(a)) if a > b => {
used = Some(method);
break 'passes;
}
// COULD NOT COUNT. Trying the next method would insert
// the component a second time if the first one worked,
// so stop and say so rather than guess.
(None, _) | (_, None) => {
return Err(HostError::coded(
"component_count_failed",
format!(
"SOLIDWORKS would not report how many components '{}' has, so \
this bridge cannot tell whether the insert of {} succeeded — \
and trying the next insert method could add it TWICE. \
Nothing further was attempted; check the assembly with \
swx_design_info and retry.",
Self::doc_info(&asm)
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("the assembly"),
r.file
),
));
}
_ => {}
}
attempts.push(json!({
"method": method,
"pass": pass,
"comError": called.err().map(|e| e.message),
}));
}
}
match used {
Some(method) => {
self.insert_method = Some(method.to_string());
let comp = Self::last_component(&asm);
if fix {
if let Some(c) = &comp {
if Self::fix_component(&asm, c) {
fixed += 1;
}
}
}
inserted.push(json!({
"filePath": r.file,
"name": comp.as_ref().and_then(|c| c.string("Name2", &[])),
"method": method,
"preloaded": preloaded.is_some(),
"positionMm": [
r.x / crate::script::MM,
r.y / crate::script::MM,
r.z / crate::script::MM,
],
"rotationDeg": [
r.rx.to_degrees(),
r.ry.to_degrees(),
r.rz.to_degrees(),
],
}));
}
None => failed.push(json!({
"filePath": r.file,
"error": "the assembly's component count did not rise after any known \
IAssemblyDoc insert call",
"preload": preloaded,
"attempts": attempts,
})),
}
}
// ORIENTATION, in ONE sweep. AddComponent5 places a component at a point
// with no rotation, so anything that has to lie on its side needs its
// transform written afterwards — and the insert call hands back no
// handle to write it to. Matching components to requests by (file,
// translation) costs one pass over the assembly instead of a search per
// insert, which is the difference between O(n) and O(n²) COM calls on a
// crate with four hundred fasteners in it.
let orient = Self::apply_transforms(&sw, &asm, &reqs, &before);
Ok(json!({
"assembly": Self::doc_info(&asm),
"requested": reqs.len(),
"insertedCount": inserted.len(),
"inserted": inserted,
"failedCount": failed.len(),
"failed": failed,
"componentCount": Self::component_count(&asm),
"method": self.insert_method,
"fixedCount": fixed,
"placedCount": orient.0,
"rotationsRequested": reqs.iter().filter(|r| r.rotated()).count(),
"placementNotes": orient.1,
"activateErrors": activate_errors,
}))
}
/// Every insert spelling this bridge knows, best first. `AddComponent5/<n>`
/// is `AddComponent5` with `swAddComponentConfigOptions_e` = n.
const INSERT_METHODS: &'static [&'static str] = &[
"AddComponent5/1",
"AddComponent5/3",
"AddComponent5/2",
"AddComponent5/0",
"AddComponent4",
"AddComponent3",
"AddComponent",
];
/// The ladder with the remembered winner promoted to the front, so a proven
/// install pays one COM call per component and an unproven one still finds
/// its way.
fn insert_ladder(cached: Option<&str>) -> Vec<&'static str> {
let mut v: Vec<&'static str> = Self::INSERT_METHODS.to_vec();
if let Some(c) = cached {
if let Some(i) = v.iter().position(|m| *m == c) {
let m = v.remove(i);
v.insert(0, m);
}
}
v
}
fn invoke_insert(asm: &Disp, method: &str, r: &crate::script::Component) -> Result<(), HostError> {
let (x, y, z) = (Var::r8(r.x), Var::r8(r.y), Var::r8(r.z));
match method {
"AddComponent4" => {
asm.call(
"AddComponent4",
&[&Var::bstr(&r.file), &Var::bstr(&r.config), &x, &y, &z],
)?;
}
"AddComponent3" => {
asm.call("AddComponent3", &[&Var::bstr(&r.file), &x, &y, &z])?;
}
"AddComponent" => {
asm.call("AddComponent", &[&Var::bstr(&r.file), &x, &y, &z])?;
}
other => {
// AddComponent5(CompName, ConfigOption, NewConfigName,
// UseConfigForPartReferences, ConfigName, X, Y, Z)
let opt: i32 = other.rsplit('/').next().and_then(|s| s.parse().ok()).unwrap_or(1);
asm.call(
"AddComponent5",
&[
&Var::bstr(&r.file),
&Var::i4(opt),
&Var::bstr(""),
&Var::boolean(false),
&Var::bstr(&r.config),
&x,
&y,
&z,
],
)?;
}
}
Ok(())
}
/// Load a component document silently and READ-ONLY. Returns OpenDoc6's
/// byref (errors, warnings) so a failure to insert can say whether the file
/// even loaded.
fn preload_component(sw: &Disp, file: &str, config: &str) -> (i32, i32) {
let mut errors = 0i32;
let mut warnings = 0i32;
let type_id = doc_type_id(file).unwrap_or(DOC_PART);
// swOpenDocOptions_Silent (1) | swOpenDocOptions_ReadOnly (2)
let _ = sw.call(
"OpenDoc6",
&[
&Var::bstr(file),
&Var::i4(type_id),
&Var::i4(1 | 2),
&Var::bstr(config),
&Var::byref_i4(&mut errors as *mut i32),
&Var::byref_i4(&mut warnings as *mut i32),
],
);
(errors, warnings)
}
/// THE SUCCESS TEST. Not the return value of the insert call — see
/// `cmd_add_component`.
///
/// `None` = COULD NOT COUNT. The `unwrap_or(0)` this replaces made a failed
/// enumeration look like an empty assembly, so `count > before` was false
/// after an insert that had actually WORKED — and the method ladder moved on
/// and inserted the SAME component again. Same shape as bug #11, with a
/// duplicated component in the user's assembly for a consequence.
fn component_count(asm: &Disp) -> Option<usize> {
let comps = asm.call("GetComponents", &[&Var::boolean(true)]).ok()?;
let listed = comps.as_array().len();
if listed > 0 {
return Some(listed);
}
// An empty list is believed only when the assembly agrees it is empty.
match asm.int("GetComponentCount", &[]) {
Some(0) => Some(0),
Some(_) => None, // it says it has components and did not list them
None => comps.is_null().then_some(0),
}
}
/// The most recently added top-level component, best effort — used only for
/// the reported name and the optional fix, never for the success test.
fn last_component(asm: &Disp) -> Option<Disp> {
let mut comps = asm.call("GetComponents", &[&Var::boolean(true)]).ok()?.as_array();
comps.pop()?.as_dispatch().map(Disp)
}
/// Fix a component in place so nothing can drag it off its coordinates.
/// Best effort: an assembly positioned by coordinates is still correct
/// without it.
fn fix_component(asm: &Disp, comp: &Disp) -> bool {
let selected = comp
.call("Select4", &[&Var::boolean(false), &Var::empty(), &Var::boolean(false)])
.ok()
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !selected {
return false;
}
let ok = asm.call("FixComponent", &[]).is_ok();
let _ = asm.call("ClearSelection2", &[&Var::boolean(true)]);
ok
}
/// Write the EXACT transform — position and rotation — onto every component
/// this call inserted.
///
/// Two live findings force this. `AddComponent5`'s X/Y/Z is not the
/// component's transform origin on this install: a part whose own centroid
/// sits at (50, 6, -25) mm inserted at (0, 0, 0) lands with the assembly's
/// centre of mass at the origin, i.e. SOLIDWORKS centred it on the point
/// rather than putting its origin there. And the insert call returns nothing
/// to hold on to, so there is no handle to correct it through. Writing the
/// transform afterwards makes the position mean exactly one thing —
/// "the component's origin goes here" — and rotation comes free with it.
///
/// The components this batch inserted are found by MATCHING THE SEQUENCE of
/// file paths: they occupy one contiguous run of the assembly's component
/// list, forwards or backwards, and the run that matches the request order
/// is the batch. That is O(n) COM calls for the whole batch instead of a
/// search per component, and it PROVES the mapping rather than assuming an
/// ordering — if no run matches, nothing is written and the response says
/// so.
fn apply_transforms(
sw: &Disp,
asm: &Disp,
reqs: &[crate::script::Component],
before: &std::collections::HashSet<String>,
) -> (usize, Vec<Value>) {
if reqs.is_empty() {
return (0, Vec::new());
}
let mut notes: Vec<Value> = Vec::new();
let math = match sw.obj("GetMathUtility", &[]) {
Ok(Some(m)) => m,
_ => {
notes.push(json!({
"error": "IMathUtility is unavailable, so no component transform could be \
written — components are wherever AddComponent5 put them",
}));
return (0, notes);
}
};
let comps: Vec<Disp> = asm
.call("GetComponents", &[&Var::boolean(true)])
.map(|v| v.as_array())
.unwrap_or_default()
.iter()
.filter_map(|c| c.as_dispatch().map(Disp))
.collect();
let paths: Vec<String> =
comps.iter().map(|c| c.string("GetPathName", &[]).unwrap_or_default()).collect();
let names: Vec<String> =
comps.iter().map(|c| c.string("Name2", &[]).unwrap_or_default()).collect();
// The components whose NAME is new since the snapshot are exactly the ones
// this call inserted — no ordering assumption at all. GetComponents does
// not return them in insertion order, nor grouped by file, which is why
// the contiguous-run match below could not find five R2 instances among
// seven components.
let new_idx: Vec<usize> = (0..comps.len())
.filter(|&i| !names[i].is_empty() && !before.contains(&names[i]))
.collect();
let mut targets: Vec<usize> = Vec::new();
if new_idx.len() == reqs.len() {
// Instances of one file are interchangeable, so a greedy per-path
// assignment over the new components is exact.
let mut used = vec![false; new_idx.len()];
for r in reqs {
let mut hit: Option<usize> = None;
for (k, &i) in new_idx.iter().enumerate() {
if !used[k] && crate::verbs::same_path(&r.file, &paths[i]) {
used[k] = true;
hit = Some(i);
break;
}
}
match hit {
Some(i) => targets.push(i),
None => {
targets.clear();
break;
}
}
}
}
if targets.len() != reqs.len() {
// Fallback: the pre-0.3.2 contiguous-run match, for the case where
// names could not be read at all.
targets.clear();
let Some((offset, forward)) = crate::script::locate_run(&paths, reqs) else {
notes.push(json!({
"error": "the components just inserted could not be located in the assembly \
by name or by matching the sequence of file paths, so no transform \
was written — every component is where AddComponent5 put it, which \
is NOT necessarily the position that was asked for",
"assemblyComponents": paths.len(),
"newByName": new_idx.len(),
"requested": reqs.len(),
}));
return (0, notes);
};
for i in 0..reqs.len() {
targets.push(if forward { offset + i } else { offset + reqs.len() - 1 - i });
}
}
let mut placed = 0usize;
for (i, r) in reqs.iter().enumerate() {
let comp = &comps[targets[i]];
let data = r.transform();
match math.call("CreateTransform", &[&Var::r8_array(&data)]).map(|v| v.as_dispatch()) {
Ok(Some(t)) => match comp.put("Transform2", &Var::dispatch(&t)) {
Ok(()) => placed += 1,
Err(e) => notes.push(json!({"filePath": r.file, "error": e.message})),
},
Ok(None) => notes
.push(json!({"filePath": r.file, "error": "CreateTransform returned nothing"})),
Err(e) => notes.push(json!({"filePath": r.file, "error": e.message})),
}
}
(placed, notes)
}
/// The SAME parser the server ran, re-run here. The host re-validating what
/// the server already validated is the pattern every mutating command
/// follows: a server-side decision is a claim.
fn component_requests(args: &Value) -> Result<Vec<crate::script::Component>, HostError> {
crate::script::parse_components(args).map_err(HostError::msg)
}
fn cmd_run_script(&mut self, args: &Value) -> Result<Value, HostError> {
use crate::script::Op;
let script = args.get("script").cloned().unwrap_or(Value::Null);
let ops = crate::script::parse(&script).map_err(HostError::msg)?;
// A HIDDEN part cannot be modelled (measured 2026-10-14): refused before
// anything runs, never as a failure halfway through.
if let Some(m) = crate::script::hidden_conflict(&ops) {
return Err(HostError::coded("document_hidden", m));
}
// PIN the target. Without this every step re-resolved "the active
// document", so a dialog, a rebuild that stole focus, or the user simply
// clicking another SOLIDWORKS window between steps could move a running
// script onto a different model halfway through. The server sends the
// title it validated ownership of; a `new_part` step replaces it with
// the document it just made.
// BY PATH when the server knows it (0.9.29): a title is not an
// identity, and a user's document may share the bridge's title.
let mut pinned: Option<String> = args
.get("targetPath")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.or_else(|| args.get("targetDocument").and_then(|v| v.as_str()).filter(|s| !s.is_empty()))
.map(String::from);
let mut pinned_doc: Option<Disp> = None;
if let Some(want) = &pinned {
// Prove the pin resolves BEFORE running anything.
pinned_doc = Some(self.resolve(&Self::doc_arg(&pinned)).map_err(|e| {
HostError::detailed(
format!("cannot target document '{want}': {}", e.message),
"swx_docs lists the titles that are open. Nothing was run.".to_string(),
)
})?);
}
// THE PINNED DOCUMENT COUNTS AS "a document" (0.10.7): measured, a
// reuseDocument naming a part that was open but not ACTIVE failed
// "needs a document" because only the active one was looked at.
let has_doc = pinned_doc.is_some() || self.resolve(&json!({})).is_ok();
crate::script::validate(&ops, has_doc).map_err(HostError::msg)?;
if let Some(d) = &pinned_doc {
if ops.iter().any(|o| o.needs_selection()) && d.flag("Visible", &[]) == Some(false) {
return Err(HostError::coded(
"document_hidden",
format!(
"'{}' was created invisible: {}",
d.string("GetTitle", &[]).unwrap_or_default(),
crate::script::HIDDEN_DOCUMENT
),
));
}
}
let mut results = Vec::new();
let mut created: Option<Value> = None;
let mut st = ScriptState::default();
for (i, op) in ops.iter().enumerate() {
let step = i + 1;
let made_before = created.as_ref().and_then(|c| c.get("created").cloned());
let detail = self.run_op(op, &pinned, &mut st).map_err(|e| {
// The failing step's structured data (a hidden new_part's
// `visibility`, an HRESULT) and its code travel with the error.
let data = e.data.clone();
let code = e.code.clone();
let err = HostError::detailed(
format!("step {step} ({}) failed: {}", op.name(), e.message),
e.detail.unwrap_or_else(|| {
format!(
"{} of {} steps ran. Anything already built is still in the open \
document; close it with swx_close_doc {{save:false}} if you want a \
clean slate.",
step - 1,
ops.len()
)
}),
);
let mut err = match &made_before {
Some(doc) => err.with_created(doc.clone()),
None => err,
};
err.data = data;
err.code = code;
err
})?;
if let Some(f) = detail.get("feature").and_then(|f| f.as_str()) {
if op.makes_feature() {
st.last_feature = Some(f.to_string());
}
}
if matches!(op, Op::NewPart { .. } | Op::NewAssembly { .. }) {
// A new document is active and starts clean; whatever the user had
// active before is still put back only if the script moved it.
let keep = st.restore_active.take();
st = ScriptState::default();
st.restore_active = keep;
pinned = detail
.get("created")
.and_then(|c| c.get("title"))
.and_then(|t| t.as_str())
.map(String::from);
st.created_here = pinned.clone();
created = Some(detail.clone());
}
results.push(json!({"step": step, "op": op.name(), "result": detail}));
}
let doc = self.resolve(&Self::doc_arg(&pinned)).ok();
// PUT THE USER'S ACTIVE DOCUMENT BACK if a selection step had to
// activate the target (review #8), unless the script made a new one.
if let (Some(prev), None) = (st.restore_active.take(), &created) {
if let Ok(sw) = self.app(false, true) {
let mut errors = 0i32;
let _ = sw.call("ActivateDoc3", &[&Var::bstr(&prev), &Var::boolean(false), &Var::i4(1), &Var::byref_i4(&mut errors as *mut i32)]);
}
}
// The release the steps ran on: `proven` (compat::step_proof) is LIVE
// only for variants measured on this very release.
let revision = self.app(false, true).ok().and_then(|sw| sw.string("RevisionNumber", &[]));
Ok(json!({
"steps": results,
"stepCount": results.len(),
"created": created,
"solidworksRevision": revision,
"targetDocument": doc.as_ref().and_then(|d| d.string("GetTitle", &[])),
"documentName": doc.as_ref().and_then(|d| d.string("GetTitle", &[])),
"doc": doc.as_ref().map(Self::doc_info).unwrap_or(Value::Null),
}))
}
/// The resolve-args for the pinned document ("the active one" only when
/// nothing is pinned, which is the case for a script that starts by
/// creating its own document).
fn doc_arg(pinned: &Option<String>) -> Value {
match pinned {
// A pin with a separator is a PATH (a title never holds one).
Some(p) if p.contains('\\') || p.contains('/') => json!({"path": p}),
Some(name) => json!({"name": name}),
None => json!({}),
}
}
/// One DSL op, as the live-proven COM call sequence, against the PINNED
/// document.
fn run_op(&mut self, op: &crate::script::Op, pinned: &Option<String>, st: &mut ScriptState) -> Result<Value, HostError> {
use crate::script::Op;
let target = Self::doc_arg(pinned);
match op {
Op::NewPart { visible } => self.cmd_new_document(&json!({"type": "part", "visible": visible})),
Op::NewAssembly { visible } => self.cmd_new_document(&json!({"type": "assembly", "visible": visible})),
Op::ClearPart => {
// EVERY FEATURE AFTER THE ORIGIN, deleted — in a part the BRIDGE
// created, and nowhere else: the host's own record decides,
// whatever the server sent (0.10.6). N geometry checks then cost
// one document instead of N.
let doc = self.resolve(&target)?;
let title = doc.string("GetTitle", &[]).unwrap_or_default();
if doc.int("GetType", &[]) != Some(DOC_PART) {
return Err(HostError::msg(format!("clear_part works on a part; '{title}' is not one")));
}
if st.created_here.as_deref() != Some(title.as_str()) && !self.host_owns(&doc) {
return Err(HostError::coded(
"clear_part_not_owned",
format!("clear_part deletes features only in a part this bridge created; '{title}' is not one"),
));
}
self.ensure_active(&doc, st)?;
// IN PASSES (0.10.8). Measured: one DeleteSelection2 returned true
// and left a top-level sketch (one a previous script left
// unconsumed). Each pass selects what is LEFT — a feature by
// IFeature::Select2, and if that refuses, by name with its own
// selection type (a sketch as "SKETCH") — and deletes it with
// swDeleteSelectionOptions_e swDelete_Children 1 | swDelete_Absorbed 2
// (help.solidworks.com, swconst; DeleteSelection2, 2006 SP1, "does
// not ask the user to confirm the deletion").
let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;
let mut last_ok = true;
let cleared = crate::modeling::clear_in_passes(
|| Ok::<_, HostError>(Self::features_after_origin(&doc)?.iter().filter_map(|f| f.string("Name", &[])).collect()),
|names, _pass| {
let _ = Self::clear_selection(&doc);
for f in Self::features_after_origin(&doc)? {
let Some(name) = f.string("Name", &[]) else { continue };
if !names.contains(&name) {
continue;
}
let selected = f.call("Select2", &[&Var::boolean(true), &Var::i4(0)]).ok().and_then(|v| v.as_bool()) == Some(true);
if !selected {
let kind = match f.string("GetTypeName2", &[]).as_deref() {
Some("ProfileFeature") => "SKETCH",
Some("3DProfileFeature") => "3DSKETCH",
Some("RefPlane") => "PLANE",
Some("RefAxis") => "AXIS",
_ => "BODYFEATURE",
};
let _ = Self::select_named(&doc, &name, kind, true, 0);
}
}
last_ok = ext.call("DeleteSelection2", &[&Var::i4(1 | 2)])?.as_bool().unwrap_or(false);
let _ = Self::clear_selection(&doc);
Ok(())
},
5,
);
let cleared = match cleared {
Ok(c) => c,
Err(crate::modeling::ClearError::Host(e)) => return Err(e),
Err(crate::modeling::ClearError::Stuck { passes, left, .. }) => {
return Err(HostError::coded(
"clear_part_incomplete",
format!(
"clear_part could not delete every feature after {passes} passes (DeleteSelection2 last returned {last_ok}; still there: {})",
left.join(", ")
),
))
}
};
let bodies = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(false)]).map(|b| b.as_array().len()).unwrap_or(0);
// The script's own memory of planes, axes and sketches is about
// features that no longer exist.
let keep_active = st.restore_active.take();
let created_here = st.created_here.take();
*st = ScriptState::default();
st.restore_active = keep_active;
st.created_here = created_here;
Ok(json!({"cleared": title, "featuresDeleted": cleared.deleted.len(), "deleted": cleared.deleted,
"passes": cleared.passes, "bodiesLeft": bodies,
"route": "DeleteSelection2(swDelete_Children | swDelete_Absorbed), repeated until the tree is empty"}))
}
Op::Sketch { plane, entities, offsets, .. } => {
let doc = self.resolve(&target)?;
self.ensure_active(&doc, st)?;
let defaults = self.default_planes(&doc, st)?;
// WHERE, and how the caller's (u, v) map onto the model:
// * a default or named plane: drawn in the plane's own sketch
// axes (the measured conventions);
// * an offset plane / a face: the BASE plane's own axes at that
// height, mapped into whatever sketch SOLIDWORKS opened through
// its ModelToSketchTransform — so a flipped plane or a face's
// own sketch axes cannot mirror the geometry (review #5, #13).
let mut resolved = Value::Null;
let mut created_plane: Option<String> = None;
let mut mapping: Option<(crate::modeling::PlaneFrame, f64)> = None;
let mut reference: Option<crate::modeling::V3> = None;
let _ = Self::clear_selection(&doc);
let on: String = match plane {
crate::script::PlaneRef::Named(n) => {
let real = Self::plane_name(&doc, n, &defaults)?;
if !Self::select_named(&doc, &real, "PLANE", false, 0)? {
return Err(HostError::msg(format!("could not select the plane '{real}'")));
}
real
}
crate::script::PlaneRef::Offset { from, offset } => {
let base = Self::plane_name(&doc, from, &defaults)?;
// A default plane's frame is the VALIDATED one (checked
// against the measured Front/Top axes); any other plane's
// comes from its own transform.
let frame = match defaults.iter().position(|d| d.name == base) {
Some(i) => defaults[i].frame,
None => Self::plane_frame(&doc, &base)
.ok_or_else(|| HostError::msg(format!("the plane '{base}' gave no transform to offset from")))?,
};
let (name, made, verified) = self.ensure_offset_plane(&doc, &base, &frame, *offset, st)?;
created_plane = made;
resolved = json!({"offsetFrom": from, "offsetMm": offset * 1000.0, "plane": name, "verified": verified});
mapping = Some((frame, *offset));
reference = Some(frame.n);
let _ = Self::clear_selection(&doc);
if !Self::select_named(&doc, &name, "PLANE", false, 0)? {
return Err(HostError::msg(format!("could not select the new plane '{name}'")));
}
name
}
crate::script::PlaneRef::FaceOffset { point, offset } => {
let fo = self.face_offset_plane(&doc, *point, *offset, &defaults, st)?;
created_plane = fo.made.clone();
resolved = fo.report.clone();
mapping = Some((fo.frame, fo.h));
// The face's OUTWARD normal stays the reference: a cut from
// a plane 12 inside the back face goes on inward.
reference = Some(fo.normal);
let _ = Self::clear_selection(&doc);
if !Self::select_named(&doc, &fo.name, "PLANE", false, 0)? {
return Err(HostError::msg(format!("could not select the new plane '{}'", fo.name)));
}
fo.name
}
crate::script::PlaneRef::Face { point } => {
// GEOMETRIC PICK (review #1): never through the view.
let picked = Self::pick_face(&doc, *point)?;
let frames = [defaults[0].frame, defaults[1].frame, defaults[2].frame];
let i = crate::modeling::parallel_default(&frames, picked.normal).ok_or_else(|| {
HostError::msg(format!(
"the face at that point is not planar and parallel to a default plane (normal {:?}); sketch on a \
named or an offset plane instead",
picked.normal
))
})?;
let frame = frames[i];
let h = frame.height_of(picked.closest);
mapping = Some((frame, h));
// A boss from a face goes OUTWARD, a cut INWARD: the face's
// own outward normal is the reference (review #5).
reference = Some(picked.normal);
resolved = json!({"offsetFrom": defaults[i].name, "offsetMm": h * 1000.0, "faceNormal": picked.normal, "pickedBy": "geometry"});
let r = self.select_items(&doc, vec![SelItem::face(picked)], 0)?;
if !r.all(1) {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("the picked face could not be selected ({})", r.notes.join("; "))));
}
resolved["selectedVia"] = r.report();
resolved["viewDependent"] = json!(r.view_dependent());
"the picked face".to_string()
}
};
let sm = doc.obj("SketchManager", &[])?.ok_or_else(|| HostError::msg("no SketchManager"))?;
sm.call("InsertSketch", &[&Var::boolean(true)])?;
// From here the sketch is OPEN. Every exit — the error path
// included — must close it, or the user's SOLIDWORKS is left
// parked in sketch-edit mode with a half-drawn profile on screen.
let m2s = Self::sketch_transform(&doc);
let normal = m2s.as_ref().map(|t| t.source_z());
let mut to_draw: Vec<crate::script::Entity> = Vec::new();
let mut failure: Option<String> = None;
let mut transform_inverted = false;
match (&mapping, &m2s) {
(None, _) => to_draw = entities.clone(),
(Some((frame, h)), Some(t)) => match crate::modeling::map_entities(frame, *h, entities, t) {
Ok((mapped, inverted)) => {
to_draw = mapped;
transform_inverted = inverted;
}
Err(z) => {
failure = Some(format!(
"the entities landed {:.6} mm off the sketch plane through the sketch's transform (and its inverse) — nothing was drawn",
z * 1000.0
))
}
},
(Some(_), None) => failure = Some("the sketch's model-to-sketch transform could not be read, so the documented axes cannot be honoured — nothing was drawn".into()),
}
// OFFSET ENTITIES (0.11.4) FIRST, into the still-empty sketch: the
// model's own edges offset by SOLIDWORKS, the side checked — so
// every segment the sketch holds afterwards is the offset's, and
// nothing the bridge draws next can be mistaken for it.
let mut offsets_report: Vec<Value> = Vec::new();
if failure.is_none() {
for (k, o) in offsets.iter().enumerate() {
match self.offset_face(&doc, o, m2s.as_ref()) {
Ok(r) => offsets_report.push(r),
Err(e) => {
failure = Some(format!("offsetEntities {} — {}", k + 1, e.message));
break;
}
}
}
}
// AddToDB: entities go straight into the sketch, without the
// snapping/inference that would merge nearby points of several
// profiles (ISketchManager::AddToDB, 2008 FCS). UNMEASURED for
// line/arc CONTOURS (0.11.0): whether SOLIDWORKS joins two
// segments whose ends coincide exactly when they are added this
// way — a contour that does not join comes back as a null
// feature, and the error says so.
sm.try_put("AddToDB", &Var::boolean(true));
let mut drawn = 0usize;
// Each drawn line's ISketchSegment and name ("Line3"), so a
// revolve can select its axis (0.11.0).
let mut segments: Vec<Option<SketchSeg>> = Vec::new();
if failure.is_none() {
for (k, e) in to_draw.iter().enumerate() {
let seg_of = |v: Var| -> Option<SketchSeg> {
v.as_dispatch().map(Disp).map(|d| SketchSeg { name: d.string("GetName", &[]), seg: d })
};
let made = match e {
// CreateLine / CreateCenterLine (2008 FCS): x1,y1,z1, x2,y2,z2.
crate::script::Entity::Line { x0, y0, x1, y1, construction } => sm
.call(
if *construction { "CreateCenterLine" } else { "CreateLine" },
&[&Var::r8(*x0), &Var::r8(*y0), &Var::r8(0.0), &Var::r8(*x1), &Var::r8(*y1), &Var::r8(0.0)],
)
.map(|v| {
let s = seg_of(v);
let ok = s.is_some();
segments.resize(k, None);
segments.push(s);
ok
}),
// CreateArc (2008 FCS): XC,YC,Zc, X1,Y1,Z1, X2,Y2,Z2,
// Direction (short: +1 counter-clockwise, −1 clockwise).
crate::script::Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, construction } => sm
.call(
"CreateArc",
&[
&Var::r8(*cx), &Var::r8(*cy), &Var::r8(0.0),
&Var::r8(*x0), &Var::r8(*y0), &Var::r8(0.0),
&Var::r8(*x1), &Var::r8(*y1), &Var::r8(0.0),
&Var::i2(if *ccw { 1 } else { -1 }),
],
)
.map(|v| match seg_of(v) {
Some(s) => {
if *construction {
// ISketchSegment::ConstructionGeometry (2001Plus).
s.seg.try_put("ConstructionGeometry", &Var::boolean(true));
}
true
}
None => false,
}),
crate::script::Entity::Rect { x0, y0, x1, y1 } => sm
.call(
"CreateCornerRectangle",
&[&Var::r8(*x0), &Var::r8(*y0), &Var::r8(0.0), &Var::r8(*x1), &Var::r8(*y1), &Var::r8(0.0)],
)
.map(|v| !v.as_array().is_empty()),
crate::script::Entity::Circle { cx, cy, r } => sm
.call("CreateCircleByRadius", &[&Var::r8(*cx), &Var::r8(*cy), &Var::r8(0.0), &Var::r8(*r)])
.or_else(|_| {
sm.call(
"CreateCircle",
&[&Var::r8(*cx), &Var::r8(*cy), &Var::r8(0.0), &Var::r8(cx + r), &Var::r8(*cy), &Var::r8(0.0)],
)
})
.map(|v| !v.is_null()),
};
match made {
Ok(true) => drawn += 1,
Ok(false) => {
failure = Some(format!("entity {} ({}) — SOLIDWORKS drew nothing", k + 1, entities[k].describe()));
break;
}
Err(err) => {
failure = Some(format!("entity {} ({}) — {}", k + 1, entities[k].describe(), err.message));
break;
}
}
}
}
sm.try_put("AddToDB", &Var::boolean(false));
Self::close_sketch(&sm)?;
if let Some(f) = failure {
return Err(HostError::msg(format!("the sketch was closed with {drawn} of {} entities: {f}", entities.len())));
}
// The sketch's own name, so the extrude can RE-SELECT it (review
// #7: an op in between clears the selection).
let sketch_name = Self::last_feature_name(&doc);
segments.resize(to_draw.len(), None);
st.pending = Some(PendingSketch {
name: sketch_name.clone().unwrap_or_default(),
normal,
reference,
segments,
mapping,
sketch_to_model: m2s.as_ref().map(|t| t.inverse()),
});
let mut out = json!({
"plane": plane.describe(),
"sketchedOn": on,
"sketch": sketch_name,
"resolvedPlane": resolved,
"createdPlane": created_plane,
"entities": entities.iter().map(|e| e.describe()).collect::<Vec<_>>(),
"sketchNormal": normal,
"mapped": mapping.is_some(),
"transformInverted": transform_inverted,
"planeFrames": st.frames_source,
});
if !offsets_report.is_empty() {
out["offsets"] = json!(offsets_report);
}
crate::script::single_entity_fields(entities, &mut out);
Ok(out)
}
Op::Extrude(ext) | Op::Cut(ext) => {
let cut = matches!(op, Op::Cut(_));
let doc = self.resolve(&target)?;
self.ensure_active(&doc, st)?;
let pending = st.pending.take().ok_or_else(|| HostError::msg("no sketch to consume"))?;
// RE-SELECT THE SKETCH BY NAME (review #7): a fillet, a pattern or a
// face appearance between the sketch and this step cleared the
// selection, and FeatureExtrusion3 / FeatureCut4 consume what is
// selected.
let _ = Self::clear_selection(&doc);
// AN UP-TO FACE (0.11.0) is the end condition's reference entity,
// selected with MARK 1 ("Mark = 1 for end condition reference
// entity", FeatureExtrusion3/FeatureCut4 remarks). The selection
// ladder starts from an empty selection, so the face goes FIRST
// and the sketch is APPENDED after it (mark 0).
let mut up_to_face: Value = Value::Null;
if let Some(crate::script::UpTo::Surface { point } | crate::script::UpTo::OffsetFrom { point, .. }) = &ext.up_to {
let picked = Self::pick_face(&doc, *point)?;
let r = self.select_items(&doc, vec![SelItem::face(picked)], 1)?;
if !r.all(1) {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("the up-to face could not be selected ({})", r.notes.join("; "))));
}
up_to_face = json!({"selectedVia": r.report(), "viewDependent": r.view_dependent(), "mark": 1});
}
let append = ext.up_to.as_ref().map(|u| !matches!(u, crate::script::UpTo::Next)).unwrap_or(false);
if pending.name.is_empty() || !Self::select_named(&doc, &pending.name, "SKETCH", append, 0)? {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("could not re-select the sketch '{}' to {}", pending.name, if cut { "cut" } else { "extrude" })));
}
// SKETCH REGIONS (0.11.4) — SOLIDWORKS' "Selected Contours", as
// its own example does it (help: "Enable Contour Selection
// Example"): ISelectionMgr::EnableContourSelection on, each region
// by SelectByID2(sketch, "SKETCHREGION", a model point inside it,
// Append, MARK 4), the feature, contour selection back as it was.
let (contour_sel, regions_report) = self.select_regions(&doc, &pending, &ext.regions)?;
// swEndConditions_e (help.solidworks.com/2024/english/api/swconst/
// SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.
// swEndConditions_e.html): Blind 0, ThroughAll 1, UpToSurface 4,
// OffsetFromSurface 5, MidPlane 6, ThroughAllBoth 9, UpToSelection
// 10, UpToNext 11. MEASURED 2026-10-16: "up to surface" is 4 here,
// not the help's 10 - see modeling::end_condition_id.
const BLIND: i32 = 0;
const THROUGH_ALL: i32 = 1;
const MID_PLANE: i32 = 6;
const THROUGH_ALL_BOTH: i32 = 9;
use crate::script::Direction;
let (sd, t1, t2, d1, d2) = match (&ext.up_to, ext.direction, ext.through_all) {
(Some(u), _, _) => (true, crate::modeling::end_condition_id(u), BLIND, ext.depth.max(0.0), 0.01),
(None, Direction::One, false) => (true, BLIND, BLIND, ext.depth, 0.01),
(None, Direction::One, true) => (true, THROUGH_ALL, BLIND, 0.01, 0.01),
(None, Direction::MidPlane, _) => (true, MID_PLANE, BLIND, ext.depth, 0.01),
(None, Direction::Both, false) => (false, BLIND, BLIND, ext.depth, ext.depth2),
// UNMEASURED: Through All - Both as Sd=true, T1=9.
(None, Direction::Both, true) => (true, THROUGH_ALL_BOTH, BLIND, 0.01, 0.01),
};
// OffsetReverse1: the offset goes BEYOND the face instead of short of it
// — the help's own words (FeatureCut4 / FeatureExtrusion3, read
// 2026-10-16): "true specifies offset in direction away from the
// sketch, false specifies offset from the face or plane in a
// direction toward the sketch".
let offset_reverse = matches!(ext.up_to, Some(crate::script::UpTo::OffsetFrom { reverse_offset: true, .. }));
// DRAFT (0.11.0): Dchk1 on, Dang1 radians - and the same on side 2
// when the extent goes two ways. Ddir1 TRUE = OUTWARD, as SOLIDWORKS
// measured it on 2026-10-16 (the help says inward; the help is wrong
// for this build; see modeling::draft_outward_flag). The canary
// 0.11.4-rc1 had passed !outward and widened every drafted feature.
let (dchk, ddir, dang) = match ext.draft {
Some(d) => (true, crate::modeling::draft_outward_flag(&d), d.angle),
None => (false, false, 0.0174533),
};
let dchk2 = dchk && !sd;
// THE DIRECTION (review #5): along the reference for a boss,
// against it for a cut, whatever way the sketch's own normal
// points; the caller's reverse flips it.
let (dir, flipped) = crate::modeling::dir_flag(pending.normal, pending.reference, ext.reverse);
let fm = match doc.obj("FeatureManager", &[]) {
Ok(Some(fm)) => fm,
other => {
Self::end_contour_selection(&doc, contour_sel);
other?;
return Err(HostError::msg("no FeatureManager"));
}
};
let made: Result<Var, HostError> = if !cut {
// FeatureExtrusion3 (2014 FCS): Sd, Flip, Dir, T1, T2, D1, D2,
// Dchk1, Dchk2, Ddir1, Ddir2, Dang1, Dang2, OffsetReverse1,
// OffsetReverse2, TranslateSurface1, TranslateSurface2, Merge,
// UseFeatScope, UseAutoSelect, T0, StartOffset, FlipStartOffset
// — the order proven live.
fm.call(
"FeatureExtrusion3",
&[
&Var::boolean(sd),
&Var::boolean(false),
&Var::boolean(dir),
&Var::i4(t1),
&Var::i4(t2),
&Var::r8(d1),
&Var::r8(d2),
&Var::boolean(dchk),
&Var::boolean(dchk2),
&Var::boolean(ddir),
&Var::boolean(ddir),
&Var::r8(dang),
&Var::r8(dang),
&Var::boolean(offset_reverse),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(true),
&Var::boolean(true),
&Var::boolean(true),
&Var::i4(0),
&Var::r8(0.0),
&Var::boolean(false),
],
)
} else if let Err(e) = self.requires(25, "IFeatureManager::FeatureCut4 (SOLIDWORKS 2017)") {
Err(e)
} else {
// FeatureCut4 (2017 FCS): Sd, Flip (the side to cut), Dir
// (reverse the direction), T1, T2, D1, D2, Dchk1, Dchk2,
// Ddir1, Ddir2, Dang1, Dang2, OffsetReverse1, OffsetReverse2,
// TranslateSurface1, TranslateSurface2, NormalCut,
// UseFeatScope, UseAutoSelect, AssemblyFeatureScope,
// AutoSelectComponents, PropagateFeatureToParts, T0,
// StartOffset, FlipStartOffset, OptimizeGeometry.
fm.call(
"FeatureCut4",
&[
&Var::boolean(sd),
&Var::boolean(false),
&Var::boolean(dir),
&Var::i4(t1),
&Var::i4(t2),
&Var::r8(d1),
&Var::r8(d2),
&Var::boolean(dchk),
&Var::boolean(dchk2),
&Var::boolean(ddir),
&Var::boolean(ddir),
&Var::r8(dang),
&Var::r8(dang),
&Var::boolean(offset_reverse),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(true),
&Var::boolean(true),
&Var::boolean(true),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::i4(0),
&Var::r8(0.0),
&Var::boolean(false),
],
)
};
// Contour selection is put back as it was on EVERY road out.
Self::end_contour_selection(&doc, contour_sel);
let f = made?;
let feature = f.as_dispatch().map(Disp);
let _ = Self::clear_selection(&doc);
// A FEATURE THAT COMES BACK NULL IS AN ERROR (issue #1).
let Some(feature) = feature else {
if !ext.regions.is_empty() {
return Err(HostError::msg(format!(
"SOLIDWORKS made no {} from the selected sketch regions (the feature came back null) — {} region(s) were \
selected by SelectByID2 SKETCHREGION at mark 4 (UNMEASURED live, 0.11.4); check that each point lies \
inside the region meant, on the sketch's plane",
if cut { "cut" } else { "extrusion" },
ext.regions.len()
)));
}
return Err(HostError::msg(if cut {
"SOLIDWORKS made no cut (the feature came back null) — nothing was removed. A cut goes along its \
plane's −NORMAL (measured: a cut from Top Plane removes −Y) and INTO a face it is sketched on; \
if the material is on the other side, pass reverse:true, or direction:\"both\" / midPlane:true"
} else if pending.segments.iter().any(|s| s.is_some()) {
"SOLIDWORKS built no extrusion (the feature came back null) — this sketch's profile is made of lines/arcs, \
drawn with AddToDB on (UNMEASURED: whether SOLIDWORKS joins segment ends that coincide when added that \
way); otherwise it may be self-intersecting, or the extrude would add nothing"
} else {
"SOLIDWORKS built no extrusion (the feature came back null) — the sketch may be open, unclosed, \
self-intersecting, or the extrude would add nothing"
}));
};
Ok(json!({
"feature": feature.string("Name", &[]),
"sketch": pending.name,
"depthMm": ext.depth * 1000.0,
"depth2Mm": (ext.direction == Direction::Both && !ext.through_all).then(|| ext.depth2 * 1000.0),
"throughAll": ext.through_all,
"direction": match ext.direction { Direction::One => "one", Direction::Both => "both", Direction::MidPlane => "midPlane" },
"reverse": ext.reverse,
"dir": {"value": dir, "flippedToFollowTheReference": flipped, "sketchNormal": pending.normal, "reference": pending.reference},
"endCondition": t1,
"upToFace": up_to_face,
"draftDeg": ext.draft.map(|d| d.angle.to_degrees()),
"draftInward": ext.draft.map(|d| !d.outward),
"regions": regions_report,
// WHAT SOLIDWORKS STORED (0.11.4, best-effort, read-only):
// IFeature::GetDefinition -> IExtrudeFeatureData2, the end
// condition, depth and draft side of direction 1 as the
// feature holds them. A reply that disagrees with the request
// is the finding (UNMEASURED: the read-back itself).
"readBack": feature.obj("GetDefinition", &[]).ok().flatten().map(|def| json!({
"endCondition": def.int("GetEndCondition", &[&Var::boolean(true)]),
"depthMm": def.float("GetDepth", &[&Var::boolean(true)]).map(|m| m * 1000.0),
"draftOutward": def.call("GetDraftOutward", &[&Var::boolean(true)]).ok().and_then(|v| v.as_bool()),
"draftAngleDeg": def.float("GetDraftAngle", &[&Var::boolean(true)]).map(|a| a.to_degrees()),
})),
}))
}
Op::Revolve(rv) | Op::RevolveCut(rv) => {
let cut = matches!(op, Op::RevolveCut(_));
let doc = self.resolve(&target)?;
self.ensure_active(&doc, st)?;
self.requires(19, "IFeatureManager::FeatureRevolve2 (SOLIDWORKS 2011)")?;
let pending = st.pending.take().ok_or_else(|| HostError::msg("no sketch to consume"))?;
let seg = pending.segments.get(rv.axis).cloned().flatten().ok_or_else(|| {
HostError::msg(format!("the revolve axis (entity {} of the sketch) was not drawn as a line SOLIDWORKS returned", rv.axis))
})?;
let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;
const BLIND: i32 = 0;
const MID_PLANE: i32 = 6;
// THE SELECTION: the sketch (mark 0) and its axis line. The help
// names the axis mark 4 in one place and 16 in another; both are
// tried, each believed only when SOLIDWORKS returns a feature (a
// null feature changes nothing, so a retry is safe).
let mut tried: Vec<String> = Vec::new();
let mut made: Option<(Disp, i32, &'static str)> = None;
for mark in [4, 16] {
let _ = Self::clear_selection(&doc);
if pending.name.is_empty() || !Self::select_named(&doc, &pending.name, "SKETCH", false, 0)? {
return Err(HostError::msg(format!("could not re-select the sketch '{}' to revolve", pending.name)));
}
let by_name = match &seg.name {
Some(n) => Self::select_named(&doc, &format!("{n}@{}", pending.name), "EXTSKETCHSEGMENT", true, mark).unwrap_or(false),
None => false,
};
let via = if by_name {
"SelectByID2 EXTSKETCHSEGMENT"
} else if Self::select_with_mark(&doc, &seg.seg, "Select4", mark) {
"ISketchSegment::Select4"
} else {
tried.push(format!("mark {mark}: the axis line could not be selected"));
continue;
};
// FeatureRevolve2 (2011): SingleDir, IsSolid, IsThin, IsCut,
// ReverseDir, BothDirectionUpToSameEntity, Dir1Type, Dir2Type,
// Dir1Angle, Dir2Angle, OffsetReverse1, OffsetReverse2,
// OffsetDistance1, OffsetDistance2, ThinType, ThinThickness1,
// ThinThickness2, Merge, UseFeatScope, UseAutoSelect.
let f = fm.call(
"FeatureRevolve2",
&[
&Var::boolean(true),
&Var::boolean(true),
&Var::boolean(false),
&Var::boolean(cut),
&Var::boolean(rv.reverse),
&Var::boolean(false),
&Var::i4(if rv.mid_plane { MID_PLANE } else { BLIND }),
&Var::i4(BLIND),
&Var::r8(rv.angle),
&Var::r8(0.0),
&Var::boolean(false),
&Var::boolean(false),
&Var::r8(0.0),
&Var::r8(0.0),
&Var::i4(0),
&Var::r8(0.0),
&Var::r8(0.0),
&Var::boolean(true),
&Var::boolean(true),
&Var::boolean(true),
],
)?;
match f.as_dispatch() {
Some(d) => {
made = Some((Disp(d), mark, via));
break;
}
None => tried.push(format!("mark {mark} ({via}): FeatureRevolve2 returned null")),
}
}
let _ = Self::clear_selection(&doc);
let Some((feature, mark, via)) = made else {
return Err(HostError::msg(format!(
"SOLIDWORKS made no {} (the feature came back null): {} — the profile may be open (do its line/arc ends join?), cross the axis, or {}",
if cut { "revolve-cut" } else { "revolve" },
tried.join("; "),
if cut { "lie outside the material" } else { "be empty" }
)));
};
Ok(json!({
"feature": feature.string("Name", &[]),
"sketch": pending.name,
"angleDeg": rv.angle.to_degrees(),
"midPlane": rv.mid_plane,
"reverse": rv.reverse,
"axis": {"entity": rv.axis, "segment": seg.name, "mark": mark, "selectedVia": via},
"route": "FeatureRevolve2",
}))
}
Op::Mirror { features, bodies, plane } => {
let doc = self.resolve(&target)?;
self.ensure_active(&doc, st)?;
let defaults = self.default_planes(&doc, st)?;
let _ = Self::clear_selection(&doc);
// THE PLANE, mark 2 ("plane or planar face", InsertMirrorFeature2
// remarks). A face goes through the selection ladder, which starts
// from an empty selection — so the plane is selected FIRST and
// everything else is appended.
let plane_report = match plane {
crate::script::PlaneRef::Named(n) => {
let real = Self::plane_name(&doc, n, &defaults)?;
if !Self::select_named(&doc, &real, "PLANE", false, 2)? {
return Err(HostError::msg(format!("could not select the mirror plane '{real}'")));
}
json!(real)
}
crate::script::PlaneRef::Offset { from, offset } => {
let base = Self::plane_name(&doc, from, &defaults)?;
let frame = match defaults.iter().position(|d| d.name == base) {
Some(i) => defaults[i].frame,
None => Self::plane_frame(&doc, &base)
.ok_or_else(|| HostError::msg(format!("the plane '{base}' gave no transform to offset from")))?,
};
let (name, made, verified) = self.ensure_offset_plane(&doc, &base, &frame, *offset, st)?;
let _ = Self::clear_selection(&doc);
if !Self::select_named(&doc, &name, "PLANE", false, 2)? {
return Err(HostError::msg(format!("could not select the mirror plane '{name}'")));
}
json!({"offsetFrom": from, "offsetMm": offset * 1000.0, "plane": name, "createdPlane": made, "verified": verified})
}
crate::script::PlaneRef::FaceOffset { point, offset } => {
let fo = self.face_offset_plane(&doc, *point, *offset, &defaults, st)?;
let _ = Self::clear_selection(&doc);
if !Self::select_named(&doc, &fo.name, "PLANE", false, 2)? {
return Err(HostError::msg(format!("could not select the mirror plane '{}'", fo.name)));
}
fo.report
}
crate::script::PlaneRef::Face { point } => {
let picked = Self::pick_face(&doc, *point)?;
let r = self.select_items(&doc, vec![SelItem::face(picked)], 2)?;
if !r.all(1) {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("the mirror face could not be selected ({})", r.notes.join("; "))));
}
json!({"face": {"atPointMm": [point[0] * 1000.0, point[1] * 1000.0, point[2] * 1000.0]}, "selectedVia": r.report(), "pickedBy": "geometry"})
}
};
// WHAT IS MIRRORED: features by name (mark 1), or every solid body (mark 256).
let mut mirrored: Vec<String> = Vec::new();
if *bodies {
let list = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?;
for b in list.as_array().iter().filter_map(|b| b.as_dispatch().map(Disp)) {
let name = b.string("Name", &[]).unwrap_or_default();
let ok = Self::select_with_mark(&doc, &b, "Select2", 256)
|| (!name.is_empty() && Self::select_named(&doc, &name, "SOLIDBODY", true, 256).unwrap_or(false));
if !ok {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("the body '{name}' could not be selected to mirror")));
}
mirrored.push(name);
}
if mirrored.is_empty() {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg("the part has no solid body to mirror"));
}
} else {
for f in features {
let name = st.resolve_feature(f)?;
if !Self::select_named(&doc, &name, "BODYFEATURE", true, 1)? {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("could not select the feature '{name}' to mirror")));
}
mirrored.push(name);
}
}
let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;
// ScopeOptions (swFeatureScope_e) is READ from the installed
// interop, never guessed: its "all bodies" member. Without it,
// the obsolete four-argument InsertMirrorFeature is the route.
let exe = self.app(false, true).ok().and_then(|sw| sw.string("GetExecutablePath", &[])).unwrap_or_default();
let scope = swconst_members(&exe, "swFeatureScope_e").ok().and_then(|m| {
let all: Vec<(String, i32)> = m.into_iter().filter(|(k, _)| k.to_ascii_lowercase().contains("all")).collect();
(all.len() == 1).then(|| all[0].clone())
});
// InsertMirrorFeature2 (2008): BMirrorBody, BGeometryPattern, BMerge, BKnit, ScopeOptions.
let (f, route) = match &scope {
Some((_, id)) => (
fm.call("InsertMirrorFeature2", &[&Var::boolean(*bodies), &Var::boolean(false), &Var::boolean(true), &Var::boolean(false), &Var::i4(*id)])?,
"InsertMirrorFeature2",
),
None => (
fm.call("InsertMirrorFeature", &[&Var::boolean(*bodies), &Var::boolean(false), &Var::boolean(true), &Var::boolean(false)])?,
"InsertMirrorFeature (swFeatureScope_e unreadable)",
),
};
let _ = Self::clear_selection(&doc);
let Some(feature) = f.as_dispatch().map(Disp) else {
return Err(HostError::msg(format!(
"SOLIDWORKS made no mirror (the feature came back null) of {} about {plane_report}",
mirrored.join(", ")
)));
};
Ok(json!({
"feature": feature.string("Name", &[]),
"mirrored": mirrored,
"mirrorBody": bodies,
"plane": plane_report,
"scope": scope.map(|(k, v)| json!({"member": k, "id": v})),
"route": route,
}))
}
Op::Hole { at, diameter, depth, counterbore, countersink } => {
// A HOLE IS CUTS (0.11.0): the face at `at`, picked geometrically,
// resolved to a default plane at a height; circles on that plane
// cut into it (script::hole_plan — the simulator runs the same plan).
let doc = self.resolve(&target)?;
self.ensure_active(&doc, st)?;
let defaults = self.default_planes(&doc, st)?;
let picked = Self::pick_face(&doc, *at)?;
let frames = [defaults[0].frame, defaults[1].frame, defaults[2].frame];
let i = crate::modeling::parallel_default(&frames, picked.normal).ok_or_else(|| {
HostError::msg(format!("the face at the hole's point is not planar and parallel to a default plane (normal {:?})", picked.normal))
})?;
let frame = frames[i];
let h = frame.height_of(picked.closest);
let outward = crate::modeling::dot(picked.normal, frame.n) > 0.0;
let plan = crate::script::hole_plan(&frame, &defaults[i].name, h, outward, *at, *diameter, *depth, *counterbore, *countersink);
let mut steps = Vec::new();
let mut made: Vec<Value> = Vec::new();
for sub in &plan {
let r = self.run_op(sub, pinned, st).map_err(|mut e| {
e.message = format!("hole, its {} ({} of {}): {}", sub.name(), steps.len() + 1, plan.len(), e.message);
e
})?;
if let Some(f) = r.get("feature").filter(|f| !f.is_null()) {
made.push(f.clone());
}
steps.push(json!({"op": sub.name(), "result": r}));
}
Ok(json!({
"feature": made.last(),
"features": made,
"face": {"atPointMm": [at[0] * 1000.0, at[1] * 1000.0, at[2] * 1000.0], "normal": picked.normal, "plane": defaults[i].name, "heightMm": h * 1000.0, "pickedBy": "geometry"},
"diameterMm": diameter * 1000.0,
"depthMm": depth.map(|d| d * 1000.0),
"throughAll": depth.is_none(),
"steps": steps,
}))
}
Op::SetAppearance { rgb, target: at } => {
let doc = self.resolve(&target)?;
let r = self.apply_appearance(&doc, at, rgb, st)?;
st.appearances.retain(|(t, _)| t != at);
st.appearances.push((at.clone(), *rgb));
Ok(r)
}
Op::Fillet { radius, edges } | Op::Chamfer { distance: radius, edges, .. } => {
let fillet = matches!(op, Op::Fillet { .. });
let doc = self.resolve(&target)?;
self.ensure_active(&doc, st)?;
self.requires(23, "IFeatureManager::FeatureFillet3 (SOLIDWORKS 2015)")?;
// The edges, picked by GEOMETRY and DE-DUPLICATED (review #1, #3):
// an edge selected twice is DESELECTED.
let (list, how) = self.collect_edges(&doc, edges, st)?;
let _ = Self::clear_selection(&doc);
// Fillet edges are marked 1 (the recorded macros' form); a chamfer
// takes plain selections (mark 0).
let want = list.len();
let edge_disps: Vec<Disp> = list.iter().map(|it| it.ent.clone()).collect();
let r = self.select_items(&doc, list, if fillet { 1 } else { 0 })?;
if !r.all(want) {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("only {} of {want} edges ended up selected — nothing was made ({})", r.count, r.notes.join("; "))));
}
let mut how = how;
how["selectedVia"] = r.report();
how["viewDependent"] = json!(r.view_dependent());
let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;
let mut route = if fillet { "FeatureFillet3" } else { "InsertFeatureChamfer" };
let mut f = if fillet {
// swFeatureFilletOptions_e: Propagate 1 | UniformRadius 2;
// swFeatureFilletType_Simple 0 (help.solidworks.com swconst).
fm.call(
"FeatureFillet3",
&[
&Var::i4(1 | 2),
&Var::r8(*radius),
&Var::r8(0.0),
&Var::r8(0.0),
&Var::i4(0),
&Var::i4(0),
&Var::i4(0),
&Var::empty(),
&Var::empty(),
&Var::empty(),
&Var::empty(),
&Var::empty(),
&Var::empty(),
&Var::empty(),
],
)?
} else {
let angle = match op {
Op::Chamfer { angle, .. } => *angle,
_ => std::f64::consts::FRAC_PI_4,
};
// InsertFeatureChamfer (2005 FCS): Options
// (swFeatureChamferTangentPropagation 4), ChamferType
// (swChamferAngleDistance 1), Width, Angle, OtherDist,
// VertexChamDist1..3.
fm.call(
"InsertFeatureChamfer",
&[&Var::i4(4), &Var::i4(1), &Var::r8(*radius), &Var::r8(angle), &Var::r8(0.0), &Var::r8(0.0), &Var::r8(0.0), &Var::r8(0.0)],
)?
};
// FeatureFillet3 is "obsolete for creating constant radius fillets"
// as of SOLIDWORKS 2020 (review #12): if it builds nothing, the
// documented route — CreateDefinition(swFmFillet) +
// ISimpleFilletFeatureData2 + CreateFeature.
let mut fallback_error: Option<String> = None;
if fillet && f.as_dispatch().is_none() {
match self.fillet_by_definition(&doc, &fm, &edge_disps, *radius) {
Ok(v) => {
f = v;
route = "CreateFeature(ISimpleFilletFeatureData2)";
}
Err(e) => fallback_error = Some(e.message),
}
}
let feature = f.as_dispatch().map(Disp);
let _ = Self::clear_selection(&doc);
let feature = feature.ok_or_else(|| {
HostError::msg(format!(
"SOLIDWORKS made no {} (the feature came back null) on {want} selected edge(s) — the size may be too \
large for the geometry{}",
if fillet { "fillet" } else { "chamfer" },
fallback_error.map(|e| format!("; the CreateFeature route failed too: {e}")).unwrap_or_default()
))
})?;
Ok(json!({"feature": feature.string("Name", &[]), "edgesSelected": want, "edges": how, "sizeMm": radius * 1000.0, "route": route}))
}
Op::Shell { thickness, faces, outward } => {
let doc = self.resolve(&target)?;
self.ensure_active(&doc, st)?;
let before = Self::last_feature_name(&doc);
// Faces to remove, picked by GEOMETRY (review #1 — a bottom face is
// hidden in an iso view), de-duplicated, marked 1
// (IModelDoc2::InsertFeatureShell's remarks).
let mut picked: Vec<Option<PickedFace>> = Vec::new();
let mut keys: Vec<String> = Vec::new();
for p in faces {
let f = Self::pick_face(&doc, *p)?;
keys.push(Self::entity_key(&doc, &f.face).unwrap_or_else(|| format!("{:?}", f.closest)));
picked.push(Some(f));
}
let keep: Vec<SelItem> = crate::modeling::dedupe(&keys).into_iter().filter_map(|i| picked[i].take()).map(SelItem::face).collect();
let want = keep.len();
let r = self.select_items(&doc, keep, 1)?;
if !r.all(want) {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("only {} of {want} faces ended up selected — no shell was made ({})", r.count, r.notes.join("; "))));
}
// InsertFeatureShell(Thickness, Outward) returns nothing (2001Plus),
// so the new feature is found as the tree's new last feature.
doc.call("InsertFeatureShell", &[&Var::r8(*thickness), &Var::boolean(*outward)])?;
let _ = Self::clear_selection(&doc);
let after = Self::last_feature_name(&doc);
if after.is_none() || after == before {
return Err(HostError::msg("SOLIDWORKS made no shell (no new feature appeared) — the thickness may be too large for the part"));
}
Ok(json!({"feature": after, "thicknessMm": thickness * 1000.0, "facesRemoved": want, "outward": outward,
"route": "InsertFeatureShell", "selectedVia": r.report(), "viewDependent": r.view_dependent()}))
}
Op::LinearPattern { feature, dirs } => {
let doc = self.resolve(&target)?;
self.ensure_active(&doc, st)?;
self.requires(23, "IFeatureManager::FeatureLinearPattern4 (SOLIDWORKS 2015)")?;
let seed = st.resolve_feature(feature)?;
let mut axes: Vec<(String, f64)> = Vec::new();
for (a, _, _) in dirs {
axes.push(self.ensure_axis(&doc, *a, st)?);
}
let _ = Self::clear_selection(&doc);
// Marks: 1 = Direction 1, 2 = Direction 2, 4 = the features to
// pattern (FeatureLinearPattern4's remarks). Selected by NAME —
// view-independent.
for (k, (ax, _)) in axes.iter().enumerate() {
if !Self::select_named(&doc, ax, "AXIS", k > 0, (k + 1) as i32)? {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("could not select the axis '{ax}' for direction {}", k + 1)));
}
}
if !Self::select_named(&doc, &seed, "BODYFEATURE", true, 4)? {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("no feature named '{seed}' to pattern")));
}
let (n1, s1) = (dirs[0].1, dirs[0].2);
let (n2, s2) = dirs.get(1).map(|d| (d.1, d.2)).unwrap_or((1, 0.0));
// A negative spacing is the flipped direction — and so is an axis
// SOLIDWORKS made pointing the other way (review #13: its
// direction is READ, not assumed).
let flip1 = (s1 < 0.0) ^ (axes[0].1 < 0.0);
let flip2 = (s2 < 0.0) ^ axes.get(1).map(|a| a.1 < 0.0).unwrap_or(false);
let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;
// FeatureLinearPattern4 (2015 FCS): Num1, Spacing1, Num2, Spacing2,
// FlipDir1, FlipDir2, DName1, DName2, GeometryPattern,
// VaryInstance, HasOffset1, HasOffset2, CtrlByNum1, CtrlByNum2,
// FromCentroid1, FromCentroid2, RevOffset1, RevOffset2, Offset1,
// Offset2.
let f = fm.call(
"FeatureLinearPattern4",
&[
&Var::i4(n1 as i32),
&Var::r8(s1.abs()),
&Var::i4(n2 as i32),
&Var::r8(s2.abs().max(0.001)),
&Var::boolean(flip1),
&Var::boolean(flip2),
&Var::bstr(""),
&Var::bstr(""),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::boolean(false),
&Var::r8(0.0),
&Var::r8(0.0),
],
)?;
let made = f.as_dispatch().map(Disp);
let _ = Self::clear_selection(&doc);
let made = made.ok_or_else(|| HostError::msg(format!("SOLIDWORKS made no linear pattern of '{seed}' (the feature came back null)")))?;
Ok(json!({"feature": made.string("Name", &[]), "seed": seed, "axes": axes.iter().map(|(n, s)| json!({"axis": n, "pointsPositive": *s > 0.0})).collect::<Vec<_>>(),
"instances": n1 * n2, "flip": [flip1, flip2]}))
}
Op::CircularPattern { feature, axis, count, angle } => {
let doc = self.resolve(&target)?;
self.ensure_active(&doc, st)?;
self.requires(21, "IFeatureManager::FeatureCircularPattern4 (SOLIDWORKS 2013)")?;
let seed = st.resolve_feature(feature)?;
let (ax, sign) = self.ensure_axis(&doc, *axis, st)?;
let _ = Self::clear_selection(&doc);
// Marks: 1 = the axis, 4 = the features (FeatureCircularPattern4).
if !Self::select_named(&doc, &ax, "AXIS", false, 1)? {
return Err(HostError::msg(format!("could not select the axis '{ax}'")));
}
if !Self::select_named(&doc, &seed, "BODYFEATURE", true, 4)? {
let _ = Self::clear_selection(&doc);
return Err(HostError::msg(format!("no feature named '{seed}' to pattern")));
}
let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;
// FeatureCircularPattern4 (2013 FCS): Number, Spacing (the TOTAL
// angle when EqualSpacing), FlipDirection, DName,
// GeometryPattern, EqualSpacing, VaryInstance. The axis's own
// direction decides which way "positive" turns.
let f = fm.call(
"FeatureCircularPattern4",
&[&Var::i4(*count as i32), &Var::r8(*angle), &Var::boolean(sign < 0.0), &Var::bstr(""), &Var::boolean(false), &Var::boolean(true), &Var::boolean(false)],
)?;
let made = f.as_dispatch().map(Disp);
let _ = Self::clear_selection(&doc);
let made = made.ok_or_else(|| HostError::msg(format!("SOLIDWORKS made no circular pattern of '{seed}' (the feature came back null)")))?;
Ok(json!({"feature": made.string("Name", &[]), "seed": seed, "axis": ax, "instances": count, "angleDeg": angle.to_degrees()}))
}
Op::SetMaterial { name, database, density } => {
let doc = self.resolve(&target)?;
let part_density = |doc: &Disp| {
doc.obj("Extension", &[]).ok().flatten()
.and_then(|ext| ext.obj("CreateMassProperty", &[]).ok().flatten())
.and_then(|mp| mp.float("Density", &[]))
.filter(|d| d.is_finite() && *d > 0.0)
};
if name.is_empty() {
// A DENSITY ALONE (0.11.0): the document's own material
// density (Document Properties > Material Properties), which
// counts only where no library material is applied.
let want = density.unwrap_or(1000.0);
if let Some(m) = Self::material_of(&doc, "").get("name").and_then(|n| n.as_str()) {
return Err(HostError::coded(
"material_applied",
format!("the library material '{m}' is applied, and its density wins over the document's — densityKgM3 alone cannot change it; name the material whose density you need"),
));
}
let exe = self.app(false, true).ok().and_then(|sw| sw.string("GetExecutablePath", &[])).unwrap_or_default();
let ids = swconst_ids(&exe, &[("swUserPreferenceDoubleValue_e", "swMaterialPropertyDensity")])
.map_err(|e| HostError::coded("density_unsettable", e))?;
let id = ids.get("swMaterialPropertyDensity").copied().unwrap_or(-1);
let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;
// IModelDocExtension::SetUserPreferenceDouble(pref, swDetailingNoOptionSpecified 0, value).
let set = ext.call("SetUserPreferenceDouble", &[&Var::i4(id), &Var::i4(0), &Var::r8(want)])?.as_bool();
let _ = doc.call("ForceRebuild3", &[&Var::boolean(false)]);
let pref = ext.float("GetUserPreferenceDouble", &[&Var::i4(id), &Var::i4(0)]);
let read = part_density(&doc);
if read.map(|r| (r - want).abs() > 0.005 * want).unwrap_or(false) {
return Err(HostError::coded(
"density_not_applied",
format!("the part's density reads {} kg/m³ after setting {want} (preference reads {pref:?})", read.unwrap_or(0.0)),
));
}
return Ok(json!({"material": Value::Null, "densityKgM3": read, "densityRequested": want, "preference": pref,
"set": set, "route": "SetUserPreferenceDouble(swMaterialPropertyDensity)"}));
}
doc.call(
"SetMaterialPropertyName2",
&[&Var::bstr(""), &Var::bstr(database), &Var::bstr(name)],
)?;
// A density WITH a material: the material's density must be it.
let read = part_density(&doc);
if let Some(want) = density {
match read {
Some(r) if (r - want).abs() <= 0.005 * want => {}
Some(r) => {
return Err(HostError::coded(
"material_density_mismatch",
format!("the material '{name}' has a density of {r} kg/m³, not the {want} asked for — apply the material whose density matches, or give densityKgM3 alone (no name) to set the part's own density"),
))
}
None => return Err(HostError::coded("density_unreadable", "the part's density could not be read back after applying the material")),
}
}
// A MATERIAL WIPES THE PART'S APPEARANCE ("when you apply a
// material… your customized part appearance is gone" — review #4).
// Every appearance this script set is applied again, and said so.
let mut again: Vec<Value> = Vec::new();
for (t, rgb) in st.appearances.clone() {
let r = self.apply_appearance(&doc, &t, &rgb, st)?;
again.push(json!({"target": r.get("target"), "rgb": r.get("rgb"), "readBack": r.get("readBack")}));
}
Ok(json!({"material": name, "database": database, "current": Self::material_of(&doc, ""), "appearanceReapplied": again,
"densityKgM3": read, "densityRequested": density}))
}
Op::SetProp { name, value } => {
let doc = self.resolve(&target)?;
let mgr = doc
.obj("Extension", &[])?
.and_then(|e| e.obj("CustomPropertyManager", &[&Var::bstr("")]).ok().flatten())
.ok_or_else(|| HostError::msg("CustomPropertyManager unavailable"))?;
// swCustomInfoText = 30; swCustomPropertyReplaceValue = 2
let r = mgr.call(
"Add3",
&[&Var::bstr(name), &Var::i4(30), &Var::bstr(value), &Var::i4(2)],
)?;
Ok(json!({"property": name, "value": value, "code": r.as_i32()}))
}
Op::SaveAs { path } => {
let doc = self.resolve(&target)?;
// COPY mode, unlike the standalone swx_save_as verb's default.
// A script step should put a FILE on disk, not silently retitle
// the document it is in the middle of building — that is the
// v0.2 rename bug wearing a different hat, and a script has no
// obvious place for the caller to have consented to it.
// swx_save_as {copy:false} remains the deliberate rename.
// THE SAME THREE-STATE RULE as the write gate: "could not
// look" is not "nothing is there". This is a pre-check and
// `save_as` re-checks below, but the defect class does not get to
// live on in a second copy of the question.
if let crate::writes::Destination::Unverifiable(io) =
crate::writes::destination_state(Some(path))
{
return Err(HostError::coded(
"destination_unverifiable",
crate::writes::unverifiable_message(path, &io),
));
}
if std::path::Path::new(path).exists() {
return Err(HostError::msg(format!(
"refusing to overwrite an existing file: {path}. A modelling script that \
silently replaces a file on disk is indistinguishable from one that \
destroys the user's work — choose a new name, or use swx_save_as, which \
is the verb where overwriting is an explicit act."
)));
}
let code = Self::save_as3(&doc, path, true)?;
if code != 0 {
return Err(HostError::msg(format!(
"SOLIDWORKS refused to save {path}: {}",
Self::save_error_text(code)
)));
}
Ok(json!({
"savedAs": path,
"copy": true,
"bytes": std::fs::metadata(path).map(|m| m.len()).ok(),
}))
}
Op::Rebuild => {
let doc = self.resolve(&target)?;
Ok(json!({"rebuilt": doc.call("EditRebuild3", &[])?.as_bool()}))
}
Op::ZoomFit => {
let doc = self.resolve(&target)?;
let _ = doc.call("ViewZoomtofit2", &[]);
Ok(json!({"zoomedToFit": true}))
}
}
}
/// Close the open sketch. `InsertSketch(true)` toggles, and SOLIDWORKS
/// leaves the finished sketch SELECTED — which is exactly what
/// `FeatureExtrusion3` consumes, so no re-selection is needed (proven live).
fn close_sketch(sm: &Disp) -> Result<(), HostError> {
sm.call("InsertSketch", &[&Var::boolean(true)])?;
Ok(())
}
/// Leave no selection behind. Called after a feature consumes its sketch, so
/// the user does not come back to a part with something highlighted that
/// they did not click.
fn clear_selection(doc: &Disp) {
let _ = doc.call("ClearSelection2", &[&Var::boolean(true)]);
}
/// SOLIDWORKS' major version: RevisionNumber "29.5.1" -> 29 (= 2021;
/// year = major + 1992). `None` when it cannot be read.
fn major_version(&mut self) -> Option<u32> {
let sw = self.app(false, true).ok()?;
crate::compat::major_of(&sw.string("RevisionNumber", &[])?)
}
/// REFUSE a call this SOLIDWORKS is too old to have, by name, instead of a
/// COM "unknown name" error halfway through a script. An unreadable version
/// is let through (the call itself then answers).
fn requires(&mut self, min_major: u32, member: &str) -> Result<(), HostError> {
match self.major_version() {
Some(m) if m < min_major => Err(HostError::coded(
"needs_solidworks_version",
format!(
"{member} needs SOLIDWORKS {} or later; this is SOLIDWORKS {} (RevisionNumber {m}) — see docs/COMPATIBILITY.md",
min_major + 1992,
m + 1992
),
)),
_ => Ok(()),
}
}
/// SelectByID2 by NAME ("Plane1" as "PLANE", "Axis1" as "AXIS",
/// "Boss-Extrude1" as "BODYFEATURE", "Sketch3" as "SKETCH") — a name is
/// view-independent, unlike a point.
fn select_named(doc: &Disp, name: &str, kind: &str, append: bool, mark: i32) -> Result<bool, HostError> {
let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;
Ok(ext
.call(
"SelectByID2",
&[&Var::bstr(name), &Var::bstr(kind), &Var::r8(0.0), &Var::r8(0.0), &Var::r8(0.0), &Var::boolean(append), &Var::i4(mark), &Var::null_dispatch(), &Var::i4(0)],
)?
.as_bool()
.unwrap_or(false))
}
/// Select an object through its own `method` (ISketchSegment::Select4,
/// IBody2::Select2: Append, ISelectData) with a MARK, appended; true when
/// the selection count grew.
fn select_with_mark(doc: &Disp, obj: &Disp, method: &str, mark: i32) -> bool {
let Some(sel) = doc.obj("SelectionManager", &[]).ok().flatten() else { return false };
let before = sel.int("GetSelectedObjectCount2", &[&Var::i4(-1)]).unwrap_or(0);
let Some(data) = sel.obj("CreateSelectData", &[]).ok().flatten() else { return false };
data.try_put("Mark", &Var::i4(mark));
let _ = obj.call(method, &[&Var::boolean(true), &Var::dispatch(&data.0)]);
sel.int("GetSelectedObjectCount2", &[&Var::i4(-1)]).unwrap_or(0) > before
}
/// The TOP-LEVEL features after the origin (FirstFeature / GetNextFeature;
/// the origin is the `OriginProfileFeature`): what a script built.
fn features_after_origin(doc: &Disp) -> Result<Vec<Disp>, HostError> {
let mut f = doc.obj("FirstFeature", &[])?;
let mut seen_origin = false;
let mut out = Vec::new();
let mut guard = 0;
while let Some(feat) = f {
guard += 1;
if guard > 100_000 {
break;
}
if seen_origin {
out.push(feat.clone());
} else if feat.string("GetTypeName2", &[]).as_deref() == Some("OriginProfileFeature") {
seen_origin = true;
}
f = feat.obj("GetNextFeature", &[]).ok().flatten();
}
if !seen_origin {
return Err(HostError::msg("this part has no Origin in its feature tree, so clear_part cannot tell what the script built"));
}
Ok(out)
}
/// The tree's last feature's name (FirstFeature / GetNextFeature).
fn last_feature_name(doc: &Disp) -> Option<String> {
let mut f = doc.obj("FirstFeature", &[]).ok().flatten()?;
let mut guard = 0;
loop {
guard += 1;
match f.obj("GetNextFeature", &[]).ok().flatten() {
Some(next) if guard < 100_000 => f = next,
_ => return f.string("Name", &[]),
}
}
}
/// A feature by name.
fn feature_named(doc: &Disp, name: &str) -> Option<Disp> {
let mut f = doc.obj("FirstFeature", &[]).ok().flatten()?;
for _ in 0..100_000 {
if f.string("Name", &[]).as_deref() == Some(name) {
return Some(f);
}
f = f.obj("GetNextFeature", &[]).ok().flatten()?;
}
None
}
fn floats(v: &Var) -> Vec<f64> {
v.as_array().iter().filter_map(|e| e.as_f64()).collect()
}
/// A plane feature's frame: IFeature::GetSpecificFeature2 → IRefPlane →
/// Transform (2004 SP4) → ArrayData.
fn plane_frame_of(feature: &Disp) -> Option<crate::modeling::PlaneFrame> {
let rp = feature.obj("GetSpecificFeature2", &[]).ok().flatten()?;
let t = rp.obj("Transform", &[]).ok().flatten()?;
let x = crate::modeling::Xform::from_array_data(&Self::floats(&t.call("ArrayData", &[]).ok()?))?;
Some(crate::modeling::PlaneFrame::from_plane_xform(&x))
}
fn plane_frame(doc: &Disp, name: &str) -> Option<crate::modeling::PlaneFrame> {
Self::plane_frame_of(&Self::feature_named(doc, name)?)
}
/// THE THREE DEFAULT PLANES, by POSITION — the first three RefPlane features
/// of the tree (Front, Top, Right) — so a localized install's own names work
/// (review #10). Each with its frame read from SOLIDWORKS (the documented
/// conventions only when a transform cannot be read).
fn default_planes(&mut self, doc: &Disp, st: &mut ScriptState) -> Result<[DefaultPlane; 3], HostError> {
if let Some(d) = &st.defaults {
return Ok(d.clone());
}
let fallback = crate::modeling::PlaneFrame::defaults();
let mut found: Vec<DefaultPlane> = Vec::new();
let mut f = doc.obj("FirstFeature", &[])?;
let mut guard = 0;
while let Some(feat) = f {
guard += 1;
if guard > 10_000 || found.len() == 3 {
break;
}
if feat.string("GetTypeName2", &[]).as_deref() == Some("RefPlane") {
let i = found.len();
let name = feat.string("Name", &[]).unwrap_or_default();
let frame = Self::plane_frame_of(&feat).unwrap_or(fallback[i]);
found.push(DefaultPlane { name, frame });
}
f = feat.obj("GetNextFeature", &[]).ok().flatten();
}
if found.len() < 3 {
return Err(HostError::msg(format!("the part has {} reference planes where Front, Top and Right were expected", found.len())));
}
let mut d = [found[0].clone(), found[1].clone(), found[2].clone()];
// THE TRANSFORMS ARE CHECKED AGAINST WHAT WAS MEASURED: Front's and Top's
// sketch axes (Top: +y = model −Z, issue #1). If the plane transforms
// agree, they are trusted for all three (Right included); if not, the
// documented conventions are used for all three, and the step says so.
if d[0].frame.same_as(&fallback[0]) && d[1].frame.same_as(&fallback[1]) {
st.frames_source = "plane transforms (they match the measured Front/Top axes)";
} else {
for (i, p) in d.iter_mut().enumerate() {
p.frame = fallback[i];
}
st.frames_source = "documented conventions (the plane transforms did not match the measured Front/Top axes)";
}
st.defaults = Some(d.clone());
Ok(d)
}
/// The plane the caller means: a plane of that name, or — for "Front
/// Plane" / "Top Plane" / "Right Plane" on an install that names them
/// differently — the default plane at that position.
fn plane_name(doc: &Disp, name: &str, defaults: &[DefaultPlane; 3]) -> Result<String, HostError> {
if Self::feature_named(doc, name).is_some() {
return Ok(name.to_string());
}
match crate::modeling::default_index(name) {
Some(i) => Ok(defaults[i].name.clone()),
None => Err(HostError::msg(format!(
"no plane named '{name}' in this document (the default planes here are '{}', '{}' and '{}')",
defaults[0].name, defaults[1].name, defaults[2].name
))),
}
}
/// The open sketch's model-to-sketch transform (IModelDoc2::GetActiveSketch2,
/// ISketch::ModelToSketchTransform, IMathTransform::ArrayData).
fn sketch_transform(doc: &Disp) -> Option<crate::modeling::Xform> {
let sk = doc.obj("GetActiveSketch2", &[]).ok().flatten()?;
let t = sk.obj("ModelToSketchTransform", &[]).ok().flatten()?;
crate::modeling::Xform::from_array_data(&Self::floats(&t.call("ArrayData", &[]).ok()?))
}
/// Activate `doc` if it is not SOLIDWORKS' active document (review #8:
/// selection works only there). The user's active document is remembered
/// once and put back when the script ends. swRebuildOnActivation_e 1 =
/// swDontRebuildActiveDoc (0 would PROMPT the user).
fn ensure_active(&mut self, doc: &Disp, st: &mut ScriptState) -> Result<(), HostError> {
let sw = self.app(false, true)?;
let id = |d: &Disp| {
let path = d.string("GetPathName", &[]).unwrap_or_default();
if path.is_empty() { d.string("GetTitle", &[]).unwrap_or_default() } else { path }
};
let want = id(doc);
let active = sw.obj("ActiveDoc", &[]).ok().flatten();
let now = active.as_ref().map(id);
if now.as_deref().map(|n| crate::verbs::same_path(n, &want) || n == want).unwrap_or(false) {
return Ok(());
}
if st.restore_active.is_none() {
st.restore_active = now.clone();
}
let mut errors = 0i32;
let r = sw.call("ActivateDoc3", &[&Var::bstr(&want), &Var::boolean(false), &Var::i4(1), &Var::byref_i4(&mut errors as *mut i32)])?;
if r.as_dispatch().is_none() {
return Err(HostError::msg(format!("could not activate '{want}' (errors {errors}) — selection works only in the active document")));
}
Ok(())
}
/// A persistent id for an entity (IModelDocExtension::GetPersistReference3,
/// 2007 SP3) — the key two references to ONE face or edge share.
fn entity_key(doc: &Disp, ent: &Disp) -> Option<String> {
let ext = doc.obj("Extension", &[]).ok().flatten()?;
let bytes: Vec<i32> = ext.call("GetPersistReference3", &[&Var::dispatch(&ent.0)]).ok()?.as_array().iter().filter_map(|b| b.as_i32()).collect();
(!bytes.is_empty()).then(|| bytes.iter().map(|b| format!("{:02x}", b & 0xff)).collect())
}
/// SELECT PICKED ENTITIES, VERIFIED (0.10.3): the selection LADDER
/// (`modeling::select_ladder`) — MultiSelect2 with a typed array, then per
/// entity IEntity::Select4, SelectByRay with the bridge's own ray, and only
/// then SelectByID2 at the point (view-dependent, and reported so). Every
/// rung is believed only when GetSelectedObjectCount2 grows by what it was
/// asked. Starts from an EMPTY selection.
fn select_items(&mut self, doc: &Disp, items: Vec<SelItem>, mark: i32) -> Result<crate::modeling::Selected, HostError> {
let n = items.len();
let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;
let sel = doc.obj("SelectionManager", &[])?.ok_or_else(|| HostError::msg("no SelectionManager"))?;
let data = sel.obj("CreateSelectData", &[])?.ok_or_else(|| HostError::msg("CreateSelectData returned nothing"))?;
data.try_put("Mark", &Var::i4(mark));
let exe = self.app(false, true).ok().and_then(|sw| sw.string("GetExecutablePath", &[])).unwrap_or_default();
let major = self.major_version();
let mut s = ComSelector { doc: doc.clone(), ext, sel, data, mark, items, exe, major, iid: None };
Ok(crate::modeling::select_ladder(&mut s, n))
}
/// A face's OUTWARD normal at a point: IFace2::Normal for a plane, else the
/// surface's normal there (ISurface::EvaluateAtPoint, 2001Plus) flipped
/// when IFace2::FaceInSurfaceSense says the face runs against its surface.
fn face_outward(face: &Disp, p: crate::modeling::V3) -> Option<crate::modeling::V3> {
use crate::modeling::{norm, scale};
let n = Self::floats(&face.call("Normal", &[]).ok()?);
if n.len() >= 3 && norm([n[0], n[1], n[2]]) > 1e-9 {
return Some([n[0], n[1], n[2]]);
}
let surf = face.obj("GetSurface", &[]).ok().flatten()?;
let e = Self::floats(&surf.call("EvaluateAtPoint", &[&Var::r8(p[0]), &Var::r8(p[1]), &Var::r8(p[2])]).ok()?);
if e.len() < 3 || norm([e[0], e[1], e[2]]) < 1e-9 {
return None;
}
let flip = face.call("FaceInSurfaceSense", &[]).ok().and_then(|v| v.as_bool()).unwrap_or(false);
Some(scale([e[0], e[1], e[2]], if flip { -1.0 } else { 1.0 }))
}
/// A point ON an edge, away from its ends: its curve at the middle of its
/// parameter range (IEdge::GetCurveParams2 [start, end, t0, t1, ...] →
/// ICurve::Evaluate2); failing that, the edge's closest point to the
/// middle of its chord.
fn edge_point(edge: &Disp) -> Option<crate::modeling::V3> {
let p = Self::floats(&edge.call("GetCurveParams2", &[]).ok()?);
if p.len() >= 8 {
if let Some(curve) = edge.obj("GetCurve", &[]).ok().flatten() {
if let Ok(v) = curve.call("Evaluate2", &[&Var::r8((p[6] + p[7]) / 2.0), &Var::i4(0)]) {
let mut f = Self::floats(&v);
if f.len() < 3 {
f = v.as_array().first().map(Self::floats).unwrap_or_default();
}
if f.len() >= 3 {
return Some([f[0], f[1], f[2]]);
}
}
}
}
if p.len() >= 6 {
let m = [(p[0] + p[3]) / 2.0, (p[1] + p[4]) / 2.0, (p[2] + p[5]) / 2.0];
let c = Self::floats(&edge.call("GetClosestPointOn", &[&Var::r8(m[0]), &Var::r8(m[1]), &Var::r8(m[2])]).ok()?);
if c.len() >= 3 {
return Some([c[0], c[1], c[2]]);
}
}
None
}
/// THE GEOMETRIC FACE PICK (review #1): every face of every visible solid
/// body, IFace2::GetClosestPointOn (2001Plus) the caller's point; the one
/// within the tolerance is the pick (`modeling::pick`). Never the view.
fn pick_face(doc: &Disp, p: crate::modeling::V3) -> Result<PickedFace, HostError> {
use crate::modeling::{dot, norm, scale, sub};
let bodies = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?;
let mut faces: Vec<PickedFace> = Vec::new();
let mut cands: Vec<(usize, f64, f64)> = Vec::new();
for b in bodies.as_array().iter().filter_map(|b| b.as_dispatch().map(Disp)) {
for f in b.call("GetFaces", &[]).map(|v| v.as_array()).unwrap_or_default().iter().filter_map(|f| f.as_dispatch().map(Disp)) {
let c = f.call("GetClosestPointOn", &[&Var::r8(p[0]), &Var::r8(p[1]), &Var::r8(p[2])]).map(|v| Self::floats(&v)).unwrap_or_default();
if c.len() < 3 {
continue;
}
let q = [c[0], c[1], c[2]];
let n = f.call("Normal", &[]).map(|v| Self::floats(&v)).unwrap_or_default();
let normal = if n.len() >= 3 && norm([n[0], n[1], n[2]]) > 1e-9 { scale([n[0], n[1], n[2]], 1.0 / norm([n[0], n[1], n[2]])) } else { [0.0; 3] };
let d = sub(p, q);
cands.push((faces.len(), norm(d), dot(d, normal)));
faces.push(PickedFace { face: f, closest: q, normal });
}
}
match crate::modeling::pick(&cands, PICK_TOL) {
Ok(i) => Ok(faces.swap_remove(i)),
Err(crate::modeling::PickError::None(near)) => Err(HostError::msg(format!(
"no face of the part passes through [{:.3}, {:.3}, {:.3}] mm (nearest {} mm away)",
p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0,
near.map(|d| format!("{:.3}", d * 1000.0)).unwrap_or_else(|| "—".into())
))),
Err(crate::modeling::PickError::Ambiguous(n)) => Err(HostError::msg(format!(
"the point [{:.3}, {:.3}, {:.3}] mm lies on {n} faces (an edge or a corner) — move it inside ONE face",
p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0
))),
}
}
/// SELECT SKETCH REGIONS (0.11.4) for the feature about to be made, the
/// way SOLIDWORKS' own example does: ISelectionMgr::EnableContourSelection
/// on, then per region IModelDocExtension::SelectByID2(<sketch>,
/// "SKETCHREGION", <a model point inside it>, Append, MARK 4). Each pick is
/// believed only when the selection count grew. Returns the selection
/// manager and what contour selection was before (to put back), and the
/// report. Nothing to do for no regions.
fn select_regions(&mut self, doc: &Disp, pending: &PendingSketch, regions: &[[f64; 2]]) -> Result<(Option<(Disp, bool)>, Value), HostError> {
if regions.is_empty() {
return Ok((None, Value::Null));
}
let sel = doc.obj("SelectionManager", &[])?.ok_or_else(|| HostError::msg("no SelectionManager"))?;
let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;
let was = sel.call("EnableContourSelection", &[]).ok().and_then(|v| v.as_bool()).unwrap_or(false);
sel.try_put("EnableContourSelection", &Var::boolean(true));
let state = Some((sel.clone(), was));
let mut picked = Vec::new();
for (k, r) in regions.iter().enumerate() {
let fail = |why: String| {
Self::end_contour_selection(doc, state.clone());
let _ = Self::clear_selection(doc);
Err(HostError::msg(format!("region {} at [{:.3}, {:.3}] mm: {why}", k + 1, r[0] * 1000.0, r[1] * 1000.0)))
};
let Some(p) = pending.model_point(r[0], r[1]) else {
return fail("the sketch's place in the model could not be read, so the point cannot be put on it".into());
};
let before = sel.int("GetSelectedObjectCount2", &[&Var::i4(-1)]).unwrap_or(0);
let ok = ext
.call(
"SelectByID2",
&[
&Var::bstr(&pending.name),
&Var::bstr("SKETCHREGION"),
&Var::r8(p[0]),
&Var::r8(p[1]),
&Var::r8(p[2]),
&Var::boolean(true),
&Var::i4(4),
&Var::null_dispatch(),
&Var::i4(0),
],
)
.map(|v| v.as_bool().unwrap_or(false));
let after = sel.int("GetSelectedObjectCount2", &[&Var::i4(-1)]).unwrap_or(0);
match ok {
Ok(true) if after > before => picked.push(json!({"atMm": [r[0] * 1000.0, r[1] * 1000.0], "modelMm": [p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0]})),
Ok(_) => return fail(format!("no region of the sketch '{}' holds that point (SelectByID2 SKETCHREGION selected nothing)", pending.name)),
Err(e) => return fail(e.message),
}
}
Ok((state, json!({"selected": picked, "via": "SelectByID2 SKETCHREGION, mark 4, EnableContourSelection on"})))
}
/// Put ISelectionMgr::EnableContourSelection back as it was.
fn end_contour_selection(_doc: &Disp, state: Option<(Disp, bool)>) {
if let Some((sel, was)) = state {
sel.try_put("EnableContourSelection", &Var::boolean(was));
}
}
/// The edges of a face's OUTER loop (IFace2::GetLoops, ILoop2::IsOuter,
/// ILoop2::GetEdges — 2001Plus).
fn outer_loop_edges(face: &Disp) -> Result<Vec<Disp>, HostError> {
let loops: Vec<Disp> = face.call("GetLoops", &[])?.as_array().iter().filter_map(|l| l.as_dispatch().map(Disp)).collect();
for lp in &loops {
if lp.call("IsOuter", &[]).ok().and_then(|v| v.as_bool()) == Some(true) {
let edges: Vec<Disp> = lp.call("GetEdges", &[])?.as_array().iter().filter_map(|e| e.as_dispatch().map(Disp)).collect();
if edges.is_empty() {
return Err(HostError::msg("the face's outer loop has no edges"));
}
return Ok(edges);
}
}
Err(HostError::msg(format!("none of the face's {} loops says it is the outer one (ILoop2::IsOuter)", loops.len())))
}
/// The open sketch's segments (ISketch::GetSketchSegments), in order.
fn sketch_segments(sketch: &Disp) -> Vec<Disp> {
sketch.call("GetSketchSegments", &[]).map(|v| v.as_array().iter().filter_map(|x| x.as_dispatch().map(Disp)).collect()).unwrap_or_default()
}
/// A sketch segment's two ends, in SKETCH space: ISketchLine / ISketchArc
/// GetStartPoint2 / GetEndPoint2 → ISketchPoint X, Y, Z — through the
/// segment's own dispatch, or its ISketchLine / ISketchArc interface (QI
/// with the IID read from the installed interop). None for anything else
/// (a spline, an ellipse): the side check then says it could not tell.
fn segment_ends(&mut self, seg: &Disp) -> Option<[crate::modeling::V3; 2]> {
let exe = self.app(false, true).ok().and_then(|sw| sw.string("GetExecutablePath", &[])).unwrap_or_default();
let mut via: Vec<Disp> = vec![seg.clone()];
for iface in ["ISketchLine", "ISketchArc"] {
if let Ok(iid) = interop_dual_iid(&exe, iface) {
if let Some(d) = seg.query_dual(&iid) {
via.push(d);
}
}
}
let point = |p: Option<Disp>| -> Option<crate::modeling::V3> {
let p = p?;
Some([p.call("X", &[]).ok()?.as_f64()?, p.call("Y", &[]).ok()?.as_f64()?, p.call("Z", &[]).ok()?.as_f64()?])
};
for d in via {
if d.dispid("GetStartPoint2").is_err() {
continue;
}
let a = point(d.obj("GetStartPoint2", &[]).ok().flatten());
let b = point(d.obj("GetEndPoint2", &[]).ok().flatten());
if let (Some(a), Some(b)) = (a, b) {
return Some([a, b]);
}
}
None
}
/// Delete sketch segments: each selected (ISketchSegment::Select4,
/// appended), then IModelDoc2::EditDelete.
fn delete_segments(doc: &Disp, segs: &[Disp]) -> Result<(), HostError> {
let _ = Self::clear_selection(doc);
for sg in segs {
if !Self::select_with_mark(doc, sg, "Select4", 0) {
let _ = Self::clear_selection(doc);
return Err(HostError::msg("a segment the offset made could not be selected to be deleted"));
}
}
doc.call("EditDelete", &[])?;
let _ = Self::clear_selection(doc);
Ok(())
}
/// OFFSET ENTITIES (0.11.4) into the open sketch: the face picked by its
/// point; its outer loop's edges (or the face itself) selected; then
/// IModelDoc2::SketchOffsetEntities2(Offset metres, BothDirections false,
/// Chain) — "the geometry selected for offset can be an edge, loop, face
/// …" (help.solidworks.com/2018/english/api/sldworksapi/SolidWorks.Interop.
/// sldworks~SolidWorks.Interop.sldworks.IModelDoc2~SketchOffsetEntities2.
/// html; the same in 2026, available since 2001Plus, so on 2021 too).
/// SOLIDWORKS' sign does NOT say which side the offset lands on (a forum
/// report: SketchOffsetEntities2(-0.01, …) went inside on one face and
/// outside on another), so the bridge LOOKS: every new segment's ends,
/// put in the model, are within half the offset of the face (inward) or
/// all farther (outward). Landed on the wrong side, the new segments are
/// deleted and the offset made with the other sign — once.
fn offset_face(&mut self, doc: &Disp, o: &crate::script::OffsetFace, m2s: Option<&crate::modeling::Xform>) -> Result<Value, HostError> {
use crate::modeling::{dot, norm, scale, sub};
let picked = Self::pick_face(doc, o.point)?;
let face = picked.face.clone();
let normal = picked.normal;
let (items, what): (Vec<SelItem>, String) = match o.loops {
crate::script::OffsetLoops::All => (vec![SelItem::face(picked)], "the face".into()),
crate::script::OffsetLoops::Outer => {
let edges = Self::outer_loop_edges(&face)?;
let n = edges.len();
(edges.into_iter().map(|e| SelItem::edge(e, None)).collect(), format!("the face's outer loop ({n} edges)"))
}
};
let n_sel = items.len();
let sketch = doc.obj("GetActiveSketch2", &[])?.ok_or_else(|| HostError::msg("no open sketch to offset into"))?;
let s2m = m2s.map(|t| t.inverse());
let want_inward = o.offset < 0.0;
let half = o.offset.abs() / 2.0;
let mut tries: Vec<Value> = Vec::new();
let mut sign = 1.0;
for attempt in 0..2 {
let before = Self::sketch_segments(&sketch).len();
let r = self.select_items(doc, items.clone(), 0)?;
if !r.all(n_sel) {
let _ = Self::clear_selection(doc);
return Err(HostError::msg(format!("{what} could not be selected to offset ({})", r.notes.join("; "))));
}
let value = o.offset * sign;
let ok = doc.call("SketchOffsetEntities2", &[&Var::r8(value), &Var::boolean(false), &Var::boolean(o.chain)])?.as_bool().unwrap_or(false);
let _ = Self::clear_selection(doc);
let new: Vec<Disp> = Self::sketch_segments(&sketch).into_iter().skip(before).collect();
if !ok || new.is_empty() {
return Err(HostError::msg(format!(
"SketchOffsetEntities2({} mm) on {what} made nothing (returned {ok}, {} new segments)",
value * 1000.0,
new.len()
)));
}
// WHERE DID IT LAND? Each end, in the model, against the face.
let mut dists: Vec<f64> = Vec::new();
let mut unread = 0usize;
for sg in &new {
match (self.segment_ends(sg), &s2m) {
(Some(ends), Some(t)) => {
for e in ends {
let p = t.apply(e);
let c = Self::floats(&face.call("GetClosestPointOn", &[&Var::r8(p[0]), &Var::r8(p[1]), &Var::r8(p[2])]).unwrap_or(Var::empty()));
if c.len() >= 3 {
let v = sub([c[0], c[1], c[2]], p);
dists.push(norm(sub(v, scale(normal, dot(v, normal)))));
} else {
unread += 1;
}
}
}
_ => unread += 1,
}
}
let inside = !dists.is_empty() && dists.iter().all(|d| *d < half);
let outside = !dists.is_empty() && dists.iter().all(|d| *d > half);
let landed = if unread > 0 || dists.is_empty() { "unknown" } else if inside { "inward" } else if outside { "outward" } else { "both" };
tries.push(json!({"offsetMm": value * 1000.0, "segments": new.len(), "landed": landed,
"farthestFromTheFaceMm": dists.iter().cloned().fold(0.0, f64::max) * 1000.0}));
let right = (want_inward && inside) || (!want_inward && outside);
if right || landed == "unknown" {
return Ok(json!({
"offsetEntities": o.describe()["offsetEntities"].clone(),
"selected": what,
"segments": new.len(),
"side": if right { landed } else { "not verified" },
"sideVerified": right,
"why": (!right).then_some("the new segments' ends could not all be read back, so the side was not checked — verify the result's mass or area"),
"tries": tries,
"route": "IModelDoc2::SketchOffsetEntities2",
}));
}
if attempt == 1 {
return Err(HostError::msg(format!(
"SketchOffsetEntities2 put the offset of {what} on the wrong side ({}) with both signs — nothing usable was drawn: {}",
if want_inward { "wanted inside the face" } else { "wanted outside it" },
serde_json::to_string(&tries).unwrap_or_default()
)));
}
Self::delete_segments(doc, &new)?;
if Self::sketch_segments(&sketch).len() != before {
return Err(HostError::msg("the offset on the wrong side could not be deleted — the sketch keeps it; nothing more was drawn"));
}
sign = -sign;
}
unreachable!("two attempts return")
}
/// THE GEOMETRIC EDGE PICK: IEdge::GetClosestPointOn over every edge.
fn pick_edge(doc: &Disp, p: crate::modeling::V3) -> Result<(Disp, crate::modeling::V3), HostError> {
use crate::modeling::{norm, sub};
let bodies = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?;
let mut edges: Vec<(Disp, crate::modeling::V3)> = Vec::new();
let mut cands: Vec<(usize, f64, f64)> = Vec::new();
for b in bodies.as_array().iter().filter_map(|b| b.as_dispatch().map(Disp)) {
for e in b.call("GetEdges", &[]).map(|v| v.as_array()).unwrap_or_default().iter().filter_map(|e| e.as_dispatch().map(Disp)) {
let c = e.call("GetClosestPointOn", &[&Var::r8(p[0]), &Var::r8(p[1]), &Var::r8(p[2])]).map(|v| Self::floats(&v)).unwrap_or_default();
if c.len() < 3 {
continue;
}
cands.push((edges.len(), norm(sub(p, [c[0], c[1], c[2]])), 0.0));
edges.push((e, [c[0], c[1], c[2]]));
}
}
match crate::modeling::pick(&cands, PICK_TOL) {
Ok(i) => Ok(edges.swap_remove(i)),
Err(crate::modeling::PickError::None(_)) => Err(HostError::msg(format!("no edge at [{:.3}, {:.3}, {:.3}] mm", p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0))),
Err(crate::modeling::PickError::Ambiguous(n)) => Err(HostError::msg(format!(
"the point [{:.3}, {:.3}, {:.3}] mm lies on {n} edges (a corner) — move it along ONE edge",
p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0
))),
}
}
/// The edges a fillet / chamfer takes, picked and DE-DUPLICATED by
/// persistent id (review #3: a box's 6 faces list its 12 edges 24 times,
/// and an already-selected edge selected again is deselected).
fn collect_edges(&mut self, doc: &Disp, sel: &crate::script::EdgeSel, st: &ScriptState) -> Result<(Vec<SelItem>, Value), HostError> {
let (listed, how): (Vec<SelItem>, Value) = match sel {
crate::script::EdgeSel::AtPoints(ps) => {
let mut v = Vec::new();
for p in ps {
let (e, at) = Self::pick_edge(doc, *p)?;
v.push(SelItem::edge(e, Some(at)));
}
(v, json!({"atPoints": ps.len()}))
}
crate::script::EdgeSel::AllOfFeature(f) => {
let name = st.resolve_feature(f)?;
let feat = Self::feature_named(doc, &name).ok_or_else(|| HostError::msg(format!("no feature named '{name}'")))?;
let mut v = Vec::new();
// IFeature::GetFaces -> IFace2::GetEdges.
for face in feat.call("GetFaces", &[])?.as_array().iter().filter_map(|f| f.as_dispatch().map(Disp)) {
for e in face.call("GetEdges", &[]).map(|x| x.as_array()).unwrap_or_default().iter().filter_map(|e| e.as_dispatch().map(Disp)) {
v.push(SelItem::edge(e, None));
}
}
(v, json!({"allOfFeature": name}))
}
};
// The persistent id; failing that, the edge's own geometry (its curve's
// start and end points, IEdge::GetCurveParams2) — never "no key", which
// would let a duplicate through to toggle itself off.
let keys: Vec<String> = listed
.iter()
.enumerate()
.map(|(i, it)| {
let e = &it.ent;
Self::entity_key(doc, e).unwrap_or_else(|| {
let p = e.call("GetCurveParams2", &[]).map(|v| Self::floats(&v)).unwrap_or_default();
if p.len() >= 6 {
p.iter().take(8).map(|x| format!("{:.9}", x)).collect::<Vec<_>>().join(",")
} else {
format!("unkeyed-{i}")
}
})
})
.collect();
let mut listed: Vec<Option<SelItem>> = listed.into_iter().map(Some).collect();
let n_listed = listed.len();
let keep: Vec<SelItem> = crate::modeling::dedupe(&keys).into_iter().filter_map(|i| listed[i].take()).collect();
if keep.is_empty() {
return Err(HostError::msg("no edges to select"));
}
let mut how = how;
how["listed"] = json!(n_listed);
how["unique"] = json!(keep.len());
Ok((keep, how))
}
/// Write a colour (and read it back). MaterialPropertyValues: [R, G, B,
/// Ambient, Diffuse, Specular, Shininess, Transparency, Emission], 0..1.
/// The current values are read first so only the COLOUR changes.
fn apply_appearance(&mut self, doc: &Disp, at: &crate::script::AppearanceTarget, rgb: &[f64; 3], st: &mut ScriptState) -> Result<Value, HostError> {
use crate::script::AppearanceTarget;
let merged = |cur: Vec<f64>| -> Vec<f64> {
let mut v = if cur.len() >= 9 && cur[..3].iter().all(|c| *c >= 0.0) { cur } else { vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.5, 0.4, 0.0, 0.0] };
v[0] = rgb[0];
v[1] = rgb[1];
v[2] = rgb[2];
v
};
let read = |d: &Disp, prop: &str| -> Vec<f64> { d.call(prop, &[]).map(|v| Self::floats(&v)).unwrap_or_default() };
let mut face_selected = false;
let mut via: Option<(Value, bool)> = None;
let (holder, prop, route): (Disp, &str, String) = match at {
AppearanceTarget::Part => (doc.clone(), "MaterialPropertyValues", "part".into()),
AppearanceTarget::Body(name) => {
let bodies: Vec<Disp> = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?.as_array().iter().filter_map(|b| b.as_dispatch().map(Disp)).collect();
if bodies.len() <= 1 {
// IBody2::MaterialPropertyValues2 is documented for NON-base
// bodies (review #9): a single-body part is coloured at the
// part level.
(doc.clone(), "MaterialPropertyValues", "part (single-body part)".into())
} else {
let body = bodies
.into_iter()
.find(|b| b.string("Name", &[]).as_deref() == Some(name.as_str()))
.ok_or_else(|| HostError::msg(format!("no solid body named '{name}' (swx_inspect_bodies lists them)")))?;
(body, "MaterialPropertyValues2", format!("body {name}"))
}
}
AppearanceTarget::FaceAt(p) => {
self.ensure_active(doc, st)?;
// GEOMETRIC pick, and the face SELECTED while it is written
// (IFace2::MaterialPropertyValues is "for the selected face" —
// review #13).
let picked = Self::pick_face(doc, *p)?;
let face = picked.face.clone();
let r = self.select_items(doc, vec![SelItem::face(picked)], 0)?;
if !r.all(1) {
let _ = Self::clear_selection(doc);
return Err(HostError::msg(format!("the face could not be selected to write its colour ({})", r.notes.join("; "))));
}
face_selected = true;
via = Some((r.report(), r.view_dependent()));
(face, "MaterialPropertyValues", "face".into())
}
};
let next = merged(read(&holder, prop));
let put = holder.put(prop, &Var::r8_array(&next));
let back = read(&holder, prop);
if face_selected {
let _ = Self::clear_selection(doc);
}
put?;
let to255 = |v: &[f64]| -> Vec<i64> { v.iter().take(3).map(|c| (c * 255.0).round() as i64).collect() };
let ok = back.len() >= 3 && (0..3).all(|i| (back[i] - rgb[i]).abs() < 1.0 / 255.0);
if !ok {
return Err(HostError::msg(format!(
"the appearance did not read back: asked {:?}, SOLIDWORKS reports {:?}",
to255(rgb),
to255(&back)
)));
}
let _ = doc.call("GraphicsRedraw2", &[]);
let mut out = json!({"rgb": to255(rgb), "target": route, "route": route, "readBack": to255(&back)});
if let Some((v, d)) = via {
out["selectedVia"] = v;
out["viewDependent"] = json!(d);
}
Ok(out)
}
/// WHERE AN OFFSET PLANE GOES, VERIFIED (review #2, #13). The plane is found
/// as the tree's new last feature (IRefPlane has no Name). Its position is
/// READ BACK through its transform; a negative offset tries
/// swRefPlaneReferenceConstraint_OptionFlip (256) and then _OptionReferenceFlip
/// (8192), deleting a plane that landed on the wrong side.
/// A plane `offset` along the OUTWARD normal of the face through `point`
/// (0.11.0): the face picked geometrically, resolved to the parallel default
/// plane at the face's height, then an offset plane of THAT default plane at
/// `height + offset·(outward · normal)` — the same planes a hole and an
/// offset sketch make, cached per script.
fn face_offset_plane(
&mut self,
doc: &Disp,
point: crate::modeling::V3,
offset: f64,
defaults: &[DefaultPlane; 3],
st: &mut ScriptState,
) -> Result<FaceOffset, HostError> {
let picked = Self::pick_face(doc, point)?;
let frames = [defaults[0].frame, defaults[1].frame, defaults[2].frame];
let i = crate::modeling::parallel_default(&frames, picked.normal).ok_or_else(|| {
HostError::msg(format!("the face at that point is not planar and parallel to a default plane (normal {:?})", picked.normal))
})?;
let frame = frames[i];
let along = if crate::modeling::dot(picked.normal, frame.n) >= 0.0 { 1.0 } else { -1.0 };
let h = frame.height_of(picked.closest) + offset * along;
let (name, made, verified) = self.ensure_offset_plane(doc, &defaults[i].name, &frame, h, st)?;
Ok(FaceOffset {
report: json!({"offsetFrom": defaults[i].name, "offsetMm": h * 1000.0, "fromFace": true, "faceNormal": picked.normal,
"faceOffsetMm": offset * 1000.0, "plane": name, "verified": verified, "pickedBy": "geometry"}),
name,
made,
frame,
h,
normal: picked.normal,
})
}
fn ensure_offset_plane(
&mut self,
doc: &Disp,
base: &str,
frame: &crate::modeling::PlaneFrame,
offset: f64,
st: &mut ScriptState,
) -> Result<(String, Option<String>, bool), HostError> {
let key = format!("{base}|{:.9}", offset);
if let Some(n) = st.planes.get(&key) {
return Ok((n.clone(), None, true));
}
let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;
let tries: &[i32] = if offset >= 0.0 { &[8] } else { &[8 | 256, 8 | 8192] };
let mut seen: Vec<String> = Vec::new();
for flags in tries {
let _ = Self::clear_selection(doc);
if !Self::select_named(doc, base, "PLANE", false, 0)? {
return Err(HostError::msg(format!("no plane named '{base}' to offset from")));
}
let before = Self::last_feature_name(doc);
let made = fm.call("InsertRefPlane", &[&Var::i4(*flags), &Var::r8(offset.abs()), &Var::i4(0), &Var::r8(0.0), &Var::i4(0), &Var::r8(0.0)])?;
let _ = Self::clear_selection(doc);
let after = Self::last_feature_name(doc);
let name = match (&after, made.as_dispatch()) {
(Some(a), _) if after != before => a.clone(),
// Name only as a fallback: IRefPlane does not have one.
(_, Some(d)) => match Disp(d).string("Name", &[]) {
Some(n) => n,
None => continue,
},
_ => continue,
};
match Self::plane_frame(doc, &name) {
Some(pf) => {
let at = frame.height_of(pf.origin);
if (at - offset).abs() < 1e-7 {
st.planes.insert(key, name.clone());
return Ok((name.clone(), Some(name), true));
}
seen.push(format!("flags {flags}: at {:.4} mm", at * 1000.0));
// On the wrong side: remove it and try the other flip.
let _ = Self::clear_selection(doc);
if Self::select_named(doc, &name, "PLANE", false, 0)? {
let _ = doc.call("EditDelete", &[]);
}
let _ = Self::clear_selection(doc);
}
None => {
// Cannot be verified: used, and said so.
st.planes.insert(key, name.clone());
return Ok((name.clone(), Some(name), false));
}
}
}
Err(HostError::msg(format!(
"SOLIDWORKS would not place a plane {:.4} mm from '{base}' ({}) — nothing was sketched",
offset * 1000.0,
seen.join("; ")
)))
}
/// A reference axis along a model axis: the intersection of two default
/// planes (IModelDoc2::InsertAxis2, 2001Plus), found as the new last
/// feature; its DIRECTION is read back (IRefAxis::GetRefAxisParams — review
/// #13), so a pattern's "positive" is the model's. One per axis per script.
fn ensure_axis(&mut self, doc: &Disp, axis: crate::script::Axis, st: &mut ScriptState) -> Result<(String, f64), HostError> {
if let Some(n) = st.axes.get(&axis) {
return Ok(n.clone());
}
let defaults = self.default_planes(doc, st)?;
let (ia, ib) = match axis {
crate::script::Axis::X => (1, 0),
crate::script::Axis::Y => (0, 2),
crate::script::Axis::Z => (1, 2),
};
let (a, b) = (defaults[ia].name.clone(), defaults[ib].name.clone());
let _ = Self::clear_selection(doc);
if !(Self::select_named(doc, &a, "PLANE", false, 0)? && Self::select_named(doc, &b, "PLANE", true, 0)?) {
let _ = Self::clear_selection(doc);
return Err(HostError::msg(format!("could not select '{a}' and '{b}' to make the {} axis", axis.name())));
}
let before = Self::last_feature_name(doc);
let ok = doc.call("InsertAxis2", &[&Var::boolean(true)])?.as_bool().unwrap_or(false);
let _ = Self::clear_selection(doc);
let after = Self::last_feature_name(doc);
if !ok || after.is_none() || after == before {
return Err(HostError::msg(format!("SOLIDWORKS made no {} axis from '{a}' and '{b}'", axis.name())));
}
let name = after.unwrap_or_default();
let idx = match axis {
crate::script::Axis::X => 0,
crate::script::Axis::Y => 1,
crate::script::Axis::Z => 2,
};
let sign = Self::feature_named(doc, &name)
.and_then(|f| f.obj("GetSpecificFeature2", &[]).ok().flatten())
.and_then(|ra| ra.call("GetRefAxisParams", &[]).ok())
.map(|v| Self::floats(&v))
.filter(|p| p.len() >= 6)
.map(|p| p[3 + idx] - p[idx])
.filter(|d| d.abs() > 1e-12)
.map(|d| d.signum())
.unwrap_or(1.0);
st.axes.insert(axis, (name.clone(), sign));
Ok((name, sign))
}
/// FeatureFillet3's documented successor (review #12): CreateDefinition
/// (swFeatureNameID_e.swFmFillet, read from the installed swconst DLL —
/// never guessed) → ISimpleFilletFeatureData2 (Initialize swConstRadiusFillet
/// 0, DefaultRadius, Edges) → CreateFeature.
fn fillet_by_definition(&mut self, _doc: &Disp, fm: &Disp, edges: &[Disp], radius: f64) -> Result<Var, HostError> {
let sw = self.app(false, true)?;
let exe = sw.string("GetExecutablePath", &[]).unwrap_or_default();
let ids = swconst_ids(&exe, &[("swFeatureNameID_e", "swFmFillet")]).map_err(HostError::msg)?;
let id = *ids.get("swFmFillet").ok_or_else(|| HostError::msg("swFmFillet was not read from swconst"))?;
let data = fm.obj("CreateDefinition", &[&Var::i4(id)])?.ok_or_else(|| HostError::msg("CreateDefinition returned nothing"))?;
data.call("Initialize", &[&Var::i4(0)])?;
data.put("DefaultRadius", &Var::r8(radius))?;
// The typed object array first (the shape MultiSelect2 needed, 0.10.3),
// the VARIANT-wrapped one if the property refuses it.
let raw: Vec<_> = edges.iter().map(|d| d.0.clone()).collect();
if data.put("Edges", &Var::dispatch_typed_array(&raw)).is_err() {
data.put("Edges", &Var::dispatch_array(&raw))?;
}
fm.call("CreateFeature", &[&Var::dispatch(&data.0)])
}
}
/// The geometric face pick tolerance (metres): a point within 2 µm of a face
/// is ON it.
const PICK_TOL: f64 = 2e-6;
/// A picked face, with its closest point to the caller's and its outward normal.
struct PickedFace {
face: Disp,
closest: crate::modeling::V3,
normal: crate::modeling::V3,
}
/// What a picked entity is, for SelectByRay / SelectByID2.
#[derive(Clone, Copy, PartialEq)]
enum SelKind {
Face,
Edge,
}
/// One entity to select, with what the ray rung needs: a point ON it and its
/// outward direction (a face's normal; filled lazily for an edge).
#[derive(Clone)]
struct SelItem {
ent: Disp,
kind: SelKind,
at: Option<crate::modeling::V3>,
outward: Option<crate::modeling::V3>,
}
impl SelItem {
fn face(p: PickedFace) -> SelItem {
let outward = (crate::modeling::norm(p.normal) > 1e-9).then_some(p.normal).or_else(|| ComBackend::face_outward(&p.face, p.closest));
SelItem { ent: p.face, kind: SelKind::Face, at: Some(p.closest), outward }
}
fn edge(e: Disp, at: Option<crate::modeling::V3>) -> SelItem {
SelItem { ent: e, kind: SelKind::Edge, at, outward: None }
}
}
/// The COM host's side of the selection ladder.
struct ComSelector {
doc: Disp,
ext: Disp,
sel: Disp,
data: Disp,
mark: i32,
items: Vec<SelItem>,
exe: String,
major: Option<u32>,
iid: Option<Result<windows::core::GUID, String>>,
}
impl ComSelector {
/// Where the entity is, and which way is OUT of it — computed only when a
/// rung that needs it is reached (an edge: its two faces' normals).
fn locate(&mut self, i: usize) -> Result<(crate::modeling::V3, crate::modeling::V3), String> {
let it = &mut self.items[i];
if it.at.is_none() {
it.at = ComBackend::edge_point(&it.ent);
}
let at = it.at.ok_or("no point on the entity could be computed")?;
if it.outward.is_none() && it.kind == SelKind::Edge {
let faces: Vec<Disp> = it.ent.call("GetTwoAdjacentFaces2", &[]).map(|v| v.as_array()).unwrap_or_default().iter().filter_map(|f| f.as_dispatch().map(Disp)).collect();
let normals: Vec<crate::modeling::V3> = faces.iter().filter_map(|f| ComBackend::face_outward(f, at)).collect();
it.outward = crate::modeling::edge_ray(at, &normals).map(|(_, d)| crate::modeling::scale(d, -1.0));
}
let out = it.outward.ok_or("its outward direction could not be computed")?;
Ok((at, out))
}
fn entity_iid(&mut self) -> Result<windows::core::GUID, String> {
if self.iid.is_none() {
self.iid = Some(interop_dual_iid(&self.exe, "IEntity"));
}
self.iid.clone().unwrap_or_else(|| Err("no IID".into()))
}
fn type_name(&self, i: usize) -> (&'static str, i32) {
// swSelectType_e: swSelFACES 2, swSelEDGES 1.
match self.items[i].kind {
SelKind::Face => ("FACE", 2),
SelKind::Edge => ("EDGE", 1),
}
}
}
impl crate::modeling::Selector for ComSelector {
fn clear(&mut self) {
let _ = self.doc.call("ClearSelection2", &[&Var::boolean(true)]);
}
fn count(&mut self) -> Option<usize> {
self.sel.int("GetSelectedObjectCount2", &[&Var::i4(-1)]).map(|c| c.max(0) as usize)
}
fn attempt(&mut self, rung: crate::modeling::Rung, items: &[usize]) -> Result<(), String> {
use crate::modeling::Rung;
let one = |items: &[usize]| -> Result<usize, String> { items.first().copied().ok_or_else(|| "no entity".to_string()) };
match rung {
Rung::MultiSelectVariantArray => {
let arr = Var::dispatch_array(&items.iter().map(|i| self.items[*i].ent.0.clone()).collect::<Vec<_>>());
self.ext.call("MultiSelect2", &[&arr, &Var::boolean(true), &Var::dispatch(&self.data.0)]).map(|_| ()).map_err(|e| e.message)
}
Rung::MultiSelectTyped => {
let arr = Var::dispatch_typed_array(&items.iter().map(|i| self.items[*i].ent.0.clone()).collect::<Vec<_>>());
self.ext.call("MultiSelect2", &[&arr, &Var::boolean(true), &Var::dispatch(&self.data.0)]).map(|_| ()).map_err(|e| e.message)
}
Rung::EntitySelect4 => {
let i = one(items)?;
let ent = self.items[i].ent.clone();
// Late binding on an IFace2/IEdge resolves only ITS names; IEntity's
// Select4 is reached through the object's IEntity interface (QI with
// the IID read from the installed interop).
let target = if ent.dispid("Select4").is_ok() {
ent
} else {
let iid = self.entity_iid()?;
ent.query_dual(&iid).ok_or("QueryInterface for IEntity failed")?
};
target.call("Select4", &[&Var::boolean(true), &Var::dispatch(&self.data.0)]).map(|_| ()).map_err(|e| e.message)
}
Rung::SelectByRay => {
if self.major.map(|m| m < 25).unwrap_or(false) {
return Err("IModelDocExtension::SelectByRay needs SOLIDWORKS 2017".into());
}
let i = one(items)?;
let (at, out) = self.locate(i)?;
let (o, d) = crate::modeling::face_ray(at, out).ok_or("no ray")?;
let (_, t) = self.type_name(i);
self.ext
.call(
"SelectByRay",
&[
&Var::r8(o[0]),
&Var::r8(o[1]),
&Var::r8(o[2]),
&Var::r8(d[0]),
&Var::r8(d[1]),
&Var::r8(d[2]),
&Var::r8(1.0e-4),
&Var::i4(t),
&Var::boolean(true),
&Var::i4(self.mark),
&Var::i4(0),
],
)
.map(|_| ())
.map_err(|e| e.message)
}
Rung::SelectByIdAtPoint => {
let i = one(items)?;
if self.items[i].at.is_none() {
self.items[i].at = ComBackend::edge_point(&self.items[i].ent);
}
let at = self.items[i].at.ok_or("no point on the entity could be computed")?;
let (kind, _) = self.type_name(i);
self.ext
.call(
"SelectByID2",
&[
&Var::bstr(""),
&Var::bstr(kind),
&Var::r8(at[0]),
&Var::r8(at[1]),
&Var::r8(at[2]),
&Var::boolean(true),
&Var::i4(self.mark),
&Var::null_dispatch(),
&Var::i4(0),
],
)
.map(|_| ())
.map_err(|e| e.message)
}
}
}
}
/// The IID of a SOLIDWORKS interface, read from the INSTALLED interop
/// (SolidWorks.Interop.sldworks.dll beside SOLIDWORKS, or api\redist) by
/// reflection — and only if that interface is DUAL, so calling it through
/// IDispatch is sound. Cached.
fn interop_dual_iid(exe_dir: &str, name: &str) -> Result<windows::core::GUID, String> {
use std::sync::Mutex;
static CACHE: Mutex<Vec<(String, windows::core::GUID)>> = Mutex::new(Vec::new());
if let Some((_, g)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(n, _)| n == name) {
return Ok(*g);
}
let base = std::path::Path::new(exe_dir.trim_end_matches(['\\', '/']));
let dll = [base.join("SolidWorks.Interop.sldworks.dll"), base.join("api").join("redist").join("SolidWorks.Interop.sldworks.dll")]
.into_iter()
.find(|p| p.is_file())
.ok_or_else(|| format!("SolidWorks.Interop.sldworks.dll was not found beside SOLIDWORKS ({exe_dir})"))?;
// InterfaceTypeAttribute 0 = InterfaceIsDual; TypeLibTypeAttribute flag 0x40 = FDual.
let script = format!(
"$ErrorActionPreference='Stop'; $a=[Reflection.Assembly]::LoadFrom('{}'); \
$t=$a.GetType('SolidWorks.Interop.sldworks.{}'); \
$it=$t.GetCustomAttributes([Runtime.InteropServices.InterfaceTypeAttribute],$false) | Select-Object -First 1; \
$tl=$t.GetCustomAttributes([Runtime.InteropServices.TypeLibTypeAttribute],$false) | Select-Object -First 1; \
[Console]::WriteLine('guid=' + $t.GUID.ToString()); \
if($it){{ [Console]::WriteLine('itype=' + [int]$it.Value) }}; \
if($tl){{ [Console]::WriteLine('tlflags=' + [int]$tl.Value) }}",
dll.display().to_string().replace('\'', "''"),
name
);
let out = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.output()
.map_err(|e| format!("could not run powershell to read the {name} IID: {e}"))?;
let text = String::from_utf8_lossy(&out.stdout);
let (guid, dual) = crate::modeling::parse_iid_probe(&text).ok_or_else(|| format!("the {name} IID could not be read from {} ({})", dll.display(), String::from_utf8_lossy(&out.stderr).trim()))?;
if !dual {
return Err(format!("{name} is not a dual interface in {} — it cannot be called by name", dll.display()));
}
let guid = windows::core::GUID::from_u128(guid);
CACHE.lock().unwrap_or_else(|e| e.into_inner()).push((name.to_string(), guid));
Ok(guid)
}
#[derive(Clone)]
struct DefaultPlane {
name: String,
frame: crate::modeling::PlaneFrame,
}
/// A plane offset from a face, made (or reused) for a sketch or a mirror.
struct FaceOffset {
name: String,
made: Option<String>,
frame: crate::modeling::PlaneFrame,
h: f64,
normal: crate::modeling::V3,
report: Value,
}
/// A sketch closed and not yet consumed: its name (to re-select it), its
/// normal, and the direction a boss from it should go.
#[derive(Clone)]
struct PendingSketch {
name: String,
normal: Option<crate::modeling::V3>,
reference: Option<crate::modeling::V3>,
/// Each drawn entity's line segment (None for anything else), in the
/// order drawn — a revolve's axis is selected from here.
segments: Vec<Option<SketchSeg>>,
/// Where the caller's (u, v) are in the model (0.11.4, a region pick): the
/// base plane's frame at a height for a mapped sketch (an offset plane, a
/// face), else the sketch's own axes (sketch-to-model transform).
mapping: Option<(crate::modeling::PlaneFrame, f64)>,
sketch_to_model: Option<crate::modeling::Xform>,
}
impl PendingSketch {
/// The model point of the caller's sketch point (u, v).
fn model_point(&self, u: f64, v: f64) -> Option<crate::modeling::V3> {
match (&self.mapping, &self.sketch_to_model) {
(Some((frame, h)), _) => Some(frame.point(u, v, *h)),
(None, Some(t)) => Some(t.apply([u, v, 0.0])),
(None, None) => None,
}
}
}
/// A drawn sketch line: its ISketchSegment and its name within the sketch.
#[derive(Clone)]
struct SketchSeg {
name: Option<String>,
seg: Disp,
}
/// What a running script remembers between ops: the feature "last" names, the
/// reference planes / axes it has made (reused, not duplicated), the pending
/// sketch, the appearances it set (a material wipes them), the default planes,
/// and the document to re-activate when it ends.
#[derive(Default)]
struct ScriptState {
/// The document THIS script's new_part made (clear_part may act on it).
created_here: Option<String>,
last_feature: Option<String>,
planes: std::collections::HashMap<String, String>,
axes: std::collections::HashMap<crate::script::Axis, (String, f64)>,
pending: Option<PendingSketch>,
appearances: Vec<(crate::script::AppearanceTarget, [f64; 3])>,
defaults: Option<[DefaultPlane; 3]>,
frames_source: &'static str,
restore_active: Option<String>,
}
impl ScriptState {
fn resolve_feature(&self, f: &str) -> Result<String, HostError> {
if f == "last" {
self.last_feature.clone().ok_or_else(|| HostError::msg("\"last\" names no feature: none was made earlier in this script — name the feature (swx_design_info lists them)"))
} else {
Ok(f.to_string())
}
}
}
/// THE swconst IDS, READ FROM THE INSTALLED INTEROP ASSEMBLY (0.9.18). The
/// `swUserPreference*_e` numbers are not published on the help pages, and this
/// bridge never guesses one — the 3D Interconnect ids were read from the same
/// DLL on 2026-09-17. PowerShell reflects over
/// `<GetExecutablePath>\SolidWorks.Interop.swconst.dll` (or `api\redist\`)
/// once per com-host process; a member it cannot read is an error, not a
/// default. UNMEASURED as a code path.
fn swconst_ids(exe_dir: &str, want: &[(&str, &str)]) -> Result<std::collections::HashMap<String, i32>, String> {
use std::sync::Mutex;
static CACHE: Mutex<Option<std::collections::HashMap<String, i32>>> = Mutex::new(None);
{
let g = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = g.as_ref() {
if want.iter().all(|(_, m)| c.contains_key(*m)) {
return Ok(c.clone());
}
}
}
let base = std::path::Path::new(exe_dir.trim_end_matches(['\\', '/']));
let dll = [base.join("SolidWorks.Interop.swconst.dll"), base.join("api").join("redist").join("SolidWorks.Interop.swconst.dll")]
.into_iter()
.find(|p| p.is_file())
.ok_or_else(|| format!("SolidWorks.Interop.swconst.dll was not found beside SOLIDWORKS ({exe_dir})"))?;
let pairs: Vec<String> = want.iter().map(|(e, m)| format!("'{e}|{m}'")).collect();
let script = format!(
"$ErrorActionPreference='Stop'; $a=[Reflection.Assembly]::LoadFrom('{}'); \
foreach($p in @({})){{ $e,$m=$p.Split('|'); \
$t=$a.GetTypes() | Where-Object {{ $_.Name -eq $e }} | Select-Object -First 1; \
[Console]::WriteLine($m + '=' + [int][Enum]::Parse($t, $m)) }}",
dll.display().to_string().replace('\'', "''"),
pairs.join(",")
);
let out = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.output()
.map_err(|e| format!("could not run powershell to read swconst ids: {e}"))?;
let text = String::from_utf8_lossy(&out.stdout);
let mut got = std::collections::HashMap::new();
for l in text.lines() {
if let Some((k, v)) = l.trim().split_once('=') {
if let Ok(n) = v.trim().parse::<i32>() {
got.insert(k.trim().to_string(), n);
}
}
}
let missing: Vec<&str> = want.iter().map(|(_, m)| *m).filter(|m| !got.contains_key(*m)).collect();
if !missing.is_empty() {
return Err(format!(
"these ids could not be read from {}: {} ({})",
dll.display(),
missing.join(", "),
String::from_utf8_lossy(&out.stderr).trim()
));
}
let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let c = g.get_or_insert_with(std::collections::HashMap::new);
c.extend(got.clone());
Ok(c.clone())
}
/// EVERY MEMBER of a swconst enum, read from the installed interop by
/// reflection (0.11.0) — for an enum whose member names the help does not
/// publish (swFeatureScope_e). UNMEASURED as a code path.
fn swconst_members(exe_dir: &str, enum_name: &str) -> Result<Vec<(String, i32)>, String> {
let base = std::path::Path::new(exe_dir.trim_end_matches(['\\', '/']));
let dll = [base.join("SolidWorks.Interop.swconst.dll"), base.join("api").join("redist").join("SolidWorks.Interop.swconst.dll")]
.into_iter()
.find(|p| p.is_file())
.ok_or_else(|| format!("SolidWorks.Interop.swconst.dll was not found beside SOLIDWORKS ({exe_dir})"))?;
let script = format!(
"$ErrorActionPreference='Stop'; $a=[Reflection.Assembly]::LoadFrom('{}'); \
$t=$a.GetTypes() | Where-Object {{ $_.Name -eq '{}' }} | Select-Object -First 1; \
foreach($m in [Enum]::GetNames($t)){{ [Console]::WriteLine($m + '=' + [int][Enum]::Parse($t, $m)) }}",
dll.display().to_string().replace('\'', "''"),
enum_name.replace('\'', "")
);
let out = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.output()
.map_err(|e| format!("could not run powershell to read {enum_name}: {e}"))?;
let text = String::from_utf8_lossy(&out.stdout);
let got: Vec<(String, i32)> = text
.lines()
.filter_map(|l| l.trim().split_once('=').and_then(|(k, v)| v.trim().parse::<i32>().ok().map(|n| (k.trim().to_string(), n))))
.collect();
if got.is_empty() {
return Err(format!("{enum_name} could not be read from {} ({})", dll.display(), String::from_utf8_lossy(&out.stderr).trim()));
}
Ok(got)
}
/// Preferences this bridge is willing to name, as (name, swUserPreferenceIntegerValue_e id).
/// Small on purpose: `swx_set_preference` reads every value back after writing
/// it, so anything listed here is something the bridge can PROVE it changed.
pub const PREFERENCES: &[(&str, i32)] = &[("lengthUnitRaw", 53), ("unitSystemRaw", 48)];