ControlForge Physics Twin (Factory Sim)
June 2026 | ControlForge v1.0.1064
A physics twin is a 3D rigid-body scene stepped in lock-step with one task's ST scan, so that wrong control logic produces a real, reproducible physical consequence — parts collide, pile up, tip, or jam, the same way every run. It is a virtual-commissioning tool: you catch the sequencing bug in the sim before it breaks real steel.
There are no PHYSICS_* ST functions — the twin is wired entirely through tags and YAML config. Your ST writes OUTPUT tags that drive actuators; the engine writes sensor/contact/pose results back to INPUT tags your next scan reads. The control logic never knows it's driving a simulation rather than real I/O.
How it works — the lock-step contract
Each scan of the bound task, synchronously:
- The engine sets INPUT tags from the last step (contacts, poses, sensor zones).
- Your ST scan runs the control logic.
- The engine maps OUTPUT tags → actuator commands.
- The world steps one fixed dt and blocks for the result (new poses + contacts).
- Loop.
Because step 4 is a barrier (one physics step per scan, blocking), a crash always lands on the same tick for the same logic — the simulation indicts the logic, not poll latency. This is the structural edge over a Factory-I/O-style sim that talks to the PLC over a wire.
Two engines
engine: | What it is | Use it for |
|---|---|---|
rigid (default) | The supervised Rapier Rust sidecar — impulse/LCP solver | stacks, boxes, conveyors, pushers, general discrete mechanics |
compliant | The in-process pkg/physics3d Hertzian penalty engine | elastic collision chains (e.g. a Newton's cradle) that a lumped-impulse solver gets wrong |
The scene schema (bodies, actuators, sensors…) is identical for both — a sim is data, not code. rigid runs as a separate pure-Rust process the runtime spawns and supervises (the Go binary stays cgo-free); compliant runs inside the runtime.
Setup
The rigid engine needs the physics sidecar binary built once:
cargo build --release --manifest-path physics-sidecar/Cargo.toml
Then declare the sidecar and the twin in your config, and start the runtime (POST /api/runtime/start). The compliant engine needs no sidecar build.
Worked example — the two-pusher slice
Two pushers face two boxes. The correct sequence fires one pusher at a time; the bug fires both at once, slamming the boxes together. That collision is real Rapier narrow-phase contact, latched into a crash tag.
tasks:
- name: Main
type: periodic
scan_time_ms: 10
programs: [plant_globals.st, plant.st]
# spawn + supervise the Rust physics worker (rigid engine)
sidecars:
- name: physics
command: physics-sidecar/target/release/physics-sidecar
health_check_interval_ms: 1000
physics_twin:
enabled: true # opt-in; nil/absent = off
sidecar: physics # the supervised worker above (rigid engine)
task: Main # physics steps once per this task's scan
step_dt_ms: 10 # fixed dt (default = the scan interval)
gravity: [0, -9.81, 0]
bodies:
- {id: ground, half: [10, 0.5, 10], pos: [0, -0.5, 0], kind: static}
- {id: boxL, half: [0.25, 0.25, 0.25], pos: [-0.8, 0.25, 0], kind: dynamic}
- {id: boxR, half: [0.25, 0.25, 0.25], pos: [0.8, 0.25, 0], kind: dynamic}
actuators:
- {id: pusherL, half: [0.25,0.25,0.25], pos: [-1.6,0.25,0], on_tag: pusher_l_cmd, velocity: [1.5, 0, 0]}
- {id: pusherR, half: [0.25,0.25,0.25], pos: [1.6,0.25,0], on_tag: pusher_r_cmd, velocity: [-1.5, 0, 0]}
contacts:
- {tag: crash, a: boxL, b: boxR} # crash := TRUE when boxL touches boxR
poses:
- {body: boxL, x_tag: box_l_x, y_tag: box_l_y}
- {body: boxR, x_tag: box_r_x, y_tag: box_r_y}
Run it, POST /api/runtime/start, and watch the tags. When plant.st fires both pusher_l_cmd and pusher_r_cmd together, crash goes TRUE — every run, on the same tick.
Scene reference
bodies — rigid bodies. kind: static (immovable, e.g. ground), dynamic (free), kinematic (moved by an actuator, ignores gravity). Shape is box (half: [hx,hy,hz]) or sphere (radius). Fields: id, shape, half/radius, pos: [x,y,z], optional rot (scaled-axis), vel, kind, material.
actuators — kinematic bodies driven by an ST OUTPUT. on_tag is a BOOL global: while it's TRUE the body moves at velocity: [vx,vy,vz]. This is how your logic pushes the world.
contacts — tag (BOOL INPUT) goes TRUE when bodies a and b touch; optional impulse_tag (REAL) reports the contact impulse. This is how a collision becomes a tag your PLC can latch.
sensors — tag (BOOL INPUT) goes TRUE when body is inside the axis-aligned zone [min] .. [max] — a virtual proximity/photo-eye.
poses — write a body's live transform to tags: x_tag/y_tag/z_tag (position), vx_tag/vy_tag/vz_tag (velocity), speed_tag (magnitude). Use these to drive HMIs or feed control.
constraints — joints between bodies. type: pin or revolute; fields body, body2, anchor, axis, length. (A Newton's-cradle pendulum is a pin per ball on the compliant engine.)
Other twin-level keys: restitution (default contact bounciness; compliant: 1 = perfectly elastic), substeps_per_tc (compliant integrator resolution, default 40).
Notes & limits
- Off by default —
physics_twin.enabledis opt-in. Therigidsidecar ships only where the sim is wanted; the runtime owns its lifecycle (spawn on start, kill on stop, fault cleanly on a sidecar crash). - Determinism is per-deployment — same sidecar build/arch gives the same crash every run; it is not bit-identical across architectures.
- Bind OUTPUT globals to
on_tag, read INPUT globals from contact/sensor/pose tags. Declare these as VAR_GLOBAL so they exist as runtime tags (see the GVL guide). pkg/simlibis the complementary analog layer — tanks/thermal/pump/flow continuous dynamics. The physics twin owns the discrete mechanical world (parts, collisions, stacking); simlib owns continuous process dynamics. Both feed tags.- The browser 3D renderer is a pure viewer — physics + control stay authoritative server-side.