← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.18+0.9.19
Cargo.lock+1−1
@@ -122,7 +122,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.18"+version = "0.9.19" dependencies = [  "miniz_oxide",  "serde",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.18"+version = "0.9.19" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+25−13
@@ -19,7 +19,7 @@ about ten milliseconds. | **Assemble** | `swx_add_component` — insert a part or sub-assembly into the active assembly, positioned and rotated. | | **Close & quit safely** | `swx_close_all_safely` (saves every open document to a recovery folder, verifies each file, then closes), `swx_shutdown` (that sweep, then a graceful quit), `swx_recover_list` (what was preserved and where), `swx_close_import_temps`, `swx_user_presence` (is someone working in this session? the bridge refuses to end a session a person is using). | | Export | `swx_export` plus per-format aliases `swx_export_step/iges/stl/3mf/glb/sat/parasolid/pdf/dxf/dwg`, `swx_pack_and_go` |-| Render | `swx_render_image` — a high-resolution PNG of the active document from SOLIDWORKS's own viewport (RealView, ambient occlusion, shadows, perspective, a plain background), at a size independent of the window; every display setting it changes is restored. |+| Render | `swx_render_image` (MEASURED live) — a high-resolution PNG of the active document from SOLIDWORKS's own viewport (RealView, ambient occlusion, shadows, perspective, a plain background), at a size independent of the window; every display setting it changes is restored. | | Read the design back | `swx_design_info`, `swx_inspect_bodies` (geometry in mm), `swx_get_parameters`, `swx_set_parameter`, `swx_mass_properties`, `swx_bom`, `swx_interference`, `swx_rebuild_check`, `swx_document_info` | | Metadata | `swx_props_get`, `swx_props_set` (custom properties, file- or configuration-scope) | | Sign-in | `swx_sign_in` — signs in to 3DEXPERIENCE for a launch this bridge started, from the user's own local vault; the password never reaches a reply, a log or the container, and a rejected password is never retried. |@@ -203,17 +203,20 @@ regardless, so "what CAD files do I have?" is answerable today.  `swx_render_image {reason, width:3840, height:2160, view:"isometric", realView:true, ambientOcclusion:true, shadows:true, perspective:true,-background:"white", zoomToFit:true}` draws the active document with SOLIDWORKS's-own viewport (PhotoView 360 is absent on this edition) through-`IModelDoc2::SaveBMP` at the size asked, converts it to PNG, and reads the size-back from the file — `capped:true` if the export only honoured the window. Every-display setting it changes (view, style, RealView, AO, shadows, perspective,-background, camera) is captured first and restored after, on errors too, and-listed in `restoredSettings`; the document is never saved. A render that-changes the screen waits for the user to be away (`user_is_active` otherwise); an-export of the current view does not. The whole COM path is UNMEASURED until the-first live run. Design, the call-by-call table and the SOLIDWORKS Visualize plan-(UI-automation, design only): [docs/RENDERING.md](docs/RENDERING.md).+zoomToFit:true, zoomFactor:2.0}` draws the active document with SOLIDWORKS's own+viewport (PhotoView 360 is absent on this edition) through `IModelDoc2::SaveBMP`+at the size asked, converts it to PNG, and reads the size back from the file —+`capped:true` if only the window size came out. MEASURED 2026-10-05 on the+110-document Moveo assembly with the window minimized: 3840×2160 in 11.6 s+(1080p in 10.4 s), a real RealView render with a floor shadow, every setting+read back restored. Every display setting it changes is captured first and+restored after, on errors too; `settings` accounts for each one the caller asked+for (`{requested, before, after, changed, restored, why}`); the document is never+saved. A render that changes the screen waits for the user to be away. Still+unmeasured: a visible window, 8192 px, background changes, `zoomFactor`'s+direction. SOLIDWORKS Visualize can be started (`swx_start {app:"visualize"}`,+through its platform shortcut, its dialogs reported and never answered); a+Visualize render is design-only. Details: [docs/RENDERING.md](docs/RENDERING.md).  ## Export formats, verified rather than assumed @@ -478,7 +481,16 @@ not.  ## Roadmap -- v0.9.18 (this): **the bridge can render.** Drew asked for a gorgeous shot+- v0.9.19 (this): **the render is measured; Visualize can be started.** The+  render ran live on 2026-10-05 (4K in 11.6 s, minimized window, every setting+  restored). It now accounts for every requested setting (`settings`: already+  as asked vs applied vs unreadable — the live run could not tell about+  shadows), takes a `zoomFactor` (the fit left the assembly at ~25% of a 16:9+  frame), and reports the swconst ids it read. `swx_start {app:"visualize"}`+  starts SOLIDWORKS Visualize through its platform shortcut (a direct start is+  refused by this edition — measured) and reports, never answers, its dialogs:+  the platform notice, the first-run content folder, the login. 552 tests.+- v0.9.18: **the bridge can render.** Drew asked for a gorgeous shot   of an assembly. `swx_render_image` draws the active document with   SOLIDWORKS's own viewport (RealView, ambient occlusion, shadows, perspective,   a plain background) at any size up to 8192 px a side through `SaveBMP`,
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.18",+  "version": "0.9.19",   "description": "Bridge for SOLIDWORKS desktop CAD — create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",
docs/ECOSYSTEM-TABLE.md+1−1
@@ -114,7 +114,7 @@ templates**, alisamsam **22**. | 48 | **A title collision between SAVED documents is impossible here — measured, not assumed** | ✗ | **✓ 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 it. SOLIDWORKS REFUSED: `errors = 65536` (`swFileWithSameTitleAlreadyOpen`). So the drain's title-collision guard — which refuses to close any document whose title another open document shares, and names both paths — can only ever fire for PATHLESS documents, which is exactly the state identity-at-birth removes. The guard stays (a document the bridge did not create can still be pathless, and an older bridge's leftovers are), and the measurement is recorded next to it in `com.rs` so nobody "simplifies" it on the assumption that saved collisions are the common case. No MCP server measures this at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 52 | **Never ends a session the user is working in** (a measured user-presence guard on every quit and sweep) | ✗ | **✓ 0.9.9, from an event MEASURED 2026-10-01**: Drew took over a bridge-launched session — a "leave as found" quit met his File Open dialog, and minutes later the session held his 34-document crate assembly. Every quit, sweep and priming now measures presence (SOLIDWORKS foreground + input under 120 s, documents the bridge did not open, an earlier take-over), refuses `user_is_active` with the evidence, fails closed on an unreadable measurement, and marks the session taken over for good; only the user's explicit override proceeds, and the gates never pass it. 0.9.10: a document is the bridge's only if a bridge verb opened or created it — in a session the user launched, every document is theirs. No MCP server has a presence guard at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 53 | **Signs the user in, unattended, without ever seeing the password** (3DEXPERIENCE ID sign-in from the user's own local vault) | ✗ | **✓ 0.9.13, from Drew's request 2026-10-02**: the login window's accessible inventory is captured every time it appears; `swx_sign_in` recognizes the form from that inventory alone, takes the login from the user's pup vault on their laptop through ab, types it through UI Automation, ticks "Remember me", verifies by the window closing, and scrubs the password from every reply, log and ledger line; it fails closed on a missing login, an unknown form or a second step, and never retries a rejected password. No MCP server signs in at all | ✗ | ✗ | ✗ | ✗ | ✗ |-| 54 | **Renders a product shot at any size, and leaves the user's view as it was** (high-resolution image export from the viewport) | ✗ | **✓ 0.9.18 (UNMEASURED live)**: `swx_render_image` sets view, style, RealView, ambient occlusion, shadows, perspective and a plain background, exports through `IModelDoc2::SaveBMP` at up to 8192 px a side, converts to PNG and reads the size back from the file (`capped:true` when only the window size came out); every changed setting is captured, restored on every path, read back and listed; the swconst ids are read from the installed interop DLL, never guessed; a render that changes the screen respects the user-presence guard. The Fusion bridge has no render verb, and no MCP server renders at all | ✗ | ✗ | ✗ | ✗ | ✗ |+| 54 | **Renders a product shot at any size, and leaves the user's view as it was** (high-resolution image export from the viewport) | ✗ | **✓ 0.9.18, MEASURED LIVE 2026-10-05** (3840×2160 of the 110-document Moveo assembly in 11.6 s, every setting restored; 0.9.19 accounts for every requested setting and adds `zoomFactor`): `swx_render_image` sets view, style, RealView, ambient occlusion, shadows, perspective and a plain background, exports through `IModelDoc2::SaveBMP` at up to 8192 px a side, converts to PNG and reads the size back from the file (`capped:true` when only the window size came out); every changed setting is captured, restored on every path, read back and listed; the swconst ids are read from the installed interop DLL, never guessed; a render that changes the screen respects the user-presence guard. The Fusion bridge has no render verb, and no MCP server renders at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 51 | **Dialogs judged by MEASURED MODALITY, not by existence** (a notification never blocks; a modal always does) | ✗ | **✓ LIVE-PROVEN 2026-10-01 on 0.9.4** — with the 3DEXPERIENCE "Connected" notice on screen, `swx_stop` quit in 5.1 s and reported it as a notification with its evidence. **0.9.4, from a blocker MEASURED 2026-10-01**: the 3DEXPERIENCE "Connected to …" notice — a title-less `#32770` tool window whose owner frame stayed ENABLED for 95 s, with no Button-class control — blocked every quit, sweep and import for minutes. Now a window blocks when its kind is known-blocking, its owner frame is disabled, or it has a Button-class control; it is a `notification` (reported with owner, ownerEnabled, styles, rect and child controls; never answered) only when all of that was measured; unread evidence fails closed. Template placeholder captions are flagged, never presented as the window's words. **0.9.5 (adversary pass 43): the window's WORDS count — a save/recovery/import kind in its text blocks whatever the evidence, two or more button-like captions in any control class block, and measured notification evidence overrides a licence-type title alone; 0.9.7: swx_dismiss_blocking_dialogs uses the same title-and-words classifier before it closes anything; 0.9.8, from a live measurement: an owner frame read disabled for an instant (SOLIDWORKS busy) is re-read before a notice is called a modal.** No MCP server looks at a dialog at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; every verb that reaches SOLIDWORKS refuses `host_app_starting` with `retryAfterSeconds` until then — **0.9.5: gated by default with a pinned 16-verb exemption list, the performance log bound to this session's process creation time (an earlier session's log is never "complete"), COM alone never enough on a machine that writes markers, an on-demand add-in told from a start by quiet + which add-in, the grace unlocking reads only, and the window-message quit rungs ungated under userAuthorized; LIVE 2026-10-01 a document verb was refused during startup. 0.9.6: every gated reply carries the readiness its gate evaluated, the log is read from its per-start header, and the on-demand rule keys on the platform DLL SWK3DCompassAddin.dll; 0.9.7 (LIVE-PROVEN 0.9.6): no marker history — a disabled platform add-in never locks the bridge out, and a log still being written holds even a machine's first start; 0.9.11, from a live lock-out: `UNLOAD_ADDIN` is never read as a load, a session older than ten minutes with COM's start complete is never held by a log rule unless startup lines are still changing, and the ready latch survives a bridge restart**; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ |
docs/RENDERING.md+112−8
@@ -7,8 +7,8 @@ bridge has no render verb at all (`docs/FUSION-PARITY-REPORT.md`), so this is new ground.  Every line here is **[measured]** (seen on Drew's laptop) or **[unmeasured]**-(written from the API documentation; not yet run live). As of 0.9.18 the whole-of track A's COM path is **[unmeasured]**.+(written from the API documentation; not yet run live). Track A was MEASURED+LIVE on 2026-10-05 (below); what is still unmeasured is named.  ## The facts this is built on @@ -25,16 +25,65 @@ of track A's COM path is **[unmeasured]**.   model path as its argument, ships no scripting interface on disk, and raises   its OWN 3DEXPERIENCE sign-in. -## Track A — `swx_render_image` (0.9.18)+## Track A — MEASURED 2026-10-05++On adom_drew2, hosted 0.9.18, SOLIDWORKS 2026 SP3 (Makers), the Moveo assembly+(110 documents, read-only), the SOLIDWORKS window MINIMIZED throughout:++| Request | Result |+|---|---|+| `{width:1920, height:1080}` | ok, 411,368 bytes, PNG header 1920×1080, `capped:false`, nothing applied or restored, 10,403 ms |+| `{width:3840, height:2160, view:"isometric", displayStyle:"shaded", perspective:true, realView:true, ambientOcclusion:true, shadows:true, zoomToFit:true}` | ok, 979,997 bytes, PNG header 3840×2160, `capped:false`, 11,597 ms. Applied: ambientOcclusion false→true, displayMode 5→4, orientation isometric, perspective false→true, realView false→true, zoom fit. Restored: every one read back equal to its old value; camera captured and restored; `restoreFailures: []` |++- [measured] The image is a real render: RealView shading, a floor shadow, the+  scene's gradient background.+- [measured] PowerShell reflection over the installed swconst DLL resolved all+  four preference ids (no `render_enum_unresolved`). 0.9.19 adds `enumIds` to+  the reply so the next run records the numbers themselves.+- [measured] SaveBMP at 4K works with the window MINIMIZED, and 4K costs only+  ~1.2 s more than 1080p — ask for 3840×2160 for hero-grade output.+- [measured] The gap that led to `settings`: `shadows:true` appeared in neither+  `appliedSettings` nor `restoredSettings` — already on, or never applied?+  0.9.19 accounts for every requested setting (below).+- [measured] Framing: `zoomToFit` leaves this assembly at ~25% of the frame's+  width (the fit box is the whole assembly at the window's aspect, then exported+  16:9). 0.9.19 adds `zoomFactor`.+- [unmeasured] flicker or resizing with the window VISIBLE; the largest size+  (8192×4608); a `background` change (not passed in the live runs); the+  direction of `zoomFactor` (a larger `Scale2` = closer?); whether shadows+  were already on.++### `settings` — every requested setting, accounted for (0.9.19)++```+"settings": {+  "<name>": {+    "requested": <what was asked: true | "isometric" | "shaded" | "white" | 2.5 …>,+    "before":    <its value before the render, or null if it could not be read>,+    "after":     <its value after applying, before the export>,+    "changed":   true | false,+    "restored":  true | false | null,   // null when nothing was changed+    "why":       "applied" | "alreadyAsRequested" | "unreadable" | "setFailed" | "idUnresolved"+  }, …+}+```++Names: `view`, `displayStyle`, `perspective`, `realView`, `ambientOcclusion`,+`shadows`, `background`, `zoomToFit`, `zoomFactor` — one row for each one the+caller asked for, and only those.++## Track A — `swx_render_image` (0.9.18; zoomFactor and settings 0.9.19)  ``` swx_render_image {reason, path?, width?, height?,                   view?: "current"|"isometric"|"front"|"back"|"left"|"right"|"top"|"bottom"|"trimetric"|"dimetric",                   displayStyle?: "shaded"|"shadedWithEdges"|"hiddenLinesRemoved"|"wireframe",                   perspective?, realView?, ambientOcclusion?, shadows?: bool,-                  background?: "current"|"white"|"black"|"transparent", zoomToFit?: bool}+                  background?: "current"|"white"|"black"|"transparent", zoomToFit?: bool,+                  zoomFactor?: number (0.1–20, default 1.0; multiplies the view scale AFTER any fit)} → {path, bytes, width, height (read back from the PNG header), requested, capped,-   appliedSettings, restoredSettings, restoreFailures, restoreIncomplete, durationMs}+   settings, appliedSettings, restoredSettings, restoreFailures, restoreIncomplete,+   enumIds, durationMs} ```  What it does, in order, all against the ACTIVE document:@@ -98,7 +147,15 @@ Budget: ab waits 600 s (`bridge.json` `timeouts.verbs.render_image`); the render's host call is bounded by the sweep ceiling (540 s). `swx_progress` shows the stage. -### Track A — measure first (the coordinator's live run)+### Track A — what the 2026-10-05 run answered, and what is still open++Answered: 1 (4K honoured, 11.6 s, RealView/shadow/gradient visible), 2 (every+setting restored), 3 (all four ids read — numbers to be recorded from+`enumIds`), 4 (GetExecutablePath worked, by implication: the DLL was found).+Still open: 5 (visible-window flicker), 6 (8192×4608), a background change,+`zoomFactor`'s direction, and the AO setting's scope.++### Track A — the original measure-first list  1. `swx_render_image {width:3840, height:2160, view:"isometric", realView:true,    ambientOcclusion:true, shadows:true, perspective:true, background:"white",@@ -114,12 +171,59 @@ shows the stage. 5. Did the window flicker or resize during a larger-than-window SaveBMP? 6. 8192×4608 — the largest size SaveBMP will produce on this GPU. -## Track B — SOLIDWORKS Visualize (DESIGN ONLY, no verb)+## Starting Visualize — MEASURED 2026-10-05, built in 0.9.19++- [measured] Starting `SLDWORKSVisualize.exe` DIRECTLY is wrong on this edition:+  the next start raised a `#32770` titled "SOLIDWORKS Visualize": "SOLIDWORKS+  Visualize must be opened from the 3DEXPERIENCE Platform or from the desktop+  shortcut created from the Platform. Do you want to go to the 3DEXPERIENCE+  Platform?" — Yes / No (No = automationId `CommandButton_7`; No exits the+  process). The coordinator answered it once, on Drew's explicit "deal with+  this"; the bridge never does.+- [measured] The right way is the PLATFORM SHORTCUT, the same form as SOLIDWORKS+  Design's: `C:\Users\Public\Desktop\SOLIDWORKS Visualize.lnk` or+  `%ProgramData%\Microsoft\Windows\Start Menu\Programs\Dassault Systemes+  SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS Visualize.lnk` → `CATSTART.exe -run+  "SWXDesktopLauncher.exe" -object "--AppName=\"SWXCVIZ_AP\" -tenant=… -monoapp+  -3DRegistryURL=…" -nowindow` (SOLIDWORKS Design: `SWXCSWK_AP`). With SOLIDWORKS+  already signed in, no login appeared; after 45 s: `SLDWORKSVisualize` (main+  window class `HwndWrapper[SLDWORKSVisualize.exe;Main Application;<guid>]`,+  title "SOLIDWORKS Visualize"), `SWVisualize.Exchange.PluginHost`,+  `SWVisualize.Queue.Server` (already running before the launch).+- [measured] FIRST RUN: a WPF dialog titled "SOLIDWORKS Visualize": "Please+  choose a location for your 'SOLIDWORKS Visualize Content' folder. This folder+  will be created to provide a place for this software to store data." — an edit+  prefilled `C:\Users\drew\Documents`, "…", "Continue". It creates a folder on+  the user's machine, a standing change: only the user answers it.+- [unmeasured] whether the shortcut accepts a model path; how to quit Visualize.++**0.9.19:** `swx_start {app:"visualize", reason, waitSeconds?}` finds the+platform shortcut (`SWX_VISUALIZE_SHORTCUT` overrides; only a `.lnk` named+"SOLIDWORKS Visualize", never the executable), invokes it the same way+SOLIDWORKS's is invoked, and waits up to `waitSeconds` (20) for the HwndWrapper+main window or a dialog. Readiness = the main window exists and no dialog is+waiting. The three dialogs are classified BY THEIR WORDS and never answered:+the platform notice → `visualize_direct_start_refused` (the fix: start through+the platform shortcut); the content folder → `waitingOn:+"userVisualizeContentFolder"` with the text and the prefilled path; a+3DEXPERIENCE login → `waitingOn:"userSignIn"`, answered only by+`swx_sign_in {userAuthorized:true, window:"visualize"}` on the user's say-so.+`swx_status.visualize` = `{running, pid, hwnd, ready, waitingOn, dialog,+companions}`. A `path` is not passed (unmeasured). No quit is built — it is the+next measurement.++## Track B — SOLIDWORKS Visualize render (DESIGN ONLY, no verb)++Parked: the main window cannot be measured until the user answers the+content-folder dialog on Drew's machine.  Visualize renders physically (Fast / Accurate modes, a render queue). It has no API on disk [measured], so the bridge would drive it through UI Automation: -1. Launch `SLDWORKSVisualize.exe "<model path>"` [measured: it takes the path].+1. Launch through the platform shortcut — `swx_start {app:"visualize"}`+   (0.9.19). [measured] the executable takes a model path, but a direct start+   is refused on this edition; whether the shortcut passes a path on is+   [unmeasured]. 2. Answer its 3DEXPERIENCE sign-in with `swx_sign_in {userAuthorized:true,    window:"visualize"}` [the sign-in path exists; the Visualize host's login was    measured on 2026-10-02].
install.sh

  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.18",+  "version": "0.9.19",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [
references/modelling-and-export.md+21−7
@@ -117,15 +117,29 @@ a verdict. ## Rendering a picture  - **`swx_render_image {reason, width, height, view, realView,-  ambientOcclusion, shadows, perspective, background, zoomToFit}`** draws the-  ACTIVE document with SOLIDWORKS's own viewport (there is no PhotoView 360 on-  this edition) at the size asked and writes a PNG. Activate the document first-  (`swx_show_assembly` / `swx_activate_document`). Read `width`/`height` in the-  reply — they come from the file; `capped:true` means only the window size came-  out. Every display setting it changed is restored (`restoredSettings`); if+  ambientOcclusion, shadows, perspective, background, zoomToFit, zoomFactor}`**+  draws the ACTIVE document with SOLIDWORKS's own viewport (there is no PhotoView+  360 on this edition) at the size asked and writes a PNG. Measured live: 4K+  (3840×2160) takes about as long as 1080p — ask for 4K for a hero shot.+  Activate the document first (`swx_show_assembly` / `swx_activate_document`).+- Read `width`/`height` in the reply — they come from the file; `capped:true`+  means only the window size came out.+- `settings` has a row for every setting you asked for: `changed:false` with+  `why:"alreadyAsRequested"` means it was already that way; `why:"unreadable"`+  or `"setFailed"` means it was NOT applied — say so rather than claiming it.+- If the model looks small after `zoomToFit` (an assembly fit leaves it at about+  a quarter of a 16:9 frame), add `zoomFactor` (e.g. 2.0); the camera is+  restored afterwards.+- Every display setting it changed is restored (`restoredSettings`); if   `restoreIncomplete` is true, tell the user which view settings may have   changed. `background:"transparent"` is not available. A render that changes   the view is refused `user_is_active` while the user is working; an export of   the current view is not. Omit `path` to write into the bridge's exports   folder; a `path` must be in a folder that exists and must not exist yet.-  Unmeasured live as of 0.9.18 — see docs/RENDERING.md in the repo.+- **SOLIDWORKS Visualize:** `swx_start {app:"visualize", reason}` starts it+  through its platform shortcut (never the executable — this edition refuses a+  direct start). Its dialogs are reported in `visualize.waitingOn` and NEVER+  answered: `userVisualizeContentFolder` (first run — the user picks a folder),+  `userSignIn` (`swx_sign_in {userAuthorized:true, window:"visualize"}` on the+  user's say-so), `visualizePlatformNotice` (started the wrong way). No render+  verb for Visualize yet.
src/comhost/com.rs+120−29
@@ -2363,6 +2363,19 @@ impl Backend for ComBackend {             // 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() {@@ -4241,9 +4254,11 @@ impl ComBackend {         }))     } -    /// `swx_render_image` (0.9.18) — EVERYTHING HERE IS UNMEASURED until the-    /// coordinator runs it on the laptop. The calls, each from the help pages-    /// cited in src/render.rs and docs/RENDERING.md:+    /// `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`);@@ -4315,44 +4330,79 @@ impl ComBackend {             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) {-            if let Some(was) = view.int("DisplayMode", &[]) {-                if was != m && view.put("DisplayMode", &Var::i4(m)).is_ok() {-                    undo.push(("displayMode", Was::Mode(was)));-                    applied.insert("displayMode".into(), json!({"from": was, "to": m}));+            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() {+                        undo.push(("displayMode", Was::Mode(was)));+                        applied.insert("displayMode".into(), json!({"from": was, "to": m}));+                        why = "applied";+                    } 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", &[]).unwrap_or(false);-            if was != p {-                let r = view.call(if p { "AddPerspective" } else { "RemovePerspective" }, &[]);-                if r.is_ok() {-                    undo.push(("perspective", Was::Persp(was)));-                    applied.insert("perspective".into(), json!({"from": was, "to": p}));-                } else {-                    steps_failed.push("perspective".into());+            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() {+                        undo.push(("perspective", Was::Persp(was)));+                        applied.insert("perspective".into(), json!({"from": was, "to": p}));+                        why = "applied";+                    } else {+                        steps_failed.push("perspective".into());+                        why = "setFailed";+                    }                 }             }+            acct.push(("perspective", json!(p), json!(was), json!(view.flag("HasPerspective", &[])), why));         }-        if let (Some(rv), Some(ext)) = (want_b("realView"), ext.as_ref()) {-            if let Some(was) = ext.flag("ViewDisplayRealView", &[]) {-                if was != rv && ext.put("ViewDisplayRealView", &Var::boolean(rv)).is_ok() {-                    undo.push(("realView", Was::Real(was)));-                    applied.insert("realView".into(), json!({"from": was, "to": rv}));+        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() {+                        undo.push(("realView", Was::Real(was)));+                        applied.insert("realView".into(), json!({"from": was, "to": rv}));+                        why = "applied";+                    } 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), Some(pid)) = (want_b(key), id(member)) {-                if let Some(was) = Self::pref_toggle(&sw, pid) {-                    if was != v && sw.call("SetUserPreferenceToggle", &[&Var::i4(pid), &Var::boolean(v)]).is_ok() {-                        undo.push((if key == "shadows" { "shadows" } else { "ambientOcclusion" }, Was::Toggle(pid, was)));-                        applied.insert(key.into(), json!({"from": was, "to": v}));+            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() {+                            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 = "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)) =@@ -4370,13 +4420,35 @@ impl ComBackend {                 }             }         }+        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()) {-            if doc.call("ShowNamedView2", &[&Var::bstr(""), &Var::i4(vid as i32)]).is_ok() {+            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) && doc.call("ViewZoomtofit2", &[]).is_ok() {-            applied.insert("zoom".into(), json!({"to": "fit"}));+        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", &[]); @@ -4443,8 +4515,27 @@ impl ComBackend {             }         }         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});+                          "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;
src/comhost/host.rs+6
@@ -166,6 +166,10 @@ pub const READ_ONLY_COMMANDS: &[&str] = &[     //                        closing, and on a Connected edition stopping was —     //                        until this route existed — not recoverable at all.     "launch_shortcut",+    //   `launch_visualize`   (0.9.19) the same act for SOLIDWORKS Visualize —+    //                        its platform shortcut, a `reason` required at the+    //                        server, ledgered. Starting is recoverable.+    "launch_visualize",     //   `close_import_temps`  CLOSES DOCUMENTS, and is exempt for one reason:     //                        the only documents it can reach are STEP/IGES     //                        conversion temporaries the BRIDGE ITSELF caused,@@ -847,6 +851,8 @@ pub const COMMANDS: &[&str] = &[     "preserve_and_close",     // 0.9.18 — swx_render_image.     "render_image",+    // 0.9.19 — swx_start {app:"visualize"}: its platform shortcut.+    "launch_visualize",     // The quit ladder: one rung per call, plus the two reference-discipline     // commands the ladder's invariant depends on.     "quit_rung",
src/comhost/mock.rs+66−1
@@ -1139,6 +1139,18 @@ impl MockBackend {             // attached", which is the branch a real machine almost always takes             // and the one the polling logic exists for. `SWX_MOCK_SHORTCUT_ATTACHES=1`             // models the fast machine.+            // SOLIDWORKS VISUALIZE (0.9.19): its platform shortcut, invoked.+            // Recorded for FakeOs, which then shows the Visualize process.+            "launch_visualize" => {+                let shortcut = args.get("shortcut").and_then(|v| v.as_str()).unwrap_or("").to_string();+                if shortcut.is_empty() {+                    return Err(HostError::coded("visualize_shortcut_missing", "no shortcut was given to invoke"));+                }+                if let Some(d) = fake_dir() {+                    let _ = std::fs::write(d.join("visualize-invoked.json"), json!({"shortcut": shortcut}).to_string());+                }+                return Ok(json!({"invoked": true, "shortcut": shortcut, "route": "shellExecute"}));+            }             "launch_shortcut" => {                 let shortcut =                     args.get("shortcut").and_then(|v| v.as_str()).unwrap_or("").to_string();@@ -3129,7 +3141,13 @@ impl MockBackend {                 }                 let mut now = before.clone();                 let mut applied = serde_json::Map::new();+                // `mock-render-unreadable-<key>`: that setting's read fails (the+                // measured `shadows` gap: requested, absent from applied).+                let unreadable = |k: &str| fault(&format!("mock-render-unreadable-{k}"));                 let mut set = |k: &str, v: Value, applied: &mut serde_json::Map<String, Value>| {+                    if unreadable(k) {+                        return;+                    }                     if now.get(k) != Some(&v) {                         applied.insert(k.into(), json!({"from": now.get(k).cloned(), "to": v}));                         now[k] = v;@@ -3153,6 +3171,15 @@ impl MockBackend {                 if args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false) {                     set("scale", json!(0.42), &mut applied);                 }+                drop(set);+                if let Some(z) = args.get("zoomFactor").and_then(|v| v.as_f64()).filter(|z| (*z - 1.0).abs() > 1e-9) {+                    if !unreadable("scale") {+                        let base = now.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);+                        applied.insert("scale".into(), json!({"from": now.get("scale").cloned(), "to": base * z}));+                        now["scale"] = json!(base * z);+                    }+                }+                let after_apply = now.clone();                 let _ = std::fs::write(&state_path, now.to_string());                 // THE EXPORT: a 24-bit BMP of the asked size — or the window's                 // when `mock-render-capped` says SaveBMP ignored the size.@@ -3201,7 +3228,45 @@ impl MockBackend {                         failures.push(json!(k));                     }                 }-                let data = json!({"appliedSettings": applied, "restoredSettings": restored, "restoreFailures": failures});+                // EVERY REQUESTED SETTING, accounted for (0.9.19): what was asked,+                // what it was, what it was after applying, whether it changed,+                // and whether it read back restored — so "already as asked" is+                // never confused with "not applied".+                let mut settings = serde_json::Map::new();+                let account = |name: &str, key: &str, requested: Value, settings: &mut serde_json::Map<String, Value>| {+                    let b = if unreadable(key) { Value::Null } else { before.get(key).cloned().unwrap_or(Value::Null) };+                    let a = if unreadable(key) { Value::Null } else { after_apply.get(key).cloned().unwrap_or(Value::Null) };+                    let changed = !b.is_null() && a != b;+                    let why = if b.is_null() { "unreadable" } else if changed { "applied" } else { "alreadyAsRequested" };+                    let restored_now = if changed { json!(after.get(key) == before.get(key)) } else { Value::Null };+                    settings.insert(name.into(), json!({"requested": requested, "before": b, "after": a,+                                                        "changed": changed, "restored": restored_now, "why": why}));+                };+                if let Some(v) = args.get("view").and_then(|v| v.as_str()).filter(|v| *v != "current") {+                    account("view", "orientation", json!(v), &mut settings);+                }+                if let Some(v) = args.get("displayStyle").filter(|v| !v.is_null()) {+                    account("displayStyle", "displayMode", v.clone(), &mut settings);+                }+                for k in ["perspective", "realView", "shadows", "ambientOcclusion"] {+                    if let Some(v) = args.get(k).filter(|v| !v.is_null()) {+                        account(k, k, v.clone(), &mut settings);+                    }+                }+                if let Some(v) = args.get("background").and_then(|v| v.as_str()).filter(|v| *v != "current") {+                    account("background", "backgroundColor", json!(v), &mut settings);+                }+                if args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false) {+                    account("zoomToFit", "scale", json!(true), &mut settings);+                }+                if let Some(z) = args.get("zoomFactor").and_then(|v| v.as_f64()).filter(|z| (*z - 1.0).abs() > 1e-9) {+                    account("zoomFactor", "scale", json!(z), &mut settings);+                }+                let data = json!({"appliedSettings": applied, "restoredSettings": restored, "restoreFailures": failures,+                                  "settings": settings,+                                  "enumIds": {"swDisplayShadowsInShadedMode": 9001, "swDisplayAmbientOcclusionShadows": 9002,+                                              "swColorsBackgroundAppearance": 9003, "swSystemColorsViewportBackground": 9004,+                                              "simulated": true}});                 if !saved {                     return Err(HostError::coded("render_failed", "SaveBMP returned false (simulated)").with_data(data));                 }
src/launchos.rs+51−1
@@ -54,8 +54,11 @@ pub const WATCHED_IMAGES: &[&str] = &[     "ENOPLMCSAClient.exe",     "EdmServerV6.exe",     // SOLIDWORKS Visualize: it raises its OWN 3DEXPERIENCE ID login (measured-    // 2026-10-02), which swx_sign_in answers under the user's authorization.+    // 2026-10-02), which swx_sign_in answers under the user's authorization;+    // swx_start {app:"visualize"} starts it (0.9.19), and these run with it.     "SLDWORKSVisualize.exe",+    "SWVisualize.Exchange.PluginHost.exe",+    "SWVisualize.Queue.Server.exe",     "SWXDesktopHelper.exe",     "sldBgDwld.exe",     // PARENT LINKS ONLY — never terminable (see `launch::NEVER_TERMINATE`).@@ -773,6 +776,20 @@ impl FakeOs {      /// Record an invocation. Called by the MOCK com backend, which is the     /// simulated equivalent of "the shortcut was run".+    /// The simulated Visualize's pid, if it is running.+    pub fn visualize_running(&self) -> Option<u32> {+        let v = self.read_json("visualize.json");+        let pid = v.get("pid").and_then(|p| p.as_u64())? as u32;+        let before = v.get("runningBefore").and_then(|b| b.as_bool()).unwrap_or(false);+        let started = v.get("startsOnInvoke").and_then(|b| b.as_bool()).unwrap_or(true)+            && self.dir.join("visualize-invoked.json").exists();+        (before || started).then_some(pid)+    }++    fn visualize_windows(&self) -> Vec<Value> {+        self.read_json("visualize.json").get("windows").and_then(|w| w.as_array()).cloned().unwrap_or_default()+    }+     pub fn record_invoke(dir: &std::path::Path, route: &str, shortcut: &str) -> u64 {         let _ = std::fs::create_dir_all(dir);         let path = dir.join("invocations.jsonl");@@ -1037,6 +1054,14 @@ impl LaunchOs for FakeOs {                 });             }         }+        // SOLIDWORKS VISUALIZE (0.9.19): `visualize.json` —+        // {pid, runningBefore?, startsOnInvoke?} — running before the bridge+        // looked, or once the bridge invoked its shortcut.+        if let Some(v) = self.visualize_running() {+            if !out.iter().any(|p| p.pid == v) {+                out.push(ProcInfo { pid: v, image: "SLDWORKSVisualize.exe".into(), created_at: Some(now.saturating_sub(30)), ppid: None });+            }+        }         Ok(out)     } @@ -1143,6 +1168,16 @@ impl LaunchOs for FakeOs {     /// one. `window-census-fails` makes the census UNREADABLE, which callers must     /// treat as unknown rather than as "nothing on screen".     fn dialog_contents(&self, hwnd: i64) -> Result<DialogContents, String> {+        // A Visualize window's words (its scenario row's `text` / `buttons`).+        if let Some(row) = self.visualize_windows().into_iter().find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd)) {+            let list = |k: &str| -> Vec<String> {+                row.get(k).and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default()+            };+            let (text, buttons) = (list("text"), list("buttons"));+            let mut controls: Vec<(String, bool)> = text.iter().map(|_| ("Static".to_string(), true)).collect();+            controls.extend(buttons.iter().map(|_| ("Button".to_string(), true)));+            return Ok(DialogContents { text, buttons, controls, ..Default::default() });+        }         if self.dir.join("dialog-contents-unreadable").exists() {             return Err("simulated: the dialog's controls could not be read".into());         }@@ -1255,6 +1290,21 @@ impl LaunchOs for FakeOs {         if self.dir.join("window-census-fails").exists() {             return Err("simulated: the window census could not be taken".into());         }+        if self.visualize_running() == Some(pid) {+            return Ok(self+                .visualize_windows()+                .iter()+                .filter_map(|w| {+                    Some(WindowInfo {+                        hwnd: w.get("hwnd")?.as_i64()?,+                        class_name: w.get("className").and_then(|x| x.as_str()).unwrap_or("").to_string(),+                        title: w.get("title").and_then(|x| x.as_str()).unwrap_or("").to_string(),+                        visible: true,+                        ..Default::default()+                    })+                })+                .collect());+        }         let mut out: Vec<WindowInfo> = Vec::new();         let read = |r: &Value| -> Option<WindowInfo> {             Some(WindowInfo {
src/lib.rs+1
@@ -52,6 +52,7 @@ pub mod server; pub mod session_history; pub mod verbs; pub mod verbs3;+pub mod visualize; pub mod writes;  /// The bridge version ab reports and every response carries.
src/render.rs+24−3
@@ -11,7 +11,10 @@ //! laptop's GPU. `swx_screenshot` (ab's PrintWindow) works but is a screen grab //! capped at the window (~1568 px wide). //!-//! HOW (all UNMEASURED until the coordinator runs it live — see docs/RENDERING.md):+//! HOW — MEASURED 2026-10-05 on the laptop (SOLIDWORKS 2026 SP3, the 110-document+//! Moveo assembly, window MINIMIZED): 1920x1080 in 10.4 s and 3840x2160 in+//! 11.6 s with isometric + shaded + perspective + RealView + AO + zoomToFit, the+//! PNG header matching, every setting read back restored. See docs/RENDERING.md: //!   * the export is `IModelDoc2::SaveBMP(FileName, Width, Height)` — "Saves the //!     current view as a bitmap (BMP) file … If WidthIn or HeightIn is less than //!     or equal to 0, then the view size is based on the current window size."@@ -78,6 +81,7 @@ pub fn changes_display(args: &Value) -> bool {         || bg != "current"         || ["displayStyle", "perspective", "realView", "ambientOcclusion", "shadows"].iter().any(|k| set(k))         || args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false)+        || args.get("zoomFactor").and_then(|v| v.as_f64()).map(|z| (z - 1.0).abs() > 1e-9).unwrap_or(false) }  /// The validated request, as the com host receives it.@@ -119,6 +123,17 @@ pub fn request(args: &Value, doc_title: &str) -> Result<Request, Value> {             return bad("bad_display_style", format!("displayStyle {s:?} is not one of {}", DISPLAY_STYLES.iter().map(|v| v.0).collect::<Vec<_>>().join(", ")), "displayStyle:\"shaded\" for a render");         }     }+    // FRAMING (0.9.19): MEASURED 2026-10-05, zoomToFit leaves the Moveo+    // assembly at ~25% of a 16:9 frame (the fit box is the window's aspect).+    // `zoomFactor` multiplies IModelView::Scale2 AFTER any fit; the camera is+    // restored as before. Its direction (>1 = closer) is UNMEASURED.+    let zoom_factor = match args.get("zoomFactor") {+        None | Some(Value::Null) => 1.0,+        Some(v) => match v.as_f64() {+            Some(z) if (0.1..=20.0).contains(&z) => z,+            _ => return bad("bad_zoom_factor", "zoomFactor must be a number from 0.1 to 20 (1.0 = as framed)".into(), "e.g. zoomToFit:true, zoomFactor:2.5 to fill a 16:9 frame"),+        },+    };     let transparent = args.get("transparentBackground").and_then(|v| v.as_bool()).unwrap_or(false);     let background = args.get("background").and_then(|v| v.as_str()).unwrap_or(if transparent { "transparent" } else { "current" }).to_string();     if background == "transparent" {@@ -179,6 +194,7 @@ pub fn request(args: &Value, doc_title: &str) -> Result<Request, Value> {         "background": background,         "backgroundColor": BACKGROUNDS.iter().find(|(n, _)| *n == background).map(|v| v.1),         "zoomToFit": args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false),+        "zoomFactor": zoom_factor,     });     Ok(Request { png, bmp, width, height, host_args }) }@@ -359,18 +375,23 @@ pub fn render_image(ctx: &crate::verbs::Ctx, args: &Value) -> Value {         "appliedSettings": applied,         "restoredSettings": restored,         "restoreFailures": restore_failures,+        // EVERY REQUESTED SETTING (0.9.19): {requested, before, after, changed,+        // restored, why} — "already as asked" is never confused with "not applied".+        "settings": r.get("settings").cloned().unwrap_or(json!({})),+        // The swconst ids the host read from the installed interop DLL, for the record.+        "enumIds": r.get("enumIds").cloned().unwrap_or(Value::Null),         // LOUD: a setting the render changed did not read back as restored.         "restoreIncomplete": restore_incomplete,         "renderer": "SOLIDWORKS viewport (IModelDoc2::SaveBMP), converted to PNG by the bridge",         "durationMs": started.elapsed().as_millis() as u64,         "progress": job.finished(),-        "measured": false,+        "measured": "2026-10-05 (window minimized; visible-window flicker, 8192 px and background changes still unmeasured)",         "_hint": if restore_incomplete {             "THE IMAGE WAS WRITTEN, BUT NOT EVERY DISPLAY SETTING READ BACK AS RESTORED — see restoreFailures and restoredSettings (value = what it was before). Tell the user which of their view settings may have changed; the document was not saved."         } else if capped {             "The image came back SMALLER than asked (capped at the window's size) — width/height are what is actually in the file. Settings were restored; the document was not saved."         } else {-            "The image is at the requested size; width/height were read back from the PNG header. Every display setting the render changed was restored (restoredSettings); the document was not saved. This path is UNMEASURED on a live machine until the coordinator runs it."+            "The image is at the requested size; width/height were read back from the PNG header. Every display setting the render changed was restored (restoredSettings; `settings` accounts for every one you asked for); the document was not saved. For hero-grade output ask for width:3840, height:2160 — measured 2026-10-05 it took only ~1.2 s longer than 1080p. If the model looks small after zoomToFit, add zoomFactor (e.g. 2.0)."         },     })) }
src/verbs.rs+12−2
@@ -580,6 +580,9 @@ fn swx_status(ctx: &Ctx, _args: &Value) -> Value {         "bridgeVersion": crate::BRIDGE_VERSION,         "mock": crate::is_mock(),         "userWrites": crate::writes::posture(),+        // SOLIDWORKS VISUALIZE (0.9.19): running, pid, main window, and what+        // it is waiting on (it is never answered by the bridge).+        "visualize": crate::visualize::status(),         // TOLD WHETHER COM IS ATTACHED, because that is the fact that decides         // whether a session which came up after a sign-in may be re-claimed as         // ours — and only this verb has it.@@ -706,6 +709,13 @@ fn swx_progress(ctx: &Ctx, _args: &Value) -> Value { // ── verbs: app lifecycle ────────────────────────────────────────────────────  fn swx_start(ctx: &Ctx, args: &Value) -> Value {+    // SOLIDWORKS VISUALIZE (0.9.19): its platform shortcut, its own readiness.+    if arg_str(args, "app") == Some("visualize") {+        return crate::visualize::start(ctx, args);+    }+    if let Some(other) = arg_str(args, "app").filter(|a| *a != "solidworks") {+        return err_code("bad_app", format!("app {other:?} is not \"solidworks\" or \"visualize\""), "Omit app for SOLIDWORKS.");+    }     gate!(ctx);     let detection = ctx.redetect();     // ATTACH FIRST. If SOLIDWORKS is already up, this is the whole verb — and on@@ -2988,7 +2998,7 @@ pub const CATALOG: &[(&str, &str, &str)] = &[     ("swx_rebuild_check", "exec", "force rebuild + broken-feature census (design health)"),     ("swx_save", "write", "save the target document"),     ("swx_screenshot", "read", "screenshot the SOLIDWORKS window via ab's generic verb"),-    ("swx_start", "process", "launch SOLIDWORKS via COM (background; cold start 30-90s)"),+    ("swx_start", "process", "launch SOLIDWORKS via COM (background; cold start 30-90s); app:\"visualize\" starts SOLIDWORKS Visualize through its platform shortcut and reports what it is waiting on"),     ("swx_state", "read", "open docs + active doc + detection (logical state of the union)"),     ("swx_status", "read", "bridge + SOLIDWORKS detection + COM state (the everyday probe)"),     ("swx_stop", "process", "quit SOLIDWORKS"),@@ -3040,7 +3050,7 @@ pub const CATALOG: &[(&str, &str, &str)] = &[     ("swx_export_pdf", "write", "export PDF (3D PDF from a part; a sheet from a drawing)"),     ("swx_export_dxf", "write", "export DXF (drawings; refused for a part on this edition)"),     ("swx_export_dwg", "write", "export DWG (drawings; refused for a part on this edition)"),-    ("swx_render_image", "write", "a high-resolution PNG of the active document from SOLIDWORKS's own viewport (RealView, ambient occlusion, shadows, perspective, a plain background) at a size independent of the window; every display setting it changes is restored, the size is read back from the file"),+    ("swx_render_image", "write", "a high-resolution PNG of the active document from SOLIDWORKS's own viewport (RealView, ambient occlusion, shadows, perspective, a plain background; zoomFactor to frame it) at a size independent of the window — measured live: 4K in ~12 s; every display setting it changes is restored and `settings` accounts for each one asked for; the size is read back from the file"), ];  /// `swx_sign_in` — UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13). See src/signin.rs.
src/visualize.rsadded+246
@@ -0,0 +1,246 @@+//! SOLIDWORKS VISUALIZE — start it, and say what it is waiting on (0.9.19).+//!+//! MEASURED 2026-10-05 on Drew's laptop:+//!   * Starting `SLDWORKSVisualize.exe` DIRECTLY is wrong on this edition: the+//!     next start raised a `#32770` titled "SOLIDWORKS Visualize": "SOLIDWORKS+//!     Visualize must be opened from the 3DEXPERIENCE Platform or from the+//!     desktop shortcut created from the Platform. Do you want to go to the+//!     3DEXPERIENCE Platform?" — Yes / No (No = automationId CommandButton_7,+//!     which exits the process).+//!   * The right way is the PLATFORM SHORTCUT, the same form as SOLIDWORKS+//!     Design's: `C:\Users\Public\Desktop\SOLIDWORKS Visualize.lnk`, or the+//!     Start-Menu group's, → `CATSTART.exe -run "SWXDesktopLauncher.exe" -object+//!     "--AppName=\"SWXCVIZ_AP\" …"`. Invoked with SOLIDWORKS already signed in,+//!     no login appeared; after 45 s: `SLDWORKSVisualize` (main window class+//!     `HwndWrapper[SLDWORKSVisualize.exe;Main Application;<guid>]`, title+//!     "SOLIDWORKS Visualize"), `SWVisualize.Exchange.PluginHost`,+//!     `SWVisualize.Queue.Server`.+//!   * FIRST RUN: a WPF dialog titled "SOLIDWORKS Visualize": "Please choose a+//!     location for your 'SOLIDWORKS Visualize Content' folder…", an edit+//!     prefilled `C:\Users\drew\Documents`, "…" and "Continue". Answering it+//!     creates a folder on the user's machine — a standing change — so the bridge+//!     NEVER answers it: `waitingOn:"userVisualizeContentFolder"`.+//!+//! The bridge answers NONE of Visualize's dialogs. A 3DEXPERIENCE login goes to+//! `swx_sign_in {userAuthorized:true, window:"visualize"}` on the user's+//! say-so. Quitting Visualize is UNMEASURED and not built. Whether the shortcut+//! accepts a model path is UNMEASURED; a `path` is not passed to it.++use serde_json::{json, Value};++pub const IMAGE: &str = "SLDWORKSVisualize.exe";++pub const SHORTCUT_GLOBS: &[&str] = &[+    r"%PUBLIC%\Desktop\SOLIDWORKS Visualize.lnk",+    r"%ProgramData%\Microsoft\Windows\Start Menu\Programs\Dassault Systemes SOLIDWORKS 3DEXPERIENCE*\SOLIDWORKS Visualize.lnk",+    r"%USERPROFILE%\Desktop\SOLIDWORKS Visualize.lnk",+    r"%APPDATA%\Microsoft\Windows\Start Menu\Programs\Dassault Systemes SOLIDWORKS 3DEXPERIENCE*\SOLIDWORKS Visualize.lnk",+];++/// The Visualize platform shortcut. `SWX_VISUALIZE_SHORTCUT` overrides. Only a+/// `.lnk` named "SOLIDWORKS Visualize" — never the executable.+pub fn find_shortcut() -> Option<String> {+    if let Ok(p) = std::env::var("SWX_VISUALIZE_SHORTCUT") {+        if !p.trim().is_empty() {+            return Some(p);+        }+    }+    SHORTCUT_GLOBS+        .iter()+        .flat_map(|g| crate::detect::expand_glob(g))+        .find(|p| p.to_lowercase().ends_with("solidworks visualize.lnk"))+}++/// What a Visualize window is, by its WORDS.+pub fn classify(title: &str, class: &str, words: &str) -> Option<&'static str> {+    let w = words.to_lowercase();+    if crate::launch::is_sign_in_window(class, title) {+        return Some("signIn");+    }+    if w.contains("must be opened from the 3dexperience platform") {+        return Some("platformNotice");+    }+    if w.contains("visualize content") && w.contains("folder") {+        return Some("contentFolder");+    }+    None+}++fn is_main_window(class: &str) -> bool {+    class.starts_with("HwndWrapper[SLDWORKSVisualize.exe;")+}++/// A window's words: its Win32 child text and buttons; a WPF window has none, so+/// its accessibility tree's names (and an edit's value, for the prefilled path —+/// not a secret) are read through ab when the bridge can.+fn words_of(os: &dyn crate::launchos::LaunchOs, hwnd: i64) -> (String, Option<String>) {+    let mut words = String::new();+    let mut path: Option<String> = None;+    if let Ok(c) = os.dialog_contents(hwnd) {+        words = c.text.iter().chain(c.buttons.iter()).cloned().collect::<Vec<_>>().join(" | ");+        path = c.text.iter().find(|t| t.len() > 3 && t.as_bytes().get(1) == Some(&b':') && t.contains('\\')).cloned();+    }+    if words.trim().is_empty() && crate::caller::ab_available() {+        let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 20, "maxNodes": 300}), std::time::Duration::from_secs(30));+        fn walk(v: &Value, names: &mut Vec<String>, path: &mut Option<String>) {+            match v {+                Value::Array(a) => a.iter().for_each(|x| walk(x, names, path)),+                Value::Object(m) => {+                    if let Some(n) = m.get("name").and_then(|n| n.as_str()).filter(|n| !n.is_empty()) {+                        names.push(n.to_string());+                    }+                    let role = m.get("role").or_else(|| m.get("controlType")).and_then(|r| r.as_str()).unwrap_or("");+                    if role.eq_ignore_ascii_case("edit") && path.is_none() {+                        *path = m.get("value").and_then(|v| v.as_str()).map(String::from);+                    }+                    m.values().for_each(|x| walk(x, names, path));+                }+                _ => {}+            }+        }+        let mut names = Vec::new();+        walk(&r, &mut names, &mut path);+        words = names.join(" | ");+    }+    (words, path)+}++/// THE STATE of Visualize on this machine: running, pid, main window, and what it+/// is waiting on. Read-only; answers nothing.+pub fn status() -> Value {+    let os = crate::launchos::os();+    let census = match os.processes() {+        Ok(c) => c,+        Err(e) => return json!({"running": Value::Null, "why": format!("no process census: {e}")}),+    };+    let Some(p) = census.iter().find(|p| p.image.eq_ignore_ascii_case(IMAGE)) else {+        return json!({"running": false, "pid": Value::Null, "hwnd": Value::Null, "waitingOn": Value::Null});+    };+    let windows = os.windows_of_pid(p.pid).unwrap_or_default();+    let mut main: Option<i64> = None;+    let mut waiting: Option<Value> = None;+    for w in windows.iter().filter(|w| w.visible) {+        if is_main_window(&w.class_name) && main.is_none() {+            main = Some(w.hwnd);+        }+        let (words, path) = words_of(os.as_ref(), w.hwnd);+        if let Some(kind) = classify(&w.title, &w.class_name, &words) {+            waiting = Some(match kind {+                "signIn" => json!({"waitingOn": "userSignIn", "hwnd": w.hwnd, "title": w.title,+                                   "how": "swx_sign_in {reason, userAuthorized:true, window:\"visualize\"} — only on the user's say-so"}),+                "contentFolder" => json!({"waitingOn": "userVisualizeContentFolder", "hwnd": w.hwnd, "title": w.title,+                                          "text": words, "prefilledPath": path,+                                          "how": "The user chooses where Visualize keeps its content (it creates a folder on their machine). The bridge never answers this."}),+                _ => json!({"waitingOn": "visualizePlatformNotice", "hwnd": w.hwnd, "title": w.title, "text": words,+                            "how": "Visualize was started directly. Do not answer it (No exits Visualize; Yes opens the platform). Start Visualize through its platform shortcut: swx_start {app:\"visualize\"}."}),+            });+            break;+        }+    }+    let waiting_on = waiting.as_ref().and_then(|w| w.get("waitingOn").cloned()).unwrap_or(Value::Null);+    json!({+        "running": true,+        "pid": p.pid,+        "hwnd": main,+        "ready": main.is_some() && waiting.is_none(),+        "waitingOn": waiting_on,+        "dialog": waiting,+        "companions": census.iter().filter(|q| q.image.starts_with("SWVisualize.")).map(|q| json!({"pid": q.pid, "image": q.image})).collect::<Vec<_>>(),+        "quit": "UNMEASURED — the bridge does not quit Visualize yet",+    })+}++/// `swx_start {app:"visualize", reason, waitSeconds?}`.+pub fn start(ctx: &crate::verbs::Ctx, args: &Value) -> Value {+    let Some(reason) = args.get("reason").and_then(|r| r.as_str()).filter(|r| !r.trim().is_empty()) else {+        return crate::verbs::err_code("reason_required", "starting SOLIDWORKS Visualize needs a reason", "Say why; it is ledgered.");+    };+    let path_note = args.get("path").filter(|p| !p.is_null()).map(|_| {+        json!("`path` was not passed to Visualize: whether its platform shortcut accepts a model path is UNMEASURED. Open the model from inside Visualize.")+    });+    let now = status();+    if now.get("running") == Some(&json!(true)) {+        if now.get("waitingOn") == Some(&json!("visualizePlatformNotice")) {+            return refuse_direct(now);+        }+        return crate::verbs::ok(json!({"app": "visualize", "launchedNow": false, "alreadyRunning": true,+                                       "visualize": now, "pathNote": path_note,+                                       "_hint": "Visualize is already running; nothing was launched. `visualize.waitingOn` says if it needs the user."}));+    }+    let Some(lnk) = find_shortcut() else {+        return crate::verbs::err_code(+            "visualize_shortcut_missing",+            "no SOLIDWORKS Visualize platform shortcut was found",+            "Visualize must be started from the 3DEXPERIENCE Platform or its desktop shortcut (\"SOLIDWORKS Visualize.lnk\" on the Public desktop or in the Start-Menu group). Ask the user to create it from the Platform. The bridge never starts SLDWORKSVisualize.exe directly.",+        );+    };+    let r = ctx.call("launch_visualize", json!({"shortcut": lnk}), 60);+    crate::ledger::record("visualize_launch", json!({"shortcut": lnk, "reason": reason, "ok": r.is_ok()}));+    if let Err(e) = r {+        return crate::verbs::err_code("visualize_launch_failed", format!("the Visualize shortcut could not be invoked: {}", e.message), "Nothing else was attempted.");+    }+    let wait = args.get("waitSeconds").and_then(|w| w.as_u64()).unwrap_or(20).min(120);+    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(wait);+    let mut st = status();+    while std::time::Instant::now() < deadline+        && !(st.get("ready") == Some(&json!(true)) || !st.get("waitingOn").map(|w| w.is_null()).unwrap_or(true))+    {+        std::thread::sleep(std::time::Duration::from_millis(500));+        st = status();+    }+    if st.get("waitingOn") == Some(&json!("visualizePlatformNotice")) {+        return refuse_direct(st);+    }+    let stage = if st.get("ready") == Some(&json!(true)) {+        "ready"+    } else if !st.get("waitingOn").map(|w| w.is_null()).unwrap_or(true) {+        "waitingOn"+    } else {+        "launching"+    };+    crate::verbs::ok(json!({+        "app": "visualize",+        "launchedNow": true,+        "shortcut": lnk,+        "stage": stage,+        "waitingOn": st.get("waitingOn").cloned().unwrap_or(Value::Null),+        "visualize": st,+        "pathNote": path_note,+        "_hint": match stage {+            "ready" => "Visualize is up (its main window exists, no dialog is waiting).",+            "waitingOn" => "Visualize is waiting on the USER (see visualize.dialog). The bridge never answers Visualize's dialogs; a 3DEXPERIENCE login can be answered with swx_sign_in {userAuthorized:true, window:\"visualize\"} on the user's say-so.",+            _ => "Visualize is starting (measured: ~45 s). Poll swx_status — its `visualize` block says running / ready / waitingOn.",+        },+    }))+}++fn refuse_direct(st: Value) -> Value {+    let mut out = crate::verbs::err_code(+        "visualize_direct_start_refused",+        "SOLIDWORKS Visualize is showing \"must be opened from the 3DEXPERIENCE Platform or from the desktop shortcut\" — it was started directly",+        "The fix: start Visualize through its platform shortcut (swx_start {app:\"visualize\"}). The bridge does not answer that dialog (No exits Visualize; Yes opens the Platform) — the user does.",+    );+    if let Value::Object(m) = &mut out {+        m.insert("visualize".into(), st);+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn the_three_measured_dialogs_are_told_apart_by_their_words() {+        assert_eq!(+            classify("SOLIDWORKS Visualize", "#32770", "SOLIDWORKS Visualize must be opened from the 3DEXPERIENCE Platform or from the desktop shortcut created from the Platform. Do you want to go to the 3DEXPERIENCE Platform? | Yes | No"),+            Some("platformNotice")+        );+        assert_eq!(+            classify("SOLIDWORKS Visualize", "HwndWrapper[SLDWORKSVisualize.exe;Main Application;x]", "Please choose a location for your 'SOLIDWORKS Visualize Content' folder. | ... | Continue"),+            Some("contentFolder")+        );+        assert_eq!(classify("Login - 3DEXPERIENCE ID - Dassault Systemes", "WindowsForms10.Window.8.app", ""), Some("signIn"));+        assert_eq!(classify("SOLIDWORKS Visualize", "HwndWrapper[SLDWORKSVisualize.exe;Main Application;x]", "File | Edit | View"), None);+    }+}
tests/render.rs+52
@@ -190,3 +190,55 @@ fn a_render_that_changes_the_screen_respects_the_user_at_the_keyboard() {         s(&r)     ); }++/// EVERY REQUESTED SETTING IS ACCOUNTED FOR (0.9.19). Measured 2026-10-05:+/// `shadows:true` appeared in neither applied nor restored — already on, or not+/// applied? `settings` says which, for every setting asked for.+#[test]+fn every_requested_setting_says_what_happened_to_it() {+    let scratch = TempDir::new("render-settings");+    session(&scratch);+    // Shadows ALREADY on in the user's view.+    std::fs::write(scratch.join("mock-display-state.json"), USERS_VIEW.replace(r#""shadows":false"#, r#""shadows":true"#)).unwrap();+    // ...and ambient occlusion unreadable on this machine.+    std::fs::write(scratch.join("mock-render-unreadable-ambientOcclusion"), "").unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    let r = b.call("swx_render_image", hero(json!({"zoomFactor": 2.5})));+    let st = &r["settings"];+    let names = ["view", "displayStyle", "perspective", "realView", "ambientOcclusion", "shadows", "background", "zoomToFit", "zoomFactor"];+    check!(+        b,+        "every requested setting has a row {requested, before, after, changed, restored, why}",+        names.iter().all(|n| ["requested", "before", "after", "changed", "restored", "why"].iter().all(|k| st[*n].get(*k).is_some())),+        s(st)+    );+    check!(+        b,+        "shadows: already on — changed false, restored null, why alreadyAsRequested",+        st["shadows"]["changed"] == false && st["shadows"]["restored"].is_null() && st["shadows"]["why"] == "alreadyAsRequested"+            && st["shadows"]["before"] == true,+        s(&st["shadows"])+    );+    check!(+        b,+        "ambient occlusion: unreadable — said so, not silently absent",+        st["ambientOcclusion"]["why"] == "unreadable" && st["ambientOcclusion"]["changed"] == false,+        s(&st["ambientOcclusion"])+    );+    check!(+        b,+        "realView: applied and restored",+        st["realView"]["changed"] == true && st["realView"]["restored"] == true && st["realView"]["before"] == false && st["realView"]["after"] == true,+        s(&st["realView"])+    );+    check!(+        b,+        "zoomFactor: applied after the fit (scale 0.42 x 2.5) and restored",+        st["zoomFactor"]["changed"] == true && st["zoomFactor"]["restored"] == true && st["zoomFactor"]["after"] == json!(1.05),+        s(&st["zoomFactor"])+    );+    check!(b, "the swconst ids are reported for the record", r["enumIds"].is_object(), s(&r["enumIds"]));+    let bad = b.call("swx_render_image", hero(json!({"zoomFactor": 0})));+    check!(b, "a zoomFactor out of range: bad_zoom_factor", bad["errorCode"] == "bad_zoom_factor", s(&bad));+}
tests/visualize.rsadded+159
@@ -0,0 +1,159 @@+//! SOLIDWORKS VISUALIZE (0.9.19): started through its platform shortcut, its+//! three measured dialogs told apart by their WORDS, and none of them answered.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++const MAIN_CLASS: &str = "HwndWrapper[SLDWORKSVisualize.exe;Main Application;6f1c]";++struct Machine {+    dir: TempDir,+    lnk: String,+}++impl Machine {+    fn new(tag: &str, scenario: Value) -> Machine {+        let dir = TempDir::new(tag);+        std::fs::write(dir.join("visualize.json"), scenario.to_string()).unwrap();+        std::fs::write(dir.join("windows.json"), "[]").unwrap();+        let lnk = format!("{}/SOLIDWORKS Visualize.lnk", dir.path());+        std::fs::write(&lnk, "a platform shortcut").unwrap();+        Machine { dir, lnk }+    }++    fn boot(&self, ab: Option<&common::AbStub>) -> Bridge {+        let mut env: Vec<(&str, &str)> = vec![+            ("SWX_LAUNCH_FAKE_DIR", Box::leak(self.dir.path().into_boxed_str())),+            ("SWX_VISUALIZE_SHORTCUT", Box::leak(self.lnk.clone().into_boxed_str())),+        ];+        if let Some(a) = ab {+            env.push(("ADOM_DIRECT_API_URL", Box::leak(a.url.clone().into_boxed_str())));+        }+        Bridge::start_in(TempDir::new("visualize-scratch"), &env)+    }++    fn invoked_with(&self) -> Option<String> {+        std::fs::read_to_string(self.dir.join("visualize-invoked.json"))+            .ok()+            .and_then(|t| serde_json::from_str::<Value>(&t).ok())+            .and_then(|v| v["shortcut"].as_str().map(String::from))+    }+}++fn start(b: &Bridge) -> Value {+    b.call("swx_start", json!({"app": "visualize", "reason": "Drew wants a Visualize render", "waitSeconds": 3}))+}++#[test]+fn visualize_is_started_through_its_platform_shortcut_and_comes_up_ready() {+    let m = Machine::new("viz-ready", json!({"pid": 56488, "startsOnInvoke": true,+        "windows": [{"hwnd": 9100, "className": MAIN_CLASS, "title": "SOLIDWORKS Visualize"}]}));+    let b = m.boot(None);+    let st0 = b.call("swx_status", json!({}));+    check!(b, "before: Visualize is not running", st0["visualize"]["running"] == false, s(&st0["visualize"]));+    let r = start(&b);+    check!(+        b,+        "launched through the PLATFORM SHORTCUT (never the executable), and ready",+        r["success"] == true && r["launchedNow"] == true && r["stage"] == "ready"+            && m.invoked_with().map(|p| p.ends_with("SOLIDWORKS Visualize.lnk")).unwrap_or(false),+        format!("{} / invoked with {:?}", s(&r), m.invoked_with())+    );+    let st = b.call("swx_status", json!({}));+    check!(+        b,+        "swx_status.visualize: running, pid, the HwndWrapper main window, nothing waiting",+        st["visualize"]["running"] == true && st["visualize"]["pid"] == 56488 && st["visualize"]["hwnd"] == 9100+            && st["visualize"]["waitingOn"].is_null(),+        s(&st["visualize"])+    );+    let again = start(&b);+    check!(b, "a second start launches nothing", again["alreadyRunning"] == true && again["launchedNow"] == false, s(&again));+}++#[test]+fn the_first_run_content_folder_dialog_is_reported_and_never_answered() {+    let ab = common::AbStub::start(json!([]));+    let m = Machine::new("viz-content", json!({"pid": 56488, "startsOnInvoke": true,+        "windows": [{"hwnd": 9101, "className": MAIN_CLASS, "title": "SOLIDWORKS Visualize",+                     "text": ["Please choose a location for your 'SOLIDWORKS Visualize Content' folder. This folder will be created to provide a place for this software to store data.",+                              "C:\\Users\\drew\\Documents"],+                     "buttons": ["...", "Continue"]}]}));+    let b = m.boot(Some(&ab));+    let r = start(&b);+    check!(+        b,+        "waitingOn userVisualizeContentFolder, with the dialog's text and the prefilled path",+        r["stage"] == "waitingOn" && r["waitingOn"] == "userVisualizeContentFolder"+            && r["visualize"]["dialog"]["prefilledPath"] == "C:\\Users\\drew\\Documents"+            && r["visualize"]["dialog"]["text"].as_str().unwrap_or("").contains("Visualize Content"),+        s(&r)+    );+    let st = b.call("swx_status", json!({}));+    check!(b, "swx_status says the same", st["visualize"]["waitingOn"] == "userVisualizeContentFolder", s(&st["visualize"]));+    check!(+        b,+        "NOTHING was answered: no click, no text set, no keystroke",+        ab.calls_to("desktop_ui_click").is_empty() && ab.calls_to("desktop_ui_set").is_empty()+            && ab.calls_to("desktop_click").is_empty() && ab.calls_to("desktop_type").is_empty()+            && ab.calls_to("desktop_press_key").is_empty(),+        format!("{:?}", ab.calls())+    );+}++#[test]+fn a_direct_start_notice_is_refused_with_the_fix_and_never_answered() {+    let m = Machine::new("viz-direct", json!({"pid": 56488, "runningBefore": true,+        "windows": [{"hwnd": 9102, "className": "#32770", "title": "SOLIDWORKS Visualize",+                     "text": ["SOLIDWORKS Visualize must be opened from the 3DEXPERIENCE Platform or from the desktop shortcut created from the Platform. Do you want to go to the 3DEXPERIENCE Platform?"],+                     "buttons": ["Yes", "No"]}]}));+    let b = m.boot(None);+    let r = start(&b);+    check!(+        b,+        "visualize_direct_start_refused, naming the fix (the platform shortcut); nothing invoked",+        r["errorCode"] == "visualize_direct_start_refused"+            && r["_hint"].as_str().unwrap_or("").contains("platform shortcut")+            && m.invoked_with().is_none(),+        s(&r)+    );+}++#[test]+fn a_visualize_login_points_at_the_sign_in_path() {+    let m = Machine::new("viz-login", json!({"pid": 56488, "startsOnInvoke": true,+        "windows": [{"hwnd": 4262148, "className": "WindowsForms10.Window.8.app.0.141b42a_r6_ad1",+                     "title": "Login - 3DEXPERIENCE ID - Dassault Systemes"}]}));+    let b = m.boot(None);+    let r = start(&b);+    check!(+        b,+        "waitingOn userSignIn, pointing at swx_sign_in with window:\"visualize\"",+        r["waitingOn"] == "userSignIn" && r["visualize"]["dialog"]["how"].as_str().unwrap_or("").contains("window:\"visualize\""),+        s(&r)+    );+}++#[test]+fn no_shortcut_means_no_start_and_a_reason_is_required() {+    let dir = TempDir::new("viz-none");+    std::fs::write(dir.join("visualize.json"), json!({"pid": 56488, "startsOnInvoke": true}).to_string()).unwrap();+    let b = Bridge::start_in(TempDir::new("viz-none-scratch"), &[("SWX_LAUNCH_FAKE_DIR", &dir.path())]);+    let r = b.call("swx_start", json!({"app": "visualize", "reason": "x"}));+    check!(+        b,+        "no platform shortcut: visualize_shortcut_missing — the executable is never started instead",+        r["errorCode"] == "visualize_shortcut_missing" && !dir.join("visualize-invoked.json").exists(),+        s(&r)+    );+    let r = b.call("swx_start", json!({"app": "visualize"}));+    check!(b, "a reason is required", r["errorCode"] == "reason_required", s(&r));+    let r = b.call("swx_start", json!({"app": "photoshop", "reason": "x"}));+    check!(b, "an unknown app is refused", r["errorCode"] == "bad_app", s(&r));+}
uninstall.sh