Files
OneCAD/skills/solidcraft-mechanisms/reference.md
T
f7967b1ff6 Joints: gears, rack and pinion, and screws (#1524)
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]>
2026-10-15 18:47:38 -04:00

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":[...]}.
  • 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

{"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.create and joint.as_built start the joint at its values (default 0). A value outside the limits starts at the nearer limit, and the reply's warnings say so: the part turned as it was joined.
  • 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. ratio is 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. offset is in b's units: degrees for an angle, mm for a distance, or an expression such as "10 deg". joint.list gives each link's ratio the same way and its offset with its unit. 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.gear {a, b, ra, rb, internal?, reverse?}, joint.rack_pinion {a, b, radius, reverse?} and joint.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 be a), 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 gives link (its index) and ratio as joint.list does; link: n in the params replaces link n and keeps its offset. A new coupling's offset keeps b where it is. joint.list links carry the coupling and an error when 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: values drives the values it lists, in order (Spindle above is cylindrical: 30 degrees, then 5 mm), and value the first; values left out keep theirs, or follow in a closed loop. Values are clamped to the limits. The reply gives values as joint.list does ({name, value, unit, limits}), and moved; when the joint can move nothing, a reason.
  • 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.

{"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, 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.