← Commit history
addin/AdomBridge/commands/mechanical/__init__.py+3−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, exploded_render, render_image, cam+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, exploded_stills, render_image, cam  for _name, _handler in {**sketches.OPERATIONS, **features.OPERATIONS, **assemblies.OPERATIONS,                         **geometry.OPERATIONS, **sketch_edits.OPERATIONS, **regular_joints.OPERATIONS, **motion_links.OPERATIONS}.items():@@ -34,9 +34,10 @@ OPERATIONS.update(purchased.OPERATIONS) OPERATIONS.update(bill_of_materials.OPERATIONS) OPERATIONS.update(efficiency.OPERATIONS) OPERATIONS.update(exploded_render.OPERATIONS)+OPERATIONS.update(exploded_stills.OPERATIONS) OPERATIONS.update(render_image.OPERATIONS) OPERATIONS.update(cam.OPERATIONS)-WRITES.update(purchased.WRITES | bill_of_materials.WRITES | efficiency.WRITES | exploded_render.WRITES | render_image.WRITES | cam.WRITES)+WRITES.update(purchased.WRITES | bill_of_materials.WRITES | efficiency.WRITES | exploded_render.WRITES | exploded_stills.WRITES | render_image.WRITES | cam.WRITES) OPERATIONS["transaction_probe"] = transactions.probe WRITES.add("transaction_probe") 
addin/AdomBridge/commands/mechanical/exploded_render.py+53−8
@@ -9,10 +9,12 @@ import threading import adsk.core import adsk.fusion -from .core import SESSION_ID, concrete, digest, identity, ref, require, resolve, target+from .core import SESSION_ID, concrete, current_target, digest, identity, ref, require, resolve, target from .exploded_view import MAX_INSTANCES, compile_steps, matrix, request_arguments, sample_plan, text  MAX_FRAMES = 3600+MOVED_NOTE = ("Fusion marks a document modified when a part moves, even after the move is reverted; the parts are back where "+              "they were (restoredWithinMm), so there is nothing to save.") # 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 @@ -27,7 +29,7 @@ def to_matrix(pose):     return matrix  -def moved_scope(design, value, steps):+def moved_scope(design, value, steps, limit=MAX_INSTANCES):     """Full paths of the occurrences the steps move, and of their ancestors (for inherited motion).     Children of a moved occurrence follow it by themselves, so they are not tracked."""     scope = set()@@ -36,7 +38,7 @@ def moved_scope(design, value, steps):             path = text(resolve(design, value, move["occurrence"], ("Occurrence",)).fullPathName)             parts = path.split("+")             scope.update("+".join(parts[:n]) for n in range(1, len(parts) + 1))-    require(len(scope) <= MAX_INSTANCES, "plan_budget", "The steps move more than %d occurrences (with their parents)." % MAX_INSTANCES)+    require(len(scope) <= limit, "plan_budget", "The steps move more than %d occurrences (with their parents)." % limit)     return scope  @@ -67,6 +69,43 @@ def capture(design, value, scope):     return {"instances": rows, "rootBodyGroup": {}}, handles  +class Mover:+    """Puts the tracked instances at the poses asked for, moving only those whose pose changes: a+    transform2 set costs about 44 ms on a 4,579-occurrence assembly, and most frames move only one+    step's parts. Plans translate from home, so an instance's pose is its home plus an offset; Fusion+    carries tracked children along when their parent moves, and their applied offsets follow."""+    def __init__(self, snapshot, handles):+        self.rows, self.handles, self.moves = snapshot["instances"], handles, 0+        self.home = {row["instanceId"]: row["homeMatrix"] for row in self.rows}+        self.applied = {key: [0.0, 0.0, 0.0] for key in self.home}+        self.below = {key: [] for key in self.home}+        for row in self.rows:+            parent = row.get("parentInstanceId")+            while parent is not None:+                self.below[parent].append(row["instanceId"])+                parent = next(r.get("parentInstanceId") for r in self.rows if r["instanceId"] == parent)++    def offset(self, key, pose):+        home = self.home[key]+        return [pose[3] - home[3], pose[7] - home[7], pose[11] - home[11]]++    def apply(self, poses):+        """poses: instanceId -> row-major matrix in mm. Parents are set before their children."""+        for row in self.rows:+            key = row["instanceId"]+            if key not in poses:+                continue+            want = self.offset(key, poses[key])+            delta = [w - a for w, a in zip(want, self.applied[key])]+            if max(abs(d) for d in delta) <= 1e-9:+                continue+            self.handles[key].transform2 = to_matrix(poses[key])+            self.moves += 1+            self.applied[key] = want+            for child in self.below[key]:+                self.applied[child] = [a + d for a, d in zip(self.applied[child], delta)]++ 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."""@@ -140,10 +179,11 @@ def exploded_view_render(app, args):     viewport = app.activeViewport     original_camera = viewport.camera +    mover = Mover(snapshot, handles)+     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"])+        mover.apply({pose["instanceId"]: pose["matrix"] for pose in poses})         return poses      written = 0@@ -173,11 +213,16 @@ def exploded_view_render(app, args):     drift = home_drift(snapshot, after)     require(drift is not None and drift <= POSE_TOLERANCE_MM, "restore_failed",             "Instances did not return to their home poses after rendering (off by %s mm)." % (round(drift, 4) if drift is not None else "a missing instance"))-    require((document.isSaved, document.isModified) == flags and not snapshots.hasPendingSnapshot, "restore_failed",+    # isModified is not compared: any move sets it and nothing in the API clears it (measured on a saved design)+    require(document.isSaved == flags[0] and not snapshots.hasPendingSnapshot, "restore_failed",             "Document state changed during rendering.")-    return {"target": value, "folder": str(folder), "frames": written, "fps": fps, "size": [width, height],+    modified = bool(document.isModified and not flags[1])+    # the revision digest reads exact transforms and revision ids, which moving and reverting can change:+    # hand back the target as it is now, so the caller's next write is not refused as stale+    return {"target": current_target(app), "sourceTarget": value, "folder": str(folder), "frames": written, "fps": fps, "size": [width, height],+            "documentModified": modified, **({"documentModifiedNote": MOVED_NOTE} if modified else {}),             "durationSeconds": round(len(times) / fps, 3), "playback": playback, "captions": captions(steps, total, playback),-            "pattern": "frame_%05d.png", "restored": True, "restoredWithinMm": round(drift, 6), "heldBack": held,+            "pattern": "frame_%05d.png", "restored": True, "restoredWithinMm": round(drift, 6), "heldBack": held, "moves": mover.moves,             "encode": "ffmpeg -framerate %d -i frame_%%05d.png -c:v libx264 -pix_fmt yuv420p -crf 18 exploded.mp4" % fps}  
addin/AdomBridge/commands/mechanical/exploded_stills.pyadded+333
@@ -0,0 +1,333 @@+"""Step images for assembly instructions. Steps come in assembly order, and each move says where its+part comes from (the way it moves out when exploded). For every step the view is saved twice from one+fixed orthographic camera: with that step's parts hovering where they go, earlier steps seated and+later steps out of view ("before"), then with them seated ("after"). One more image shows the view+before anything is assembled, so "after" of one step is the context of the next and the difference+between two images is exactly the parts that came in. Each moved part's box and centre in both images+come back normalized to the image, for callouts and arrows. Only parts whose pose changes are moved+between images, and every move is reverted at the end."""+import math+from pathlib import Path+import threading++import adsk.core++from .core import SESSION_ID, current_target, require, target+from .exploded_render import MOVED_NOTE, POSE_TOLERANCE_MM, Mover, capture, home_drift, moved_scope+from .exploded_view import compile_steps, request_arguments, sample_plan++MAX_STEPS = 64+MAX_MOVES = 2048+MAX_INSTANCES = 4096+# bodies read per moved part for its box (a subassembly's own bodies and its children's)+MAX_BOX_BODIES = 200+# later steps' parts are parked this many view extents to the right, well out of frame+PARK_EXTENTS = 4.0+# room around the moved parts when the camera frames them+MARGIN = 1.12+# at most this many occurrences outside the subassembly are moved out of view+MAX_CONTEXT = 1000+++def unit(v):+    n = math.sqrt(sum(c * c for c in v))+    require(n > 1e-12, "camera_invalid", "The camera's eye, target and up do not define a view.")+    return [c / n for c in v]+++def cross(a, b):+    return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]+++class View:+    """Fusion frames an orthographic camera so that the image's shorter side spans viewExtents, centred+    on the target (measured at 1280x720, 960x960 and 720x1280: every point within a pixel). Units mm."""+    def __init__(self, camera, width, height):+        self.eye = [camera.eye.x * 10, camera.eye.y * 10, camera.eye.z * 10]+        self.target = [camera.target.x * 10, camera.target.y * 10, camera.target.z * 10]+        self.up = [camera.upVector.x, camera.upVector.y, camera.upVector.z]+        self.extents = camera.viewExtents * 10+        forward = unit([t - e for t, e in zip(self.target, self.eye)])+        self.right = unit(cross(forward, self.up))+        self.true_up = cross(self.right, forward)+        self.width, self.height = width, height+        self.scale = min(width, height) / self.extents++    def point(self, p):+        d = [a - b for a, b in zip(p, self.target)]+        x = self.width / 2 + sum(a * b for a, b in zip(d, self.right)) * self.scale+        y = self.height / 2 - sum(a * b for a, b in zip(d, self.true_up)) * self.scale+        return [round(x / self.width, 5), round(y / self.height, 5)]++    def box(self, lo, hi):+        corners = [self.point([x, y, z]) for x in (lo[0], hi[0]) for y in (lo[1], hi[1]) for z in (lo[2], hi[2])]+        return [min(c[0] for c in corners), min(c[1] for c in corners), max(c[0] for c in corners), max(c[1] for c in corners)]++    def describe(self):+        return {"eye": self.eye, "target": self.target, "up": self.up, "extentsMm": self.extents, "orthographic": True,+                "rule": "x = width/2 + dot(p - target, right) * min(width, height) / extentsMm; y likewise along up, downwards"}+++def axis_of(v):+    """The world axis nearest a direction, as a unit vector."""+    i = max(range(3), key=lambda k: abs(v[k]))+    return [(1.0 if v[i] > 0 else -1.0) if k == i else 0.0 for k in range(3)]+++def choose_direction(current, approaches):+    """The isometric view (looking down from one of the four upper corners) that shows the parts coming+    in best: each step's approach direction, weighted by its moves, should cross the screen rather than+    point along the line of sight, and come from the camera's side rather than from behind the parts.+    Ties keep the direction nearest the current view."""+    up = axis_of(current["up"])+    flat = [k for k in range(3) if up[k] == 0.0]+    best = None+    for a in (1.0, -1.0):+        for b in (1.0, -1.0):+            f = [0.0, 0.0, 0.0]+            f[flat[0]], f[flat[1]] = a, b+            f = unit([fi - ui for fi, ui in zip(f, up)])+            score = 0.0+            for d, n in approaches:+                along = sum(x * y for x, y in zip(d, f))+                across = math.sqrt(max(0.0, 1.0 - along * along))+                score += n * across * (1.0 if along <= 0.2 else 0.3)+            near = sum(x * y for x, y in zip(f, current["forward"]))+            key = (round(score, 6), near)+            if best is None or key > best[0]:+                best = (key, f)+    return best[1], up+++def frame(forward, up, corners, width, height):+    """Target, true up and extents (mm) that fit every corner, the image's shorter side spanning the+    extents (how Fusion frames an orthographic camera)."""+    right = unit(cross(forward, up))+    true_up = cross(right, forward)+    xs = [sum(a * b for a, b in zip(c, right)) for c in corners]+    ys = [sum(a * b for a, b in zip(c, true_up)) for c in corners]+    span_x, span_y = max(xs) - min(xs), max(ys) - min(ys)+    short, long_ = (span_y, span_x) if width >= height else (span_x, span_y)+    extents = MARGIN * max(short, long_ * min(width, height) / max(width, height), 1.0)+    lo = [min(c[k] for c in corners) for k in range(3)]+    hi = [max(c[k] for c in corners) for k in range(3)]+    middle = [(a + b) / 2 for a, b in zip(lo, hi)]+    # centre on what the camera sees: the middle of the projected extents, not of the 3D box+    dx = (max(xs) + min(xs)) / 2 - sum(a * b for a, b in zip(middle, right))+    dy = (max(ys) + min(ys)) / 2 - sum(a * b for a, b in zip(middle, true_up))+    center = [m + dx * r + dy * u for m, r, u in zip(middle, right, true_up)]+    return center, true_up, extents, math.dist(lo, hi)+++def context_paths(design, moved):+    """When every moved part sits in one subassembly, the occurrences outside it (the siblings along+    its path, still visible); empty when the parts sit at the root."""+    split = [p.split("+") for p in moved]+    common = []+    for names in zip(*split):+        if any(n != names[0] for n in names):+            break+        common.append(names[0])+    if not common or any(len(s) <= len(common) for s in split):+        return []+    out, level = [], design.rootComponent.occurrences+    for depth, name in enumerate(common):+        keep = None+        for i in range(level.count):+            occ = level.item(i)+            if occ.name == name:+                keep = occ+            elif occ.isLightBulbOn:+                out.append(occ.fullPathName)+        if keep is None:+            return []+        level = keep.childOccurrences+    return out if len(out) <= MAX_CONTEXT else []+++def world_box(occurrence):+    """The occurrence's box in world mm from its body proxies (a referenced design's own box reads 0),+    falling back to its children's bodies; None when it holds no bodies."""+    lo, hi, read = None, None, 0+    pending = [occurrence]+    while pending and read < MAX_BOX_BODIES:+        occ = pending.pop(0)+        bodies = occ.bRepBodies+        for i in range(bodies.count):+            if read >= MAX_BOX_BODIES:+                break+            box = bodies.item(i).boundingBox+            read += 1+            a = [box.minPoint.x * 10, box.minPoint.y * 10, box.minPoint.z * 10]+            b = [box.maxPoint.x * 10, box.maxPoint.y * 10, box.maxPoint.z * 10]+            lo = a if lo is None else [min(u, v) for u, v in zip(lo, a)]+            hi = b if hi is None else [max(u, v) for u, v in zip(hi, b)]+        children = occ.childOccurrences+        pending.extend(children.item(i) for i in range(children.count))+    return (lo, hi) if lo is not None else None+++def exploded_view_stills(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("step_*.png")), "destination_exists",+            "folder already holds step images; pass overwrite:true to replace them.")+    for old in folder.glob("step_*.png"):+        old.unlink()+    width, height = int(args.get("width", 1280)), int(args.get("height", 720))+    # assembly order in, explode order inside: the plan explodes the last step first+    listed = [{"durationSeconds": 1.0, **{k: v for k, v in step.items() if k in ("title", "instruction", "moves")}} for step in args["steps"]]+    explode = list(reversed(listed))+    total, _, _ = request_arguments({"unit": args.get("unit"), "steps": explode, "playback": "assemble", "sampleTimes": []}, MAX_STEPS, MAX_MOVES)++    document, design, value = target(app, args)+    snapshots = design.snapshots+    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)+    context = args.get("context", "auto")+    require(context in ("auto", "keep"), "invalid_input", "context must be auto or keep.")+    scope = moved_scope(design, value, explode, MAX_INSTANCES)+    snapshot, handles = capture(design, value, scope)+    compiled = compile_steps(design, value, snapshot, handles, explode)+    count = len(listed)+    # which assembly step each moved instance belongs to (1-based), from its own moves+    owner = {}+    for step in compiled:+        for move in step["moves"]:+            owner.setdefault(move["instanceId"], count - step["index"])+    # what lies outside the moved parts' subassembly is moved out of view with the later steps' parts+    hidden_paths = set()+    if context == "auto":+        moved_paths = [row["path"] for row in snapshot["instances"] if row["instanceId"] in owner]+        hidden_paths = set(context_paths(design, moved_paths)) - scope+        if hidden_paths:+            extra, extra_handles = capture(design, value, hidden_paths)+            snapshot = dict(snapshot, instances=snapshot["instances"] + extra["instances"])+            handles = dict(handles, **extra_handles)+    rows = snapshot["instances"]+    parent = {row["instanceId"]: row.get("parentInstanceId") for row in rows}+    paths = {row["instanceId"]: row["path"] for row in rows}+    hidden = {row["instanceId"] for row in rows if row["path"] in hidden_paths}+    names = {row["instanceId"]: row.get("componentName") for row in rows}+    viewport = app.activeViewport+    original_camera = viewport.camera+    mover = Mover(snapshot, handles)+    boxes = {key: world_box(handles[key]) for key in owner}  # read at home, before anything moves+    mode = args.get("camera", "auto")+    require(mode in ("auto", "fit", "current"), "invalid_input", "camera must be auto, fit or current.")++    def state(k, seated):+        """Poses with steps before k seated, step k hovering (or seated) and later steps parked."""+        when = float(k) if seated else float(k - 1)+        poses = {p["instanceId"]: list(p["matrix"]) for p in sample_plan(snapshot, compiled, [when], total, "assemble")[0]["poses"]}+        shift, parked = {}, {}+        for row in rows:  # parents first, so a parked parent carries its tracked children+            key = row["instanceId"]+            own = park if owner.get(key, 0) > k or key in hidden else [0.0, 0.0, 0.0]+            up = parked.get(parent[key], [0.0, 0.0, 0.0])+            parked[key] = [a + b for a, b in zip(own, up)]+            for axis, index in enumerate((3, 7, 11)):+                poses[key][index] += parked[key][axis]+            shift[key] = [poses[key][3] - row["homeMatrix"][3], poses[key][7] - row["homeMatrix"][7], poses[key][11] - row["homeMatrix"][11]]+        return poses, shift++    images, steps_out, written = {}, [], 0+    try:+        camera = viewport.camera+        camera.isSmoothTransition = False+        camera.cameraType = adsk.core.CameraTypes.OrthographicCameraType+        park = [0.0, 0.0, 0.0]+        if mode != "current":+            # frame the moved parts, seated and exploded, then hold that camera for every image+            exploded = {p["instanceId"]: p["translationFromHome"] for p in sample_plan(snapshot, compiled, [0.0], total, "assemble")[0]["poses"]}+            corners = []+            for key, box in boxes.items():+                if box is None:+                    continue+                for shift in ([0.0, 0.0, 0.0], exploded[key]):+                    corners.extend([x + shift[0], y + shift[1], z + shift[2]]+                                   for x in (box[0][0], box[1][0]) for y in (box[0][1], box[1][1]) for z in (box[0][2], box[1][2]))+            require(corners, "plan_identity_unavailable", "None of the moved parts has a body to frame.")+            eye, target_point = camera.eye, camera.target+            forward = unit([(target_point.x - eye.x), (target_point.y - eye.y), (target_point.z - eye.z)])+            current = {"forward": forward, "up": [camera.upVector.x, camera.upVector.y, camera.upVector.z]}+            if mode == "auto":+                approaches = [(unit(move["worldTranslation"]), 1) for step in compiled for move in step["moves"]]+                forward, up = choose_direction(current, approaches)+            else:+                up = current["up"]+            center, true_up, extents, diagonal = frame(forward, up, corners, width, height)+            back = diagonal + 2000.0  # mm behind the target, clear of everything framed+            camera.target = adsk.core.Point3D.create(*[c / 10 for c in center])+            camera.eye = adsk.core.Point3D.create(*[(c - f * back) / 10 for c, f in zip(center, forward)])+            camera.upVector = adsk.core.Vector3D.create(*true_up)+            camera.viewExtents = extents / 10+        viewport.camera = camera+        camera = viewport.camera+        camera.isSmoothTransition = False+        view = View(camera, width, height)+        park = [c * view.extents * PARK_EXTENTS for c in view.right]++        def shoot(name):+            nonlocal written+            viewport.camera = camera+            viewport.refresh()+            adsk.doEvents()+            path = folder / name+            require(viewport.saveAsImageFile(str(path), width, height), "render_failed", "Fusion could not save " + name + ".")+            written += 1+            return name++        poses, _ = state(0, True)+        mover.apply(poses)+        images["context"] = shoot("step_000_after.png")+        for k in range(1, count + 1):+            before_poses, before_shift = state(k, False)+            mover.apply(before_poses)+            before = shoot("step_%03d_before.png" % k)+            after_poses, after_shift = state(k, True)+            mover.apply(after_poses)+            after = shoot("step_%03d_after.png" % k)+            instances = []+            for key in sorted((key for key, step in owner.items() if step == k), key=lambda key: paths[key]):+                row = {"path": paths[key], "componentName": names[key]}+                box = boxes.get(key)+                for label, shift in (("before", before_shift), ("after", after_shift)):+                    if box is None:+                        row[label] = None+                        continue+                    lo = [a + s for a, s in zip(box[0], shift[key])]+                    hi = [a + s for a, s in zip(box[1], shift[key])]+                    row[label] = {"point": view.point([(a + b) / 2 for a, b in zip(lo, hi)]), "box": view.box(lo, hi)}+                instances.append(row)+            step_in = listed[k - 1]+            steps_out.append({"order": k, "title": step_in.get("title", "Step %d" % k), "instruction": step_in.get("instruction", ""),+                              "before": before, "after": after, "context": images["context"] if k == 1 else steps_out[-1]["after"],+                              "instances": instances})+    finally:+        if snapshots.hasPendingSnapshot:+            snapshots.revertPendingSnapshot()+        original_camera.isSmoothTransition = False+        viewport.camera = original_camera+        viewport.refresh()+    after_capture, _ = capture(design, value, scope | hidden_paths)+    drift = home_drift(snapshot, after_capture)+    require(drift is not None and drift <= POSE_TOLERANCE_MM, "restore_failed",+            "Instances did not return to their home poses after rendering (off by %s mm)." % (round(drift, 4) if drift is not None else "a missing instance"))+    # isModified is not compared: any move sets it and nothing in the API clears it (measured on a saved design)+    require(document.isSaved == flags[0] and not snapshots.hasPendingSnapshot, "restore_failed",+            "Document state changed during rendering.")+    modified = bool(document.isModified and not flags[1])+    # the target as it is now (moving and reverting can change the revision digest), and the one the steps were bound to+    return {"target": current_target(app), "sourceTarget": value, "folder": str(folder), "size": [width, height], "camera": view.describe(), "images": written,+            "documentModified": modified, **({"documentModifiedNote": MOVED_NOTE} if modified else {}),+            "steps": steps_out, "moves": mover.moves, "hiddenContext": len(hidden), "restored": True, "restoredWithinMm": round(drift, 6),+            "coordinates": "normalized to the image: x right, y down, 0..1"}+++OPERATIONS = {"exploded_view_stills": exploded_view_stills}+WRITES = {"exploded_view_stills"}
addin/AdomBridge/commands/mechanical/exploded_view.py+4−4
@@ -95,12 +95,12 @@ def capture(design, value):     return {'instances':rows,'rootBodyGroup':root_group}, handles  -def request_arguments(args):+def request_arguments(args, max_steps=MAX_STEPS, max_moves=MAX_MOVES):     require(args.get('unit')=='mm', 'invalid_input', 'Exploded planning distances require explicit mm.')     playback=args.get('playback','explode')     require(playback in ('explode','assemble'), 'invalid_input', 'playback must be explode or assemble.')     steps=args.get('steps')-    require(isinstance(steps,list) and 1<=len(steps)<=MAX_STEPS,'invalid_input','Supply 1–32 ordered steps.')+    require(isinstance(steps,list) and 1<=len(steps)<=max_steps,'invalid_input','Supply 1–%d ordered steps.'%max_steps)     total=0.;count=0     for step in steps:         require(isinstance(step,dict) and set(step)<= {'durationSeconds','moves','title','instruction'} and {'durationSeconds','moves'}<=set(step),@@ -114,7 +114,7 @@ def request_arguments(args):         require(math.isfinite(end) and end>total,                 'invalid_input', 'Step duration is too small to represent after preceding steps.')         total=end-        moves=step['moves'];require(isinstance(moves,list) and 1<=len(moves)<=MAX_MOVES,'invalid_input','Each step requires 1–128 moves.')+        moves=step['moves'];require(isinstance(moves,list) and 1<=len(moves)<=max_moves,'invalid_input','Each step requires 1–%d moves.'%max_moves)         count+=len(moves)         for move in moves:             require(isinstance(move,dict) and set(move)=={'occurrence','direction','distance','frame'}, 'invalid_input','Move fields must be occurrence, direction, distance and frame.')@@ -125,7 +125,7 @@ def request_arguments(args):             require(math.hypot(*direction)>1e-12,'invalid_input','Direction must be nonzero.')             require(type(distance) in (int,float) and math.isfinite(distance) and 0<distance<=1e6,                     'invalid_input','Distance must be positive and at most 1e6 mm; use direction for sign.')-    require(count<=MAX_MOVES and total<=3600,'plan_budget','Plan exceeds 128 moves or 3600 seconds.')+    require(count<=max_moves and total<=3600,'plan_budget','Plan exceeds %d moves or 3600 seconds.'%max_moves)     times=args.get('sampleTimes',[])     require(isinstance(times,list) and len(times)<=MAX_SAMPLES-2 and all(type(v) in (int,float) and math.isfinite(v) and 0<=v<=total for v in times),             'invalid_input','Supply at most 62 finite sample times within the plan duration.')
addin/AdomBridge/commands/mechanical/schema.json+145
@@ -7472,6 +7472,151 @@     },     "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)."   },+  "exploded_view_stills": {+    "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": 64,+        "description": "Steps in ASSEMBLY order. Each move gives the direction and distance its part comes from (the way it moves out when exploded).",+        "items": {+          "type": "object",+          "required": [+            "moves"+          ],+          "additionalProperties": false,+          "properties": {+            "title": {+              "type": "string"+            },+            "instruction": {+              "type": "string"+            },+            "moves": {+              "type": "array",+              "minItems": 1,+              "maxItems": 2048,+              "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"+                    ]+                  }+                }+              }+            }+          }+        }+      },+      "operationId": {+        "type": "string"+      },+      "folder": {+        "type": "string",+        "description": "Absolute folder on the PC for step_000_after.png (nothing assembled) and step_NNN_before.png / step_NNN_after.png per step (created if missing)."+      },+      "overwrite": {+        "type": "boolean",+        "description": "Replace step images already in folder (default false)."+      },+      "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)."+      },+      "camera": {+        "type": "string",+        "enum": [+          "auto",+          "fit",+          "current"+        ],+        "description": "auto (default): the isometric view that best shows the parts coming in, framing the moved parts seated and exploded; fit: the current view direction, framing them; current: the view as it is. Always orthographic."+      },+      "context": {+        "type": "string",+        "enum": [+          "auto",+          "keep"+        ],+        "description": "auto (default): when every moved part sits in one subassembly, everything outside it is moved out of view in every image (and back afterwards); keep: show the whole design."+      }+    },+    "description": "Step images for assembly instructions, from one fixed orthographic camera: per step, in assembly order, the step's parts hovering where they go with earlier steps seated and later steps out of view (before), then seated (after); step_000_after.png shows nothing assembled, so consecutive images differ by exactly the parts that came in. Returns each moved part's box and centre in both images, normalized to the image, and the camera. Moves only the parts whose pose changes, reverts every move, restores the camera and verifies each instance is back home. Up to 64 steps and 2048 moves. Journaled; follow a long run with fusion_mech_operation_status."+  },   "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.",
docs/MECHANICAL.md+20−1
@@ -57,7 +57,26 @@ A [finite exploded-preview controller candidate](EXPLODED-PREVIEWS.md) now exist  ## 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.+`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, moving only the parts whose pose changes from one frame to the next (`moves` in the reply; each move costs about 44 ms on a 4,579-occurrence assembly). 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 that the document's saved flag is unchanged. Fusion marks a document modified whenever a part moves, even after the move is reverted, and its API cannot clear that flag (measured on a saved design), so the reply's `documentModified` says when the render set it; the parts are back where they were and there is nothing to save. 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 step images for assembly instructions++`exploded_view_stills` makes the pictures for step-by-step assembly instructions. Its `steps` come in **assembly order**, and each move gives the direction and distance its part comes from (the way it moves out when exploded); `title` and `instruction` are optional. For every step it saves two images from one fixed orthographic camera:++- `step_NNN_before.png`: that step's parts hovering where they go, earlier steps seated and later steps' parts moved well out of frame;+- `step_NNN_after.png`: the same with the step's parts seated.++`step_000_after.png` shows the view before anything is assembled, so each image's predecessor is its context: the difference between `step_003_before.png` and `step_002_after.png` is exactly the parts step 3 brings in, which is how a viewer highlights them. The reply lists, per step, every moved part's `point` (its centre) and `box` in the before and after images, normalized to the image (x right, y down, 0 to 1), for callouts and insertion arrows, plus the `camera` (eye, target, up, `extentsMm`) and the rule that projects a point.++`camera` chooses the view:++- `auto` (the default): of the four isometric views from above, the one that best shows the parts coming in: each step's approach direction should cross the screen rather than point along the line of sight, and come from the camera's side. It frames the moved parts, seated and exploded, so a subassembly inside a big assembly fills the picture.+- `fit`: the current view direction, framing the moved parts the same way.+- `current`: the view as it is, made orthographic.++`context` chooses what else shows. `auto` (the default): when every moved part sits in one subassembly, the occurrences outside it that are visible move out of view in every image, so a panel inside a big assembly appears on its own; parts at the root hide nothing, so a base part always stays. `keep` shows the whole design.++Fusion frames an orthographic image so that its shorter side spans the camera's `viewExtents`, centred on the target (measured at 1280 x 720, 960 x 960 and 720 x 1280, every point within a pixel); the projections in the reply follow that rule. It moves only the parts whose pose changes between images, about four moves per moved part in all. It takes up to 64 steps and 2048 moves (an animation takes 32 and 128) and tracks up to 4,096 moved parts with their parents. Afterwards it reverts every move, restores the camera, verifies every part is home and reports `documentModified` like the animation. It is journaled. Measured on a 4,579-occurrence assembly: an electronics panel of 4 steps and 92 parts gave 9 images in 40 s with 368 moves.  ## Render a picture 
rust/core/tests/golden/mech_capabilities.json+146
@@ -7487,6 +7487,151 @@     },     "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)."    },+   "exploded_view_stills": {+    "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": 64,+      "description": "Steps in ASSEMBLY order. Each move gives the direction and distance its part comes from (the way it moves out when exploded).",+      "items": {+       "type": "object",+       "required": [+        "moves"+       ],+       "additionalProperties": false,+       "properties": {+        "title": {+         "type": "string"+        },+        "instruction": {+         "type": "string"+        },+        "moves": {+         "type": "array",+         "minItems": 1,+         "maxItems": 2048,+         "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"+            ]+           }+          }+         }+        }+       }+      }+     },+     "operationId": {+      "type": "string"+     },+     "folder": {+      "type": "string",+      "description": "Absolute folder on the PC for step_000_after.png (nothing assembled) and step_NNN_before.png / step_NNN_after.png per step (created if missing)."+     },+     "overwrite": {+      "type": "boolean",+      "description": "Replace step images already in folder (default false)."+     },+     "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)."+     },+     "camera": {+      "type": "string",+      "enum": [+       "auto",+       "fit",+       "current"+      ],+      "description": "auto (default): the isometric view that best shows the parts coming in, framing the moved parts seated and exploded; fit: the current view direction, framing them; current: the view as it is. Always orthographic."+     },+     "context": {+      "type": "string",+      "enum": [+       "auto",+       "keep"+      ],+      "description": "auto (default): when every moved part sits in one subassembly, everything outside it is moved out of view in every image (and back afterwards); keep: show the whole design."+     }+    },+    "description": "Step images for assembly instructions, from one fixed orthographic camera: per step, in assembly order, the step's parts hovering where they go with earlier steps seated and later steps out of view (before), then seated (after); step_000_after.png shows nothing assembled, so consecutive images differ by exactly the parts that came in. Returns each moved part's box and centre in both images, normalized to the image, and the camera. Moves only the parts whose pose changes, reverts every move, restores the camera and verifies each instance is back home. Up to 64 steps and 2048 moves. Journaled; follow a long run with fusion_mech_operation_status."+   },    "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.",@@ -7726,6 +7871,7 @@   "exploded_preview_sample",   "exploded_preview_show",   "exploded_view_render",+  "exploded_view_stills",   "export",   "feature_create",   "feature_edit",
rust/core/tests/golden/mech_validation.json
@@ -1 +1 @@