← Commit history
CHANGELOG.md+26
@@ -3,6 +3,32 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.21 (2026-10-29)++- `fusion_replicate_layout` (the standard's replicate_layout): one channel's placement copied to matching channels round+  each target's anchor, turned by the anchors' rotation difference; bottom parts stay mirrored; one guarded move.+- `fusion_symbol_prims` and `fusion_set_visibility` (the standard's): a component's drawing counted (pins, lines,+  rectangles, polygons, body rectangle in mil, footprints), and a parameter shown on the sheet or hidden by adding or+  removing its >NAME text in the component's symbols. Offline on a .lbr.+- component_classes answers not_supported: an EAGLE board has net classes but no component classes or rooms. 339 verbs.++From Drew Owens (PR #26): exploded-view animations for operators, and photoreal renders.++- `exploded_view_render` plays the same steps as `exploded_view_plan` on the real occurrences and saves one viewport image per+  frame from a camera fitted to the most exploded state. Then it reverts every move and verifies each instance is home and the+  document's flags are unchanged. It refuses a design that already has an uncaptured move (the revert would discard it), and+  names any part that did not reach its planned pose (`heldBack`). The reply carries timed captions from the steps' titles and+  instructions, and the ffmpeg line that joins the frames into MP4 (Fusion's API has no video export). Journaled.+- `render_image` renders with Fusion's local renderer from a fitted standard view or the current one, at a size and quality,+  optionally transparent (PNG only) or with the ground. It waits for the render, then restores the camera and every render+  setting. Journaled. Fusion's API cannot choose the render environment or cancel a render; after `render_timeout` the render may+  still write the file.+- Edge cases checked live on Fusion 2705:+  - nested children in their parent's frame, a rotated part along its own axis, a rigidly jointed part and component-pattern copies;+  - someone's uncaptured move refused and left in place, overwrite, the frame budget, bad folders and sessions;+  - render settings and camera restored, JPEG output, and a real timeout.+- 64 typed operations; the add-in is 1.0.26, and the exe embeds the new `schema.json`.+ ## 1.13.20 (2026-10-29)  - Engine 0.20.0 (standard 0.23.0). `fusion_bodies` gives a part turned off the axes its rectangle along its own axes
addin/AdomBridge/AdomBridge.manifest+1−1
@@ -6,7 +6,7 @@     "description": {         "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360."     },-    "version": "1.0.25",+    "version": "1.0.26",     "runOnStartup": true,     "supportedOS": "windows" }\ No newline at end of file
addin/AdomBridge/commands/mechanical/__init__.py+4−2
@@ -14,7 +14,7 @@ import adsk.core from .core import SESSION_ID, MechanicalError, document_identity, require from .operations import OPERATIONS, WRITES from .journal import Journal, JournalError-from . import handoff, transactions, sketches, features, assemblies, purchased, bill_of_materials, geometry, sketch_edits, regular_joints, motion_links, configurations, render_scene, exploded_view, exploded_preview, efficiency+from . import handoff, transactions, sketches, features, assemblies, purchased, bill_of_materials, geometry, sketch_edits, regular_joints, motion_links, configurations, render_scene, exploded_view, exploded_preview, efficiency, exploded_render, render_image  for _name, _handler in {**sketches.OPERATIONS, **features.OPERATIONS, **assemblies.OPERATIONS,                         **geometry.OPERATIONS, **sketch_edits.OPERATIONS, **regular_joints.OPERATIONS, **motion_links.OPERATIONS}.items():@@ -33,7 +33,9 @@ WRITES.update(exploded_preview.WRITES) OPERATIONS.update(purchased.OPERATIONS) OPERATIONS.update(bill_of_materials.OPERATIONS) OPERATIONS.update(efficiency.OPERATIONS)-WRITES.update(purchased.WRITES | bill_of_materials.WRITES | efficiency.WRITES)+OPERATIONS.update(exploded_render.OPERATIONS)+OPERATIONS.update(render_image.OPERATIONS)+WRITES.update(purchased.WRITES | bill_of_materials.WRITES | efficiency.WRITES | exploded_render.WRITES | render_image.WRITES) OPERATIONS["transaction_probe"] = transactions.probe WRITES.add("transaction_probe") 
addin/AdomBridge/commands/mechanical/exploded_render.pyadded+131
@@ -0,0 +1,131 @@+"""Exploded-view animation frames for operators: play an exploded_view_plan on the real occurrences,+save one viewport image per frame from a fixed camera, then revert every move and prove each instance+is back home. Fusion has no video export in its API, so the frames are joined into MP4 outside Fusion+(ffmpeg), and the steps' titles and instructions come back as timed captions."""+import math+from pathlib import Path+import threading++import adsk.core+import adsk.fusion++from .core import SESSION_ID, require, target+from .exploded_view import capture, compile_steps, matrix, request_arguments, sample_plan++MAX_FRAMES = 3600+# how far a part may sit from its planned pose before it counts as held back (mm, and matrix terms)+POSE_TOLERANCE_MM = 0.01+++def to_matrix(pose):+    """A plan pose (row-major, translation in mm) as a Fusion matrix (cm)."""+    values = list(pose)+    for index in (3, 7, 11):+        values[index] /= 10+    matrix = adsk.core.Matrix3D.create()+    require(matrix.setWithArray(values), "plan_frame_invalid", "Fusion refused a sampled pose.")+    return matrix+++def held_back(snapshot, poses, handles):+    """Instances Fusion did not put where the plan says (a joint, a pattern or a constraint held them):+    the frames would show them standing still, so the caller is told which ones."""+    paths = {row["instanceId"]: row["path"] for row in snapshot["instances"]}+    out = []+    for pose in poses:+        actual = matrix(handles[pose["instanceId"]].transform2)+        gap = max(abs(a - b) * (1 if index in (3, 7, 11) else 1000) for index, (a, b) in enumerate(zip(actual, pose["matrix"])))+        if gap > POSE_TOLERANCE_MM:+            # offByMm: translation in mm, or a rotation difference as the shift it makes 1 m from the origin+            out.append({"path": paths[pose["instanceId"]], "offByMm": round(gap, 3)})+    return out+++def captions(steps, total, playback):+    """Each step's title and instruction with its time range in the video (reversed for assemble)."""+    out = []+    for step in steps:+        start, end = step["startSeconds"], step["endSeconds"]+        if playback == "assemble":+            start, end = total - end, total - start+        out.append({"index": step["index"], "startSeconds": round(start, 3), "endSeconds": round(end, 3),+                    "title": step["title"], "instruction": step["instruction"]})+    return sorted(out, key=lambda c: c["startSeconds"])+++def frame_times(total, fps, hold):+    """Times to sample: every 1/fps through the plan, then the last pose held for hold seconds."""+    count = int(math.floor(total * fps + 1e-9)) + 1+    times = [min(k / fps, total) for k in range(count)]+    if times[-1] < total:+        times.append(total)+    return times + [total] * int(round(hold * fps))+++def exploded_view_render(app, args):+    require(threading.current_thread() is threading.main_thread(), "wrong_thread", "Rendering requires the main thread.")+    require(args.get("sessionId") == SESSION_ID, "session_mismatch", "Rendering requires the current session.")+    folder = Path(args["folder"])+    require(folder.is_absolute(), "invalid_destination", "folder must be an absolute path.")+    folder.mkdir(parents=True, exist_ok=True)+    require(args.get("overwrite", False) or not any(folder.glob("frame_*.png")), "destination_exists",+            "folder already holds frames; pass overwrite:true to replace them.")+    for old in folder.glob("frame_*.png"):+        old.unlink()+    fps, width, height = int(args.get("fps", 24)), int(args.get("width", 1280)), int(args.get("height", 720))+    total, _, playback = request_arguments({**args, "sampleTimes": []})+    times = frame_times(total, fps, float(args.get("holdSeconds", 1.0)))+    require(len(times) <= MAX_FRAMES, "plan_budget", "The animation exceeds %d frames; lower fps or shorten the steps." % MAX_FRAMES)++    document, design, value = target(app, args)+    snapshots = design.snapshots+    # rendering ends by reverting every uncaptured move, so it never starts on someone else's+    require(not snapshots.hasPendingSnapshot, "pending_snapshot",+            "The design has an uncaptured move; capture or revert it first (rendering reverts every move it makes).")+    flags = (document.isSaved, document.isModified)+    snapshot, handles = capture(design, value)+    steps = compile_steps(design, value, snapshot, handles, args["steps"])+    viewport = app.activeViewport+    original_camera = viewport.camera++    def pose_at(seconds):+        poses = sample_plan(snapshot, steps, [seconds], total, playback)[0]["poses"]+        for pose in poses:+            handles[pose["instanceId"]].transform2 = to_matrix(pose["matrix"])+        return poses++    written = 0+    try:+        # frame the most exploded state so every part stays in view, then hold that camera; check there+        # that every part reached its planned pose+        held = held_back(snapshot, pose_at(total if playback == "explode" else 0.0), handles)+        if args.get("fit", True):+            viewport.fit()+        camera = viewport.camera+        camera.isSmoothTransition = False+        for index, seconds in enumerate(times):+            pose_at(seconds)+            viewport.camera = camera+            viewport.refresh()+            adsk.doEvents()+            path = folder / ("frame_%05d.png" % index)+            require(viewport.saveAsImageFile(str(path), width, height), "render_failed", "Fusion could not save frame %d." % index)+            written += 1+    finally:+        if snapshots.hasPendingSnapshot:+            snapshots.revertPendingSnapshot()+        original_camera.isSmoothTransition = False+        viewport.camera = original_camera+        viewport.refresh()+    after, _ = capture(design, value)+    require(after == snapshot, "restore_failed", "Instances did not return to their home poses after rendering.")+    require((document.isSaved, document.isModified) == flags and not snapshots.hasPendingSnapshot, "restore_failed",+            "Document state changed during rendering.")+    return {"target": value, "folder": str(folder), "frames": written, "fps": fps, "size": [width, height],+            "durationSeconds": round(len(times) / fps, 3), "playback": playback, "captions": captions(steps, total, playback),+            "pattern": "frame_%05d.png", "restored": True, "heldBack": held,+            "encode": "ffmpeg -framerate %d -i frame_%%05d.png -c:v libx264 -pix_fmt yuv420p -crf 18 exploded.mp4" % fps}+++OPERATIONS = {"exploded_view_render": exploded_view_render}+WRITES = {"exploded_view_render"}
addin/AdomBridge/commands/mechanical/render_image.pyadded+84
@@ -0,0 +1,84 @@+"""Photoreal pictures of a design: Fusion's local renderer from a chosen view, waited on, with the+camera and every render setting it touched put back. Journaled, since a high-quality render of a big+assembly runs for minutes. Fusion's API lists render environments but cannot choose one, so the+document's current environment is used."""+from pathlib import Path+import threading+import time++import adsk.core+import adsk.fusion++from .core import SESSION_ID, require, target++VIEWS = {"iso": "IsoTopRightViewOrientation", "iso-left": "IsoTopLeftViewOrientation", "front": "FrontViewOrientation",+         "back": "BackViewOrientation", "top": "TopViewOrientation", "bottom": "BottomViewOrientation",+         "left": "LeftViewOrientation", "right": "RightViewOrientation"}+++def render_image(app, args):+    require(threading.current_thread() is threading.main_thread(), "wrong_thread", "Rendering requires the main thread.")+    require(args.get("sessionId") == SESSION_ID, "session_mismatch", "Rendering requires the current session.")+    destination = Path(args["filename"])+    require(destination.is_absolute() and destination.suffix.lower() in (".png", ".jpg", ".jpeg"), "invalid_destination",+            "filename must be an absolute .png or .jpg path.")+    require(not (args.get("transparent", False) and destination.suffix.lower() != ".png"), "invalid_destination",+            "A transparent background needs a .png filename; JPEG has no transparency.")+    require(args.get("overwrite", False) or not destination.exists(), "destination_exists", "Pass overwrite:true to replace the image.")+    destination.parent.mkdir(parents=True, exist_ok=True)+    if destination.exists():+        destination.unlink()+    document, design, value = target(app, args)+    flags = (document.isSaved, document.isModified)+    rendering = design.renderManager.rendering+    scene = design.renderManager.sceneSettings+    viewport = app.activeViewport+    saved = {"aspectRatio": rendering.aspectRatio, "resolutionWidth": rendering.resolutionWidth,+             "resolutionHeight": rendering.resolutionHeight, "renderQuality": rendering.renderQuality,+             "isBackgroundTransparent": rendering.isBackgroundTransparent}+    saved_ground = scene.isGroundDisplayed+    camera = viewport.camera+    started = time.perf_counter()+    try:+        rendering.aspectRatio = adsk.fusion.RenderAspectRatios.CustomRenderAspectRatio+        rendering.resolutionWidth = int(args.get("width", 1920))+        rendering.resolutionHeight = int(args.get("height", 1080))+        rendering.renderQuality = int(args.get("quality", 75))+        rendering.isBackgroundTransparent = bool(args.get("transparent", False))+        if "ground" in args:+            scene.isGroundDisplayed = bool(args["ground"])+        view = args.get("view", "iso")+        if view != "current":+            framed = viewport.camera+            framed.isSmoothTransition = False+            framed.viewOrientation = getattr(adsk.core.ViewOrientations, VIEWS[view])+            viewport.camera = framed+            viewport.fit()+            viewport.refresh()+        future = rendering.startLocalRender(str(destination))+        require(future is not None, "render_failed", "Fusion did not start the render.")+        deadline = started + float(args.get("timeoutSeconds", 600))+        states = adsk.fusion.LocalRenderStates+        while future.renderState not in (states.FinishedLocalRenderState, states.FailedLocalRenderState):+            # Fusion's API cannot cancel a local render: it keeps running and may still write the file later+            require(time.perf_counter() < deadline, "render_timeout",+                    "The render did not finish in time; lower quality or size. Fusion keeps rendering in the background and may still write the file.")+            adsk.doEvents()+            time.sleep(0.2)+        require(future.renderState == states.FinishedLocalRenderState, "render_failed", "Fusion's renderer reported a failure.")+    finally:+        for name, setting in saved.items():+            setattr(rendering, name, setting)+        scene.isGroundDisplayed = saved_ground+        camera.isSmoothTransition = False+        viewport.camera = camera+        viewport.refresh()+    require(destination.exists() and destination.stat().st_size > 0, "render_failed", "The rendered image was not written.")+    return {"target": value, "filename": str(destination), "bytes": destination.stat().st_size,+            "size": [future.imageWidth, future.imageHeight], "seconds": round(time.perf_counter() - started, 1),+            "view": args.get("view", "iso"), "quality": int(args.get("quality", 75)),+            "settingsRestored": True, "documentFlagsUnchanged": (document.isSaved, document.isModified) == flags}+++OPERATIONS = {"render_image": render_image}+WRITES = {"render_image"}
addin/AdomBridge/commands/mechanical/schema.json+220
@@ -7295,5 +7295,225 @@       "operationId",       "target"     ]+  },+  "exploded_view_render": {+    "type": "object",+    "required": [+      "op",+      "sessionId",+      "operationId",+      "target",+      "unit",+      "steps",+      "folder"+    ],+    "properties": {+      "sessionId": {+        "type": "string"+      },+      "target": {+        "type": "object",+        "required": [+          "documentId",+          "revision"+        ],+        "additionalProperties": false,+        "properties": {+          "documentId": {+            "type": "string"+          },+          "revision": {+            "type": "string"+          }+        }+      },+      "unit": {+        "type": "string",+        "enum": [+          "mm"+        ]+      },+      "steps": {+        "type": "array",+        "minItems": 1,+        "maxItems": 32,+        "items": {+          "type": "object",+          "required": [+            "durationSeconds",+            "moves"+          ],+          "additionalProperties": false,+          "properties": {+            "title": {+              "type": "string"+            },+            "durationSeconds": {+              "type": "number",+              "exclusiveMinimum": 0,+              "maximum": 3600+            },+            "moves": {+              "type": "array",+              "minItems": 1,+              "maxItems": 128,+              "items": {+                "type": "object",+                "required": [+                  "occurrence",+                  "direction",+                  "distance",+                  "frame"+                ],+                "additionalProperties": false,+                "properties": {+                  "occurrence": {+                    "type": "object",+                    "description": "Exact guarded occurrence reference from inspect; every repeated/nested instance is distinct."+                  },+                  "direction": {+                    "type": "array",+                    "minItems": 3,+                    "maxItems": 3,+                    "items": {+                      "type": "number",+                      "minimum": -1000000000.0,+                      "maximum": 1000000000.0+                    }+                  },+                  "distance": {+                    "type": "number",+                    "exclusiveMinimum": 0,+                    "maximum": 1000000.0+                  },+                  "frame": {+                    "type": "string",+                    "enum": [+                      "world",+                      "parent",+                      "local"+                    ]+                  }+                }+              }+            },+            "instruction": {+              "type": "string"+            }+          }+        }+      },+      "playback": {+        "type": "string",+        "enum": [+          "explode",+          "assemble"+        ]+      },+      "operationId": {+        "type": "string"+      },+      "folder": {+        "type": "string",+        "description": "Absolute folder on the PC for frame_00000.png ... (created if missing)."+      },+      "overwrite": {+        "type": "boolean",+        "description": "Replace frames already in folder (default false)."+      },+      "fps": {+        "type": "integer",+        "minimum": 1,+        "maximum": 60,+        "description": "Frames per second (default 24)."+      },+      "width": {+        "type": "integer",+        "minimum": 320,+        "maximum": 3840,+        "description": "Frame width in pixels (default 1280)."+      },+      "height": {+        "type": "integer",+        "minimum": 240,+        "maximum": 2160,+        "description": "Frame height in pixels (default 720)."+      },+      "holdSeconds": {+        "type": "number",+        "minimum": 0,+        "maximum": 30,+        "description": "Hold the last pose this long (default 1)."+      },+      "fit": {+        "type": "boolean",+        "description": "Fit the camera to the most exploded state first (default true); false keeps the current view."+      }+    },+    "description": "Render an exploded-view animation for operators: play the same steps as exploded_view_plan on the real occurrences, save one viewport image per frame from a fixed camera (fitted to the most exploded state), then revert every move and verify each instance is back home and the document state unchanged. Refuses a design with an uncaptured move. Journaled; follow a long render with fusion_mech_operation_status. Returns the frame folder, timed captions from the steps' titles and instructions, and the ffmpeg line that joins the frames into MP4 (Fusion's API has no video export)."+  },+  "render_image": {+    "type": "object",+    "description": "Render a photoreal image with Fusion's local renderer: from a standard view fitted to the design (iso by default) or the current view, at a size and quality (0 to 100; 75 by default), optionally on a transparent background or without the ground. Waits for the render, then restores the camera and every render setting it changed. Journaled: a large, high-quality render runs for minutes, so follow it with fusion_mech_operation_status. Fusion's API cannot choose the render environment; the document's own is used.",+    "properties": {+      "operationId": {+        "type": "string"+      },+      "filename": {+        "type": "string",+        "description": "Absolute .png or .jpg path on the PC."+      },+      "overwrite": {+        "type": "boolean"+      },+      "view": {+        "type": "string",+        "enum": [+          "iso",+          "iso-left",+          "front",+          "back",+          "top",+          "bottom",+          "left",+          "right",+          "current"+        ]+      },+      "width": {+        "type": "integer",+        "minimum": 64,+        "maximum": 7680+      },+      "height": {+        "type": "integer",+        "minimum": 64,+        "maximum": 4320+      },+      "quality": {+        "type": "integer",+        "minimum": 0,+        "maximum": 100+      },+      "transparent": {+        "type": "boolean"+      },+      "ground": {+        "type": "boolean",+        "description": "Show the ground plane (shadows and reflections); default leaves the document's setting."+      },+      "timeoutSeconds": {+        "type": "number",+        "minimum": 10,+        "maximum": 3600+      }+    },+    "required": [+      "op",+      "sessionId",+      "operationId",+      "target",+      "filename"+    ]   } }
docs/MECHANICAL.md+8
@@ -39,6 +39,14 @@ passed six instances across 60 poses, source/joint/BOM preservation and F3D reop installed public routing remains untested. See [the evidence](build-2026-10-06-exploded-plans.md). A [finite exploded-preview controller candidate](EXPLODED-PREVIEWS.md) now exists in source (61 typed operations): independent copied graphics, explicit finite samples/show/dispose, and session-owned presentation leases. Its native backend and installed public routing remain unqualified; the first backend attempt stopped before graphics creation. Camera framing and visible-output acceptance are separate future gates. +## Render an exploded-view animation++`exploded_view_render` takes the same `unit`, `steps` and `playback` as `exploded_view_plan`, plus an absolute `folder`, `fps` (default 24), `width` and `height` (default 1280 x 720) and `holdSeconds` (default 1). It plays the steps on the real occurrences and saves one viewport image per frame. The camera is fitted to the most exploded state unless `fit:false` keeps the current view. Afterwards it reverts every move and verifies that each instance is home and the document's saved and modified flags are unchanged. It refuses a design that already has an uncaptured move, since the revert would discard it. It is journaled. The reply lists the frames, timed `captions` from the steps' titles and instructions, and the ffmpeg line that joins the frames into MP4; Fusion's API has no video export. A three-step check (screws out, lid off, housing slid off the base) rendered 133 frames, in both explode and assemble order, and was joined into captioned MP4. At the most exploded pose it checks that every part reached its planned pose and names any that did not in `heldBack` (with how far off), so a part a constraint holds never just stands still in the video; live, a rigidly jointed post, component-pattern copies, a nested child moved in its parent's frame and a rotated part moved along its own axis all reached their poses and came back home. Frames are the viewport, so joint glyphs and other visible decorations appear in them; hide them first for clean frames.++## Render a picture++`render_image` renders a photoreal image with Fusion's local renderer to an absolute `.png` or `.jpg` `filename`. The `view` is `iso` by default, or `iso-left`, `front`, `back`, `top`, `bottom`, `left` or `right`, each fitted to the design; `current` keeps the view as it is. It takes `width` and `height` (1920 x 1080 by default), `quality` from 0 to 100 (75 by default), `transparent` and `ground`. It waits for the render, then restores the camera and every render setting it changed, and reports whether the document's saved and modified flags are unchanged. It is journaled, since a large high-quality render runs for minutes. Fusion's API lists render environments but cannot choose one, so the document's own is used. A 1280 x 720 iso render at quality 60 took 28 s. A transparent background needs a `.png`. Fusion's API cannot cancel a render, so after `render_timeout` the render keeps running in the background and may still write the file (seen live: a 1920 x 1080 quality 95 render timed out at 10 s and delivered the file later), while the settings and camera are already back.+ ## Inspect render settings and prepare a detached camera  `render_scene_inspect` is a guarded read. Pass the current `sessionId` and exact
rust/core/tests/golden/mech_capabilities.json+222
@@ -7310,6 +7310,226 @@      "operationId",      "target"     ]+   },+   "exploded_view_render": {+    "type": "object",+    "required": [+     "op",+     "sessionId",+     "operationId",+     "target",+     "unit",+     "steps",+     "folder"+    ],+    "properties": {+     "sessionId": {+      "type": "string"+     },+     "target": {+      "type": "object",+      "required": [+       "documentId",+       "revision"+      ],+      "additionalProperties": false,+      "properties": {+       "documentId": {+        "type": "string"+       },+       "revision": {+        "type": "string"+       }+      }+     },+     "unit": {+      "type": "string",+      "enum": [+       "mm"+      ]+     },+     "steps": {+      "type": "array",+      "minItems": 1,+      "maxItems": 32,+      "items": {+       "type": "object",+       "required": [+        "durationSeconds",+        "moves"+       ],+       "additionalProperties": false,+       "properties": {+        "title": {+         "type": "string"+        },+        "durationSeconds": {+         "type": "number",+         "exclusiveMinimum": 0,+         "maximum": 3600+        },+        "moves": {+         "type": "array",+         "minItems": 1,+         "maxItems": 128,+         "items": {+          "type": "object",+          "required": [+           "occurrence",+           "direction",+           "distance",+           "frame"+          ],+          "additionalProperties": false,+          "properties": {+           "occurrence": {+            "type": "object",+            "description": "Exact guarded occurrence reference from inspect; every repeated/nested instance is distinct."+           },+           "direction": {+            "type": "array",+            "minItems": 3,+            "maxItems": 3,+            "items": {+             "type": "number",+             "minimum": -1000000000.0,+             "maximum": 1000000000.0+            }+           },+           "distance": {+            "type": "number",+            "exclusiveMinimum": 0,+            "maximum": 1000000.0+           },+           "frame": {+            "type": "string",+            "enum": [+             "world",+             "parent",+             "local"+            ]+           }+          }+         }+        },+        "instruction": {+         "type": "string"+        }+       }+      }+     },+     "playback": {+      "type": "string",+      "enum": [+       "explode",+       "assemble"+      ]+     },+     "operationId": {+      "type": "string"+     },+     "folder": {+      "type": "string",+      "description": "Absolute folder on the PC for frame_00000.png ... (created if missing)."+     },+     "overwrite": {+      "type": "boolean",+      "description": "Replace frames already in folder (default false)."+     },+     "fps": {+      "type": "integer",+      "minimum": 1,+      "maximum": 60,+      "description": "Frames per second (default 24)."+     },+     "width": {+      "type": "integer",+      "minimum": 320,+      "maximum": 3840,+      "description": "Frame width in pixels (default 1280)."+     },+     "height": {+      "type": "integer",+      "minimum": 240,+      "maximum": 2160,+      "description": "Frame height in pixels (default 720)."+     },+     "holdSeconds": {+      "type": "number",+      "minimum": 0,+      "maximum": 30,+      "description": "Hold the last pose this long (default 1)."+     },+     "fit": {+      "type": "boolean",+      "description": "Fit the camera to the most exploded state first (default true); false keeps the current view."+     }+    },+    "description": "Render an exploded-view animation for operators: play the same steps as exploded_view_plan on the real occurrences, save one viewport image per frame from a fixed camera (fitted to the most exploded state), then revert every move and verify each instance is back home and the document state unchanged. Refuses a design with an uncaptured move. Journaled; follow a long render with fusion_mech_operation_status. Returns the frame folder, timed captions from the steps' titles and instructions, and the ffmpeg line that joins the frames into MP4 (Fusion's API has no video export)."+   },+   "render_image": {+    "type": "object",+    "description": "Render a photoreal image with Fusion's local renderer: from a standard view fitted to the design (iso by default) or the current view, at a size and quality (0 to 100; 75 by default), optionally on a transparent background or without the ground. Waits for the render, then restores the camera and every render setting it changed. Journaled: a large, high-quality render runs for minutes, so follow it with fusion_mech_operation_status. Fusion's API cannot choose the render environment; the document's own is used.",+    "properties": {+     "operationId": {+      "type": "string"+     },+     "filename": {+      "type": "string",+      "description": "Absolute .png or .jpg path on the PC."+     },+     "overwrite": {+      "type": "boolean"+     },+     "view": {+      "type": "string",+      "enum": [+       "iso",+       "iso-left",+       "front",+       "back",+       "top",+       "bottom",+       "left",+       "right",+       "current"+      ]+     },+     "width": {+      "type": "integer",+      "minimum": 64,+      "maximum": 7680+     },+     "height": {+      "type": "integer",+      "minimum": 64,+      "maximum": 4320+     },+     "quality": {+      "type": "integer",+      "minimum": 0,+      "maximum": 100+     },+     "transparent": {+      "type": "boolean"+     },+     "ground": {+      "type": "boolean",+      "description": "Show the ground plane (shadows and reflections); default leaves the document's setting."+     },+     "timeoutSeconds": {+      "type": "number",+      "minimum": 10,+      "maximum": 3600+     }+    },+    "required": [+     "op",+     "sessionId",+     "operationId",+     "target",+     "filename"+    ]    }   },   "transactions": {@@ -7346,6 +7566,7 @@   "exploded_preview_dispose",   "exploded_preview_sample",   "exploded_preview_show",+  "exploded_view_render",   "export",   "feature_create",   "feature_edit",@@ -7370,6 +7591,7 @@   "plane_create",   "regular_joint_create",   "regular_joint_edit",+  "render_image",   "rigid_group_create",   "rigid_group_delete",   "rigid_group_edit",
rust/core/tests/golden/mech_validation.json
@@ -1 +1 @@