Skip to main content

lib/bb/tui/viz/robot_scene.ex

defmodule BB.TUI.Viz.RobotScene do
  @moduledoc """
  Builds an `ExRatatui.ThreeD.Scene` from a `BB.Robot` topology and live joint
  positions, running forward kinematics.

  The robot is Z-up (URDF); the whole tree is wrapped in a -90°-about-X root frame
  so it renders in the engine's Y-up conventions. Pure: `(robot_struct, positions)`
  in, `Scene` out.
  """

  require Logger

  alias ExRatatui.ThreeD.{Camera, Light, Material, Mesh, Node, Object, Scene, Transform}

  @default_color {160, 160, 170}
  @half_pi :math.pi() / 2.0

  @doc """
  Builds the 3D scene for `robot` at the given joint `positions`.

  Options: `:lights` and `:background` override the defaults.
  """
  @spec build(struct(), %{atom() => number()}, keyword()) :: Scene.t()
  def build(robot, positions, opts \\ []) do
    root_node = %Node{
      transform: %Transform{rotation: {:axis_angle, {1.0, 0.0, 0.0}, -@half_pi}},
      children: [link_node(robot, robot.root_link, positions)]
    }

    Node.to_scene(root_node,
      lights: Keyword.get(opts, :lights, default_lights()),
      background: Keyword.get(opts, :background, {16, 16, 22})
    )
  end

  @doc "A sensible default orbit camera for the arm."
  @spec default_camera() :: Camera.t()
  def default_camera do
    %Camera{position: {0.4, 0.35, 0.4}, target: {0.0, 0.12, 0.0}}
  end

  defp default_lights do
    [
      Light.ambient({110, 110, 120}, 1.0),
      Light.directional({1.0, 1.0, 1.0}, {255, 255, 255}, intensity: 1.0),
      Light.directional({-1.0, 1.0, -1.0}, {255, 255, 255}, intensity: 0.5)
    ]
  end

  # A link node: identity frame, optional visual, children = child joint nodes.
  defp link_node(robot, link_name, positions) do
    link = Map.fetch!(robot.links, link_name)

    %Node{
      transform: %Transform{},
      visual: visual_object(link.visual),
      children: Enum.map(link.child_joints, &joint_node(robot, &1, positions))
    }
  end

  # A joint node: frame = origin ∘ motion, child = the joint's child link.
  defp joint_node(robot, joint_name, positions) do
    joint = Map.fetch!(robot.joints, joint_name)
    value = Map.get(positions, joint_name, 0.0)

    %Node{
      transform: joint_frame(joint, value),
      children: [link_node(robot, joint.child_link, positions)]
    }
  end

  defp joint_frame(joint, value) do
    origin = origin_transform(joint.origin)
    Transform.compose(origin, motion(joint.type, joint.axis, value))
  end

  defp motion(type, axis, value) when type in [:revolute, :continuous] do
    %Transform{rotation: {:axis_angle, axis_or_z(axis), value}}
  end

  defp motion(:prismatic, axis, value) do
    {ax, ay, az} = axis_or_z(axis)
    %Transform{position: {ax * value, ay * value, az * value}}
  end

  defp motion(_type, _axis, _value), do: %Transform{}

  defp axis_or_z(nil), do: {0.0, 0.0, 1.0}
  defp axis_or_z({_, _, _} = axis), do: axis

  # Joint origin: %{position:, orientation:}. Build translate ∘ rpy.
  defp origin_transform(nil), do: %Transform{}

  defp origin_transform(%{position: pos, orientation: rpy}) do
    Transform.compose(%Transform{position: pos}, rpy_transform(rpy))
  end

  # URDF rpy = Rz(yaw)·Ry(pitch)·Rx(roll).
  defp rpy_transform({roll, pitch, yaw}) do
    rx = %Transform{rotation: {:axis_angle, {1.0, 0.0, 0.0}, roll}}
    ry = %Transform{rotation: {:axis_angle, {0.0, 1.0, 0.0}, pitch}}
    rz = %Transform{rotation: {:axis_angle, {0.0, 0.0, 1.0}, yaw}}
    Transform.compose(Transform.compose(rz, ry), rx)
  end

  defp visual_object(nil), do: nil

  defp visual_object(%{geometry: geometry} = visual) do
    {mesh, geom_transform} = geometry_mesh(geometry)
    origin = visual_origin_transform(Map.get(visual, :origin))

    %Object{
      mesh: mesh,
      material: %Material{color: color(Map.get(visual, :material))},
      transform: Transform.compose(origin, geom_transform)
    }
  end

  # Link visual origin is a 2-tuple {position, orientation} (or nil).
  defp visual_origin_transform(nil), do: %Transform{}

  defp visual_origin_transform({pos, rpy}) do
    Transform.compose(%Transform{position: pos}, rpy_transform(rpy))
  end

  # geometry -> {mesh, transform applying scale (+ axis fix for cylinder)}
  defp geometry_mesh({:box, %{x: x, y: y, z: z}}) do
    {Mesh.cube(), %Transform{scale: {x, y, z}}}
  end

  defp geometry_mesh({:sphere, %{radius: r}}) do
    {Mesh.sphere(), %Transform{scale: {2.0 * r, 2.0 * r, 2.0 * r}}}
  end

  defp geometry_mesh({:cylinder, %{radius: r, height: h}}) do
    # Primitive axis +Y; URDF axis +Z. Scale in primitive frame then +90° about X.
    {Mesh.cylinder(),
     %Transform{rotation: {:axis_angle, {1.0, 0.0, 0.0}, @half_pi}, scale: {2.0 * r, h, 2.0 * r}}}
  end

  defp geometry_mesh(other) do
    Logger.warning("RobotScene: unsupported geometry #{inspect(other)}; using a unit cube")
    {Mesh.cube(), %Transform{}}
  end

  defp color(%{color: %{red: r, green: g, blue: b}}), do: {ch(r), ch(g), ch(b)}
  defp color(_), do: @default_color

  defp ch(v), do: v |> Kernel.*(255) |> round() |> max(0) |> min(255)
end