No description
Find a file
2026-09-18 09:59:35 +00:00
.godot New Update 2026-09-18 17:58:17 +08:00
addons/godot_ai first commit 2026-09-15 16:30:31 +08:00
Assets New Controls and Models 2026-09-17 17:18:05 +08:00
python_bridge Rename project to Comfac Robot Arm Controller; set version 0.5 2026-09-18 14:14:21 +08:00
Scenes New Update 2026-09-18 17:58:17 +08:00
Scripts New Update 2026-09-18 17:58:17 +08:00
Themes Updated Commit 2026-09-18 13:14:01 +08:00
.editorconfig first commit 2026-09-15 16:30:31 +08:00
.gitignore Gate bridge joint feedback on the active app mode 2026-09-18 13:43:35 +08:00
export_presets.cfg New Update 2026-09-18 17:58:17 +08:00
icon.svg first commit 2026-09-15 16:30:31 +08:00
icon.svg.import first commit 2026-09-15 16:30:31 +08:00
project.godot New Update 2026-09-18 17:58:17 +08:00
README.md New Update 2026-09-18 17:58:17 +08:00

Comfac Robot Arm Controller

Version 0.7.5 — pre-release.

Everything described below works and is verified in simulation. The physical-arm path (the Python bridge and the two hardware modes) is fully implemented and has been tested end-to-end against a mock arm, but has not yet been validated on real hardware. That validation is what 1.0 waits on.

A Godot test bench for a LeRobot-style (SO-100 / SO-101) robot arm.

It runs three jobs in one scene: simulate a pair of arms, drive a physical arm from the on-screen controls, or mirror a physical arm back into Godot. The same UI and the same joint state serve all three, so you can develop against the simulation and then swap in real hardware without changing how you work.


Requirements

Engine Godot 4.7.2-stable (project features 4.7, GL Compatibility)
Renderer gl_compatibility — chosen for broad/mobile support
Physics Jolt Physics
Display driver D3D12 on Windows
Optional Python 3 for the hardware bridge (see Hardware bridge)

Open the folder as a project in Godot and press F5. The main scene is res://Scenes/main_environment.tscn.


The two arms

Arm Role
Leader The controller. On-screen sliders command it, and in Godot -> Robot its values are forwarded to the real arm.
Follower The mirror. It never reads sliders or hardware itself — it just reflects whatever ArmState says, which is why the same script works in both directions.

Both arms share the LeRobot joint naming so they line up 1:1 with the physical arm's motors:

base · shoulder_pan · shoulder_lift · elbow_flex · wrist_flex · wrist_roll · gripper

base is view-only. It spins the whole model for inspection and has no physical motor behind it, so it is never forwarded to hardware.


Resting pose

Both arms start in — and reset to — a single resting pose, defined once as ArmState.REST_POSE:

Joint Resting value
base
shoulder_pan
shoulder_lift 25°
elbow_flex 40°
wrist_flex 80°
wrist_roll
gripper

This mirrors how a physical SO-100 / SO-101 sits when idle: the elbow folds and the wrist curls, rather than the arm standing straight out.

The values are not arbitrary. The base is a large block and the leader arm carries a protruding grip handle, and between them they rule out most folded poses: folding the shoulder down drives the wrist assembly straight through the base, and letting the wrist unwind drops the handle through the desk. The pose above was tuned per-mesh against the actual models and is verified to keep every part of both arms clear of the base and above the desk surface.

Because REST_POSE is the only definition, the startup pose, the full-control Reset and the shift-stick Reset All Joints agree by construction.


Operating modes

The active mode (AppMode) decides both what is visible and what the camera shows.

Mode Control flow Arms shown Camera views
Simulation Only Sliders drive the Leader; the Follower mirrors it in Godot only Both Two side-by-side views — Leader (left), Follower (right)
Godot -> Robot Sliders drive the Leader and are forwarded to the physical arm Leader only One full-width Leader view
Robot -> Godot Physical servo feedback drives both Godot arms Follower only One full-width Follower view

Switch modes from Menu → Control Mode.


Camera system

Each view is its own SubViewport with its own Camera3D, sharing the main World3D — so the two arms really are rendered by two independent cameras at once, rather than one camera trying to frame both.

  • Per-arm isolation. The arms sit only 10 units apart, so both cameras would otherwise see both. Each arm's meshes and its desk are assigned their own visual layer at runtime and each camera gets a matching cull_mask, so a view shows its own arm and desk plus the shared room — never its neighbour. The desk half of that matters as much as the arm half: with the arm culled but its desk left on the shared layer, the neighbouring view contains a bare, empty table.
  • Auto-framing. On load each rig measures its arm's bounding box and backs the camera off far enough to frame it, fitting the box's two axes against the view's two half-angles. Swapping or rescaling a model still lands in shot.
  • Labels and borders. Each view is labelled (LEADER ARM / FOLLOWER ARM) and bordered, so the split is unambiguous.
  • Cost when hidden. A hidden view's SubViewport is set to UPDATE_DISABLED, so the single-view modes pay for one world render, not two.

Looking around

Orbiting is linked — one gesture drives every visible view together, so the two arms always stay at the same relative angle and remain comparable.

Input Action
Right-drag (mouse) Orbit / tilt
Mouse wheel Zoom
One-finger drag (touch) Orbit / tilt
Two-finger pinch (touch) Zoom

Dragging over the control panels does not orbit — those consume the event first. Yaw is unlimited (full 360°); tilt is clamped to keep the arm framed.


HUD

Status bar

A strip across the top of the screen summarises what the app is doing, so the operator never has to open a menu to find out:

Chip Shows
Mode Simulation Only / Godot -> Robot / Robot -> Godot
Kill switch Armed or STOPPED
Hardware bridge HW Online or HW Offline

Each chip carries an icon drawn in code (status_icon.gd: rings, arrows, a stop sign, an LED) rather than shipped as art, so an icon recolours the instant its state changes and adds nothing to the export size. The bar itself is built at runtime by ui_status_bar.gd, which subscribes to AppMode, ArmState and HardwareBridge and re-reads their state on every signal. The bar ignores mouse input throughout, so it never steals clicks from the controls underneath.

Motion

The HUD is not static:

  • Intro. On load the screen is covered by an opaque black veil and every panel starts transparent. The veil fades away while each panel slides in from its nearest edge, staggered over roughly 0.7 s.
  • Panels. The settings window slides down into place on open and back up on close, tab pages cross-fade, and the two control schemes slide past each other when you switch between them.
  • Exit. Quitting fades the screen back to black before the app closes, so exiting reads as deliberate rather than abrupt.

All of it runs through ui_animator.gd, a small set of static tween helpers (enter / exit / fade_in / fade_out / fade_from_black). Two properties of those helpers are deliberate:

  • Each one binds its tween to the node it animates, so a tween can never outlive its node.
  • Each is self-resetting — it restores the node's rest position and modulate when it finishes — so repeated open/close cycles cannot accumulate drift.

The intro veil is created by main_ui.gd, which also reuses it for the exit fade. That script registers itself in the fade_overlay group, which is how the exit page reaches it without holding a hard reference.


Environment

The bench is dressed as a small office rather than a bare plane, so the arms read as a real desk-top installation. It all lives in Scenes/office_environment.tscn, instanced by the main scene:

  • Room — a single box rendered with cull_mode = CULL_FRONT, so you see its inward faces. From inside it is a closed room; if the orbit camera swings outside, the near wall is culled and you still see the arms rather than the outside of a black box.
  • Desks — one laminate-topped desk per arm, each centred under its own arm (at x = 0 and x = 10). A single shared bench cannot centre both arms at once: each would sit near one end of it, which reads as lopsided in a view that shows only that arm. Each top's surface sits exactly at y = 0, which is where the arms' bases already are, so neither arm had to be moved to stand on it.
  • Window — an unshaded bright quad on the back wall. It motivates the key light and stops the backdrop reading as a flat wall.
  • Lighting — deliberately soft and diffuse rather than dramatic. A weak, steeply-angled key light comes through the window with partial (shadow_opacity), widened (light_angular_distance) shadows; a warm ceiling fitting carries most of the illumination; and a coloured ambient fill lifts the shaded sides. The shadows are intentionally light: a strong low directional light threw long hard-edged shadows across the desk, which read as harsh rather than as office lighting. There is deliberately no sky: the background is a flat interior colour, since daylight outdoors would be wrong for an office.

The room shell has shadow casting switched off so it does not block the light.


Controls

Two interchangeable control schemes drive the exact same joint state, so you can switch mid-operation without anything jumping.

Slider handles are deliberately oversized (48px, against the ~44-48px touch target guidelines) because a fingertip is far less precise than a cursor — they were easy to miss on the mobile build at their original size.

Full control

One slider per joint, each with /+ nudge buttons (5° per press) and a Reset that returns every joint to the resting pose.

Joint Slider range
Gripper 90° … 90°
Wrist Roll 360° … 360°
Wrist Flex / Elbow Flex / Shoulder Lift / Shoulder Pan 95° … 95°
Base 180° … 180°

Shift-stick

A joint selector picks which joint is active, a second slider rotates it, and the current angle is shown numerically. Reset All Joints returns every joint to the resting pose.

This is the faster scheme for driving one joint at a time on a touchscreen.

Note: both Reset buttons now do the same thing — they call ArmState.reset_all_joints(), which returns every joint to the resting pose. Resetting still works while the kill switch is tripped, because ArmState accepts "system" sourced writes in that state.


Safety

An emergency-stop strip is always on screen (top-left), independent of the settings menu — stopping the arm never requires opening a menu first. The same control also appears on the Safety tab.

Tripping the kill switch:

  • blocks every joint write in ArmState (except "system" sourced writes, so resets still work), and
  • tells the physical arm to stop over the hardware bridge.

The button reads EMERGENCY STOP when armed and RESUME when tripped, with a colour-coded status label. While tripped, joint sliders and nudge buttons are disabled; reset stays available by design.

Sliders are also locked in Robot -> Godot, since dragging them there would fight the incoming hardware feedback.


Settings menu

A single tabbed overlay keeps the main screen clear. Open it with the Menu button (top-right) or close it with Close / Esc.

The overlay runs at a larger text scale than the HUD (22px body text, 30px section titles, against 18px in the controls panel) so it is comfortable to read on a phone. That comes from its own Themes/settings_theme.tres, which defines only font sizes and lets everything else — panel and button styleboxes included — fall through to the shared HUD theme.

Tab Contents
Safety Emergency stop and its status
Control Mode Operating mode, hardware-bridge status, connect/disconnect
Calibration Per-joint travel limits and zeroing
Exit Quit the app, behind a confirmation prompt

Calibration

Per joint you can view and edit:

  • Min ° / Max ° — the travel limits enforced by ArmState when clamping
  • Offset ° — the zero reference

Actions: Apply Limits (also echoed to the arm if connected) and Set Current As Zero (whatever the joint reads now becomes the new offset).

Calibration is the source of truth for clamping and is persisted to disk at user://arm_calibration.cfg, so it survives restarts. Defaults are 90° … 90° with a 0° offset for every joint.


Hardware bridge

Godot has no native serial support, so a small companion Python process owns the servo bus and exposes it over a local TCP socket using newline-delimited JSON.

  • Default endpoint: 127.0.0.1:9999
  • Connect from Menu → Control Mode → Connect to Hardware

Outbound (Godot → Python → servos)

{"cmd":"set_joint","joint":"shoulder_pan","degrees":45.0}
{"cmd":"kill_switch","active":true}
{"cmd":"calibrate","joint":"shoulder_pan","min":-90.0,"max":90.0,"offset":0.0}
{"cmd":"mode","mode":"robot_to_godot"}

Inbound (servos → Python → Godot)

{"event":"joint_feedback","joint":"shoulder_pan","degrees":47.2}
{"event":"connected"}
{"event":"error","message":"..."}

Joint names must match ArmState.JOINT_NAMES exactly, or that joint is silently ignored. The bridge auto-reconnects every 2 s while connecting.

Bridged by python_bridge/arm_bridge.py, which ships in this repo — see python_bridge/README.md for setup. It can run with --mock to exercise this whole path with no hardware attached. Simulation modes need nothing at all; only Godot -> Robot and Robot -> Godot need the bridge running.


Architecture

ArmState is the single source of truth for every joint angle. The arms never talk to each other; they all read and write through it. That is what makes bidirectional sync possible without the two arms fighting over ownership.

   sliders ─┐
            ├─►  ArmState  ─┬─►  Leader holders   (arm_controller.gd)
  hardware ─┘   (clamped,   │
                persisted)  ├─►  Follower holders (robort_arm_follower.gd)
                            │
                            └─►  (Godot -> Robot) ─► hardware bridge

Every change carries a source:

Source Meaning
ui A slider or nudge button. The only source forwarded to hardware.
hardware Feedback from the physical arm
system Reset / calibration / init — the only source allowed while the kill switch is tripped

Project structure

arm-test/
├── Assets/                     Source .glb models (see note below)
├── Scenes/
│   ├── main_environment.tscn   Main scene: both arms, environment, UI
│   ├── main_ui.tscn            HUD, camera views and settings overlay
│   ├── office_environment.tscn Room, desk, window and office lighting
│   ├── lerobot_leader.tscn     Leader arm rig
│   └── lerobot_follower.tscn   Follower arm rig
├── Scripts/
│   ├── arm_state.gd            AUTOLOAD — joint state, clamping, calibration
│   ├── app_mode.gd             AUTOLOAD — operating mode
│   ├── hardware_bridge.gd      AUTOLOAD — TCP client for the Python bridge
│   ├── arm_controller.gd       Leader: UI → ArmState → holders
│   ├── robort_arm_follower.gd  Follower: mirrors ArmState
│   ├── camera_views.gd         Mode-driven camera views, linked orbit
│   ├── camera_view_rig.gd      One view's orbit rig + auto-framing
│   ├── main_ui.gd              HUD intro / exit fade, attached to MainUI
│   ├── ui_animator.gd          Static tween helpers for UI motion
│   ├── ui_status_bar.gd        Top status bar (mode / kill switch / bridge)
│   ├── status_icon.gd          Code-drawn icons for the status bar
│   ├── settings_menu.gd        Tabbed settings overlay
│   ├── kill_switch_panel.gd    Emergency stop (HUD strip + Safety tab)
│   ├── mode_panel.gd           Control Mode tab
│   ├── calibration_panel.gd    Calibration tab
│   ├── shift_stick_panel.gd    Shift-stick control scheme
│   ├── resetSliders.gd         Full-control Reset
│   └── exit.gd                 Exit tab
├── Themes/
│   ├── panel_theme.tres        Shared UI theme (panels, text sizes, buttons)
│   └── settings_theme.tres     Larger-text variant for the settings overlay only
├── python_bridge/
│   ├── arm_bridge.py           TCP server owning the LeRobot servo bus
│   └── README.md               Bridge setup, protocol and troubleshooting
├── addons/godot_ai/            Editor tooling — not part of the app
├── export_presets.cfg
└── project.godot

About Assets/: the .glb files are the original models. The arm scenes embed their meshes directly, so nothing references the .glb at runtime — they are kept as source art for regenerating the rigs.

About addons/godot_ai/: this is editor tooling for an AI-assistant bridge, not part of the app. It is also why project.godot registers a fourth autoload, _mcp_game_helper — the three listed above are the application's own. The plugin and that autoload can be removed for a clean release build.


Export

The project version is 0.7.5, set in project.godot (application/config/version) and mirrored into both export presets, so builds are self-describing.

Two presets ship in export_presets.cfg:

Preset Platform Output Notable settings
Android Android Build/ComfacRobotArmController-0.7.5.apk arm64-v8a only, immersive mode, landscape
Windows Desktop Windows Build/ComfacRobotArmController-0.7.5.exe x86_64, no embedded PCK

Android identity is net.comfac_it.robot_arm_controller (reverse-DNS of the project remote), version name 0.7.5, version code 2increment the version code for every upload, and note the package name is permanent once published.

Bumping the version for a release touches three places (shown here for the current 0.7.5):

project.godot        application/config/version     "0.7.5"
export_presets.cfg   version/name (Android)         "0.7.5"
                     version/code (Android)         increment
                     application/file_version (Win) "0.7.5.0"
                     application/product_version    "0.7.5"
                     export_path (both)             Build/ComfacRobotArmController-0.7.5.*

The two Windows version fields are deliberately not the same string: file_version is four-part (0.7.5.0) because Windows file versions require four components, while product_version is three-part (0.7.5).

Both presets still use the stock Godot logo as the app icon. icon.svg is the default engine icon, and the Android launcher and Windows icon fields are left empty so Godot falls back to it. A real icon is worth doing before any public release.

The app is laid out for landscape, and the UI is anchored/container-driven so it adapts to other aspect ratios — the control column docks right and the camera views take the remaining width.

Both presets use export_filter="all_resources", which bundles every resource in the project — including the unreferenced .glb files (~5 MB). Switching to dependency-based filtering, or removing those assets, will shrink the mobile build.


Known limitations

  • Camera framing is computed once from the arm's resting pose, so tilt is clamped to a gentle range; a hard top-down swing would crop the arm.
  • base is a viewing joint only and has no hardware counterpart.
  • The full-control panel is sized for 1080p-class landscape; on a much shorter window its content would need a scroll container.