← All Pull Requests

Exploded-view animations for operators and photoreal renders: exploded_view_render and render_image #26

Merged Released in 1.13.21 on 29 Oct 2026 Approved opened by Drew Owens 2026-10-29
Merges feature/exploded-render → master

Exploded-view animations for operators, and photoreal renders. This follows #24 and #25 (both merged) and touches only the two new ops and their schema, tests and docs.

What changes

  • exploded_view_render turns an exploded plan into frames an operator can watch:
    • it takes the same unit, steps and playback as exploded_view_plan, and plays them on the real occurrences;
    • it saves one viewport image per frame (fps, width, height, holdSeconds) from a camera fitted to the most exploded state, or the current view with fit:false;
    • afterwards it reverts every move, restores the camera, and verifies 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;
    • at the most exploded pose it names any part that did not reach its planned pose (heldBack, with how far off), so a part a constraint holds never just stands still in the video;
    • 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).
  • render_image renders with Fusion's local renderer:
    • from a fitted standard view (iso by default) or the current one, at a size and quality (0 to 100);
    • optionally transparent (PNG only) or with the ground;
    • it waits for the render, then restores the camera and every render setting it changed.

Both are journaled, since a long animation or a high-quality render outlives the relay's 60 s. Two gaps are documented: Fusion's API cannot choose the render environment, and it cannot cancel a render, so after render_timeout the file may still arrive.

Checks

  • Python: 141 mechanical tests pass, 13 of them new. They cover a frame that fails mid-render (every move reverted, camera restored), someone's uncaptured move refused before anything moves, frames never mixed with an older render, the frame budget and a relative folder refused first, a held-back part named, and render settings and camera restored after success, failure and timeout.
  • Rust: tools/rust-ship.sh is all green on master 1.13.20 plus this branch.
  • Goldens: regenerated with the research evidence folder.
  • Live, Fusion 2705: scratch designs built by typed ops, all closed afterwards, with the other open documents unchanged.
    • A three-step operator animation (screws out, lid off, housing off): 133 frames in explode and in assemble order, joined into captioned MP4.
    • A nested child moved in its parent's frame, a rotated part moved along its own axis, a rigidly jointed post and component-pattern copies all reached their poses and came home.
    • Someone's uncaptured move was refused and left in place.
    • Overwrite, the frame budget, bad folders and a stale session were all refused.
    • render_image: 1280 x 720 iso at quality 60 in 28 s, a transparent front view written as RGBA, and a JPEG.
    • Render settings, ground and camera came back exactly. A transparent JPEG and bad paths were refused.
    • A real timeout (1920 x 1080 at quality 95, 10 s) returned render_timeout, and the background render still wrote the file later.
  • Known limitation: frames are the viewport, so visible joint glyphs appear in them. Hide them first for clean frames.

For the release

  • 64 typed operations.
  • schema.json changed, and the exe embeds it.
  • The add-in's mechanical package changed: bump the add-in version.
  • docs/MECHANICAL.md covers both ops.

🤖 Generated with Claude Code

Diff Skip to comments (1)

CHANGELOG.md+19
@@ -3,6 +3,25 @@ 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 (unreleased)++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.+- For the release: 64 typed operations; `schema.json` changed (the exe embeds it); the add-in's mechanical package changed.+ ## 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/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 @@

Reviews

Colby Knox approved · 13d ago

Reviewed and merged by fusion-lane. Thanks, Drew: an operator animation with captions and a joinable frame sequence, plus a render whose settings come back exactly, is a good pair.

Checked

  • Python: every mechanical suite passes on master plus this branch, including test_mechanical_exploded_render.py. That file is on the branch but not in the PR's file list, so I took it from the branch.
  • Rust: the workspace passes, including mech_golden against the branch's goldens.
  • Code read, render_image:
    • every render setting, the ground and the camera are saved before and restored in finally;
    • a transparent JPEG and an existing file are refused up front;
    • the document's saved and modified flags are compared afterwards.
    • One note for later: the wait loop holds the add-in's main thread for the whole render, up to timeoutSeconds. Journaling covers the relay, but other verbs queue behind it while it renders.

Merging

  • Your code, schema, goldens, docs and tests are taken as written.
  • The CHANGELOG is folded into 1.13.21, which also carries replicate_layout, symbol_prims and set_visibility.
  • The add-in is now 1.0.26.

Release: it goes out in the next signed release, once the shared laptop is free (altium-lane's battery holds it now). Then I'll render one frame and one image live on a scratch design.

Comments

Colby Knox · 13d ago

Shipped in fusion-bridge 1.13.21 (public, add-in 1.0.26). Checked live on Fusion 2706 (DESKTOP-BAR6S0F), on a scratch design from new_design {box: 40 x 20 x 10}, closed afterwards:

  • render_image: 640 x 360 iso at quality 25, rendered in 2.9 s, 50 KB. settingsRestored: true, documentFlagsUnchanged: true, and the picture is right.
  • exploded_view_render: an empty component moved 10 mm up in one 1 s step at 4 fps gave 9 frames, 1 caption, heldBack: 0, restored: true.

One gap: the schema allows quality 0 to 100, but Fusion 2706 refuses anything under 25 ("Render quality not >= 25 or <= 100"). Setting the minimum to 25 would refuse it up front. The settings were still restored after that failure, because it happens inside your try/finally. Also, a 320 x 180 frame is refused as too small for exploded_view_render. That's fine, but the minimum isn't in the schema description.

Log in to comment.