Agent skill for mechanisms: joints by coordinates, loops, links, motion (#1315)

A solidcraft-mechanisms agent skill with a worked example that a test replays through the MCP server (the actuator follows the hinge with the stated stroke and the drive stops at the rod's limit); the skills index and docs/mcp.md list it.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-15 02:13:48 -04:00
1 parent 6ef0b22118
commit 81d9b7e04a
5 files changed
+291 -2

No files matched your search

+3 -1
View File
@@ -9,6 +9,7 @@ description that tell the agent when to load it, and an optional supporting refe
| [`solidcraft-modeling`](solidcraft-modeling/SKILL.md) | builds a new part (enclosure, bracket, plate, flange, …) |
| [`solidcraft-editing`](solidcraft-editing/SKILL.md) | changes, fixes or extends an existing design |
| [`solidcraft-checking`](solidcraft-checking/SKILL.md) | builds, changes or ports a design, next to the two above: parameter ranges, every allowed value, exact STEP comparison, regression tests, designs kept in git |
| [`solidcraft-mechanisms`](solidcraft-mechanisms/SKILL.md) | builds, drives or animates an assembly: components, joints, motion links, closed loops such as actuators, contact sets, motion studies |
They teach the agent to:
- plan the part as numbers
@@ -18,7 +19,7 @@ They teach the agent to:
They also list the traps that otherwise cost failed calls. Examples are the sketch axes of the XZ plane, edge picks that snap to the nearest edge, and cuts that miss but still report success.
They are written for SolidCraft 0.8 and later.
They are written for SolidCraft 0.8 and later; `solidcraft-mechanisms` for `main` after 0.11.1 (the next release), which counts the degrees of freedom of closed loops.
## Install
@@ -44,6 +45,7 @@ Copy-Item -Recurse solidcraft\skills\solidcraft-* $HOME\.claude\skills\
- The worked example in `solidcraft-modeling/reference.md` runs in CI. `skill_reference_example_builds_as_documented` in `crates/mcp/src/tests.rs` builds it, checks the documented volume, changes a parameter and checks it again.
- The sketch-plane table is covered by `sketch_plane_axes_match_the_skill_reference` in the same file.
- The worked example in `solidcraft-mechanisms/reference.md` runs in CI too: `mechanisms_skill_example_moves_as_documented` builds it, drives the hinge and checks the actuator stroke, the linked panel and the stop against their analytic values.
- `skills_name_only_real_tools_and_commands` checks that every tool and command the skills call exists.
- `solidcraft-checking/scripts/tests` checks that skill's helpers, its traps and conventions, and the facts behind the corrections in `solidcraft-modeling`: `PYTHONDONTWRITEBYTECODE=1 uv run --no-project --with pytest --with build123d pytest -p no:cacheprovider skills/solidcraft-checking/scripts/tests`. The tests run the `solidcraft-cli` on `PATH`, not the workspace build, and CI does not run them.
- When a command, a parameter name or a behaviour that these files describe changes, update them in the same PR.
+79
View File
@@ -0,0 +1,79 @@
---
name: solidcraft-mechanisms
description: Use when building, driving or animating an assembly or mechanism in SolidCraft through its MCP server - components, grounding, joints and joint origins, rigid groups, motion links, closed loops such as an actuator on a hinged panel or a linkage, contact sets, motion studies, degrees of freedom - or when a drive moves nothing, stops early or a part jumps when it is joined. To model the parts themselves, use solidcraft-modeling.
---
# SolidCraft mechanisms
## Overview
An assembly is components placed by joints. Work the mechanism out as world points and axes first: every pin, slide and hinge, which part each one joins, and the value you expect at each pose. Then give every joint origin by those coordinates, and check each step against a number you computed yourself. A drive that reports success can still have moved nothing. Command shapes, a worked example, the traps and reply sizes are in `reference.md` next to this file. To build the parts themselves, use `solidcraft-modeling`.
Written against SolidCraft `main` after 0.11.1, which counts the degrees of freedom of closed loops and carries motion links through chains made in any order. The traps in `reference.md` say which answers are about to change.
## Before the first call
1. Read `reference.md`.
2. **Open:** if the design is not open yet, run `open {path}`. When the server is bridged to the app and the current design is `dirty`, ask before opening anything.
3. **Map it:** run `inspect_design {"measure":false,"sketches":false}`, then `execute joint.list`. `joint.list` shows `fixed` (the grounded occurrences, always with the root, 0), `dof` per occurrence, `conflicts`, the joints, links and named origins, at about 0.5 KB per joint. `execute motion.list` lists the motion studies.
4. **Existing design:** take `edit.checkpoint` in its own call before you move bodies into components. `edit.restore_checkpoint {checkpoint}` goes back exactly.
5. **Write the plan:**
- the components, and which one is grounded
- each joint: type, the two occurrences, its world point and axis, limits
- each motion link and its ratio
- for each loop, the relation you will check, such as actuator stroke against hinge angle, and where it should stop
- the pose to return to before saving
## Build, in stages
1. **Components and grounding.**
- Bodies made in the root go into components with `component.create {"name":..., "parent":"root", "activate":false}`, then `component.move_bodies {"bodies":[...], "component":...}`.
- A part that moves in two pieces, such as an actuator's barrel and rod, becomes two components.
- Ground the base: `occurrence.ground {"occurrence":"Frame:1", "grounded":true}`. Occurrences are named `<component>:<n>`. Joint commands also take a component name (its first occurrence); `occurrence.ground` does not.
2. **Open-chain joints in one batch.** Read `occurrence.properties` of each moving occurrence before the batch and after it. If something moved, split the batch to find the joint.
3. **Each joint that closes a loop, in a call of its own.** Then check `conflicts` in its reply.
4. **Motion links.**
5. **Contact sets and the motion study.**
## Joints
- **Origins by world coordinates:** `{"occurrence":..., "point":[x,y,z], "z":[ax,ay,az]}`. Always give `occurrence`; without it the nearest body is used. Use the same form on both sides; `frame`, `face`, `circle` and named origins are in `reference.md`.
- **Pass `flip: true`** when both origins are the same world point and axis, which is the case when you join parts where they sit. Without it, b's z mates against a's and part b turns 180 degrees.
- **Parts already in place:** `joint.as_built {"type":..., "a":..., "b":..., "at":{origin}, "name":..., "limits":[[min,max]]}` keeps them where they are.
- **Values and limits** are degrees for angles and mm for distances. An angle turns right-handed about the joint's z. A drive past a joint's own limits is clamped to them, which is not a failure. A new joint starts at 0, so its limits must include 0, or the part turns to the nearer limit as it is joined. On a joint with several values, drive with `values`: `value` resets all but the first to 0.
- **Every chain must reach something grounded.** A part that is neither grounded nor joined to a grounded part is never placed. A drive on a joint in that chain answers with the new value and no conflicts, and nothing moves. `dof: 6` on an occurrence in `joint.list` is the sign. Fix it with `joint.rigid_group {"occurrences":["Frame:1", ...]}`, or with a joint to the grounded structure.
## Linked and closed motion
- **Motion links:** `joint.motion_link {"a":..., "b":..., "ratio":r, "offset":o}` sets b = r·a + o, clamped to b's own limits. `offset` is in radians for an angle (mm for a distance), unlike every other joint value. A mirrored part takes `ratio: -1`, and limits mirrored too. Chains (a drives b, b drives c) work in any order of creation. Drive only the head of a chain.
- **Closed loops:** an actuator between a frame and a hinged panel takes three joints besides the hinge:
- a revolute root pin (frame and barrel)
- a slider (barrel and rod), with z along the actuator from root to tip on both origins, both at the root pin, and the stroke as its limits
- a revolute tip pin (rod and panel), which closes the loop
A four-bar or a slider-crank follows the same pattern. Drive the hinge; the pins and the slider follow.
- **Stops:** at the end of the stroke the drive stops, with `stopped_by_loop: {joint, fraction}` naming the joint that could not follow. A loop that cannot close at all is listed under `conflicts`, and nothing in it moves.
- **dof** counts the loop: here the panel, barrel and rod report 1 each. At a pose where the actuator lies along its tip pin's path, a part can report 0 and still move; drive a little and list again.
## Contact and motion studies
- **Contact:** `contact.create {"occurrences":[...], "name":...}` and `contact.enable_sets {}`. A drive then stops where two bodies of one set would pass through each other, with `stopped_by_contact: {a, b, fraction}`. `contact.check {}` lists the pairs that overlap now.
- **Motion study:** `motion.study {"name":..., "steps":N, "keys":[{"joint":..., "points":[[step, value], ...]}]}`. Values are degrees or mm, linear between points. Key only the driving joints; linked joints and loops follow.
- **Play:** `motion.play {"study":..., "step":s}` sets the joints there, which changes the design, and answers every occurrence's pose (about 0.2 KB per occurrence). `motion.export {"study":..., "samples":n}` gives the poses at every step without changing the design.
## Verify
- **Each joint:** `execute occurrence.properties {"occurrence":"Wing:1"}` (under 1 KB) gives the world bbox and centre of mass of one occurrence. Joining must not move them.
- **Each drive:** compute the expected value of every follower, and read it back from `joint.list` (degrees and mm, with `unit`). For an actuator from root R to tip T on a panel hinged at H: `L(θ) = |T(θ) - R|`, where `T(θ)` is T turned by θ about the hinge axis through H, and the stroke is `L(θ) - L(0)`. A closed loop matches to 1e-6 mm.
- **Each stop:** solve for where the limit is reached, and compare with the value the drive stopped at. Stops are found by bisection, so they land within about 0.01 degrees.
- **Links:** b must equal r·a + o exactly, unless that is outside b's limits; then b sits at the limit and the mirror stops following.
- **The whole motion:** `motion.play` at a few steps, or one `motion.export`, and compare the poses with the values you planned.
- **Look:** when the server is bridged, `screenshot` after `motion.play` shows the app window at that step.
## Save
- Drive the joints back to the pose you planned to keep (`motion.play` at step 0, or `joint.drive`), then `save {path}`.
- An `.urdf` export leaves out each joint that closes a loop, because URDF is a tree.
## Red flags
| Thought | Do instead |
|---|---|
| "The origins are at the same point, so `flip` doesn't matter" | Pass `flip: true`, or the part turns 180 degrees. |
| "The drive answered values, so it moved" | Look for `dof: 6` in `joint.list`; a floating chain answers and moves nothing. |
| "`joint.drive` says 0.349, the drive failed" | Its `values` are radians; `joint.list` gives degrees. (Once its replies carry `unit`, they are degrees.) |
| "Key every joint of the loop in the study" | Key the driving joint only; the loop follows. |
| "Check the pose with `component.list`" | `occurrence.properties` on the one occurrence. `component.list` is 140 KB on a 36-part assembly. |
| "Open the design with `file.open`" | The `open` tool, or `doc.open`; there is no `file.open`. |
+153
View File
@@ -0,0 +1,153 @@
# SolidCraft mechanism reference for agents
These are the shapes of the commands an assembly needs, so an agent rarely has to call `list_commands`. All of them run through the `execute` tool or a `batch`. The worked example below runs in CI (`mechanisms_skill_example_moves_as_documented` in `crates/mcp/src/tests.rs`).
- Joint values, limits and study keys are in degrees for angles and mm for distances. Expressions work too: `"30 deg"`, `"stroke/2"`.
- Points and axes are world coordinates, as the model sits when you make the joint.
## Joint origins
Each of `a` and `b` in `joint.create` is one of these:
| Form | Shape | Notes |
|---|---|---|
| Point | `{"occurrence":"Wing","point":[0,30,0],"z":[1,0,0]}` | The usual form. `z` is the joint axis (default +Z). |
| Frame | `{"occurrence":"Wing","frame":{"origin":[0,30,0],"z":[1,0,0],"x":[0,1,0]}}` | `x` fixes where angle 0 is. |
| Face | `{"occurrence":"Wing","face":[0,20,3]}` | The centre of the planar face through that point, axis along its normal. It follows the face when the part changes. |
| Circle | `{"occurrence":"Actuator rod","circle":[0,32,0]}` | The centre of the round edge through that point, axis along the cylinder. Pick a point on the rim. |
| Named | `"Hinge on frame"` | Made first with `joint.origin {"name":..., "occurrence":..., "point":[...], "z":[...]}`. |
- `occurrence` is an occurrence name (`Wing:1`), an id, or a component name (its first occurrence). Without it, the body nearest the point is used, which can be the wrong part where two touch.
- **Use the same form on both sides.** `point` picks its x axis from z. A `frame` with an `x` on one side and a `point` on the other turned a test panel 90 degrees when it was joined.
- **`flip`:** `false` (the default) mates the origins as two faces meet: b's z points against a's. Two origins at one point with the same `z` then turn part b 180 degrees about the axis, and a joint that closes a loop reports a conflict ("off by 71 mm and 180 degrees" on SELENE). `true` lines b's z up with a's, so parts stay where they are. `joint.edit {"joint":..., "flip":true}` changes it afterwards.
## Joint types
| `type` | Values, in order | Units |
|---|---|---|
| `rigid` | none | |
| `revolute` | angle | deg |
| `slider` | distance along z | mm |
| `cylindrical` | angle, distance | deg, mm |
| `pin_slot` | angle, slide | deg, mm |
| `planar` | x, y, angle | mm, mm, deg |
| `ball` | yaw, pitch, roll | deg |
A slider's value grows as b moves along +z of its origin. Give its two origins the same point, so 0 is the position as modelled.
## Commands
```json
{"command":"component.create","params":{"name":"Actuator rod","parent":"root","activate":false}}
{"command":"component.move_bodies","params":{"bodies":["Rod"],"component":"Actuator rod"}}
{"command":"occurrence.ground","params":{"occurrence":"Frame:1","grounded":true}}
{"command":"joint.create","params":{"type":"slider","name":"Stroke","flip":true,"limits":[[-5,10]],"a":{"occurrence":"Actuator barrel","point":[0,0,-40],"z":[0,0.6,0.8]},"b":{"occurrence":"Actuator rod","point":[0,0,-40],"z":[0,0.6,0.8]}}}
{"command":"joint.as_built","params":{"type":"revolute","a":"Frame","b":"Wing 2","at":{"occurrence":"Frame","point":[0,0,0],"z":[1,0,0]},"name":"Hinge 2","limits":[[-60,0]]}}
{"command":"joint.rigid_group","params":{"occurrences":["Frame:1","Lamp:1"]}}
{"command":"joint.motion_link","params":{"a":"Hinge","b":"Hinge 2","ratio":-1}}
{"command":"joint.drive","params":{"joint":"Hinge","value":20}}
{"command":"joint.drive","params":{"joint":"Spindle","values":[30,5]}}
{"command":"joint.limits","params":{"joint":"Hinge","min":0,"max":60}}
{"command":"joint.edit","params":{"joint":"Hinge","flip":true}}
{"command":"joint.delete","params":{"link":0}}
{"command":"contact.create","params":{"occurrences":["Frame:1","Wing 2:1"],"name":"Wing 2 stop"}}
{"command":"contact.enable_sets","params":{}}
{"command":"motion.study","params":{"name":"Open","steps":10,"keys":[{"joint":"Hinge","points":[[0,0],[10,25]]}]}}
{"command":"motion.play","params":{"study":"Open","step":5}}
{"command":"motion.export","params":{"study":"Open","samples":3}}
{"command":"occurrence.properties","params":{"occurrence":"Wing:1"}}
```
- `joint.as_built` takes occurrences, not origins. `at` is the joint's frame (default: b's component origin).
- `joint.rigid_group` joins every occurrence to the first one with a rigid as-built joint.
- `joint.motion_link`: b = ratio·a + offset, clamped to b's limits. `offset` is a plain number in radians for an angle and mm for a distance: 10 degrees is `0.17453`. `ia`/`ib` pick a value of a multi-value joint (default 0). `joint.delete {"link": n}` removes link n (its index in `joint.list` `links`).
- `joint.drive`: `values` drives the values it lists, in order (`Spindle` above is cylindrical: 30 degrees, then 5 mm); values left out keep theirs, or follow in a closed loop. `value` drives the first value and **resets the others to 0**, so use `values` on a joint with several. Values are clamped to the limits.
- `motion.study {"study":..., ...}` edits a study; `keys` given replace all keys.
- `motion.play` sets the joints to the study's values at a step (fractions work) and changes the design. `motion.export` does not change it.
## Worked example: a panel on an actuator
A frame holds a panel on a hinge along X at the origin. An actuator runs from a root pin on the frame, R = (0, 0, -40), to a tip pin on the panel, T = (0, 30, 0), 30 mm from the hinge: 50 mm long, along (0, 0.6, 0.8). Its rod slides -5 to +10 mm. A second panel on the other side mirrors the first through a motion link.
```json
{"commands":[
{"command":"solid.box","params":{"length":40,"width":10,"height":50,"corner":[-20,-10,-50],"operation":"new","body_name":"Post"}},
{"command":"solid.box","params":{"length":40,"width":40,"height":10,"corner":[-20,0,-50],"operation":"new","body_name":"Base"}},
{"command":"solid.box","params":{"length":40,"width":40,"height":3,"corner":[-20,0,0],"operation":"new","body_name":"Panel"}},
{"command":"solid.box","params":{"length":40,"width":40,"height":3,"corner":[-20,-40,0],"operation":"new","body_name":"Panel 2"}},
{"command":"solid.cylinder","params":{"radius":5,"height":20,"base":[0,0,-40],"axis":[0,0.6,0.8],"operation":"new","body_name":"Barrel"}},
{"command":"solid.cylinder","params":{"radius":2,"height":35,"base":[0,9,-28],"axis":[0,0.6,0.8],"operation":"new","body_name":"Rod"}},
{"command":"component.create","params":{"name":"Frame","parent":"root","activate":false}},
{"command":"component.move_bodies","params":{"bodies":["Post","Base"],"component":"Frame"}},
{"command":"component.create","params":{"name":"Wing","parent":"root","activate":false}},
{"command":"component.move_bodies","params":{"bodies":["Panel"],"component":"Wing"}},
{"command":"component.create","params":{"name":"Wing 2","parent":"root","activate":false}},
{"command":"component.move_bodies","params":{"bodies":["Panel 2"],"component":"Wing 2"}},
{"command":"component.create","params":{"name":"Actuator barrel","parent":"root","activate":false}},
{"command":"component.move_bodies","params":{"bodies":["Barrel"],"component":"Actuator barrel"}},
{"command":"component.create","params":{"name":"Actuator rod","parent":"root","activate":false}},
{"command":"component.move_bodies","params":{"bodies":["Rod"],"component":"Actuator rod"}},
{"command":"occurrence.ground","params":{"occurrence":"Frame:1","grounded":true}},
{"command":"joint.create","params":{"type":"revolute","name":"Hinge","flip":true,"a":{"occurrence":"Frame","point":[0,0,0],"z":[1,0,0]},"b":{"occurrence":"Wing","point":[0,0,0],"z":[1,0,0]}}},
{"command":"joint.create","params":{"type":"revolute","name":"Root pin","flip":true,"a":{"occurrence":"Frame","point":[0,0,-40],"z":[1,0,0]},"b":{"occurrence":"Actuator barrel","point":[0,0,-40],"z":[1,0,0]}}},
{"command":"joint.create","params":{"type":"slider","name":"Stroke","flip":true,"limits":[[-5,10]],"a":{"occurrence":"Actuator barrel","point":[0,0,-40],"z":[0,0.6,0.8]},"b":{"occurrence":"Actuator rod","point":[0,0,-40],"z":[0,0.6,0.8]}}},
{"command":"joint.create","params":{"type":"revolute","name":"Tip pin","flip":true,"a":{"occurrence":"Actuator rod","point":[0,30,0],"z":[1,0,0]},"b":{"occurrence":"Wing","point":[0,30,0],"z":[1,0,0]}}},
{"command":"joint.as_built","params":{"type":"revolute","a":"Frame","b":"Wing 2","at":{"occurrence":"Frame","point":[0,0,0],"z":[1,0,0]},"name":"Hinge 2","limits":[[-60,0]]}},
{"command":"joint.motion_link","params":{"a":"Hinge","b":"Hinge 2","ratio":-1}}
]}
```
**Expected:**
- After the batch, `joint.list` reports `dof` 1 for Wing, Wing 2, the barrel and the rod (the loop and the link leave one motion), 0 for Frame, and no `conflicts`.
- **Actuator length:** the tip pin turns to `(0, 30 cos θ, 30 sin θ)`, so `L(θ)² = 2500 + 2400 sin θ` and `L(0) = 50`. The stroke is `L(θ) - 50`.
- `joint.drive {"joint":"Hinge","value":20}`: the stroke is `L(20°) - 50 = 7.6268` mm, and Hinge 2 is -20. `occurrence.properties {"occurrence":"Wing:1"}` puts the panel's centre of mass at (0, 18.281, 8.250), and Wing 2's at (0, -18.281, 8.250).
- `joint.drive {"joint":"Hinge","value":45}` stops at 27.28 degrees with `stopped_by_loop: {"joint":"Stroke", ...}`: the rod reaches +10 mm where `L(θ) = 60`, at `sin θ = 11/24`, θ = 27.280 degrees.
## At scale: SELENE
The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft` by 0xlillith, 36 occurrences, 1353 features):
- **Actuators split:** each of the four actuators was one component. Its barrel and its rod (with the eyes and pins that move with them) went into components of their own with `component.create` and `component.move_bodies`.
- **Loops:** per actuator, a revolute root pin (structure and barrel), a slider with the stroke as its limits, `[[-6, 0.4]]` mm, and a revolute wing pin (rod and wing). All `flip: true`, origins by world point, with z along X for the pins and along the actuator (root to tip) for the slider.
- **Links:** the six wheels follow the first (`ratio: 1`); the starboard tilt mirrors the port one (`ratio: -1`).
- **Floating modules:** in v7, 18 components (pedestals, avionics, the lid and others) were neither grounded nor joined, so driving the turret or the dish moved nothing. One `joint.rigid_group` with the structure fixed it.
- **Hidden:** the arm's flexible hoses are rigid bodies on one link, and their sweep sketches stay in place, so both drift off as the arm moves. `browser.visibility {"items":[...], "visible":false}` hid them, and `{"folder":"joints","visible":false}` the joint glyphs for the recording.
- **Numbers:** the port tilt hinge is at (0, -140, 158) along X, the actuator root at (x, -91, 134) and its wing pin at (x, -180.41, 169.71). From these, the stroke at 20 degrees is -2.87 mm, and the rod reaches -6 mm at 32.230 degrees. The app agrees: a drive to 20 degrees gives -2.87 mm, and a drive towards 45 degrees stops at 32.23 degrees with `stopped_by_loop`.
## Traps
| Symptom | Cause and fix |
|---|---|
| A part turns 180 degrees when joined; a loop reports "off by ... and 180 degrees" | `flip` left out. Pass `flip: true`, or `joint.edit {"joint":..., "flip":true}`. |
| A part turns to a limit as soon as it is joined | A new joint starts at 0 and is clamped to its limits. Make the limits include 0: `[[10, 60]]` on a hinge turns it 10 degrees. |
| A part turns by some other angle when joined | The two origins have different forms or x axes. Use `point` + `z` on both sides, or `joint.as_built`. |
| `joint.drive` or `motion.play` answers values and no conflicts (`[]` from a drive, `0` from play), and nothing moves | The chain is joined to nothing grounded (`dof: 6` in `joint.list`). Ground it, join it, or `joint.rigid_group` it with the structure. |
| `joint.drive` answers `values: [0.349]` after a drive to 20 | Its `values` are radians. `joint.list` answers degrees with `unit`. |
| A drive stops short, `stopped_by_loop` | A loop reached a limit or can't close further; `joint` names the joint that stopped it. Check the limits against your stroke numbers. |
| A part of a loop reports `dof: 0` at the pose it was modelled in, and moves once driven | `dof` is counted at the current pose. A part whose motion vanishes there (an actuator in line with the path of its tip pin, so it only extends) counts 0. Drive a little and list again. |
| A cylindrical, pin-slot, planar or ball joint loses its other values after a drive | `joint.drive {"value":v}` resets every value but the first to 0. Drive with `values: [v]`, which keeps the ones it leaves out. |
| A linked angle is off by far more than its `offset` | `joint.motion_link` reads `offset` in radians for angles: `"offset":10` adds 573 degrees. |
| A linked joint stops following | Its value is clamped to its own limits. Hinge 2 in the example has `[[-60, 0]]`, so a drive of Hinge below 0 leaves it at 0. Give a mirrored joint the mirrored limits. |
| A drive stops short, `stopped_by_contact` | Two bodies of a contact set touch. `fraction` is how far the move got. |
| `occurrence.ground` says there is no occurrence `Frame` | It takes occurrence names only: `Frame:1`. |
| `a joint needs two different occurrences` | Both origins found the same part. Give `occurrence` on both. |
| "unknown command" for `file.open` | Open a design with the `open` tool or `doc.open {path}`. |
| Rigid hoses, cables or springs drift as the mechanism moves | Bodies don't bend. Hide them with `browser.visibility`, or model them per link. |
| `solidcraft-cli snapshot SCRIPT --in design` runs on an empty design | `snapshot` ignores `--in`. Run `solidcraft-cli run SCRIPT --in design --save out.solidcraft` first. |
| A URDF export lacks a joint | `export` to `.urdf` leaves out each joint that closes a loop, with a warning, because URDF is a tree. |
**Coming changes.** A pending engine change reworks these answers. `joint.drive` and `motion.play` will give `values` in degrees and mm with their `unit`, and say `moved` (and why not). `joint.list` will list `floating` chains, and `quiet: true` will leave out the `dof` lists. When it lands, the radians row above no longer applies, and a drive on a floating chain says why it moved nothing.
## Reply cost
| Call | Size |
|---|---|
| `component.list` | **140 KB** on SELENE (a 4x4 transform per occurrence). Avoid it; use `occurrence.properties`. |
| `joint.list` | about 0.5 KB per joint: 16 KB for 29 joints |
| `motion.play` | about 0.2 KB per occurrence, per step: 9 KB on SELENE |
| `motion.export` | the same per sample; pass `samples`, or `path` to write it to a file |
| `joint.create`, `joint.drive`, `joint.motion_link` | the `dof` list of every occurrence again, 40 B each |
| `occurrence.properties` | under 1 KB per occurrence |
## Showing it moving
Bridged to the app, `motion.play {"study":..., "step":s}` moves the user's viewport, and `screenshot` captures the window at each step. To record outside MCP, drive the app's control channel (`docs/control-protocol.md`): `engine.execute` with `motion.play`, then `ui.screenshot` with a `path`, once per frame.