← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.17+0.9.18
Cargo.lock+17−1
@@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"+ [[package]] name = "ascii" version = "1.1.0"@@ -44,6 +50,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+]+ [[package]] name = "proc-macro2" version = "1.0.107"@@ -107,8 +122,9 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.17"+version = "0.9.18" dependencies = [+ "miniz_oxide",  "serde",  "serde_json",  "tiny_http",
Cargo.toml+3−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.17"+version = "0.9.18" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"@@ -29,6 +29,8 @@ tiny_http = "0.12" # COM host returns), so nothing here is worth a derive struct except Command. serde = { version = "1", features = ["derive"] } serde_json = "1"+# miniz_oxide: pure-Rust deflate for the PNG swx_render_image writes (0.9.18).+miniz_oxide = "0.8"  [target.'cfg(windows)'.dependencies] # windows: late-bound IDispatch (GetIDsOfNames + Invoke), GetActiveObject,
README.md+30−2
@@ -9,7 +9,7 @@ One native binary. No Python, no PowerShell, no interpreter to provision: ab spawns `swx-bridge.exe` directly (`spawn.kind: "exe"`) and it is serving in about ten milliseconds. -## What it does — 68 verbs+## What it does — 69 verbs  | Area | Verbs | |---|---|@@ -19,6 +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. | | 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. |@@ -198,6 +199,22 @@ instead of ever attempting an install. The verbs that need no COM `swx_find_files`, `swx_action_ledger`, `swx_verb_times`, `swx_uninstall`) work regardless, so "what CAD files do I have?" is answerable today. +## Rendering++`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).+ ## Export formats, verified rather than assumed  Every format below was exported from a real solid on a live SOLIDWORKS Design@@ -461,7 +478,18 @@ not.  ## Roadmap -- v0.9.17 (this): **the resize is the measured one.** The window-tall-enough step+- v0.9.18 (this): **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`,+  converts it to PNG, and reads the size back from the file (`capped:true` if+  only the window size came out). Every display setting it changes is captured+  and restored — on errors too — and listed; the document is never saved; a+  render that changes the screen waits for the user to be away. The swconst ids+  it needs are read from the installed interop DLL, never guessed. Visualize+  (track B) is designed in docs/RENDERING.md, to be measured first. The COM+  path is UNMEASURED. 545 tests.+- v0.9.17: **the resize is the measured one.** The window-tall-enough step   that clears the cookie banner now calls `desktop_set_window_bounds   {hwnd, x:300, y:30, w:1100, h:1450}` — the call that was measured to work on   2026-10-02 (email box at y=787, banner at y=1020) — instead of a resize verb
bridge.json+5−3
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.17",-  "description": "Bridge for SOLIDWORKS desktop CAD \u2014 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.",+  "version": "0.9.18",+  "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",   "homepage": "https://wiki.adom.inc/adom/adom-bridge",@@ -66,6 +66,7 @@     "swx_readiness",     "swx_rebuild_check",     "swx_recover_list",+    "swx_render_image",     "swx_reset_workspace",     "swx_run_macro",     "swx_run_script",@@ -147,7 +148,8 @@       "shutdown": 900,       "close_import_temps": 600,       "stop": 420,-      "sign_in": 240+      "sign_in": 240,+      "render_image": 600     }   },   "detect": {
docs/ECOSYSTEM-TABLE.md+1
@@ -114,6 +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 | ✗ | ✗ | ✗ | ✗ | ✗ | | 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.mdadded+149
@@ -0,0 +1,149 @@+# Rendering — what the bridge can draw, and what it still has to measure++Drew, 2026-10-02: *"Does SW have rendering like fusion so we can get a really+gorgeous shot of this assembly?"* — then *"go ahead and try it… and if it works,+let's add a bunch of intel into the bridge to handle rendering."* The Fusion+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]**.++## The facts this is built on++- [measured] PhotoView 360 is ABSENT on this edition (3DEXPERIENCE SOLIDWORKS+  for Makers 2026). The renderer is the viewport itself: RealView, ambient+  occlusion, shadows and perspective, on an RTX 4090 Laptop GPU.+- [measured] `swx_show_assembly` + `ViewZoomtofit2` work live on the Moveo+  assembly (`C:\Users\Public\adom-hero\BCN3D-Moveo-master\CAD files\BCN3D Moveo+  assembly.SLDASM`, 110 documents, read-only) — the test subject.+- [measured] `swx_screenshot` (ab's PrintWindow by handle) works, but it is a+  screen grab capped at the window, about 1568 px wide — not an export.+- [measured] SOLIDWORKS Visualize 2026 is installed at+  `…\SOLIDWORKS 3DEXPERIENCE R2026x\Visualize\SLDWORKSVisualize.exe`, takes a+  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)++```+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}+→ {path, bytes, width, height (read back from the PNG header), requested, capped,+   appliedSettings, restoredSettings, restoreFailures, restoreIncomplete, durationMs}+```++What it does, in order, all against the ACTIVE document:++1. Validates: a `reason`; 16–8192 px per side (default 1920×1080); `path`+   absolute, ending `.png`, its folder EXISTING (never created — omit `path` for+   `<data root>\exports\<doc>-<view>-<w>x<h>-<ts>.png`), and not an existing+   file. `background:"transparent"` is refused `render_option_unsupported`:+   the export has no alpha channel.+2. Captures every setting it is asked to change, then applies it (only what+   differs). Calls and enums — each from the official API help:++| Setting | Call | Values |+|---|---|---|+| view | `IModelDoc2::ShowNamedView2("", id)` [help](https://help.solidworks.com/2024/english/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.IModelDoc2~ShowNamedView2.html) | `swStandardViews_e`: front 1, back 2, left 3, right 4, top 5, bottom 6, isometric 7, trimetric 8, dimetric 9 [help](https://help.solidworks.com/2024/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swStandardViews_e.html) |+| displayStyle | `IModelView::DisplayMode` get/put [help](https://help.solidworks.com/2024/english/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.IModelView~DisplayMode.html) | `swViewDisplayMode_e`: wireframe 1, hiddenLinesRemoved 2, shaded 4, shadedWithEdges 5 [help](https://help.solidworks.com/2024/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swViewDisplayMode_e.html) |+| perspective | `IModelView::HasPerspective` / `AddPerspective` / `RemovePerspective` [help](https://help.solidworks.com/2024/english/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.IModelView~HasPerspective.html) | bool |+| realView | `IModelDocExtension::ViewDisplayRealView` get/put [help](https://help.solidworks.com/2017/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~viewdisplayrealview.html) | bool |+| shadows | `ISldWorks::Get/SetUserPreferenceToggle(swDisplayShadowsInShadedMode)` — a SYSTEM setting [example](https://help.solidworks.com/2016/english/api/sldworksapi/Display_Shadows_in_Shaded_Mode_Example_CPlusPlus_COM.htm) | bool |+| ambientOcclusion | `ISldWorks::Get/SetUserPreferenceToggle(swDisplayAmbientOcclusionShadows)` [enum](https://help.solidworks.com/2020/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swUserPreferenceToggle_e.html) — its scope (system vs document) [unmeasured] | bool |+| background | `ISldWorks::Get/SetUserPreferenceIntegerValue(swColorsBackgroundAppearance)` → Plain (0) [help](https://help.solidworks.com/2024/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swColorsBackgroundAppearance_e.html), and `(swSystemColorsViewportBackground)` → COLORREF | white 0xFFFFFF, black 0 |+| zoomToFit | `IModelDoc2::ViewZoomtofit2` [help](https://help.solidworks.com/2024/english/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.IModelDoc2~ViewZoomtofit2.html) | — |+| camera (restored) | `IModelView::Orientation3`, `Translation3`, `Scale2` get/put | MathTransform, MathVector, double |++   The `swUserPreference*_e` NUMBERS are not on the help pages. The bridge never+   guesses one: it reads them by reflection from the installed+   `SolidWorks.Interop.swconst.dll` beside SLDWORKS.exe+   (`ISldWorks::GetExecutablePath`), the way the 3D Interconnect ids were read on+   2026-09-17, and a render that needs one it cannot read is refused+   `render_enum_unresolved` before anything changes.+3. Exports with `IModelDoc2::SaveBMP(path, width, height)` — "If WidthIn or the+   HeightIn is less than or equal to 0, then the view size is based on the+   current window size" [help](https://help.solidworks.com/2024/english/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.IModelDoc2~SaveBMP.html).+   Why not "Save As PNG" with print capture: the export options+   `swTiffScreenOrPrintCapture` (0 screen, 1 print), `swTiffPrintDPI`,+   `swTiffPrintPaperSize`, `swTiffPrintDrawingPaperWidth/Height` and+   `swTiffPrintScaleToFit` are documented "For drawings only"+   ([System Options > Export > TIF/PSD/JPG/PNG](https://help.solidworks.com/2017/english/api/swconst/filesaveastifpsdoptions.htm)),+   so for a part or assembly only SaveBMP takes a pixel size. They are not+   touched.+4. Restores, newest first, on EVERY path (the error path too), reads each one+   back, and lists it in `restoredSettings` (`value` = what it was, `readBack`,+   `restored`). A restore that did not read back is `restoreIncomplete:true`+   with `restoreFailures`. The camera is put back last. The document is never+   saved.+5. Converts the BMP to a PNG itself (the BMP is deleted), writes it, and+   VERIFIES BY EFFECT: the file exists, starts with the PNG signature, and its+   IHDR width × height are the reply's `width`/`height`. If they differ from the+   request, `capped:true` — the export honoured only the window size.++Guards: the readiness gate (it is a COM verb); the user-presence guard when the+request changes anything on screen (a view, a style, RealView, AO, shadows,+perspective, a background, a zoom) — exactly like a sweep, `user_is_active`+with the evidence. A render of the CURRENT view with nothing changed is NOT+presence-guarded: it changes nothing the user sees. [unmeasured] whether SaveBMP+at a size larger than the window flickers or resizes the viewport while it+renders — the first live run must watch for it, and if it does, that case moves+under the guard too.++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)++1. `swx_render_image {width:3840, height:2160, view:"isometric", realView:true,+   ambientOcclusion:true, shadows:true, perspective:true, background:"white",+   zoomToFit:true, reason}` on the Moveo assembly: did SaveBMP honour 3840×2160+   (`capped:false`)? How long (`durationMs`)? Does the image show RealView, AO and+   shadows, or does SaveBMP render a plainer pipeline than the screen?+2. Did every setting read back restored (`restoreIncomplete:false`)? Compare the+   viewport before and after by eye (and `swx_screenshot`).+3. Were the swconst ids read (no `render_enum_unresolved`)? Record the four ids.+   Is `swDisplayAmbientOcclusionShadows` a system or a document setting?+4. Does `GetExecutablePath` return the folder holding SLDWORKS.exe and the+   swconst DLL?+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)++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].+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].+3. Wait for the import to finish.+4. In the Output Tools panel: output size, render mode (Fast / Accurate),+   passes or time limit, output folder and file name.+5. Start the render; poll the OUTPUT FILE (exists, size stable, PNG header) —+   verification by effect, never by a progress bar alone.+6. Close Visualize (or leave it, on the user's say-so).++### Track B — MEASURE FIRST (exactly what to capture live, nothing assumed)++- The main window: class name and title pattern, and whether its accessibility+  tree is lazy (the login page's was).+- How "import finished" shows: a status text, a progress control's disappearance,+  the model in the scene tree — its role, name, automationId.+- The Output Tools panel: how it is opened (button name / automationId), and+  each control: width, height, aspect lock, render mode (Fast / Accurate), passes+  / time limit, output folder, file name, format — role, name, automationId,+  `settable` / `invokable`, and whether a background ValuePattern set works or+  real keystrokes are needed (the login page refused a background set).+- The render ("Start Render") button: name, automationId, `invokable`.+- How completion shows: a dialog, a status text, the render queue entry — and+  whether the file appears only at the end (so polling the file is enough).+- Whether a second render can be queued while one runs.+- Whether Visualize keeps its own camera / environment per project, and where+  the project file goes (so the bridge never writes over the user's).
install.sh

  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.17",+  "version": "0.9.18",   "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+16
@@ -113,3 +113,19 @@ do **not** transfer:  `docs/FUSION-PARITY-REPORT.md` in the bridge repo has all 132 Fusion verbs with 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+  `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.
src/comhost/com.rs+277
@@ -2633,6 +2633,7 @@ impl Backend for ComBackend {             "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(),@@ -4240,6 +4241,222 @@ 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:+    ///+    ///   * `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();++        // ── 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}));+                }+            }+        }+        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());+                }+            }+        }+        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}));+                }+            }+        }+        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(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(vid) = args.get("viewId").and_then(|v| v.as_i64()) {+            if doc.call("ShowNamedView2", &[&Var::bstr(""), &Var::i4(vid as i32)]).is_ok() {+                applied.insert("orientation".into(), json!({"to": args.get("view")}));+            }+        }+        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"}));+        }+        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", &[]);+        let data = json!({"appliedSettings": applied, "restoredSettings": restored, "restoreFailures": failures,+                          "stepsFailed": steps_failed});+        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@@ -5281,6 +5498,66 @@ impl ComBackend {     } } +/// 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())+}+ /// 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.
src/comhost/host.rs+10
@@ -208,6 +208,14 @@ pub const READ_ONLY_COMMANDS: &[&str] = &[     //                        callable without an opt-in.     "release_com",     "com_refs",+    //   `render_image`       (0.9.18) changes the VIEW and a handful of display+    //                        preferences (orientation, display style, RealView,+    //                        shadows, ambient occlusion, perspective, viewport+    //                        background) — every one captured before, restored+    //                        after on every path and read back — and writes a+    //                        NEW image file the server chose. It never saves or+    //                        edits the document. Same reasoning as `show`.+    "render_image", ];  /// Does this command need authorization? Anything not known to be side-effect@@ -837,6 +845,8 @@ pub const COMMANDS: &[&str] = &[     "import",     "close_import_temps",     "preserve_and_close",+    // 0.9.18 — swx_render_image.+    "render_image",     // 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+108
@@ -3103,6 +3103,114 @@ impl MockBackend {             // HAS SOLIDWORKS FINISHED STARTING? The simulator says yes unless told             // otherwise: `mock-startup-incomplete` (still starting) or             // `mock-startup-unreadable` (SOLIDWORKS will not say).+            // ── swx_render_image (0.9.18): the simulated viewport. The DISPLAY+            //    STATE lives in the data root (`mock-display-state.json`) so a+            //    test can read it before and after: whatever the render applies+            //    is restored on every path, the error path included.+            "render_image" => {+                self.require_running()?;+                let state_path = crate::scratch::root().join("mock-display-state.json");+                let default_state = json!({+                    "displayMode": 5, "perspective": false, "realView": false, "shadows": false,+                    "ambientOcclusion": false, "backgroundAppearance": 3, "backgroundColor": 13421772,+                    "orientation": "user-view", "scale": 1.0+                });+                let before: Value = std::fs::read_to_string(&state_path)+                    .ok()+                    .and_then(|t| serde_json::from_str(&t).ok())+                    .unwrap_or(default_state);+                if fault("mock-render-enum-unresolved")+                    && ["shadows", "ambientOcclusion"].iter().any(|k| args.get(*k).map(|v| !v.is_null()).unwrap_or(false))+                {+                    return Err(HostError::coded(+                        "render_enum_unresolved",+                        "the swconst ids for the shadow / ambient-occlusion toggles could not be read from the installed SolidWorks.Interop.swconst.dll (simulated)",+                    ));+                }+                let mut now = before.clone();+                let mut applied = serde_json::Map::new();+                let mut set = |k: &str, v: Value, applied: &mut serde_json::Map<String, Value>| {+                    if now.get(k) != Some(&v) {+                        applied.insert(k.into(), json!({"from": now.get(k).cloned(), "to": v}));+                        now[k] = v;+                    }+                };+                if let Some(id) = args.get("viewId").and_then(|v| v.as_i64()) {+                    set("orientation", json!(format!("standard-view-{id}")), &mut applied);+                }+                if let Some(m) = args.get("displayModeId").and_then(|v| v.as_i64()) {+                    set("displayMode", json!(m), &mut applied);+                }+                for k in ["perspective", "realView", "shadows", "ambientOcclusion"] {+                    if let Some(b) = args.get(k).and_then(|v| v.as_bool()) {+                        set(k, json!(b), &mut applied);+                    }+                }+                if let Some(c) = args.get("backgroundColor").and_then(|v| v.as_i64()) {+                    set("backgroundAppearance", json!(0), &mut applied);+                    set("backgroundColor", json!(c), &mut applied);+                }+                if args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false) {+                    set("scale", json!(0.42), &mut applied);+                }+                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.+                let (mut w, mut h) = (+                    args.get("width").and_then(|v| v.as_u64()).unwrap_or(1920) as u32,+                    args.get("height").and_then(|v| v.as_u64()).unwrap_or(1080) as u32,+                );+                if fault("mock-render-capped") {+                    w = w.min(1568);+                    h = h.min(882);+                }+                let saved = !fault("mock-render-fails") && {+                    let bmp_path = args.get("bmpPath").and_then(|v| v.as_str()).unwrap_or("");+                    let stride = ((w * 3 + 3) & !3) as usize;+                    let mut b = vec![0u8; 54 + stride * h as usize];+                    b[0..2].copy_from_slice(b"BM");+                    b[10..14].copy_from_slice(&54u32.to_le_bytes());+                    b[14..18].copy_from_slice(&40u32.to_le_bytes());+                    b[18..22].copy_from_slice(&(w as i32).to_le_bytes());+                    b[22..26].copy_from_slice(&(h as i32).to_le_bytes());+                    b[26..28].copy_from_slice(&1u16.to_le_bytes());+                    b[28..30].copy_from_slice(&24u16.to_le_bytes());+                    for y in 0..h as usize {+                        for x in 0..w as usize {+                            let o = 54 + y * stride + x * 3;+                            b[o..o + 3].copy_from_slice(&[(x % 256) as u8, (y % 256) as u8, 128]);+                        }+                    }+                    std::fs::write(bmp_path, &b).is_ok()+                };+                // RESTORE — on every path, then read back.+                let restore = !fault("mock-render-skips-restore");+                if restore {+                    let _ = std::fs::write(&state_path, before.to_string());+                }+                let after: Value = std::fs::read_to_string(&state_path)+                    .ok()+                    .and_then(|t| serde_json::from_str(&t).ok())+                    .unwrap_or(Value::Null);+                let mut restored = serde_json::Map::new();+                let mut failures = Vec::new();+                for (k, _) in applied.iter() {+                    let ok = after.get(k) == before.get(k);+                    restored.insert(k.clone(), json!({"value": before.get(k), "readBack": after.get(k), "restored": ok}));+                    if !ok {+                        failures.push(json!(k));+                    }+                }+                let data = json!({"appliedSettings": applied, "restoredSettings": restored, "restoreFailures": failures});+                if !saved {+                    return Err(HostError::coded("render_failed", "SaveBMP returned false (simulated)").with_data(data));+                }+                let mut out = data;+                out["document"] = json!(self.active.clone().map(|p| base_name(&p).to_string()));+                out["saveBmp"] = json!(true);+                Ok(out)+            }+             "startup_state" => {                 self.publish_session();                 Ok(json!({
src/lib.rs+1
@@ -42,6 +42,7 @@ pub mod perflog; pub mod presence; pub mod quit; pub mod readiness;+pub mod render; pub mod disk; pub mod scratch; pub mod signin;
src/render.rsadded+432
@@ -0,0 +1,432 @@+//! `swx_render_image` — a high-resolution image of the active document,+//! rendered by SOLIDWORKS's own viewport (0.9.18, track A).+//!+//! Drew, 2026-10-02: *"Does SW have rendering like fusion so we can get a really+//! gorgeous shot of this assembly?"* — and, if it works, *"add a bunch of intel+//! into the bridge to handle rendering."* The Fusion bridge has no render verb+//! at all (docs/FUSION-PARITY-REPORT.md).+//!+//! MEASURED: PhotoView 360 is ABSENT on this edition, so the renderer is the+//! viewport itself — RealView, ambient occlusion, shadows and perspective on the+//! 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):+//!   * 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."+//!     <https://help.solidworks.com/2024/english/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.IModelDoc2~SaveBMP.html>+//!     It takes a pixel size independent of the window, which the TIF/PNG+//!     "print capture" options do NOT for a model: swTiffPrintDPI, the paper+//!     size and the custom width/height are documented "For drawings only"+//!     (System Options > Export > TIF/PSD/JPG/PNG,+//!     <https://help.solidworks.com/2017/english/api/swconst/filesaveastifpsdoptions.htm>);+//!   * the bridge then converts the BMP to a PNG itself and verifies it BY+//!     EFFECT: the file exists, starts with the PNG signature, and its IHDR+//!     width × height is read back — never echoed from the arguments. If the+//!     export came back smaller than asked (window-capped), the reply says+//!     `capped:true` with the real size;+//!   * every display setting the render changes is CAPTURED BEFORE, RESTORED+//!     AFTER (on error too), read back, and listed in `restoredSettings`. The+//!     document is never saved. The user's settings are never changed silently.++use serde_json::{json, Value};++pub const MAX_SIDE: u64 = 8192;+pub const DEFAULT_W: u64 = 1920;+pub const DEFAULT_H: u64 = 1080;++/// Standard views, `swStandardViews_e` (published:+/// <https://help.solidworks.com/2024/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swStandardViews_e.html>).+pub const VIEWS: &[(&str, i32)] = &[+    ("front", 1),+    ("back", 2),+    ("left", 3),+    ("right", 4),+    ("top", 5),+    ("bottom", 6),+    ("isometric", 7),+    ("trimetric", 8),+    ("dimetric", 9),+];++/// Display styles, `swViewDisplayMode_e` (published:+/// <https://help.solidworks.com/2024/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swViewDisplayMode_e.html>),+/// set through `IModelView::DisplayMode`.+pub const DISPLAY_STYLES: &[(&str, i32)] = &[+    ("wireframe", 1),+    ("hiddenLinesRemoved", 2),+    ("shaded", 4),+    ("shadedWithEdges", 5),+];++/// Backgrounds this bridge can set: a PLAIN viewport colour+/// (`swColorsBackgroundAppearance_e.swColorsBackgroundAppearance_Plain` = 0,+/// <https://help.solidworks.com/2024/english/api/swconst/SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.swColorsBackgroundAppearance_e.html>)+/// with `swSystemColorsViewportBackground` as a Win32 COLORREF.+pub const BACKGROUNDS: &[(&str, i64)] = &[("white", 0x00FF_FFFF), ("black", 0)];++/// Does this request change anything ON SCREEN? Anything but a plain export of+/// the current view does (orientation, display style, RealView, AO, shadows,+/// perspective, background, zoom) — and that must respect the user-presence+/// guard exactly as a sweep does.+pub fn changes_display(args: &Value) -> bool {+    let set = |k: &str| args.get(k).map(|v| !v.is_null()).unwrap_or(false);+    let view = args.get("view").and_then(|v| v.as_str()).unwrap_or("current");+    let bg = args.get("background").and_then(|v| v.as_str()).unwrap_or("current");+    view != "current"+        || bg != "current"+        || ["displayStyle", "perspective", "realView", "ambientOcclusion", "shadows"].iter().any(|k| set(k))+        || args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false)+}++/// The validated request, as the com host receives it.+pub struct Request {+    pub png: std::path::PathBuf,+    pub bmp: std::path::PathBuf,+    pub width: u64,+    pub height: u64,+    pub host_args: Value,+}++fn sanitize(s: &str) -> String {+    let s: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect();+    let s = s.trim_matches('_').to_string();+    if s.is_empty() { "document".into() } else { s }+}++/// VALIDATE. Never creates a directory outside the data root's `exports\`.+pub fn request(args: &Value, doc_title: &str) -> Result<Request, Value> {+    let bad = |code: &str, msg: String, hint: &str| Err(crate::verbs::err_code(code, msg, hint));+    let dim = |k: &str, d: u64| -> Result<u64, Value> {+        match args.get(k) {+            None | Some(Value::Null) => Ok(d),+            Some(v) => match v.as_u64() {+                Some(n) if (16..=MAX_SIDE).contains(&n) => Ok(n),+                _ => Err(crate::verbs::err_code("bad_size", format!("{k} must be a whole number of pixels from 16 to {MAX_SIDE}"), "e.g. width:3840, height:2160")),+            },+        }+    };+    let width = dim("width", DEFAULT_W)?;+    let height = dim("height", DEFAULT_H)?;+    let view = args.get("view").and_then(|v| v.as_str()).unwrap_or("current").to_string();+    if view != "current" && !VIEWS.iter().any(|(n, _)| *n == view) {+        return bad("bad_view", format!("view {view:?} is not one of current, {}", VIEWS.iter().map(|v| v.0).collect::<Vec<_>>().join(", ")), "view:\"isometric\" is the usual hero angle");+    }+    let style = args.get("displayStyle").and_then(|v| v.as_str());+    if let Some(s) = style {+        if !DISPLAY_STYLES.iter().any(|(n, _)| *n == s) {+            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");+        }+    }+    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" {+        return bad(+            "render_option_unsupported",+            "a transparent background is not available: SaveBMP writes a 24-bit image with no alpha channel".into(),+            "Use background:\"white\" (or \"black\") and remove it afterwards, or leave it \"current\". Nothing was changed.",+        );+    }+    if background != "current" && !BACKGROUNDS.iter().any(|(n, _)| *n == background) {+        return bad("bad_background", format!("background {background:?} is not one of current, white, black, transparent"), "background:\"white\" is the usual product shot");+    }+    let exports = crate::scratch::root().join("exports");+    let png = match args.get("path").and_then(|p| p.as_str()).filter(|p| !p.trim().is_empty()) {+        Some(p) => {+            let p = crate::verbs::safe_out(p)?;+            let pb = std::path::PathBuf::from(&p);+            if !p.to_lowercase().ends_with(".png") {+                return bad("bad_path", format!("path must end in .png: {p}"), "The render is written as a PNG.");+            }+            match pb.parent() {+                Some(dir) if dir.is_dir() => {}+                _ => {+                    return bad(+                        "output_dir_missing",+                        format!("the folder for {p} does not exist"),+                        "Create it first, or omit `path` to write into the bridge's exports folder. The bridge never creates folders outside its own data root.",+                    )+                }+            }+            if pb.exists() {+                return bad("output_exists", format!("{p} already exists"), "Choose a new file name; the bridge never writes over an existing file.");+            }+            pb+        }+        None => {+            if let Err(e) = std::fs::create_dir_all(&exports) {+                return bad("output_dir_missing", format!("the exports folder {} could not be created: {e}", exports.display()), "Point SWX_SCRATCH_DIR somewhere writable, or pass `path`.");+            }+            let ts = crate::launchos::epoch_ms();+            exports.join(format!("{}-{}-{}x{}-{}.png", sanitize(doc_title.rsplit(['\\', '/']).next().unwrap_or(doc_title).split('.').next().unwrap_or("document")), sanitize(&view), width, height, ts))+        }+    };+    let _ = std::fs::create_dir_all(&exports);+    let bmp = exports.join(format!(".render-{}.bmp", crate::launchos::epoch_ms()));+    let host_args = json!({+        "bmpPath": bmp.to_string_lossy(),+        "width": width,+        "height": height,+        "view": view,+        "viewId": VIEWS.iter().find(|(n, _)| *n == view).map(|v| v.1),+        "displayStyle": style,+        "displayModeId": style.and_then(|s| DISPLAY_STYLES.iter().find(|(n, _)| *n == s).map(|v| v.1)),+        "perspective": args.get("perspective").and_then(|v| v.as_bool()),+        "realView": args.get("realView").and_then(|v| v.as_bool()),+        "ambientOcclusion": args.get("ambientOcclusion").and_then(|v| v.as_bool()),+        "shadows": args.get("shadows").and_then(|v| v.as_bool()),+        "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),+    });+    Ok(Request { png, bmp, width, height, host_args })+}++// ── BMP in, PNG out ─────────────────────────────────────────────────────────++/// Decode a 24- or 32-bit uncompressed BMP into (width, height, RGB rows top-down).+pub fn decode_bmp(b: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {+    if b.len() < 54 || &b[0..2] != b"BM" {+        return Err("not a BMP file".into());+    }+    let le32 = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);+    let off = le32(10) as usize;+    let w = le32(18) as i32;+    let h = le32(22) as i32;+    let bpp = u16::from_le_bytes([b[28], b[29]]);+    let comp = le32(30);+    if w <= 0 || h == 0 || !(bpp == 24 || bpp == 32) || !(comp == 0 || comp == 3) {+        return Err(format!("unsupported BMP: {w}x{h}, {bpp} bpp, compression {comp}"));+    }+    let (w, top_down, h) = (w as u32, h < 0, h.unsigned_abs());+    let bytes_pp = (bpp / 8) as usize;+    let stride = ((w as usize * bytes_pp) + 3) & !3;+    if b.len() < off + stride * h as usize {+        return Err("the BMP is shorter than its header says".into());+    }+    let mut rgb = Vec::with_capacity(w as usize * h as usize * 3);+    for y in 0..h as usize {+        let src_row = if top_down { y } else { h as usize - 1 - y };+        let row = &b[off + src_row * stride..off + src_row * stride + w as usize * bytes_pp];+        for px in row.chunks(bytes_pp) {+            rgb.extend_from_slice(&[px[2], px[1], px[0]]);+        }+    }+    Ok((w, h, rgb))+}++fn crc32(data: &[u8]) -> u32 {+    let mut table = [0u32; 256];+    for (i, t) in table.iter_mut().enumerate() {+        let mut c = i as u32;+        for _ in 0..8 {+            c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };+        }+        *t = c;+    }+    let mut c = 0xFFFF_FFFFu32;+    for &x in data {+        c = table[((c ^ x as u32) & 0xFF) as usize] ^ (c >> 8);+    }+    c ^ 0xFFFF_FFFF+}++/// Encode RGB8 as a PNG.+pub fn encode_png(w: u32, h: u32, rgb: &[u8]) -> Vec<u8> {+    let mut raw = Vec::with_capacity((w as usize * 3 + 1) * h as usize);+    for row in rgb.chunks(w as usize * 3) {+        raw.push(0); // filter: none+        raw.extend_from_slice(row);+    }+    let z = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);+    let mut out = b"\x89PNG\r\n\x1a\n".to_vec();+    let mut chunk = |kind: &[u8], data: &[u8]| {+        out.extend_from_slice(&(data.len() as u32).to_be_bytes());+        let mut c = kind.to_vec();+        c.extend_from_slice(data);+        out.extend_from_slice(&c);+        out.extend_from_slice(&crc32(&c).to_be_bytes());+    };+    let mut ihdr = Vec::new();+    ihdr.extend_from_slice(&w.to_be_bytes());+    ihdr.extend_from_slice(&h.to_be_bytes());+    ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); // 8-bit, truecolour, deflate, no filter, no interlace+    chunk(b"IHDR", &ihdr);+    chunk(b"IDAT", &z);+    chunk(b"IEND", &[]);+    out+}++/// READ BACK: (width, height) from a PNG's IHDR, or why it is not a PNG.+pub fn png_dims(b: &[u8]) -> Result<(u32, u32), String> {+    if b.len() < 24 || &b[0..8] != b"\x89PNG\r\n\x1a\n" {+        return Err("the file does not start with the PNG signature".into());+    }+    if &b[12..16] != b"IHDR" {+        return Err("the PNG's first chunk is not IHDR".into());+    }+    let be32 = |o: usize| u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);+    Ok((be32(16), be32(20)))+}++// ── the verb ────────────────────────────────────────────────────────────────++pub fn render_image(ctx: &crate::verbs::Ctx, args: &Value) -> Value {+    let started = std::time::Instant::now();+    if args.get("reason").and_then(|r| r.as_str()).map(|r| r.trim().is_empty()).unwrap_or(true) {+        return crate::verbs::err_code("reason_required", "swx_render_image needs a reason", "Say what the image is for; it is ledgered.");+    }+    let state = ctx.call("app_state", json!({}), 30).ok();+    let title = state+        .as_ref()+        .and_then(|s| s.get("activeDocument").and_then(|d| d.as_str()))+        .unwrap_or("document")+        .to_string();+    let req = match request(args, &title) {+        Ok(r) => r,+        Err(e) => return e,+    };+    let job = ctx.jobs.start("render_image", format!("Rendering {}x{}", req.width, req.height), 60);+    job.update("capture, apply, export (SaveBMP), restore", json!({"stage": "render", "width": req.width, "height": req.height}));+    let host = ctx.call("render_image", req.host_args.clone(), crate::verbs3::SWEEP_MAX_SECS);+    let _cleanup = Bmp(req.bmp.clone());+    let r = match host {+        Ok(r) => r,+        Err(e) => {+            // The host restores before it errors; what it restored rides on the error.+            let mut out = crate::verbs::err_code(+                e.code.as_deref().unwrap_or("render_failed"),+                format!("the render failed: {}", e.message),+                "Every display setting the render changed was restored (see restoredSettings); nothing was saved.",+            );+            if let (Value::Object(m), Some(d)) = (&mut out, e.data.as_ref()) {+                for k in ["appliedSettings", "restoredSettings", "restoreFailures"] {+                    if let Some(v) = d.get(k) {+                        m.insert(k.into(), v.clone());+                    }+                }+            }+            crate::ledger::record("render_image", json!({"ok": false, "error": e.message, "reason": args.get("reason")}));+            return out;+        }+    };+    let restored = r.get("restoredSettings").cloned().unwrap_or(Value::Null);+    let applied = r.get("appliedSettings").cloned().unwrap_or(Value::Null);+    let fail = |code: &str, msg: String| {+        let mut out = crate::verbs::err_code(code, msg, "Settings were restored; nothing was saved.");+        if let Value::Object(m) = &mut out {+            m.insert("appliedSettings".into(), applied.clone());+            m.insert("restoredSettings".into(), restored.clone());+        }+        out+    };+    job.update("converting to PNG", json!({"stage": "convert"}));+    let bmp = match std::fs::read(&req.bmp) {+        Ok(b) => b,+        Err(e) => return fail("render_no_file", format!("SOLIDWORKS reported the export but no image is on disk at {} ({e})", req.bmp.display())),+    };+    let (w, h, rgb) = match decode_bmp(&bmp) {+        Ok(x) => x,+        Err(e) => return fail("render_bad_file", format!("the exported image could not be read: {e}")),+    };+    let png = encode_png(w, h, &rgb);+    if let Err(e) = std::fs::write(&req.png, &png) {+        return fail("render_write_failed", format!("the PNG could not be written to {}: {e}", req.png.display()));+    }+    // VERIFY BY EFFECT: read the file back.+    let back = std::fs::read(&req.png).unwrap_or_default();+    let (pw, ph) = match png_dims(&back) {+        Ok(d) => d,+        Err(e) => return fail("render_bad_file", format!("the written file is not a valid PNG: {e}")),+    };+    let capped = (pw as u64, ph as u64) != (req.width, req.height);+    let restore_failures = r.get("restoreFailures").cloned().unwrap_or(json!([]));+    let restore_incomplete = restore_failures.as_array().map(|a| !a.is_empty()).unwrap_or(false);+    crate::ledger::record(+        "render_image",+        json!({"ok": true, "path": req.png.to_string_lossy(), "width": pw, "height": ph, "capped": capped,+               "document": r.get("document"), "reason": args.get("reason")}),+    );+    crate::verbs::ok(json!({+        "path": std::fs::canonicalize(&req.png).unwrap_or(req.png.clone()).to_string_lossy(),+        "bytes": back.len(),+        "width": pw,+        "height": ph,+        "requested": {"width": req.width, "height": req.height},+        "capped": capped,+        "document": r.get("document"),+        "appliedSettings": applied,+        "restoredSettings": restored,+        "restoreFailures": restore_failures,+        // 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,+        "_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 temporary BMP is removed on every path.+struct Bmp(std::path::PathBuf);+impl Drop for Bmp {+    fn drop(&mut self) {+        let _ = std::fs::remove_file(&self.0);+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn bmp(w: u32, h: u32, top_down: bool) -> Vec<u8> {+        let stride = ((w * 3 + 3) & !3) as usize;+        let mut b = vec![0u8; 54 + stride * h as usize];+        b[0..2].copy_from_slice(b"BM");+        b[10..14].copy_from_slice(&54u32.to_le_bytes());+        b[14..18].copy_from_slice(&40u32.to_le_bytes());+        b[18..22].copy_from_slice(&(w as i32).to_le_bytes());+        b[22..26].copy_from_slice(&(if top_down { -(h as i32) } else { h as i32 }).to_le_bytes());+        b[26..28].copy_from_slice(&1u16.to_le_bytes());+        b[28..30].copy_from_slice(&24u16.to_le_bytes());+        // bottom-up: the LAST row in the file is the top row. Mark the top-left pixel red.+        let top_row = if top_down { 0 } else { h as usize - 1 };+        let o = 54 + top_row * stride;+        b[o..o + 3].copy_from_slice(&[0, 0, 255]); // BGR red+        b+    }++    #[test]+    fn a_bmp_becomes_a_png_whose_header_says_its_size() {+        for td in [false, true] {+            let (w, h, rgb) = decode_bmp(&bmp(37, 21, td)).unwrap();+            assert_eq!((w, h), (37, 21));+            assert_eq!(&rgb[0..3], &[255, 0, 0], "the top-left pixel is red (orientation kept)");+            let png = encode_png(w, h, &rgb);+            assert_eq!(png_dims(&png).unwrap(), (37, 21));+            // A real decoder agrees: inflate the IDAT back to the rows.+            let idat_len = u32::from_be_bytes([png[33], png[34], png[35], png[36]]) as usize;+            let raw = miniz_oxide::inflate::decompress_to_vec_zlib(&png[41..41 + idat_len]).unwrap();+            assert_eq!(raw.len(), (37 * 3 + 1) * 21);+        }+        assert!(png_dims(b"not a png at all, really not").is_err());+        assert!(decode_bmp(b"BMxx").is_err());+    }++    #[test]+    fn only_a_current_view_export_leaves_the_screen_alone() {+        assert!(!changes_display(&json!({"width": 3840, "height": 2160, "reason": "x"})));+        assert!(!changes_display(&json!({"view": "current", "background": "current"})));+        for a in [json!({"view": "isometric"}), json!({"realView": true}), json!({"shadows": false}), json!({"zoomToFit": true}), json!({"background": "white"}), json!({"displayStyle": "shaded"})] {+            assert!(changes_display(&a), "{a}");+        }+    }+}
src/verbs.rs+7−1
@@ -3040,6 +3040,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_sign_in` — UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13). See src/signin.rs.@@ -3264,7 +3265,11 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value {     // session the user is working in — measured, fail-closed. See     // src/presence.rs.     let mut presence: Option<Value> = None;-    if crate::presence::GUARDED.contains(&command) {+    // A RENDER THAT CHANGES THE DISPLAY (orientation, style, RealView, AO,+    // shadows, perspective, background, zoom) is guarded like a sweep; an+    // export of the CURRENT view changes nothing on screen and is not (0.9.18).+    let render_touches_screen = command == "swx_render_image" && crate::render::changes_display(args);+    if crate::presence::GUARDED.contains(&command) || render_touches_screen {         match crate::presence::guard(ctx, command, args) {             Ok(evidence) => presence = Some(evidence),             Err(refusal) => {@@ -3382,6 +3387,7 @@ fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {         "swx_export_pdf" => return crate::verbs3::export_as(ctx, args, "pdf"),         "swx_export_dxf" => return crate::verbs3::export_as(ctx, args, "dxf"),         "swx_export_dwg" => return crate::verbs3::export_as(ctx, args, "dwg"),+        "swx_render_image" => crate::render::render_image,          other => {             let valid: Vec<&str> = CATALOG.iter().map(|(v, _, _)| *v).collect();
tests/e2e.rs+3
@@ -1182,6 +1182,9 @@ fn every_verb_over_http_against_the_real_binary() {     // nobody's screen, and the reply says what it measured.     // Unattended sign-in (0.9.13): with no launch waiting at a sign-in window,     // it types nothing anywhere and says so.+    // Rendering (0.9.18): a small PNG of the current view, size read back.+    let rr = b.call("swx_render_image", json!({"width": 32, "height": 24, "reason": "coverage"}));+    check!(b, "render_image writes a PNG of the asked size", rr["success"] == true && rr["width"] == 32 && rr["height"] == 24, s(&rr));     let si = b.call("swx_sign_in", json!({"reason": "coverage"}));     check!(b, "sign_in with nothing waiting: sign_in_not_waiting", si["errorCode"] == "sign_in_not_waiting", s(&si));     let pres = b.call("swx_user_presence", json!({}));
tests/render.rsadded+192
@@ -0,0 +1,192 @@+//! `swx_render_image` (0.9.18) in the simulator. The simulated viewport keeps+//! its DISPLAY STATE in the data root (`mock-display-state.json`) so every test+//! can compare it before and after: whatever the render applies is restored,+//! on success and on error. The COM side (com.rs) is UNMEASURED.++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()+}++fn session(scratch: &TempDir) {+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({"running": true, "untitled": 0, "active": "C:\\Users\\Public\\adom-hero\\Moveo.SLDASM",+               "docs": [{"docType": "assembly", "path": "C:\\Users\\Public\\adom-hero\\Moveo.SLDASM",+                         "title": "Moveo.SLDASM", "saved": true, "dirty": false}],+               "files": {}}).to_string(),+    )+    .unwrap();+}++fn display(scratch: &TempDir) -> Value {+    std::fs::read_to_string(scratch.join("mock-display-state.json"))+        .ok()+        .and_then(|t| serde_json::from_str(&t).ok())+        .unwrap_or(Value::Null)+}++const USERS_VIEW: &str = r#"{"displayMode":5,"perspective":false,"realView":false,"shadows":false,"ambientOcclusion":false,"backgroundAppearance":3,"backgroundColor":13421772,"orientation":"user-view","scale":1.0}"#;++fn boot(tag: &str) -> (Bridge, TempDir) {+    let scratch = TempDir::new(tag);+    session(&scratch);+    std::fs::write(scratch.join("mock-display-state.json"), USERS_VIEW).unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    (b, scratch)+}++fn hero(extra: Value) -> Value {+    let mut a = json!({"width": 64, "height": 48, "view": "isometric", "displayStyle": "shaded",+                       "perspective": true, "realView": true, "ambientOcclusion": true, "shadows": true,+                       "background": "white", "zoomToFit": true, "reason": "a hero shot of the Moveo assembly"});+    if let (Value::Object(m), Value::Object(e)) = (&mut a, extra) {+        m.extend(e);+    }+    a+}++#[test]+fn a_render_writes_a_png_of_the_asked_size_and_restores_every_setting() {+    let (b, scratch) = boot("render-ok");+    let before = display(&scratch);+    let r = b.call("swx_render_image", hero(json!({})));+    check!(b, "the render succeeds", r["success"] == true, s(&r));+    let path = r["path"].as_str().unwrap_or("").to_string();+    let bytes = std::fs::read(&path).unwrap_or_default();+    check!(+        b,+        "the default path is in the data root's exports folder, named for the document, view and size",+        path.contains("exports") && path.contains("Moveo-isometric-64x48-") && path.ends_with(".png"),+        path.clone()+    );+    check!(b, "the file is a PNG (signature)", bytes.starts_with(b"\x89PNG\r\n\x1a\n"), format!("{} bytes", bytes.len()));+    let (w, h) = (u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]), u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]));+    check!(+        b,+        "width/height in the reply are the PNG header's, and they are what was asked",+        (w, h) == (64, 48) && r["width"] == 64 && r["height"] == 48 && r["capped"] == false && r["bytes"] == bytes.len(),+        s(&r)+    );+    let restored = r["restoredSettings"].as_object().cloned().unwrap_or_default();+    check!(+        b,+        "every setting the render changed is listed as restored, with its read-back",+        ["displayMode", "perspective", "realView", "shadows", "ambientOcclusion", "backgroundColor", "orientation", "scale"]+            .iter()+            .all(|k| restored.get(*k).map(|v| v["restored"] == true).unwrap_or(false))+            && r["restoreIncomplete"] == false,+        s(&r["restoredSettings"])+    );+    check!(b, "THE USER'S VIEW IS EXACTLY AS IT WAS", display(&scratch) == before, format!("{} vs {}", display(&scratch), before));+    check!(b, "the temporary BMP is gone", !std::fs::read_dir(scratch.join("exports")).unwrap().flatten().any(|e| e.file_name().to_string_lossy().ends_with(".bmp")), String::new());+}++#[test]+fn a_failed_render_restores_every_setting_too() {+    let (b, scratch) = boot("render-fails");+    let before = display(&scratch);+    std::fs::write(scratch.join("mock-render-fails"), "").unwrap();+    let r = b.call("swx_render_image", hero(json!({})));+    check!(+        b,+        "SaveBMP failed: render_failed, with what was applied and what was restored",+        r["errorCode"] == "render_failed" && r["appliedSettings"]["realView"].is_object()+            && r["restoredSettings"]["realView"]["restored"] == true,+        s(&r)+    );+    check!(b, "...and the user's view is exactly as it was", display(&scratch) == before, display(&scratch).to_string());+}++#[test]+fn a_restore_that_did_not_take_is_said_loudly() {+    let (b, scratch) = boot("render-norestore");+    std::fs::write(scratch.join("mock-render-skips-restore"), "").unwrap();+    let r = b.call("swx_render_image", hero(json!({})));+    check!(+        b,+        "restoreIncomplete:true, the settings named, the hint says so",+        r["restoreIncomplete"] == true && r["restoreFailures"].as_array().map(|a| !a.is_empty()).unwrap_or(false)+            && r["_hint"].as_str().unwrap_or("").contains("NOT EVERY DISPLAY SETTING"),+        s(&r)+    );+}++#[test]+fn a_window_capped_export_says_so() {+    let (b, scratch) = boot("render-capped");+    std::fs::write(scratch.join("mock-render-capped"), "").unwrap();+    let r = b.call("swx_render_image", hero(json!({"width": 2000, "height": 1000})));+    check!(+        b,+        "capped:true, with the size actually in the file (read back), not the size asked",+        r["success"] == true && r["capped"] == true && r["width"] == 1568 && r["height"] == 882+            && r["requested"]["width"] == 2000,+        s(&r)+    );+}++#[test]+fn bad_destinations_and_options_are_refused_before_anything_changes() {+    let (b, scratch) = boot("render-refusals");+    let before = display(&scratch);+    let out = TempDir::new("render-out");+    let missing = format!("{}/no-such-folder/hero.png", out.path());+    let r = b.call("swx_render_image", hero(json!({"path": missing})));+    check!(b, "a folder that does not exist: output_dir_missing (never created)", r["errorCode"] == "output_dir_missing" && !out.join("no-such-folder").exists(), s(&r));+    let existing = out.join("taken.png");+    std::fs::write(&existing, "x").unwrap();+    let r = b.call("swx_render_image", hero(json!({"path": existing.to_string_lossy()})));+    check!(b, "an existing file: output_exists (never written over)", r["errorCode"] == "output_exists" && std::fs::read(&existing).unwrap() == b"x", s(&r));+    let r = b.call("swx_render_image", hero(json!({"background": "transparent"})));+    check!(b, "transparent: render_option_unsupported, said plainly", r["errorCode"] == "render_option_unsupported", s(&r));+    let r = b.call("swx_render_image", hero(json!({"path": "relative.png"})));+    check!(b, "a relative path: unsafe_path", r["errorCode"] == "unsafe_path", s(&r));+    let r = b.call("swx_render_image", hero(json!({"width": 100000})));+    check!(b, "an absurd size: bad_size", r["errorCode"] == "bad_size", s(&r));+    let r = b.call("swx_render_image", json!({"width": 64, "height": 48}));+    check!(b, "no reason: reason_required", r["errorCode"] == "reason_required", s(&r));+    check!(b, "...and NONE of those changed the user's view", display(&scratch) == before, display(&scratch).to_string());+    // A path that IS valid: written exactly there.+    let good = out.join("hero.png");+    let r = b.call("swx_render_image", hero(json!({"path": good.to_string_lossy()})));+    check!(b, "a valid path: written there", r["success"] == true && good.is_file(), s(&r));+}++#[test]+fn an_unreadable_enum_id_refuses_before_changing_anything() {+    let (b, scratch) = boot("render-enum");+    let before = display(&scratch);+    std::fs::write(scratch.join("mock-render-enum-unresolved"), "").unwrap();+    let r = b.call("swx_render_image", hero(json!({})));+    check!(b, "render_enum_unresolved — the bridge never guesses a swconst id", r["errorCode"] == "render_enum_unresolved", s(&r));+    check!(b, "...and nothing changed", display(&scratch) == before, display(&scratch).to_string());+}++#[test]+fn a_render_that_changes_the_screen_respects_the_user_at_the_keyboard() {+    let scratch = TempDir::new("render-presence");+    session(&scratch);+    std::fs::write(scratch.join("mock-display-state.json"), USERS_VIEW).unwrap();+    let machine = TempDir::new("render-presence-m");+    std::fs::write(machine.join("windows.json"), "[]").unwrap();+    std::fs::write(machine.join("last-input-secs"), "2").unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+    b.call("swx_start", json!({}));+    let pid = b.call("swx_get_app_state", json!({}))["processId"].as_u64().unwrap();+    std::fs::write(machine.join("foreground.json"), json!({"hwnd": 4242, "pid": pid, "title": "SOLIDWORKS", "className": "Afx"}).to_string()).unwrap();+    let r = b.call("swx_render_image", hero(json!({})));+    check!(b, "a render that changes the view, the user at the keyboard: user_is_active", r["errorCode"] == "user_is_active", s(&r));+    let r = b.call("swx_render_image", json!({"width": 64, "height": 48, "reason": "the current view, as it is"}));+    check!(+        b,+        "an export of the CURRENT view changes nothing on screen: allowed, and nothing was applied",+        r["success"] == true && r["appliedSettings"].as_object().map(|m| m.is_empty()).unwrap_or(false),+        s(&r)+    );+}
tests/signin.rs+11−5
@@ -858,6 +858,10 @@ fn the_page_that_refuses_a_background_set_is_typed_safely_and_read_back() {     let moved: Value = serde_json::from_str(&page_one().to_string().replace(         r#""centerX":584,"centerY":470"#, r#""centerX":600,"centerY":520"#)).unwrap();     ab.on_call_set("desktop_ui_set", "desktop_ui_tree", moved);+    // The login window in front from the start (the raise is still asserted):+    // a watcher-thread focus flip raced the settle loop under full-suite load.+    std::fs::write(m.dir.join("foreground.json"),+        json!({"hwnd": 777001, "pid": 1, "title": "Login - 3DEXPERIENCE ID", "className": "x"}).to_string()).unwrap();     let r = std::thread::scope(|sc| {         sc.spawn(|| drive_keystrokes(&m, &ab, Some(USER.to_string()), true));         b.call("swx_sign_in", json!({"reason": "sign in"}))@@ -927,11 +931,13 @@ fn a_collided_user_name_stops_before_continue_and_says_what_was_seen() {     let b = m.boot(TempDir::new("signin-ogin-scratch"), &ab, &[]);     b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));     wait_for_sign_in_stage(&b);-    let r = std::thread::scope(|sc| {-        // MEASURED: the field ended up holding "ogin" — the user's keystrokes.-        sc.spawn(|| drive_keystrokes(&m, &ab, Some("ogin".to_string()), true));-        b.call("swx_sign_in", json!({"reason": "sign in"}))-    });+    // Deterministic (no watcher thread — it flaked under full-suite load): the+    // login window is already in front, and the moment the bridge types, the+    // field holds "ogin" — MEASURED: the user's keystrokes, not ours.+    std::fs::write(m.dir.join("foreground.json"),+        json!({"hwnd": 777001, "pid": 1, "title": "Login - 3DEXPERIENCE ID", "className": "x"}).to_string()).unwrap();+    ab.on_call_set("desktop_type", "desktop_ui_tree", with_username_value("ogin"));+    let r = b.call("swx_sign_in", json!({"reason": "sign in"}));     check!(         b,         "read-back mismatch: sign_in_fill_failed with what was observed; Continue never pressed; the password never typed",
uninstall.sh