Attach parts and pose joints

Copy Markdown View Source
Mix.install([{:smith, "~> 0.2.0"}, {:kino, "~> 0.19.0"}])

Before you start

After named assemblies, connect a pivoting arm to a base. A joint describes an attachment frame; a connection sets the relative pose. This lesson checks a pose without pretending to design a complete hinge.

Run cells in order. Lengths are millimeters and modeling angles are degrees. Each 3D preview supports orbit, zoom, and fullscreen.

Build the parts

The arm's pivot is 4 mm from its left end. Both bores have a 3 mm diameter. The pin is reference geometry, so it will not become a printed part. These are joint-placement examples, not a complete retained hinge design.

alias Smith.{Assembly, Plane, Sketch}

base = Sketch.rounded_rectangle(20, 20, 2)
  |> Smith.extrude(4)
  |> Smith.hole(on: Plane.xy(z: 4), diameter: 3, through: :all)
arm = Sketch.rounded_rectangle(40, 8, 2, align: {:min, :center})
  |> Smith.extrude(3)
  |> Smith.hole(on: Plane.xy(z: 3), at: {4, 0}, diameter: 3, through: :all)

Smith.Kino.render(arm, label: "1 · Arm and pivot bore")

Name the attachment frames

on: names an immediate member, and at: defines a plane in that member's original recipe coordinates. Placement follows afterwards. The base frame is 0.4 mm above the base top; the arm frame lies at its pivot on the bottom face. Both normals point along +Z, and both X axes point along +X.

fixture = Assembly.new(:hinge)
  |> Assembly.part(:base, base, print: [on_bed: true])
  |> Assembly.part(:arm, arm, position: {30, 0, 10}, print: [on_bed: true],
    exploded_offset: {0, 0, 12})
  |> Assembly.reference(:pin, Smith.cylinder(1.4, 9))
  |> Assembly.joint(:base_axis, on: :base, at: Plane.xy(z: 4.4))
  |> Assembly.joint(:arm_axis, on: :arm, at: Plane.xy(origin: {4, 0, 0}))

{:ok, unconnected} = Smith.evaluate(fixture)
Smith.Kino.render(unconnected, label: "2 · Parts before connection")

A joint is a frame, not a topology handle. It stays tied to the member's recipe coordinates, so changing a hole location also requires updating its attachment frame. Define shared dimensions once when building a parameterized design.

Connect and rotate

The source moves to the target. A revolute connection rotates about the target's local Z axis. The source's initial placement is overridden. Limits are inclusive; values outside them fail instead of being clamped. Positive angles follow the right-hand rule.

poses = for angle <- [0, 45, 90] do
  recipe = Assembly.connect(fixture, :arm_axis,
    to: :base_axis, kind: :revolute, angle: angle, limits: [angle: {-90, 90}])
  {:ok, result} = Smith.evaluate(recipe)
  {:ok, joint} = Assembly.fetch_joint(result, :arm_axis)
  {x, y, z} = joint.frame.origin
  true = abs(x) < 1.0e-7 and abs(y) < 1.0e-7 and abs(z - 4.4) < 1.0e-7
  {:ok, part} = Assembly.fetch(result, :arm)
  {:ok, volume} = OCEx.volume(part.shape)
  expected = (40 * 8 - (4 - :math.pi()) * 4 - :math.pi() * 1.5 * 1.5) * 3
  true = abs(volume - expected) < 1.0e-5
  {angle, result}
end

poses |> Enum.map(fn {angle, result} ->
  Smith.Kino.render(result, label: "3 · Arm at #{angle}°")
end) |> Kino.Layout.grid(columns: 1)

Each pose starts from fixture. Appending another connection to an already-connected source would give that member two controlling connections and fail. Use a function or comprehension to create variants from the unconnected recipe.

Inspect alignment and clearance

The returned frame has an origin and three world-space unit axes: u, v, and n. A connection aligns the complete frame, without an implicit reversal of the normal. Use opposing local planes if your mounting convention requires face-to-face alignment.

{45, selected} = Enum.find(poses, fn {angle, _} -> angle == 45 end)
{:ok, pivot} = Assembly.fetch_joint(selected, :arm_axis)
{:ok, moving} = Assembly.fetch(selected, :arm)
{:ok, fixed} = Assembly.fetch(selected, :base)
{:ok, fit} = Smith.Inspection.run(%{arm: moving, base: fixed}, checks: [
  {:clearance, :arm, :base, minimum: 0.4, tolerance: 1.0e-7}
])
:passed = fit.status
[check] = fit.checks
clearance = check.measured
Kino.DataTable.new([%{origin: inspect(pivot.frame.origin),
  axis: inspect(pivot.frame.n), clearance_mm: clearance}])

Joint evaluation calculates placement. It does not check collisions, strength, or travel clearance. The check above measures the closest material distance and checks for interference in this pose. A mechanism with a range of motion needs checks across that range.

Other motion types

Rigid connections have no motion coordinates. Linear joints translate along target-local Z. Cylindrical joints combine that translation with rotation about Z. Ball joints take three angles around the fixed target X, then Y, then Z axes. The examples below demonstrate transforms on the same arm; they do not change its geometry into a physical slider or ball socket.

variants = [
  {"Rigid", [kind: :rigid]},
  {"Linear · 8 mm", [kind: :linear, offset: 8, limits: [offset: {0, 10}]]},
  {"Cylindrical · 45°, 8 mm", [kind: :cylindrical, angle: 45, offset: 8]},
  {"Ball · tilted arm", [kind: :ball, angles: {20, 0, 30}, limits: [x: {-30, 30}]]}
]
variants |> Enum.map(fn {label, opts} ->
  recipe = Assembly.connect(fixture, :arm_axis, [to: :base_axis] ++ opts)
  Smith.Kino.render(recipe, label: label)
end) |> Kino.Layout.grid(columns: 2)

Dependencies resolve target-first even when connections are declared in another order. Each moving member can have only one controlling connection. Cycles and self-connections fail. Inside a subassembly, local connections resolve first; connecting [:module, :pin] at its parent level then moves the whole module, retaining its internal pose. This is directed assembly placement, not a solver for closed linkages.

Exploded view

The exploded view starts with the selected joint pose. It lifts the arm 12 mm along world Z and retains its 45° rotation. The connection and installed result remain unchanged; no part recipe is evaluated again.

{:ok, exploded} = Assembly.view(selected, :exploded)
Smith.Kino.render(exploded, label: "Exploded · Arm at 45°")

Export a pose

The installed STEP captures the selected 45° pose. STL and 3MF files use each leaf's print placement. The report includes joint frames, connection parameters, and the tree's resolved world poses. Reference geometry remains separate.

{:ok, files} = Smith.export(selected, "output/models", name: "joint-hinge",
  angular_tolerance: 0.1)
2 = length(files.parts)
[%{kind: :revolute, values: %{angle: 45}}] = files.connections
{:ok, printable_files} = :zip.extract(File.read!(files.print_pack), [:memory])
4 = length(printable_files)
true = Enum.all?(files.parts, & &1.verification.mesh.watertight)
Kino.Text.new(files.print_pack)