simvx.core.physics.pymunk_backend

What maps cleanly to Chipmunk2D

  • bodies / motion types: BodyMode STATIC/KINEMATIC/DYNAMIC -> pymunk.Body STATIC/KINEMATIC/DYNAMIC; mass + per-shape moment.

  • shapes: Circle/Box(Poly)/Capsule(rounded poly)/Segment/ConvexPolygon(Poly)/ ConcavePolygon(static segment soup) -> pymunk.Circle / pymunk.Poly / pymunk.Segment.

  • step: space.step(dt) at the caller’s fixed timestep.

  • forces: apply_force_at_world_point / apply_impulse_at_world_point / per-step torque accumulation.

  • joints: fixed/pin/hinge/spring/groove -> PinJoint / PivotJoint / DampedSpring / GrooveJoint (a weld is a PivotJoint + a stiff DampedRotarySpring to lock the relative angle).

  • queries: raycast -> segment_query; shapecast/overlap -> shape_query.

  • events: a per-pair on_collision handler edge-diffs body / sensor pairs into the engine’s two event streams (contacts and sensor overlaps).

What needs care (documented honesty caveats)

  • CombineMode: Chipmunk MULTIPLIES both coefficients (mu_a * mu_b for friction, e_a * e_b for elasticity) and has no per-contact combine-mode switch, so the engine’s per-coefficient :class:~simvx.core.physics.material.CombineMode cannot be honoured natively for every neighbour pair. The raw coefficients are handed to Chipmunk and its fixed rule decides (see _combine_into_shape).

  • swept motion: Chipmunk exposes no swept-shape time-of-impact for an un-added shape, so :meth:PymunkPhysics2D.sweep_body substeps a transient probe body with shape_query, exactly like :class:BuiltinPhysics2D. distance is therefore the last substep proved non-penetrating, not an exact touch distance.

  • KINEMATIC bulk WRITE: pymunk.batch SET is experimental, so per-body writes are used on the (cold) set paths; the bulk READ uses pymunk.batch zero-copy when present.

  • collision layers: Chipmunk’s cpBitmask is 32 bits wide, so only the low 32 bits of a collision layer survive into the native broadphase. A body whose layer uses only bits at or above bit 32 is invisible to the broadphase and therefore to queries, while Builtin (full-width Python ints) still sees it.

PymunkPhysics2D: the optional native (Chipmunk2D / pymunk) 2D backend.

A native :class:~simvx.core.physics.world2d.Physics2DWorld implementation and an OPTIONAL accelerator: installing pymunk (the pymunk extra) makes

class:

~simvx.core.physics.root.PhysicsRoot2D auto-select it, exactly mirroring the engine’s miniaudio “installed -> used” model. When pymunk is absent, auto-discovery silently falls back to

class:

~simvx.core.physics.builtin.world2d.BuiltinPhysics2D (no crash, no warning spam).

The contract is parity, not bit-identity: “switching backends never changes how a game plays”. A game using CharacterBody2D must behave the same (within tolerance) on pymunk as on Builtin: a character is an ordinary KINEMATIC body here too, and the shared collide-and-slide policy in

mod:

simvx.core.physics.slide drives it through the same

meth:

~simvx.core.physics.world2d.Physics2DWorld.sweep_body primitive, even though Chipmunk2D’s solver differs from the pure-Python tier.

Module Contents

Classes

PymunkPhysics2D

Native Chipmunk2D (pymunk) 2D backend implementing the full 2D world interface.

Functions

register

Self-register the pymunk backend with the backend registry.

Data

API

class simvx.core.physics.pymunk_backend.PymunkPhysics2D(*, gravity: simvx.core.math.Vec2 | None = None)[source]

Bases: simvx.core.physics.world2d.Physics2DWorld

Native Chipmunk2D (pymunk) 2D backend implementing the full 2D world interface.

See :class:~simvx.core.physics.world2d.Physics2DWorld for the contract. Every @abstractmethod is implemented over a single pymunk.Space.

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 sensors-see-static.

Chipmunk exposes each arbiter’s total_impulse, so the ENTER payload is a real measurement: the solver’s total impulse projected onto the contact normal and summed over the pair’s arbiters (see _collect_enter_impulses). No cross-platform determinism, vehicles or soft bodies.

SENSOR_DETECTS_STATIC is real, and it is this adapter that makes it so: Chipmunk keeps static shapes in an index of their own and queries it only against the moving one, so a static-vs-static pair is never generated, and a sensor is therefore held KINEMATIC (see

Func:

_motion_mode) to put it in the index that IS queried. Measured on a sensor overlapping one box: one ENTER whichever mode the box is in, and one for another STATIC sensor too, where before the STATIC pairs reported nothing.

SLEEP is absent, and that is a property of Chipmunk rather than of this adapter: a cpSpace sleeps nothing until it is given a finite sleep-time threshold, and asking a body in such a space to sleep aborts the process at the C level instead of raising. Nothing here ever falls asleep, so :meth:sleeping is permanently False and the whole sleep surface is inert; see the capability’s own documentation for what a caller does about it.

property body_count: int[source]
clear() None[source]

Remove every body and joint, emptying the world.

See :meth:~simvx.core.physics.world2d.Physics2DWorld.clear. Routes through the existing remove_joint / destroy_body so each is correctly removed from the live pymunk.Space (joints first, then bodies). The per-step edge-diff buffers are reset too. 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. A handle here names a _ShapeDef spec, not a live pymunk.Shape: the Chipmunk shapes belong to the bodies built from it, which keep them and keep simulating. Dropping the spec is the whole of it. 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]

Accept the flag and integrate discretely: Chipmunk2D has no CCD.

The one live setter this backend cannot honour, for the same reason it already ignores create_body’s continuous argument: Chipmunk has no swept / speculative integration of any kind, so there is nothing to switch on. Advertised through the absence of

Attr:

~simvx.core.physics.capability.Capability.CONTINUOUS rather than raised, so a scene that merely prefers CCD still runs and a caller that requires it can check for it by name instead of by backend.

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]

Accept the call and do nothing: this space sleeps nothing.

The one sleep-surface call that CANNOT be forwarded. Chipmunk asserts sleep_time_threshold < INFINITY inside cpBodySleep and aborts the whole process when it does not hold, and this space leaves the threshold at its default of infinity, so forwarding would turn a harmless call into a crash. Advertised through the absence of

Attr:

~simvx.core.physics.capability.Capability.SLEEP rather than raised, exactly as :meth:set_body_continuous handles the missing CCD.

set_body_can_sleep(handle: simvx.core.physics.world2d.BodyHandle, enabled: bool) None[source]

Record the flag; nothing sleeps here, so nothing changes.

Kept honest rather than dropped: a caller that reads it back gets what it set, and forbidding sleep is already true of every body in this space.

sleeping(handle: simvx.core.physics.world2d.BodyHandle) bool[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]
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]
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]
raycast(origin: simvx.core.math.Vec2, direction: simvx.core.math.Vec2, max_dist: float, *, mask: int = 4294967295) simvx.core.physics.world2d.RaycastHit2D | None[source]
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]
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]
overlap(shape: simvx.core.physics.world2d.ShapeHandle, transform: object, *, mask: int = 4294967295) list[simvx.core.physics.world2d.BodyHandle][source]
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 shapes against the space.

See :meth:~simvx.core.physics.world2d.Physics2DWorld.sweep_body. Chipmunk exposes no swept-shape time-of-impact for an un-added shape, so this substeps along motion with a step count sized from the mover’s smallest feature and capped at 64; 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**_SWEEP_REFINE_STEPS) 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.

Structurally non-mutating: the sweep runs on a transient un-added probe body built by :meth:_probe_body, so the space’s spatial index is never touched (the mover’s own shapes stay indexed at its real pose, 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. Fraction 0 is never sampled (the substep loop starts at st = 1), so this backend cannot report the cast axis as a normal.

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.pymunk_backend.register() None[source]

Self-register the pymunk backend with the backend registry.

Called on import (below) and idempotent (register_backend replaces a same- named entry), so importing this module installs the "pymunk" backend as an auto-discoverable native (mirroring miniaudio’s “installed -> used” model).

simvx.core.physics.pymunk_backend.__all__

[‘PymunkPhysics2D’, ‘register’]