← 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, exploded_stills, 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, layout  for _name, _handler in {**sketches.OPERATIONS, **features.OPERATIONS, **assemblies.OPERATIONS,                         **geometry.OPERATIONS, **sketch_edits.OPERATIONS, **regular_joints.OPERATIONS, **motion_links.OPERATIONS}.items():@@ -37,7 +37,8 @@ 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 | exploded_stills.WRITES | render_image.WRITES | cam.WRITES)+OPERATIONS.update(layout.OPERATIONS)+WRITES.update(purchased.WRITES | bill_of_materials.WRITES | efficiency.WRITES | exploded_render.WRITES | exploded_stills.WRITES | render_image.WRITES | cam.WRITES | layout.WRITES) OPERATIONS["transaction_probe"] = transactions.probe WRITES.add("transaction_probe") 
addin/AdomBridge/commands/mechanical/core.py+14
@@ -364,6 +364,20 @@ def target(app, args, write=False, model_write=True):     return doc, design, {"documentId": key, "revision": rev}  +def read_target(app, args):+    """target() for a read that only needs the guard: the target's document must be the active one, and+    a revision, when the target carries one, must be current. The revision digest costs about 5 s on a+    4,579-occurrence assembly, so a read not asked to check one does not compute it, and its target then+    carries no revision."""+    _, design, key = active(app)+    wanted = args.get("target")+    require(isinstance(wanted, dict), "target_required", "Pass target from mechanical inspect.")+    require(wanted.get("documentId") == key, "wrong_document", "Target is not the active document.", activeDocumentId=key)+    if "revision" in wanted:+        return target(app, args)+    return app.activeDocument, design, {"documentId": key}++ def current_target(app):     doc, design, key = active(app)     return {"documentId": key, "revision": revision(design)}
addin/AdomBridge/commands/mechanical/layout.pyadded+198
@@ -0,0 +1,198 @@+"""What an assembly planner reads from the design: the parts of a scope with their boxes, the joints and+rigid groups between them, and batches of rays. Both operations change nothing.++assembly_layout lists the visible occurrences directly under a scope occurrence (or under the root), each+with its box in root space, and the scope component's joints and rigid groups between them. It is+journaled: the first guarded read after the add-in loads registers every entity of a big assembly (about+45 s on a 4,579-occurrence one) and can outlive the relay's 60 s, so the caller follows it with+fusion_mech_operation_status.++ray_cast casts rays, each against the few occurrences named for it, in each occurrence's own component+coordinates (the inverse of its root-space transform): a component holds a handful of bodies where a whole+scope can hold thousands (a ray against a 4,579-occurrence assembly's panel component took about 0.25 s,+against one part's component about a millisecond). It is not journaled: a plan casts thousands of rays,+and the journal keeps every request and result with no eviction (10,000 records, 64 MiB), so journaled ray+batches would fill it within tens of plans. It stops at budgetSeconds instead, and before its reply passes+1 MiB, and says how many rays it answered; the caller sends the rest.+"""+from collections import deque+import json+import time++import adsk.core+import adsk.fusion++from .core import active, current_target, occurrence_at, read_target, require, target++DEFAULT_BODIES = 200          # bodies read per part for its box (a body proxy's box costs about 26 ms on a big assembly)+MAX_UNITS = 4096              # visible parts in one scope+MAX_LAYOUT_BYTES = 2 * 1024 * 1024+MAX_REPLY_BYTES = 1024 * 1024+DEFAULT_BUDGET = 30.0         # seconds of ray casting per call, inside the relay's 60 s+_clock = time.perf_counter+++def part_box(occurrence, cap):+    """The root-space box (mm) of an occurrence's bodies and its children's, read breadth first from at+    most cap bodies. Returns (box or None when it has no bodies, whether the cap stopped the walk)."""+    lo = hi = None+    read, pending, stopped = 0, deque([occurrence]), False+    while pending and read < cap:+        node = pending.popleft()+        bodies = node.bRepBodies+        for i in range(bodies.count):+            if read >= cap:+                stopped = True+                break+            box = bodies.item(i).boundingBox+            read += 1+            low = [box.minPoint.x * 10, box.minPoint.y * 10, box.minPoint.z * 10]+            high = [box.maxPoint.x * 10, box.maxPoint.y * 10, box.maxPoint.z * 10]+            lo = low if lo is None else [min(u, v) for u, v in zip(lo, low)]+            hi = high if hi is None else [max(u, v) for u, v in zip(hi, high)]+        kids = node.childOccurrences+        pending.extend(kids.item(i) for i in range(kids.count))+    return ([lo, hi] if lo else None), stopped or (read >= cap and bool(pending))+++def part_path(occurrence, scope, names):+    """The scope part an occurrence belongs to, "" for the scope component's own geometry. A joint's+    occurrences are in the scope component's context, so their paths start with the part's own name;+    names (every child's, longest first) keep a name that holds "+" whole."""+    if occurrence is None:+        return ""+    path = occurrence.fullPathName+    head = next((n for n in names if path == n or path.startswith(n + "+")), path.split("+")[0])+    return scope + "+" + head if scope else head+++def assembly_layout(app, args):+    # the guard and the reply are inspect's: an optional target, and the current target returned+    if args.get("target"):+        doc, design, value = target(app, args)+    else:+        doc, design, _ = active(app)+        value = current_target(app)+    root = design.rootComponent+    scope = args.get("scope") or ""+    cap = args.get("maxBodies", DEFAULT_BODIES)+    start = None+    if scope:+        start = occurrence_at(design, scope)+        require(start is not None and start.fullPathName == scope, "occurrence_missing",+                "No occurrence at %r: pass the full path of the subassembly (\"Panel:1\", \"Cell:1+Panel:1\")." % scope[:200])+    component = start.component if start else root+    children = start.childOccurrences if start else root.occurrences+    require(children.count <= 20000, "inspection_budget", "The scope has more than 20000 occurrences directly under it.")+    kids = [children.item(i) for i in range(children.count)]+    names = sorted((k.name for k in kids), key=len, reverse=True)+    units, hidden = [], 0+    for occurrence in kids:+        if not occurrence.isLightBulbOn:+            hidden += 1+            continue+        require(len(units) < MAX_UNITS, "layout_budget", "The scope has more than %d visible parts; pass the path of a subassembly as scope." % MAX_UNITS)+        box, capped = part_box(occurrence, cap)+        unit = {"path": occurrence.fullPathName, "name": occurrence.name, "component": occurrence.component.name,+                "grounded": occurrence.isGrounded, "box": box, "children": occurrence.childOccurrences.count}+        if capped:+            unit["boxCapped"] = True+        units.append(unit)+    joints, groups, unreadable = [], [], 0+    for collection, label in ((component.joints, "joint"), (component.asBuiltJoints, "asBuilt")):+        for i in range(collection.count):+            joint = collection.item(i)+            try:+                if joint.isSuppressed:+                    continue+                joints.append([part_path(joint.occurrenceOne, scope, names), part_path(joint.occurrenceTwo, scope, names), label])+            except Exception:+                unreadable += 1  # a joint to geometry Fusion cannot name here; it constrains nothing the plan reads+    for i in range(component.rigidGroups.count):+        group = component.rigidGroups.item(i)+        try:+            members = group.occurrences+            groups.append([part_path(members.item(k), scope, names) for k in range(members.count)])+        except Exception:+            unreadable += 1+    result = {"target": value, "document": doc.name, "scope": scope,+              "scopeName": start.component.name if start else doc.name,+              "units": units, "joints": joints, "rigidGroups": groups, "frameBodies": component.bRepBodies.count,+              "hiddenParts": hidden, "unreadableLinks": unreadable, "maxBodies": cap,+              "unit": "mm", "space": "root", "boxes": "axis-aligned in root space, from each part's bodies and its children's"}+    require(len(json.dumps(result)) <= MAX_LAYOUT_BYTES, "layout_budget", "The layout exceeds its 2 MiB bound; pass the path of a subassembly as scope.")+    return result+++def ray_cast(app, args):+    started = _clock()+    _, design, value = read_target(app, args)+    paths, rays = args.get("occurrences") or [], args.get("rays") or []+    budget = float(args.get("budgetSeconds", DEFAULT_BUDGET))+    for n, ray in enumerate(rays):+        require(all(0 <= i < len(paths) for i in ray["against"]), "invalid_input",+                "rays[%d].against names an index past the %d occurrences." % (n, len(paths)))+        require(any(v != 0 for v in ray["direction"]), "invalid_input", "rays[%d].direction must be nonzero." % n)+    parts, missing = {}, []++    def part(index):+        """The occurrence's component and the inverse of its root-space transform, once per call."""+        if index not in parts:+            occurrence = occurrence_at(design, paths[index])+            if occurrence is None or occurrence.fullPathName != paths[index]:+                parts[index] = None+                missing.append(index)+            else:+                inverse = occurrence.transform2.copy()+                inverse.invert()+                parts[index] = (occurrence.component, inverse)+        return parts[index]++    hits, failed, casts, size, stopped = [], 0, 0, 2, None+    for ray in rays:+        # every call answers at least one ray, so a caller sending the rest always gets further+        if hits and _clock() - started > budget:+            stopped = "budget"+            break+        x, y, z = ray["origin"]+        dx, dy, dz = ray["direction"]+        longest = ray["length"]+        row = []+        for index in ray["against"]:+            found_part = part(index)+            if found_part is None:+                continue+            component, inverse = found_part+            try:+                origin = adsk.core.Point3D.create(x / 10, y / 10, z / 10)+                direction = adsk.core.Vector3D.create(dx, dy, dz)+                origin.transformBy(inverse)+                direction.transformBy(inverse)+                points = adsk.core.ObjectCollection.create()+                # a component's own bodies report invisible (their occurrence is what shows): do not filter on it+                found = component.findBRepUsingRay(origin, direction, adsk.fusion.BRepEntityTypes.BRepBodyEntityType, -1.0, False, points)+                casts += 1+                near = [origin.distanceTo(points.item(k)) * 10 for k in range(found.count)]+                near = [d for d in near if d <= longest]+                if near:+                    row.append([index, round(min(near), 2)])+            except Exception:+                failed += 1+        row.sort(key=lambda hit: hit[1])+        row_bytes = len(json.dumps(row)) + 1+        if hits and size + row_bytes > MAX_REPLY_BYTES:+            stopped = "reply_bytes"+            break+        size += row_bytes+        hits.append(row)+    result = {"target": value, "unit": "mm", "space": "root", "hits": hits, "cast": len(hits), "complete": len(hits) == len(rays),+              "missing": sorted(missing), "failed": failed, "casts": casts, "seconds": round(_clock() - started, 3)}+    if stopped:+        result["stopped"] = stopped+        result["hint"] = ("Answered the first %d of %d rays (%s); send the rest in another call."+                          % (len(hits), len(rays), "budgetSeconds reached" if stopped == "budget" else "the reply reached 1 MiB"))+    return result+++OPERATIONS = {"assembly_layout": assembly_layout, "ray_cast": ray_cast}+WRITES = {"assembly_layout"}  # journaled, model read-only: a big assembly's first layout outlives the relay's 60 s
addin/AdomBridge/commands/mechanical/schema.json+149
@@ -8001,5 +8001,154 @@       "op",       "target"     ]+  },+  "assembly_layout": {+    "type": "object",+    "required": [+      "op",+      "sessionId",+      "operationId"+    ],+    "properties": {+      "sessionId": {+        "type": "string"+      },+      "operationId": {+        "type": "string"+      },+      "target": {+        "type": "object",+        "required": [+          "documentId"+        ],+        "additionalProperties": false,+        "properties": {+          "documentId": {+            "type": "string"+          },+          "revision": {+            "type": "string",+            "description": "Refuse with stale_revision unless the model is still at this revision (costs the revision digest, about 5 s on a 4,579-occurrence assembly)."+          }+        },+        "description": "Optional, as for inspect: the active document must be this one, and the model at revision when one is given. The reply carries the current target either way."+      },+      "scope": {+        "type": "string",+        "description": "Full path of the occurrence whose parts to list (\"Panel:1\", \"Cell:1+Panel:1\"). Empty or omitted: the root component's."+      },+      "maxBodies": {+        "type": "integer",+        "minimum": 1,+        "maximum": 20000,+        "description": "Bodies read per part for its box, breadth first through its children (default 200; a body proxy's box costs about 26 ms on a big assembly). A part whose walk stopped there has boxCapped:true."+      }+    },+    "description": "Read one scope's parts for planning an assembly order, without changing anything: every visible occurrence directly under the scope occurrence (or the root), each with path, name, component name, grounded, the number of its children and its box (axis-aligned, root space, mm, from its bodies and its children's; null for a part with no bodies), plus the scope component's unsuppressed joints and as-built joints ([partOne, partTwo, \"joint\"|\"asBuilt\"], \"\" for the scope's own geometry) and rigid groups as part paths, frameBodies (bodies of the scope component itself), document and scopeName. Journaled like efficiency_report: the first guarded read after the add-in loads registers every entity of a big assembly and can outlive the relay's 60 s, so follow it with fusion_mech_operation_status. Returns the current target; pass it to ray_cast."+  },+  "ray_cast": {+    "type": "object",+    "required": [+      "op",+      "target",+      "unit",+      "rays"+    ],+    "properties": {+      "target": {+        "type": "object",+        "required": [+          "documentId"+        ],+        "additionalProperties": false,+        "properties": {+          "documentId": {+            "type": "string"+          },+          "revision": {+            "type": "string",+            "description": "Refuse with stale_revision unless the model is still at this revision (costs the revision digest, about 5 s on a 4,579-occurrence assembly)."+          }+        },+        "description": "From assembly_layout or inspect. The active document must be this one; with revision the model must still be at it. Without revision no digest is computed and the reply's target carries none."+      },+      "unit": {+        "type": "string",+        "enum": [+          "mm"+        ]+      },+      "occurrences": {+        "type": "array",+        "maxItems": 4096,+        "items": {+          "type": "string"+        },+        "description": "Full root-context paths of the occurrences the rays are cast against; each ray names its own by index."+      },+      "rays": {+        "type": "array",+        "maxItems": 8192,+        "description": "Rays in root space. Each is cast against each occurrence it names, in that occurrence's own component coordinates; hidden bodies count too. An empty list only checks the target.",+        "items": {+          "type": "object",+          "required": [+            "origin",+            "direction",+            "length",+            "against"+          ],+          "additionalProperties": false,+          "properties": {+            "origin": {+              "type": "array",+              "minItems": 3,+              "maxItems": 3,+              "items": {+                "type": "number",+                "minimum": -1000000000.0,+                "maximum": 1000000000.0+              },+              "description": "Start point, mm."+            },+            "direction": {+              "type": "array",+              "minItems": 3,+              "maxItems": 3,+              "items": {+                "type": "number",+                "minimum": -1000000000.0,+                "maximum": 1000000000.0+              },+              "description": "Direction (any nonzero length)."+            },+            "length": {+              "type": "number",+              "exclusiveMinimum": 0,+              "maximum": 1000000000.0,+              "description": "Hits farther than this (mm) are dropped."+            },+            "against": {+              "type": "array",+              "minItems": 1,+              "maxItems": 256,+              "items": {+                "type": "integer",+                "minimum": 0,+                "maximum": 4095+              },+              "description": "Indexes into occurrences."+            }+          }+        }+      },+      "budgetSeconds": {+        "type": "number",+        "minimum": 1,+        "maximum": 50,+        "description": "Stop casting after this many seconds (default 30) so the reply comes back inside the relay's 60 s; the reply then has complete:false and answers the first `cast` rays. Send the rest in another call."+      }+    },+    "description": "Cast batches of rays against named occurrences, without changing anything (assembly planning: what a part seats on, what blocks its way in, whether a tool reaches a screw). Each ray is cast against each occurrence it names, in that occurrence's own component coordinates, about a millisecond on a part, where a ray against a big subassembly's component (thousands of bodies) took a quarter of a second. Returns hits: a row per ray answered, [[occurrence index, distance in mm rounded to 0.01], ...], one entry per occurrence the ray meets within length (its nearest hit), nearest first; a ray that starts inside a body meets that body's exit face. Also cast (rays answered, in order), complete, missing (indexes with no occurrence at that path), failed (casts Fusion refused) and seconds. Not journaled: it stops at budgetSeconds or before its reply passes 1 MiB (stopped: budget|reply_bytes) and the caller sends the remaining rays. Requests over the CLI's argument limit (128 KiB) go in several calls."   } }
contracts/describe.json+299−1
@@ -4173,6 +4173,50 @@       "output": {         "idempotency": "object",         "operations": {+          "assembly_layout": {+            "description": "Read one scope's parts for planning an assembly order, without changing anything: every visible occurrence directly under the scope occurrence (or the root), each with path, name, component name, grounded, the number of its children and its box (axis-aligned, root space, mm, from its bodies and its children's; null for a part with no bodies), plus the scope component's unsuppressed joints and as-built joints ([partOne, partTwo, \"joint\"|\"asBuilt\"], \"\" for the scope's own geometry) and rigid groups as part paths, frameBodies (bodies of the scope component itself), document and scopeName. Journaled like efficiency_report: the first guarded read after the add-in loads registers every entity of a big assembly and can outlive the relay's 60 s, so follow it with fusion_mech_operation_status. Returns the current target; pass it to ray_cast.",+            "properties": {+              "maxBodies": {+                "description": "Bodies read per part for its box, breadth first through its children (default 200; a body proxy's box costs about 26 ms on a big assembly). A part whose walk stopped there has boxCapped:true.",+                "maximum": 20000,+                "minimum": 1,+                "type": "integer"+              },+              "operationId": {+                "type": "string"+              },+              "scope": {+                "description": "Full path of the occurrence whose parts to list (\"Panel:1\", \"Cell:1+Panel:1\"). Empty or omitted: the root component's.",+                "type": "string"+              },+              "sessionId": {+                "type": "string"+              },+              "target": {+                "additionalProperties": false,+                "description": "Optional, as for inspect: the active document must be this one, and the model at revision when one is given. The reply carries the current target either way.",+                "properties": {+                  "documentId": {+                    "type": "string"+                  },+                  "revision": {+                    "description": "Refuse with stale_revision unless the model is still at this revision (costs the revision digest, about 5 s on a 4,579-occurrence assembly).",+                    "type": "string"+                  }+                },+                "required": [+                  "documentId"+                ],+                "type": "object"+              }+            },+            "required": [+              "op",+              "sessionId",+              "operationId"+            ],+            "type": "object"+          },           "assembly_properties": {             "description": "High-accuracy physical properties of whole root assembly (default) or an exact Component/Occurrence/BRepBody. Returns mass kg, volume mm3, area mm2, center of mass mm, mean density kg/mm3 and definition/instance coordinate scope.",             "properties": {@@ -10486,6 +10530,111 @@             ],             "type": "object"           },+          "ray_cast": {+            "description": "Cast batches of rays against named occurrences, without changing anything (assembly planning: what a part seats on, what blocks its way in, whether a tool reaches a screw). Each ray is cast against each occurrence it names, in that occurrence's own component coordinates, about a millisecond on a part, where a ray against a big subassembly's component (thousands of bodies) took a quarter of a second. Returns hits: a row per ray answered, [[occurrence index, distance in mm rounded to 0.01], ...], one entry per occurrence the ray meets within length (its nearest hit), nearest first; a ray that starts inside a body meets that body's exit face. Also cast (rays answered, in order), complete, missing (indexes with no occurrence at that path), failed (casts Fusion refused) and seconds. Not journaled: it stops at budgetSeconds or before its reply passes 1 MiB (stopped: budget|reply_bytes) and the caller sends the remaining rays. Requests over the CLI's argument limit (128 KiB) go in several calls.",+            "properties": {+              "budgetSeconds": {+                "description": "Stop casting after this many seconds (default 30) so the reply comes back inside the relay's 60 s; the reply then has complete:false and answers the first `cast` rays. Send the rest in another call.",+                "maximum": 50,+                "minimum": 1,+                "type": "number"+              },+              "occurrences": {+                "description": "Full root-context paths of the occurrences the rays are cast against; each ray names its own by index.",+                "items": {+                  "type": "string"+                },+                "maxItems": 4096,+                "type": "array"+              },+              "rays": {+                "description": "Rays in root space. Each is cast against each occurrence it names, in that occurrence's own component coordinates; hidden bodies count too. An empty list only checks the target.",+                "items": {+                  "additionalProperties": false,+                  "properties": {+                    "against": {+                      "description": "Indexes into occurrences.",+                      "items": {+                        "maximum": 4095,+                        "minimum": 0,+                        "type": "integer"+                      },+                      "maxItems": 256,+                      "minItems": 1,+                      "type": "array"+                    },+                    "direction": {+                      "description": "Direction (any nonzero length).",+                      "items": {+                        "maximum": 1000000000.0,+                        "minimum": -1000000000.0,+                        "type": "number"+                      },+                      "maxItems": 3,+                      "minItems": 3,+                      "type": "array"+                    },+                    "length": {+                      "description": "Hits farther than this (mm) are dropped.",+                      "exclusiveMinimum": 0,+                      "maximum": 1000000000.0,+                      "type": "number"+                    },+                    "origin": {+                      "description": "Start point, mm.",+                      "items": {+                        "maximum": 1000000000.0,+                        "minimum": -1000000000.0,+                        "type": "number"+                      },+                      "maxItems": 3,+                      "minItems": 3,+                      "type": "array"+                    }+                  },+                  "required": [+                    "origin",+                    "direction",+                    "length",+                    "against"+                  ],+                  "type": "object"+                },+                "maxItems": 8192,+                "type": "array"+              },+              "target": {+                "additionalProperties": false,+                "description": "From assembly_layout or inspect. The active document must be this one; with revision the model must still be at it. Without revision no digest is computed and the reply's target carries none.",+                "properties": {+                  "documentId": {+                    "type": "string"+                  },+                  "revision": {+                    "description": "Refuse with stale_revision unless the model is still at this revision (costs the revision digest, about 5 s on a 4,579-occurrence assembly).",+                    "type": "string"+                  }+                },+                "required": [+                  "documentId"+                ],+                "type": "object"+              },+              "unit": {+                "enum": [+                  "mm"+                ],+                "type": "string"+              }+            },+            "required": [+              "op",+              "target",+              "unit",+              "rays"+            ],+            "type": "object"+          },           "regular_joint_create": {             "description": "Create a geometry-solving regular joint in the root component using two distinct instance-context attachments or joint origins. All seven native motion families; offsets mm, angle and angular limits deg. Read back freshly resolved attachment graph, native parameters and root-space occurrence placement. Root/non-root sides must be unambiguous; both grounded and ancestor/descendant pairs are refused. Inferred joints are unsupported. Existing pending positions must be captured or reverted first. Before applying drive values, checks native joint-transform anchor offset magnitude/axiality and zero-angle axis collinearity. Native joint matrices have explicit provenance; physical angle/flip sense requires independent occurrence-geometry acceptance.",             "properties": {@@ -12213,9 +12362,53 @@         "op": "inspect"       },       "input": {-        "op": "required enum: inspect|inspect_entity|new_design|parameter_set|parameter_create|health|export|transaction_probe|sketch_create|sketch_geometry|sketch_constraint|sketch_dimension|sketch_project|sketch_edit|plane_create|feature_create|feature_edit|entity_delete|parameter_batch|component_create|occurrence_insert|occurrence_update|occurrence_delete|component_metadata|joint_create|joint_edit|joint_delete|interference|bom|bom_export|cad_import|mcmaster_discover|mcmaster_open|material_list|material_assign|rigid_group_create|rigid_group_edit|rigid_group_delete|assembly_properties|geometry_query|measure|sketch_curve_edit|sketch_offset|construction_axis|sketch_diagnostics|joint_origin_create|joint_origin_edit|joint_origin_delete|regular_joint_create|regular_joint_edit|motion_link_create|motion_link_edit|motion_link_delete|configuration_inspect|render_scene_inspect|exploded_view_plan|exploded_preview_create|exploded_preview_sample|exploded_preview_show|exploded_preview_dispose|exploded_preview_inspect|efficiency_report|exploded_view_render|exploded_view_stills|render_image|cam_tools|cam_job|cam_inspect",+        "op": "required enum: inspect|inspect_entity|new_design|parameter_set|parameter_create|health|export|transaction_probe|sketch_create|sketch_geometry|sketch_constraint|sketch_dimension|sketch_project|sketch_edit|plane_create|feature_create|feature_edit|entity_delete|parameter_batch|component_create|occurrence_insert|occurrence_update|occurrence_delete|component_metadata|joint_create|joint_edit|joint_delete|interference|bom|bom_export|cad_import|mcmaster_discover|mcmaster_open|material_list|material_assign|rigid_group_create|rigid_group_edit|rigid_group_delete|assembly_properties|geometry_query|measure|sketch_curve_edit|sketch_offset|construction_axis|sketch_diagnostics|joint_origin_create|joint_origin_edit|joint_origin_delete|regular_joint_create|regular_joint_edit|motion_link_create|motion_link_edit|motion_link_delete|configuration_inspect|render_scene_inspect|exploded_view_plan|exploded_preview_create|exploded_preview_sample|exploded_preview_show|exploded_preview_dispose|exploded_preview_inspect|efficiency_report|exploded_view_render|exploded_view_stills|render_image|cam_tools|cam_job|cam_inspect|assembly_layout|ray_cast",         "operationId": "required unique write ID; reuse unchanged for retry",         "operationSchemas": {+          "assembly_layout": {+            "description": "Read one scope's parts for planning an assembly order, without changing anything: every visible occurrence directly under the scope occurrence (or the root), each with path, name, component name, grounded, the number of its children and its box (axis-aligned, root space, mm, from its bodies and its children's; null for a part with no bodies), plus the scope component's unsuppressed joints and as-built joints ([partOne, partTwo, \"joint\"|\"asBuilt\"], \"\" for the scope's own geometry) and rigid groups as part paths, frameBodies (bodies of the scope component itself), document and scopeName. Journaled like efficiency_report: the first guarded read after the add-in loads registers every entity of a big assembly and can outlive the relay's 60 s, so follow it with fusion_mech_operation_status. Returns the current target; pass it to ray_cast.",+            "properties": {+              "maxBodies": {+                "description": "Bodies read per part for its box, breadth first through its children (default 200; a body proxy's box costs about 26 ms on a big assembly). A part whose walk stopped there has boxCapped:true.",+                "maximum": 20000,+                "minimum": 1,+                "type": "integer"+              },+              "operationId": {+                "type": "string"+              },+              "scope": {+                "description": "Full path of the occurrence whose parts to list (\"Panel:1\", \"Cell:1+Panel:1\"). Empty or omitted: the root component's.",+                "type": "string"+              },+              "sessionId": {+                "type": "string"+              },+              "target": {+                "additionalProperties": false,+                "description": "Optional, as for inspect: the active document must be this one, and the model at revision when one is given. The reply carries the current target either way.",+                "properties": {+                  "documentId": {+                    "type": "string"+                  },+                  "revision": {+                    "description": "Refuse with stale_revision unless the model is still at this revision (costs the revision digest, about 5 s on a 4,579-occurrence assembly).",+                    "type": "string"+                  }+                },+                "required": [+                  "documentId"+                ],+                "type": "object"+              }+            },+            "required": [+              "op",+              "sessionId",+              "operationId"+            ],+            "type": "object"+          },           "assembly_properties": {             "description": "High-accuracy physical properties of whole root assembly (default) or an exact Component/Occurrence/BRepBody. Returns mass kg, volume mm3, area mm2, center of mass mm, mean density kg/mm3 and definition/instance coordinate scope.",             "properties": {@@ -18529,6 +18722,111 @@             ],             "type": "object"           },+          "ray_cast": {+            "description": "Cast batches of rays against named occurrences, without changing anything (assembly planning: what a part seats on, what blocks its way in, whether a tool reaches a screw). Each ray is cast against each occurrence it names, in that occurrence's own component coordinates, about a millisecond on a part, where a ray against a big subassembly's component (thousands of bodies) took a quarter of a second. Returns hits: a row per ray answered, [[occurrence index, distance in mm rounded to 0.01], ...], one entry per occurrence the ray meets within length (its nearest hit), nearest first; a ray that starts inside a body meets that body's exit face. Also cast (rays answered, in order), complete, missing (indexes with no occurrence at that path), failed (casts Fusion refused) and seconds. Not journaled: it stops at budgetSeconds or before its reply passes 1 MiB (stopped: budget|reply_bytes) and the caller sends the remaining rays. Requests over the CLI's argument limit (128 KiB) go in several calls.",+            "properties": {+              "budgetSeconds": {+                "description": "Stop casting after this many seconds (default 30) so the reply comes back inside the relay's 60 s; the reply then has complete:false and answers the first `cast` rays. Send the rest in another call.",+                "maximum": 50,+                "minimum": 1,+                "type": "number"+              },+              "occurrences": {+                "description": "Full root-context paths of the occurrences the rays are cast against; each ray names its own by index.",+                "items": {+                  "type": "string"+                },+                "maxItems": 4096,+                "type": "array"+              },+              "rays": {+                "description": "Rays in root space. Each is cast against each occurrence it names, in that occurrence's own component coordinates; hidden bodies count too. An empty list only checks the target.",+                "items": {+                  "additionalProperties": false,+                  "properties": {+                    "against": {+                      "description": "Indexes into occurrences.",+                      "items": {+                        "maximum": 4095,+                        "minimum": 0,+                        "type": "integer"+                      },+                      "maxItems": 256,+                      "minItems": 1,+                      "type": "array"+                    },+                    "direction": {+                      "description": "Direction (any nonzero length).",+                      "items": {+                        "maximum": 1000000000.0,+                        "minimum": -1000000000.0,+                        "type": "number"+                      },+                      "maxItems": 3,+                      "minItems": 3,+                      "type": "array"+                    },+                    "length": {+                      "description": "Hits farther than this (mm) are dropped.",+                      "exclusiveMinimum": 0,+                      "maximum": 1000000000.0,+                      "type": "number"+                    },+                    "origin": {+                      "description": "Start point, mm.",+                      "items": {+                        "maximum": 1000000000.0,+                        "minimum": -1000000000.0,+                        "type": "number"+                      },+                      "maxItems": 3,+                      "minItems": 3,+                      "type": "array"+                    }+                  },+                  "required": [+                    "origin",+                    "direction",+                    "length",+                    "against"+                  ],+                  "type": "object"+                },+                "maxItems": 8192,+                "type": "array"+              },+              "target": {+                "additionalProperties": false,+                "description": "From assembly_layout or inspect. The active document must be this one; with revision the model must still be at it. Without revision no digest is computed and the reply's target carries none.",+                "properties": {+                  "documentId": {+                    "type": "string"+                  },+                  "revision": {+                    "description": "Refuse with stale_revision unless the model is still at this revision (costs the revision digest, about 5 s on a 4,579-occurrence assembly).",+                    "type": "string"+                  }+                },+                "required": [+                  "documentId"+                ],+                "type": "object"+              },+              "unit": {+                "enum": [+                  "mm"+                ],+                "type": "string"+              }+            },+            "required": [+              "op",+              "target",+              "unit",+              "rays"+            ],+            "type": "object"+          },           "regular_joint_create": {             "description": "Create a geometry-solving regular joint in the root component using two distinct instance-context attachments or joint origins. All seven native motion families; offsets mm, angle and angular limits deg. Read back freshly resolved attachment graph, native parameters and root-space occurrence placement. Root/non-root sides must be unambiguous; both grounded and ancestor/descendant pairs are refused. Inferred joints are unsupported. Existing pending positions must be captured or reverted first. Before applying drive values, checks native joint-transform anchor offset magnitude/axiality and zero-angle axis collinearity. Native joint matrices have explicit provenance; physical angle/flip sense requires independent occurrence-geometry acceptance.",             "properties": {
docs/MECHANICAL.md+14
@@ -126,6 +126,20 @@ A [finite exploded-preview controller candidate](EXPLODED-PREVIEWS.md) now exist  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. Like the animation, the images leave out joint, sketch and construction glyphs unless `glyphs:true` (see [Clean captures](#clean-captures)). 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. +## Plan an assembly order: layout and rays++Two reads give an assembly planner what it needs from the design. Neither changes anything.++`assembly_layout` lists one scope's parts: every visible occurrence directly under `scope`, a full occurrence path such as `Cell:1+Panel:1` (the root's when `scope` is omitted). Each part has its `path`, `name`, `component` name, `grounded`, the number of its `children` and its `box`: axis-aligned in root space, in mm, from the part's bodies and its children's, `null` for a part without bodies. The box is read breadth first from at most `maxBodies` bodies (default 200; a body's box costs about 26 ms on a 4,579-occurrence assembly), and `boxCapped` marks a part whose walk stopped there. The reply also lists the scope component's unsuppressed joints and as-built joints as `[partOne, partTwo, "joint" | "asBuilt"]` and its rigid groups, both as part paths (`""` is the scope's own geometry), with `frameBodies`, the bodies of the scope component itself. It is journaled like `efficiency_report` (`sessionId`, `operationId`): on a big assembly the first guarded read after the add-in loads registers every entity, about 45 s, so follow it with `fusion_mech_operation_status` once the relay's 60 s pass. `target` is optional, as for `inspect`, and the reply carries the current target.++`ray_cast` casts rays against named occurrences. `occurrences` lists full occurrence paths; each ray has an `origin` and a `direction` in root space (mm), a `length`, and `against`, indexes into `occurrences`. Each ray is cast against each occurrence it names in that occurrence's own component coordinates (the inverse of its root-space transform), hidden bodies included: about a millisecond on a part, where a ray against the component of a big subassembly (thousands of bodies, in a 4,579-occurrence assembly) took a quarter of a second. `hits` has a row per ray answered: `[index, distance]` for each occurrence the ray meets within `length` (its nearest hit), nearest first, distances in mm rounded to 0.01. A ray that starts inside a body meets that body's exit face. `missing` lists the indexes with no occurrence at their path, and `failed` counts the casts Fusion refused.++`ray_cast` is not journaled. A plan casts thousands of rays, and the journal keeps every request and result and never evicts (10,000 records, 64 MiB), so journaled batches would fill it within tens of plans. Instead a call stops at `budgetSeconds` (default 30, at most 50) or before its reply passes 1 MiB, answers the first `cast` rays and says `complete: false` and why (`stopped`); send the rest in the next call. Every call answers at least one ray. The `adom-bridge` CLI passes the arguments on its command line, which Linux caps at 128 KiB, so a big batch goes in several calls of a few hundred rays.++The guard is the usual one: the active document must be the target's, and with a `revision` the model must still be at it (`stale_revision` otherwise). Without a revision `ray_cast` computes no revision digest (about 5 s a call on a 4,579-occurrence assembly) and its reply's target carries none. A planner reads the layout, which returns the full target, casts with `{documentId}` alone, and checks the revision once at the end with a `ray_cast` that has the full target and no rays.++Measured through the relay: a `ray_cast` call answers in about 0.85 s round trip on a small design. A planner read a 15-part and an 11-part testbed (850 and 760 distinct rays) in five calls each; its whole plan took 7.1 to 7.8 s, where the same reads as modeling scripts took 11.3 to 11.7 s, with identical results.+ ## 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 25 to 100 (75 by default; Fusion refuses less than 25), `transparent` and `ground`. Fusion's renderer never draws joint, sketch or construction glyphs, but fitting a standard view counts them, so they are hidden while the view is fitted and back before the render starts (`glyphsHiddenForFit` in the reply; see [Clean captures](#clean-captures)). It is journaled, since a large high-quality render runs for minutes. A 1280 x 720 iso render at quality 60 took 28 s.
rust/bridge/src/mech.rs+50
@@ -325,6 +325,56 @@ mod tests {         assert_eq!(s["state"], "succeeded", "{s}");     } +    #[test]+    fn a_ray_batch_is_validated_and_answered_without_a_journal_record() {+        let st = scratch("rays");+        session(&st, "S1", 7);+        let ray = json!({"origin": [30, 30, 100], "direction": [0, 0, -1], "length": 200, "against": [0]});+        let batch = |rays: Value| json!({"op": "ray_cast", "target": {"documentId": "d"}, "unit": "mm", "occurrences": ["Plate:1"], "rays": rays});+        let sent = RefCell::new(Value::Null);+        let r = mechanical(&st, &[7], &batch(json!([ray])), &|cmd, a| {+            assert_eq!(cmd, "mechanical");+            *sent.borrow_mut() = a.clone();+            json!({"success": true, "operation": "ray_cast", "data": {"hits": [[[0, 90.0]]], "cast": 1, "complete": true}})+        });+        assert_eq!(r["data"]["hits"], json!([[[0, 90.0]]]), "{r}");+        assert!(sent.borrow().get("_dispatch").is_none(), "a read carries no dispatch");+        assert!(!st.join("operations-v1.sqlite3").exists(), "a ray batch leaves no journal record");+        // a malformed ray, or one too many, is refused here and never reaches Fusion+        let mut too_many = Vec::new();+        too_many.resize(8193, ray.clone());+        for bad in [json!([{"origin": [0, 0], "direction": [0, 0, 1], "length": 1, "against": [0]}]),+                    json!([{"origin": [0, 0, 0], "direction": [0, 0, 1], "length": 1, "against": ["0"]}]),+                    json!([{"origin": [0, 0, 0], "direction": [0, 0, 1], "length": 0, "against": [0]}]),+                    json!([{"origin": [0, 0, 0], "direction": [0, 0, 1], "against": [0]}]),+                    json!([{"origin": [0, 0, 0], "direction": [0, 0, 1], "length": 1, "against": [0], "weight": 2}]),+                    json!(too_many)] {+            let r = mechanical(&st, &[7], &batch(bad), &|_, _| panic!("refused before the add-in"));+            assert_eq!(r["errorCode"], "invalid_input", "{r}");+        }+    }++    #[test]+    fn an_assembly_layout_is_journaled_like_the_other_long_reads() {+        let st = scratch("layout");+        session(&st, "S1", 7);+        let args = json!({"op": "assembly_layout", "sessionId": "S1", "operationId": "l1", "scope": "Panel:1"});+        let r = mechanical(&st, &[7], &args, &|_, a| {+            let id = a["_dispatch"]["dispatchId"].as_str().unwrap().to_string();+            std::fs::create_dir_all(dispatch_dir(&st)).unwrap();+            std::fs::write(claim_path(&st, &id), json!({"state": "started", "observedDocument": {"documentId": "d"}}).to_string()).unwrap();+            let result = json!({"success": true, "sessionId": "S1", "operation": "assembly_layout", "operationId": "l1",+                                "data": {"target": {"documentId": "d", "revision": "r"}, "units": [], "joints": [], "rigidGroups": []}});+            json!({"success": true, "dispatchId": id, "nativeExecutionStarted": true, "observedDocument": {"documentId": "d"}, "result": result})+        });+        assert_eq!(r["durability"]["terminalRecorded"], true, "{r}");+        assert_eq!(r["data"]["target"]["revision"], "r");+        assert_eq!(status(&st, &[7], &json!({"sessionId": "S1", "operationId": "l1"}))["state"], "succeeded");+        // journaled, so it needs the live session and an operation ID like any write+        let r = mechanical(&st, &[7], &json!({"op": "assembly_layout", "sessionId": "S0", "operationId": "l2"}), &|_, _| panic!("never dispatched"));+        assert_eq!(r["errorCode"], "session_mismatch");+    }+     #[test]     fn writes_need_the_live_session_and_reads_do_not() {         let st = scratch("session");
rust/core/tests/golden/mech_capabilities.json+150
@@ -8016,6 +8016,155 @@      "op",      "target"     ]+   },+   "assembly_layout": {+    "type": "object",+    "required": [+     "op",+     "sessionId",+     "operationId"+    ],+    "properties": {+     "sessionId": {+      "type": "string"+     },+     "operationId": {+      "type": "string"+     },+     "target": {+      "type": "object",+      "required": [+       "documentId"+      ],+      "additionalProperties": false,+      "properties": {+       "documentId": {+        "type": "string"+       },+       "revision": {+        "type": "string",+        "description": "Refuse with stale_revision unless the model is still at this revision (costs the revision digest, about 5 s on a 4,579-occurrence assembly)."+       }+      },+      "description": "Optional, as for inspect: the active document must be this one, and the model at revision when one is given. The reply carries the current target either way."+     },+     "scope": {+      "type": "string",+      "description": "Full path of the occurrence whose parts to list (\"Panel:1\", \"Cell:1+Panel:1\"). Empty or omitted: the root component's."+     },+     "maxBodies": {+      "type": "integer",+      "minimum": 1,+      "maximum": 20000,+      "description": "Bodies read per part for its box, breadth first through its children (default 200; a body proxy's box costs about 26 ms on a big assembly). A part whose walk stopped there has boxCapped:true."+     }+    },+    "description": "Read one scope's parts for planning an assembly order, without changing anything: every visible occurrence directly under the scope occurrence (or the root), each with path, name, component name, grounded, the number of its children and its box (axis-aligned, root space, mm, from its bodies and its children's; null for a part with no bodies), plus the scope component's unsuppressed joints and as-built joints ([partOne, partTwo, \"joint\"|\"asBuilt\"], \"\" for the scope's own geometry) and rigid groups as part paths, frameBodies (bodies of the scope component itself), document and scopeName. Journaled like efficiency_report: the first guarded read after the add-in loads registers every entity of a big assembly and can outlive the relay's 60 s, so follow it with fusion_mech_operation_status. Returns the current target; pass it to ray_cast."+   },+   "ray_cast": {+    "type": "object",+    "required": [+     "op",+     "target",+     "unit",+     "rays"+    ],+    "properties": {+     "target": {+      "type": "object",+      "required": [+       "documentId"+      ],+      "additionalProperties": false,+      "properties": {+       "documentId": {+        "type": "string"+       },+       "revision": {+        "type": "string",+        "description": "Refuse with stale_revision unless the model is still at this revision (costs the revision digest, about 5 s on a 4,579-occurrence assembly)."+       }+      },+      "description": "From assembly_layout or inspect. The active document must be this one; with revision the model must still be at it. Without revision no digest is computed and the reply's target carries none."+     },+     "unit": {+      "type": "string",+      "enum": [+       "mm"+      ]+     },+     "occurrences": {+      "type": "array",+      "maxItems": 4096,+      "items": {+       "type": "string"+      },+      "description": "Full root-context paths of the occurrences the rays are cast against; each ray names its own by index."+     },+     "rays": {+      "type": "array",+      "maxItems": 8192,+      "description": "Rays in root space. Each is cast against each occurrence it names, in that occurrence's own component coordinates; hidden bodies count too. An empty list only checks the target.",+      "items": {+       "type": "object",+       "required": [+        "origin",+        "direction",+        "length",+        "against"+       ],+       "additionalProperties": false,+       "properties": {+        "origin": {+         "type": "array",+         "minItems": 3,+         "maxItems": 3,+         "items": {+          "type": "number",+          "minimum": -1000000000.0,+          "maximum": 1000000000.0+         },+         "description": "Start point, mm."+        },+        "direction": {+         "type": "array",+         "minItems": 3,+         "maxItems": 3,+         "items": {+          "type": "number",+          "minimum": -1000000000.0,+          "maximum": 1000000000.0+         },+         "description": "Direction (any nonzero length)."+        },+        "length": {+         "type": "number",+         "exclusiveMinimum": 0,+         "maximum": 1000000000.0,+         "description": "Hits farther than this (mm) are dropped."+        },+        "against": {+         "type": "array",+         "minItems": 1,+         "maxItems": 256,+         "items": {+          "type": "integer",+          "minimum": 0,+          "maximum": 4095+         },+         "description": "Indexes into occurrences."+        }+       }+      }+     },+     "budgetSeconds": {+      "type": "number",+      "minimum": 1,+      "maximum": 50,+      "description": "Stop casting after this many seconds (default 30) so the reply comes back inside the relay's 60 s; the reply then has complete:false and answers the first `cast` rays. Send the rest in another call."+     }+    },+    "description": "Cast batches of rays against named occurrences, without changing anything (assembly planning: what a part seats on, what blocks its way in, whether a tool reaches a screw). Each ray is cast against each occurrence it names, in that occurrence's own component coordinates, about a millisecond on a part, where a ray against a big subassembly's component (thousands of bodies) took a quarter of a second. Returns hits: a row per ray answered, [[occurrence index, distance in mm rounded to 0.01], ...], one entry per occurrence the ray meets within length (its nearest hit), nearest first; a ray that starts inside a body meets that body's exit face. Also cast (rays answered, in order), complete, missing (indexes with no occurrence at that path), failed (casts Fusion refused) and seconds. Not journaled: it stops at budgetSeconds or before its reply passes 1 MiB (stopped: budget|reply_bytes) and the caller sends the remaining rays. Requests over the CLI's argument limit (128 KiB) go in several calls."    }   },   "transactions": {@@ -8041,6 +8190,7 @@   }  },  "writes": [+  "assembly_layout",   "bom_export",   "cad_import",   "cam_job",
rust/core/tests/golden/mech_validation.json
@@ -1 +1 @@