Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit44fd9e59bd

README.md

Joint

Connects two bodies with a joint constraint: the hinges, pins, ropes, springs and sliders that mechanisms and structures are made of, from a door, a chain or a ragdoll to a bridge, a truss or a tower that bends, shows its stress and breaks under load. Each end is a rigid body (an entity with Physics) or a soft body (an entity with SoftBody).

The engine solves joints inside its physics step, measures the load each one carries (tension or compression along a member, shear, bending; Physics.jointReaction / Physics.jointReactions read it) and breaks one when that load passes its breakForce / breakTorque (Physics.onJointBreak reports it), so a structure of members pinned at shared nodes bends, reports where it is overloaded and collapses inside the engine's own step.

Joint kinds: "fixed", "hinge", "ball", "prismatic", "spring", "rope", "generic", "rail".

Two bodies that join on their own when matching points come within reach (a trailer hitch, train couplers, a docking port, a door catch) take a Latch on each point: the step makes the Joint the Latch states and frees it on a release or a break.

Public fields: kind, connected, localAnchorX/Y/Z, remoteAnchorX/Y/Z, axisX/Y/Z, remoteAxisX/Y/Z (remoteAxis), collideConnected, limited, limitMin, limitMax (minAngle, maxAngle), swingLimit, twistLimit, motorMode, motorTarget, motorVelocity, motorStiffness, motorDamping, motorMaxForce, motorModel, lockedAxes, axisLimits, axisMotors, stiffness, damping, restLength, maxDistance, yieldForce, yieldTorque, reelSpeed, breakForce, breakTorque, region, remoteRegion, and a rail's path, closed, ends, curve, tolerance, friction.

connected is the other body: write the entity or its id, and it reads back as the entity; a name written into it is kept as written and found when the joint is built. It is an entity reference, so a Joint a scene's build.luau writes between two entities the build spawns (connected = post or connected = post.id) names the entity the build placed, through every rebuild, save, load and play.

-- scenes/main.scene/build.luau: a plank bridge, each plank hinged to the one before
function content()
    local prev = entity.spawn("bank", { position = { 0, 2, 0 } })
    prev.component.add("Physics", { kind = "static" })
    prev.component.add("BoxCollider", { half = { 0.5, 0.25, 1 } })
    for i = 1, 10 do
        local plank = entity.spawn("plank" .. i, { position = { i, 2, 0 } })
        plank.component.add("Physics", { mass = 2 })
        plank.component.add("BoxCollider", { half = { 0.45, 0.05, 1 } })
        plank.component.add("Joint", { connected = prev, kind = "hinge", axisZ = 1, axisY = 0, localAnchorX = -0.5, remoteAnchorX = 0.5 })
        prev = plank
    end
end

Methods: :setMotor(targetVelocity, maxForce), :onBreak(fn), :isBroken(), :breakEvent(), :reattach(), :state(), :reaction(), :jointEntityId().

Several joints on one body (trusses, hands, two-handed holds)

A body takes any number of joints. The first Joint on an entity has no name; add each further one under a name of its own, or with Physics.addJoint, which names it (joint2, joint3, ...) and returns it. Each is its own solver joint: its own kind, anchors, limits, motor, break thresholds, reaction, state, break record and removal.

A Joint on an entity that is no body itself (a marker parented under a beam's end, a hand under an arm) joins the nearest body above it, at that entity's position and orientation, with its anchor and axis measured in the entity's own frame. connected may name such an entity too, so two beams are pinned end to end through a marker on each. A joint that cannot be built says why in :errors(): an end with no body at or above it (a collider with no Physics is static geometry: give it Physics { kind = "static" } to join a joint to it), a connected id no entity carries, or both ends on one body.

-- a beam pinned at both ends
beam.component.add("Joint", { connected = leftPost.id, kind = "hinge", axisZ = 1, axisY = 0, localAnchorX = -2 })
beam.component.add("Joint", { name = "right", connected = rightPost.id, kind = "hinge", axisZ = 1, axisY = 0, localAnchorX = 2 })

-- a hand holding a crate on top of the arm joint the hand already carries
local grab = Physics.addJoint(hand, crate, { kind = "fixed", breakForce = 2000 })
Physics.jointReaction(grab)          -- this joint's load alone
Physics.removeJoint(grab)            -- let go; the arm joint stays

Physics.jointState, Physics.jointReaction, Physics.setJointMotor and Physics.removeJoint take the Joint itself, or an entity and the Joint's name. A break record's entityId and joint name the entity and the Joint that broke, and Physics.bodyState(id).joints lists each joint a body is in the same way.

:reaction() reads the joint's load (its stress) the way a structure is read: axial along the member at this body's end (tension positive, compression negative), connectedAxial at the other body's, shear, bending, torsion, a rope's or spring's tension, and load, the share of breakForce / breakTorque it stands at. Physics.jointReactions(joints) reads a whole structure's joints in one call, and Physics.listenJointLoads(joints) keeps each joint's peak between two reads; the physics guide's "Reading a joint's load" covers both, with a stress-colouring recipe.

Joint frames, limits and motors (doors, ragdoll joints, sliders, 6DOF)

Each body carries a joint frame at its anchor, its X axis along axis (this body) or remoteAxis (the connected body; zero takes axis). linX / linY / linZ are travel along the frame's axes and angX / angY / angZ turns about them; zero is where the two frames coincide. The frame's Y and Z are the body's own carried by the shortest turn taking its X onto axis (axis = {0, 1, 0} puts the frame's Y along the body's -X). An axis reads positive as this body travels along it or turns right-handed about it, relative to the connected body.

  • Hinge range: limitMin / limitMax in degrees (a prismatic's travel in metres). Writing either turns limited on.
  • Ball cone: swingLimit is the half-angle in degrees the axis swings inside, twistLimit how far it twists either way; negative (the default) leaves each free.
  • Any axis: axisLimits = { angZ = {-30, 30}, linY = { min = 0, max = 0.5 } }.
  • Motor: motorMode = "velocity" holds motorVelocity (rad/s or m/s); motorMode = "position" pulls toward motorTarget (degrees or metres) with motorStiffness / motorDamping; neither applies more than motorMaxForce (0 uncapped). It drives a hinge's angle, a prismatic's travel, each angle of a ball joint and every free axis of a generic joint; axisMotors = { angX = { mode = "velocity", velocity = 2, maxForce = 50 } } drives single axes. :setMotor(v, maxForce) and Physics.setJointMotor set the same fields.
  • Contacts: collideConnected = false stops the two bodies colliding with each other (limbs, links, parts overlapping at the joint).
  • Generic: kind = "generic" holds the axes lockedAxes names ({ "linX", "linZ", "angX", "angY", "angZ" } leaves a slider along linY).

Every field is live: write it on the running joint and the next step uses it. Physics.jointState(id) reads the angle, travel, swing and twist, velocity, atLimit, and the force the limit and motor applied.

-- a door that opens 0 to 110 degrees, closed by a capped motor
door.component.add("Joint", { connected = frame.id, kind = "hinge", axis = {0, 1, 0}, limitMin = 0, limitMax = 110 })
local joint = door.component.get("Joint")
joint.motorMode = "position"; joint.motorTarget = 0; joint.motorMaxForce = 50

-- a shoulder on a cone that does not collide with the torso
arm.component.add("Joint", { connected = torso.id, kind = "ball", swingLimit = 70, twistLimit = 30, collideConnected = false })
entity(id).component.add("Joint", {
    connected = frameEntityId,
    kind = "hinge",
    axis = {0, 1, 0},
})

Springs: stiffness, damping, restLength

A "spring" joint pulls its two anchors toward restLength metres apart with stiffness (newtons per metre of stretch) and resists the speed its length changes at with damping. Left out (the fields read -1, the kind's own), stiffness is 100 and damping 10, and restLength is the distance the anchors stood at when the joint was built. Writing restLength on the live joint moves where it settles, and writing a negative one keeps the length it rests at.

A hinge, ball, prismatic or generic joint moves freely about its axes unless a limit or a motor holds it: its stiffness and damping default to none (the fields read -1, the kind's own). Set on one with no motorMode, they are a spring pulling each of its axes (a hinge's angle, a prismatic's travel, each angle of a ball joint, every free axis of a generic joint) back to where its frames align, read per unit of acceleration. A motorMode = "position" motor with motorTarget pulls toward any other position.

-- a bungee: two bodies held 5 m apart by a soft spring
a.component.add("Joint", { connected = b.id, kind = "spring", restLength = 5, stiffness = 200, damping = 20 })

-- a self-closing door: a hinge with a spring back to its closed angle
door.component.add("Joint", { connected = frame.id, kind = "hinge", axis = {0, 1, 0}, stiffness = 20, damping = 4 })

A "rope" joint is a maximum-distance limit solved inside the physics step: the bodies move freely while the anchors are closer than maxDistance (slack rope) and are held from separating beyond it (taut rope). It requires maxDistance > 0, and maxDistance is live: writing it on the running joint retunes the limit in place.

Without a yieldForce the rope holds at any force: lengthening it lets the load drop, and shortening it reels any load in at the solver's correction speed (3 m/s), however heavy. A rope given a yieldForce winds in at its reelSpeed, pulling with at most that force.

Giving way and holding again: yieldForce, yieldTorque

yieldForce (newtons) is the most force a joint holds its travel with, and yieldTorque (newton-metres) its angles. While the load needs more, the joint gives way at exactly that force or torque; once the load falls below it, the joint holds again wherever it then stands. It keeps working throughout: only breakForce / breakTorque break a joint. 0 (the default) holds without bound.

  • Rope (yieldForce): pays out. maxDistance grows to the length the rope gave way to and reads back as it stands, so the rope holds the load from there once it is light enough. Writing a shorter maxDistance winds the load in at reelSpeed (metres per second, 1 by default), slowing to arrive, pulling with at most yieldForce (a winch with a slip clutch, a tow rope taken up): it gains the full shortening when its load is below the yield, and pays out instead, holding from where it went, when the load is above it. A shock load (a body falling onto the rope) pays out as it brakes.
  • Prismatic (yieldForce), hinge (yieldTorque), ball (yieldTorque), generic (yieldForce on its free travels, yieldTorque on its free angles): the axes hold still below the yield and slide or turn at it above (a friction slider, a breakaway barrier, a slip clutch on a drum, a stiff hinge). The axes take one of a yield, a motor or a stiffness spring; a motor capped by motorMaxForce is the yield that drives somewhere.

:state() (Physics.jointState) reads slipping, true while the joint gave way over the last step, and a rope's length; :reaction() reads the force it carries, which stands at the yield while it slips. A rope's payout is stated on its carrying entity as the joint.length named value too. A negative yield, or a reelSpeed of 0 or less, is refused naming the field: Physics.addJoint raises it, and a live write keeps the value the joint holds and reports it in :errors().

-- a winch rope that holds 3000 N: a heavier load pays it out, a lighter one is reeled in
local rope = Physics.addJoint(hook, load, { kind = "rope", maxDistance = 4, yieldForce = 3000 })
rope.maxDistance = Physics.jointState(rope).length - 1   -- reel in a metre
print(Physics.jointState(rope).slipping, Physics.jointReaction(rope).tension)

-- a crash barrier that holds a 20 kN push and slides at it above
Physics.addJoint(barrier, post, { kind = "prismatic", axis = {1, 0, 0}, yieldForce = 20000 })
entity(ballId).component.add("Joint", {
    connected = hookEntityId,
    kind = "rope",
    maxDistance = 8,
})

A soft body end is anchored on the body as a whole (its frame, the rigid motion that best fits its particles), or on a region of its particles: region for this entity, remoteRegion for the connected one, each { box = { min, max } } (every particle between the corners) or { nearest = point } (the one particle nearest the point), in the entity's unit box (-0.5 to 0.5 on each axis) unless space = "world". A region end's anchor is measured from the centre of its particles. Motors, breakForce / breakTorque and Physics.jointReaction work the same at either kind of end, and the joint is removed with either body.

-- a soft punching bag hung by its top particle from a fixed hook
bag.component.add("Joint", { connected = hook.id, kind = "ball", region = { nearest = { 0, 0.5, 0 } } })

-- two jellies held face to face, breaking under 200 N
a.component.add("Joint", {
    connected = b.id, kind = "fixed", breakForce = 200,
    region = { box = { min = { 0.4, -0.5, -0.5 }, max = { 0.5, 0.5, 0.5 } } },
    remoteRegion = { box = { min = { -0.5, -0.5, -0.5 }, max = { -0.4, 0.5, 0.5 } } },
})

A hinge's turn can be a part of a ShaftNetwork: { name = "drum", joint = drumEntity } couples it to motors, gears, clutches, brakes and other hinges (a winch, a crane's slew, a geared contraption, a propeller on a shaft), the network's torque acting on the joint's body about its axis and, opposite, on the connected body. The ShaftNetwork component README has the fields.

Rails: a point held to a path (tracks, coasters, zip lines, sliding doors)

kind = "rail" holds the point at this body's anchor (localAnchor) to path on the connected body: free to move along the path under gravity, contacts, forces and its motor, held across it. A rollercoaster car on its track, a train on its line, a trolley on a zip line, a door on a curved track, a bead on a wire, a camera dolly.

  • The path: path is the points it runs through, { {x, y, z}, ... } in the connected entity's local space, so a path on a moving body moves with it (at least 2 points; 3 for a loop). A spline from modules.spline is taken as it is, its closed with it. curve = "smooth" (the default) runs a smooth curve through the points; "linear" straight runs between them.
  • Ends: closed = true makes the path a loop. An open path's ends are ends = "capped" (the default: the point stops at them) or "open" (the point runs off, the rail lets go of it, and state().onPath reads false with left naming the end). :reattach() takes a point that ran off back onto the path's nearest point: move the body back to the path first.
  • Held: the point stands on the path to within the solver's precision, through bends and loops at speed; a point found more than tolerance metres (default 0.01) off it when a step begins is put back on it. A frictionless rail does no work: a cart keeps its energy round a loop. A soft body (SoftBody) is held by the particles region names ({ nearest = point } for one), through the body's own give: under load they stand off the path by as much as the body stretches there.
  • Driven: motorMode = "velocity" holds motorVelocity m/s along the path (positive toward its end), motorMode = "position" brings the point to motorTarget metres along it with motorStiffness / motorDamping; neither pushes harder than motorMaxForce newtons (0 uncapped). :setMotor(speed, maxForce) sets a velocity motor. friction is a coefficient on the force holding the point to the path.
  • Refused: a path that cannot be held (fewer points than the path needs, two consecutive points together, a point that is no position), a tolerance of 0 or less, a negative friction, or a field another kind takes (stiffness, damping, restLength, maxDistance, limitMin / limitMax, swingLimit / twistLimit, lockedAxes, axisLimits, axisMotors, yieldForce, yieldTorque) is refused by name, as for every joint kind: Physics.addJoint raises the reason and takes the Joint off the entity, and a Joint added as a component stands down with lifecycle "failed" and the reason as its disabledReason.
  • Read: :state() (Physics.jointState) reads progress (metres from the path's start), speed, length, closed, laps (times round a loop), onPath, left, atEnd ("start" / "end" while a capped end holds the point), offset (metres off the path), railMotorForce, frictionForce, tangent, normal and curvature. :reaction() reads the force holding the point, with localForce along the path (x), toward the bend's centre (y) and across (z). breakForce releases the rail above that force, with the break event every joint sends.
-- a coaster car on a looping track
car.component.add("Joint", { connected = track.id, kind = "rail", path = trackPoints, closed = true, collideConnected = false })
local s = car.component.get("Joint"):state()
print(s.progress, s.speed, s.laps)

-- a zip line: the trolley's point 1 m above its centre, stopped at the far end
Physics.addJoint(trolley, cable, { kind = "rail", path = { {0, 12, 0}, {15, 6, 0}, {30, 3, 0} }, localAnchor = {0, 1, 0}, friction = 0.02 })

-- a sliding door on a curved track, driven open by a capped motor
Physics.addJoint(door, frame, { kind = "rail", path = arcPoints, motorMode = "position", motorTarget = 1.2, motorMaxForce = 300 })