simvx.core.physics.builtin.world2d

Narrowphase

Exact circle-circle, ROTATION-honouring circle-box (a 2D improvement over the 3D AABB approximation), circle/capsule segment reductions, and the keystone poly-poly SAT + Sutherland-Hodgman edge clipping (box and segment route through the poly path as degenerate polys). Concave (static edge soup) is broadphase-AABB-culled then tested per candidate segment. The solver mirrors the 3D structure with 2D scalar-cross-product angular terms: the 2D system carries a real scalar moment of inertia, so contacts apply linear AND angular impulse, unlike the linear-only 3D basic tier.

Dynamics

  • forces: apply_force / apply_torque / apply_impulse (force/torque accumulate and clear each step; impulse is instantaneous; the lever-arm variants add a scalar angular term via the 2D cross product).

  • joints: create_fixed / pin / hinge / spring / groove joint + remove_joint, solved in the SAME velocity loop as contacts plus a Baumgarte position pass. A 2D hinge equals a pin here since 2D rotation is 1-DOF, and there is no motor or limit. The groove is a 1-DOF slider-on-a-line.

  • sleeping: a settled DYNAMIC body sleeps after sleep_time_threshold and wakes at every disturbance (set / force / impulse / an awake contact / a joint or spring whose OTHER end moved since the previous step).

  • CCD: continuous bodies centre-sweep vs STATIC and clamp to the TOI.

Basic-tier honesty

  • Joints converge in a few iterations (small solver_iterations / position_iterations) and stiff springs are soft. A loaded chain really sags: six 1 kg beads at 1200 units/s^2 settle with the worst link 22.0% long here and the tip 341 units down on 300 units of chain, where pymunk settles the same chain on its rest length. Anchoring is exact on both – what differs is how hard the constraint pulls. Motors, angular limits and breakable joints are in the 2D seam on NEITHER backend.

  • CCD is a CENTRE sweep vs STATIC only (no rotational / dynamic-vs-dynamic sweep); a fast body grazing a corner can still tunnel it. pymunk honours it properly.

  • SAT resolves a DEEP interpenetration along the minimum-overlap axis, which is the shortest way OUT rather than the way the body came in. Once the penetration passes half the two shapes’ summed extent along that axis, the shortest way out is the FAR side, so a pair that was solidly touching at the previous step is recovered out the face its remembered contact normal names (a resting body teleported into its support, a collider grown through its floor). A pair with NO solid history keeps the blind minimum deliberately: the entry face of a fresh deep overlap is not knowable (a tunnelled arrival and an unstick shove present the same state and need opposite answers), so past the midplane a history-less pair still exits the far face. The manifold points are the clipped incident edge, so on a deep overlap they can also sit outside the reference collider, which makes the contact lever arms approximate there. A full clipping-with-feature-caching manifold and true deep-overlap handling are deferred to the pymunk (Chipmunk2D) backend.

  • Warm-starting / accumulated-impulse caching: each contact’s per- manifold-point normal + tangent impulse is cached by the persistent body-pair id and applied before the iteration loop (standard Box2D technique), so a tall stack converges in a couple of iterations instead of rebuilding its support from zero. Full feature-id manifold persistence (vs the point-index keying used here) is the pymunk concern.

  • Capsule / poly / segment / concave scalar moments are the AABB-box estimate (_moment_from_aabb); the exact per-shape composite is a pymunk concern.

  • Concave broadphase is a linear AABB scan over candidate segments (a BVH is deferred to pymunk), and concave is STATIC-ONLY: it is never the moving shape.

BuiltinPhysics2D world: the pure-Python 2D physics backend.

Concrete :class:~simvx.core.physics.world2d.Physics2DWorld implementation, pure Python (numpy only). The 2D sibling of

class:

~simvx.core.physics.builtin.world.BuiltinPhysics: the engine’s default, always-available 2D backend and the behavioural parity target for the optional native pymunk (Chipmunk2D) backend.

Mechanism: semi-implicit (symplectic) Euler integration, scalar rotation, real per-shape scalar moment of inertia. Where it simplifies versus a production solver the simplification is commented and the pymunk backend is named as the exact alternative; nothing silently fakes a result. This solver is a tier above its 3D sibling, refuses no shape pair, and is not covered by that module’s honesty list.

The GEOMETRY is written for clarity over raw throughput. What is optimised is how OFTEN it runs: a sweep-and-prune broad phase proposes only the pairs whose world AABBs meet, and a pair whose bodies have not moved replays its last answer instead of re-deriving it. 2D needs both more than 3D does, because a 2D contact costs more: this solver carries a real moment of inertia, so every contact point has a lever arm and a Coulomb friction cone where the 3D tier applies one linear impulse at the centre of mass.

step() runs integrate -> CCD -> collide -> solve (contacts + joints) -> position-correct -> sleep, mirroring

meth:

~simvx.core.physics.builtin.world.BuiltinPhysics.step. Every Physics2DWorld method is implemented: there are no stubs.

Module Contents

Classes

BuiltinPhysics2D

Pure-Python default 2D backend (basic tier).

Data

API

class simvx.core.physics.builtin.world2d.BuiltinPhysics2D(*, gravity: simvx.core.math.Vec2 | None = None)[source]

Bases: simvx.core.physics.world2d.Physics2DWorld

Pure-Python default 2D backend (basic tier).

See :class:`~simvx.core.physics.world2d.Physics2DWorld` for the full contract.
Implements the COMPLETE interface: shapes + bodies + integrator + bulk readers

, narrowphase + sequential-impulse solver, forces + joints + sleeping + CCD, queries + events + the swept body primitive, one-way platforms, and the groove joint. No method is a stub.

Initialization

Initialise the world.

Args: gravity: World gravity acceleration vector (Vec2), metres/s^2. Y-up: Vec2(0, -9.81) is “down”.

capabilities() frozenset[simvx.core.physics.capability.Capability][source]

Advertise the measured contact impulse and continuous collision.

2D sibling of :meth:BuiltinPhysics.capabilities: this backend owns its sequential-impulse solver, so the contact-event impulse is the converged normal impulse it actually applied, and its integrator honours the CONTINUOUS flag by sweeping a flagged body against STATIC geometry, and SLEEP by parking a settled DYNAMIC body. SENSOR_DETECTS_STATIC holds for the same reason it does in 3D: the sensor sweep considers every body the mask admits, not only the ones that can move. No determinism, vehicles or soft bodies. Explicit (not inherited) so the claim is a deliberate, tested promise.

property body_count: int[source]

Number of bodies currently in the world (len of the body table).

clear() None[source]

Remove every body and joint, emptying the world.

See :meth:~simvx.core.physics.world2d.Physics2DWorld.clear. 2D sibling of

Meth:

BuiltinPhysics.clear: resets the body / joint tables and the per-step edge-diff + warm-start caches. Gravity, shapes, and handle counters are intentionally NOT reset.

create_circle(radius: float) simvx.core.physics.world2d.ShapeHandle[source]
create_box(half_extents: simvx.core.math.Vec2) simvx.core.physics.world2d.ShapeHandle[source]
create_capsule(radius: float, height: float) simvx.core.physics.world2d.ShapeHandle[source]
create_segment(a: simvx.core.math.Vec2, b: simvx.core.math.Vec2, radius: float = 0.0) simvx.core.physics.world2d.ShapeHandle[source]
create_convex_polygon(points: numpy.ndarray) simvx.core.physics.world2d.ShapeHandle[source]
create_concave_polygon(segments: numpy.ndarray) simvx.core.physics.world2d.ShapeHandle[source]
destroy_shape(shape: simvx.core.physics.world2d.ShapeHandle) None[source]

Release this world’s record of a shape handle.

See :meth:~simvx.core.physics.world2d.Physics2DWorld.destroy_shape. 2D sibling of :meth:BuiltinPhysics.destroy_shape: nothing native to free, so the record is simply dropped from the shape table. Bodies built from it hold their own _Shape2D record directly and are unaffected. Unknown handles are a silent no-op.

create_body(shape: simvx.core.physics.world2d.ShapeHandle, body_type: simvx.core.physics.world.BodyMode, transform: object, *, mass: float = 1.0, scale: simvx.core.math.Vec2 | None = None, can_sleep: bool = True, linear_damping: float = DEFAULT_LINEAR_DAMPING, angular_damping: float = DEFAULT_ANGULAR_DAMPING, gravity_scale: float = DEFAULT_GRAVITY_SCALE, collision_layer: int = 1, collision_mask: int = 4294967295, is_sensor: bool = False, material: simvx.core.physics.material.PhysicsMaterial | None = None, continuous: bool = False) simvx.core.physics.world2d.BodyHandle[source]
destroy_body(handle: simvx.core.physics.world2d.BodyHandle) None[source]
set_body_transform(handle: simvx.core.physics.world2d.BodyHandle, transform: object, *, scale: simvx.core.math.Vec2 | None = None, wake: bool = True) None[source]
set_body_velocity(handle: simvx.core.physics.world2d.BodyHandle, linear: simvx.core.math.Vec2, angular: float = 0.0) None[source]
set_body_mode(handle: simvx.core.physics.world2d.BodyHandle, mode: simvx.core.physics.world.BodyMode, *, wake: bool = True) None[source]
set_body_mass(handle: simvx.core.physics.world2d.BodyHandle, mass: float, *, wake: bool = True) None[source]
set_body_filter(handle: simvx.core.physics.world2d.BodyHandle, collision_layer: int, collision_mask: int, *, wake: bool = True) None[source]
set_body_material(handle: simvx.core.physics.world2d.BodyHandle, material: simvx.core.physics.material.PhysicsMaterial | None) None[source]
set_body_damping(handle: simvx.core.physics.world2d.BodyHandle, linear: float, angular: float) None[source]
set_body_gravity_scale(handle: simvx.core.physics.world2d.BodyHandle, scale: float) None[source]
set_body_continuous(handle: simvx.core.physics.world2d.BodyHandle, enabled: bool) None[source]
set_body_shape(handle: simvx.core.physics.world2d.BodyHandle, shape: simvx.core.physics.world2d.ShapeHandle, *, wake: bool = True) None[source]
body_velocity(handle: simvx.core.physics.world2d.BodyHandle) tuple[simvx.core.math.Vec2, float][source]
body_transform(handle: simvx.core.physics.world2d.BodyHandle) tuple[simvx.core.math.Vec2, float][source]
body_mass(handle: simvx.core.physics.world2d.BodyHandle) float[source]
wake(handle: simvx.core.physics.world2d.BodyHandle) None[source]
sleep(handle: simvx.core.physics.world2d.BodyHandle) None[source]
set_body_can_sleep(handle: simvx.core.physics.world2d.BodyHandle, enabled: bool) None[source]
sleeping(handle: simvx.core.physics.world2d.BodyHandle) bool[source]
step(dt: float) None[source]
drain_contact_events() list[simvx.core.physics.world2d.ContactEvent2D][source]
drain_overlap_events() list[simvx.core.physics.world2d.OverlapEvent2D][source]
register_bodies(handles: list[simvx.core.physics.world2d.BodyHandle]) None[source]
read_transforms(out: numpy.ndarray) None[source]
read_velocities(out: numpy.ndarray) None[source]
apply_impulse(handle: simvx.core.physics.world2d.BodyHandle, impulse: simvx.core.math.Vec2, *, at: simvx.core.math.Vec2 | None = None, angular: float = 0.0) None[source]
apply_force(handle: simvx.core.physics.world2d.BodyHandle, force: simvx.core.math.Vec2, *, at: simvx.core.math.Vec2 | None = None) None[source]
apply_torque(handle: simvx.core.physics.world2d.BodyHandle, torque: float) None[source]
create_fixed_joint(a: simvx.core.physics.world2d.BodyHandle, b: simvx.core.physics.world2d.BodyHandle) simvx.core.physics.world2d.JointHandle[source]
create_pin_joint(a: simvx.core.physics.world2d.BodyHandle, b: simvx.core.physics.world2d.BodyHandle, anchor: simvx.core.math.Vec2) simvx.core.physics.world2d.JointHandle[source]
create_hinge_joint(a: simvx.core.physics.world2d.BodyHandle, b: simvx.core.physics.world2d.BodyHandle, anchor: simvx.core.math.Vec2) simvx.core.physics.world2d.JointHandle[source]
create_spring_joint(a: simvx.core.physics.world2d.BodyHandle, b: simvx.core.physics.world2d.BodyHandle, rest_length: float, stiffness: float, damping: float) simvx.core.physics.world2d.JointHandle[source]
create_groove_joint(a: simvx.core.physics.world2d.BodyHandle, b: simvx.core.physics.world2d.BodyHandle, groove_a: simvx.core.math.Vec2, groove_b: simvx.core.math.Vec2, anchor_b: simvx.core.math.Vec2) simvx.core.physics.world2d.JointHandle[source]

Constrain b’s anchor_b to slide along a’s groove segment.

groove_a / groove_b are body-LOCAL points in a’s frame defining the groove segment; anchor_b is a body-local point in b’s frame. They are stored exactly as given – the constraint records keep every arm in its own body’s frame (see :class:_GrooveConstraint2D) – so the rail turns with a. The groove direction must be non-zero.

remove_joint(handle: simvx.core.physics.world2d.JointHandle) None[source]
set_one_way(handle: simvx.core.physics.world2d.BodyHandle, enabled: bool, normal: simvx.core.math.Vec2 = _DEFAULT_UP_2D) None[source]

Mark a body as a one-way platform.

Stores the enabled flag and the world-space pass-through (“solid side”) normal on the body. When enabled, the contact filter (:meth:_one_way_rejects, applied in :meth:_collide and :meth:sweep_body) keeps a contact only when the other body lands from the +normal side and discards it when the other body passes up through. The normal is normalised (a degenerate zero normal falls back to +Y, a floor).

A platform that stops resolving contacts from one side has taken support away from whatever was resting on that side, so a real change here is a support-taking mutation like a filter edit or a shape swap: the wake reaches the body’s sleeping neighbours and not only the platform itself. Without it a settled stack would hang in the air above a platform that no longer holds it, with no later step able to notice. Any real change takes the same path, including the off direction and a normal edit, which ADD support rather than take it: the affected bodies still need a step awake to settle against the new rule. A write that changes neither the flag nor the normal takes nothing away and wakes nothing, so a game may drive this every frame.

raycast(origin: simvx.core.math.Vec2, direction: simvx.core.math.Vec2, max_dist: float, *, mask: int = 4294967295) simvx.core.physics.world2d.RaycastHit2D | None[source]

Cast a ray, return the nearest body whose layer matches mask.

2D sibling of the 3D raycast: a single query-mask convention (mask & body.collision_layer: the OBSERVER decides, unlike the body-body AND rule). Reuses the _raycast_body_2d per-shape helpers (the exact analytic casts CCD already needs) over every body; the nearest positive t within max_dist wins. An infinite max_dist is fine (the casts are analytic). Returns None on a clean miss.

raycast_all(origin: simvx.core.math.Vec2, direction: simvx.core.math.Vec2, max_dist: float, *, mask: int = 4294967295) list[simvx.core.physics.world2d.RaycastHit2D][source]

Cast a ray, return EVERY hit within max_dist sorted by distance.

2D sibling of the 3D raycast_all: the same per-body _raycast_body_2d cast as :meth:raycast, but collects all hits and sorts by distance (so the first element is the nearest). Single query-mask convention.

shapecast(shape: simvx.core.physics.world2d.ShapeHandle, origin: simvx.core.math.Vec2, direction: simvx.core.math.Vec2, max_dist: float, *, mask: int = 4294967295) simvx.core.physics.world2d.SweepHit2D | None[source]

Sweep shape from origin along direction, earliest-TOI contact.

Basic-tier honesty: a SUBSTEPPED sweep (mirrors the 3D shapecast), not a true continuous TOI: a transient probe body is advanced along the ray and the first substep that the narrowphase reports penetrating an OTHER body is the earliest hit; the probe backs off to the previous non-penetrating substep and reports the contact. Substep count is sized from the probe’s smallest feature, capped at 64. A fast sweep past a very thin collider can tunnel between substeps; an analytic shape-cast (conservative advancement) is deferred to the pymunk backend. Single query-mask convention (mask & body.layer). Concave shapes are STATIC-only and cannot be the moving probe (rejected). Returns None when the sweep stays clear.

overlap(shape: simvx.core.physics.world2d.ShapeHandle, transform: object, *, mask: int = 4294967295) list[simvx.core.physics.world2d.BodyHandle][source]

All bodies overlapping shape at transform, sorted by handle.

2D sibling of the 3D overlap: places a transient probe body at transform (a Transform2D / bare position / (position, rotation) pair, via the same _unpack_transform) and returns every body it overlaps whose layer & mask is set. Broadphase AABB cull then the exact narrowphase, mirroring _collide’s prefilter. Sorted by handle for determinism. Concave shapes are STATIC-only and cannot be the probe.

sweep_body(handle: simvx.core.physics.world2d.BodyHandle, motion: simvx.core.math.Vec2, *, from_transform: tuple[simvx.core.math.Vec2, float] | None = None, skin: float = 0.0) simvx.core.physics.world2d.SweepHit2D | None[source]

Substepped, non-mutating sweep of a body’s shape (basic tier).

2D sibling of :meth:~simvx.core.physics.builtin.world.BuiltinPhysics.sweep_body and the primitive the collide-and-slide policy is built on. Basic-tier honesty: a SUBSTEPPED narrowphase sweep (the narrowphase is analytic overlap, not a continuous TOI), sized from the mover’s smallest feature and capped at 64 substeps; the substep that penetrates a blocking body brackets the contact, and the bracket is BISECTED against that blocker alone, so distance is a bound good to |motion| / (substeps * 2**8) rather than to one whole substep. It is still a lower bound and may be exactly 0.0 for a sweep that begins in contact. A fast mover vs a very thin collider can still tunnel between substeps (an analytic sweep is a pymunk concern).

Structurally non-mutating: the mover’s shape and pose are wrapped in a TRANSIENT _Body2D probe, so nothing in the body table is touched and from_transform costs nothing. skin is accepted and IGNORED (the substep quantum already exceeds any sane skin).

A body blocks only when it is not the mover, is not a sensor, passes the canonical AND layer/mask rule, is not rejected by the one-way filter, and presents a contact that OPPOSES the sweep. The one-way test runs BEFORE the opposition test and honours a one-way MOVER as well as a one-way blocker, so a body sweeping UP through a one-way platform passes and a top landing is blocked. Sensors take no part in collision resolution, so one never blocks a sweep and (because the ground and step-up probes route through here) never counts as ground or as a step surface. A non-opposing touch, e.g. the floor a character already rests on while it walks, is a touch and not a blocker: reporting it would halt every slide at distance 0. Fraction 0 is never sampled (the substep loop starts at s = 1), so this backend cannot report the cast axis as a normal.

property gravity: simvx.core.math.Vec2
property solver_iterations: int
property position_iterations: int
property sleep_time_threshold: float
property sleep_velocity_threshold: float
property contact_slop: float
move_and_collide(handle: simvx.core.physics.world2d.BodyHandle, motion: simvx.core.math.Vec2) simvx.core.physics.world2d.SweepHit2D | None
__slots__

()

simvx.core.physics.builtin.world2d.__all__

[‘BuiltinPhysics2D’]