Motion links can carry a coupling (gear pair, rack and pinion, screw) whose ratio follows radii, picked round faces or pitch. Design format 23. Co-authored-by: SychPL <[email protected]> Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
16 KiB
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":[...]}. |
occurrenceis 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.
pointpicks its x axis from z. Aframewith anxon one side and apointon 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 samezthen 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).truelines 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
{"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.createandjoint.as_builtstart the joint at itsvalues(default 0). A value outside the limits starts at the nearer limit, and the reply'swarningssay so: the part turned as it was joined.joint.as_builttakes occurrences, not origins.atis the joint's frame (default: b's component origin).joint.rigid_groupjoins every occurrence to the first one with a rigid as-built joint.joint.motion_link: b = ratio·a + offset, clamped to b's limits.ratiois in b's units per a's: mm per degree when an angle drives a distance (a pinion of pitch radius r moves its rack r·π/180 mm per degree), degrees per mm the other way, and a plain factor between two angles or two distances.offsetis in b's units: degrees for an angle, mm for a distance, or an expression such as"10 deg".joint.listgives each link'sratiothe same way and itsoffsetwith itsunit.ia/ibpick a value of a multi-value joint (default 0).joint.delete {"link": n}removes link n (its index injoint.listlinks).joint.gear {a, b, ra, rb, internal?, reverse?},joint.rack_pinion {a, b, radius, reverse?}andjoint.screw {joint | a, b; pitch, reverse?}make a motion link whose ratio comes from its sizes: gears -ra/rb (+ra/rb internal), a rack r mm per radian of its pinion (either joint may bea), a screw pitch per turn. Each size is mm, an expression, or{occurrence, circle: [x,y,z]}: the radius of the round face there, read again whenever the design changes. The reply giveslink(its index) andratioasjoint.listdoes;link: nin the params replaces link n and keeps its offset. A new coupling's offset keeps b where it is.joint.listlinkscarry thecouplingand anerrorwhen it can't be worked out (a picked face gone: the last ratio stays). A value two links would set, or a coupling that would drive its own driver, is refused.joint.drive:valuesdrives the values it lists, in order (Spindleabove is cylindrical: 30 degrees, then 5 mm), andvaluethe first; values left out keep theirs, or follow in a closed loop. Values are clamped to the limits. The reply givesvaluesasjoint.listdoes ({name, value, unit, limits}), andmoved; when the joint can move nothing, areason.motion.study {"study":..., ...}edits a study;keysgiven replace all keys.motion.playsets the joints to the study's values at a step (fractions work) and changes the design.motion.exportdoes 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.
{"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.listreportsdof1 for Wing, Wing 2, the barrel and the rod (the loop and the link leave one motion), 0 for Frame, and noconflicts. - Actuator length: the tip pin turns to
(0, 30 cos θ, 30 sin θ), soL(θ)² = 2500 + 2400 sin θandL(0) = 50. The stroke isL(θ) - 50. joint.drive {"joint":"Hinge","value":20}: the stroke isL(20°) - 50 = 7.6268mm, 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 withstopped_by_loop: {"joint":"Stroke", ...}: the rod reaches +10 mm whereL(θ) = 60, atsin θ = 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.createandcomponent.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). Allflip: 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_groupwith 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, and the reply has warnings |
A new joint starts at 0, or its values, inside its limits: [[10, 60]] on a hinge turns it 10 degrees. Make the limits include 0, or join the parts where the limits allow. |
| 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 answers moved: false, or motion.play lists the joint under unmoved |
Read reason. Most often the chain is joined to nothing grounded: unconnected names the part, and joint.list lists the chain under floating, its occurrences with dof: 6. Ground it, join it, or joint.rigid_group it with the structure. |
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 gear, rack or screw turns its partner the wrong way | The joints' axes point opposite ways (or the rack lies on the other side of the pinion). Run the command again with reverse: true and link: n. |
already follows a motion link from joint.gear |
A joint follows one link at most. Make the joint that already follows the driving one (a), as in a gear train G1 → G2 → G3. |
| 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. If the parts are bodies of one component (an imported STEP file often brings them so), run component.from_bodies {"bodies": [...]} first. |
"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. |
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, joint.gear, joint.rack_pinion, joint.screw |
the dof list of every occurrence again, 40 B each; quiet: true leaves it out |
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.