from manim import *
import pymunk
from pymunk import Body, autogeometry
from typing import Callable, Dict, Any, Tuple, Union
import numpy as np
from manim.mobject.geometry.arc import Circle
from manim.mobject.geometry.line import Line
from manim.mobject.mobject import Mobject
from manim.utils.bezier import subdivide_bezier
from manim_pymunk.utils.img_tools import get_normalized_convex_polygons
from manim_pymunk.utils.logger_tool import manim_pymunk_logger
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
[docs]
class VSpace(Mobject, metaclass=ConvertToOpenGL):
"""Pymunk physical space management is generally not used by users.
This object has already been created in SpaceScene.
The Manim visualization manager for a Pymunk physical space.
The VSpace class encapsulates the Pymunk `Space` object, managing rigid bodies,
shapes, and constraints within the simulation. It utilizes Manim's updater
mechanism to synchronize the physical simulation results with Mobject
visual states in real-time.
Parameters
----------
gravity
The gravity acceleration vector $(g_x, g_y)$. Defaults to $(0, -9.81)$.
sub_step
The number of sub-steps per frame for physical simulation. Increasing
this value improves numerical stability and collision accuracy.
Defaults to 8.
Examples
--------
.. manim:: VSpaceExample
import random
from manim_pymunk import *
class VSpaceExample(SpaceScene):
def construct(self):
COLLISION_TYPE = 123
# 1. 地板
floor = Line(start=LEFT * 5, end=RIGHT * 5, stroke_width=12, color=BLUE)
floor.to_edge(DOWN, buff=0.1)
self.add_static_body(floor)
# 2. 生成石头
stone_num = 15
stones = [
Dot(color=BLUE).move_to(
random.uniform(1, 3) * UP + random.uniform(-2, 2) * RIGHT
)
for _ in range(stone_num)
]
self.add_dynamic_body(*stones)
self.set_collision_type(floor, *stones, collision_type=COLLISION_TYPE)
def post_solve_callback(arbiter, space, data):
# 测试获取碰撞瞬间的冲量长度
if arbiter.total_impulse.length > 0.2:
print(f"Impact Strength: {arbiter.total_impulse.length:.2f}")
return True
self.set_collision_detection_handler(
collision_type_a=COLLISION_TYPE,
collision_type_b=COLLISION_TYPE,
post_solve=post_solve_callback,
)
self.apply_impulse_at_local_point(*stones, impulse=(0, 0.1, 0))
self.apply_force_at_world_point(stones[0], force=(0.1, 0, 0))
start_pt = (0, 2, 0)
end_pt = (0, -7, 0)
self.add(Line(start_pt, end_pt, color=RED))
self.wait(1.5)
results = self.get_line_query(floor, start_pt, end_pt, stroke_width=0.1)
if results:
hit_point = results[0][2] # 获取碰撞点坐标
print(f"Floor detected at: {hit_point}")
# 在探测到的位置画一个临时的红圈验证
self.add(Dot(hit_point, color=YELLOW, radius=0.1))
final_vel = self.get_velocity_at_local_point(stones[-1])
print(f"Last stone velocity: {final_vel}")
self.wait(1)
"""
def __init__(
self, gravity: Tuple[float, float] = (0, -9.81), sub_step: int = 8, **kwargs
):
super().__init__(**kwargs)
self.space = pymunk.Space()
self.space.gravity = gravity
self.space.sleep_time_threshold = 1
self.sub_step: int = sub_step
# ================================== init ==================================
[docs]
def init_updater(self):
self.add_updater(self.__step_updater)
# ================================== updater ==================================
def __step_updater(self, vspace, dt):
"""Executes a single frame update step for the physical simulation.
Divides the frame duration into multiple sub-steps and performs incremental
`step` calculations on the physical space. This significantly improves
numerical stability and precision, preventing high-speed objects from
tunneling through boundaries.
Parameters
----------
vspace
The VSpace object itself, acting as the controller for the simulation.
dt
The time increment for the current frame (in seconds).
"""
sub_dt = dt / self.sub_step
for _ in range(self.sub_step):
vspace.space.step(sub_dt)
def __simulate_updater(self, mob: Mobject):
"""Synchronizes a Mobject's position and rotation with its associated physical body.
Reads the latest kinematic state (position and angle) from the Pymunk `Body`
and updates the Mobject's transform. This ensures that the visual
representation in Manim stays perfectly aligned with the physics simulation.
Parameters
----------
mob
The Manim Mobject to be synchronized. It must have a `.body`
attribute linked to a Pymunk body.
"""
x, y = mob.body.position
mob.move_to((x, y, 0))
mob.rotate(mob.body.angle - mob.angle)
mob.angle = mob.body.angle
# =============================== space ==================================
[docs]
def remove_body_shapes_constraints(
self, *items: Union[pymunk.Body, pymunk.Shape, pymunk.constraints.Constraint]
) -> None:
"""Removes physical bodies, shapes, or constraints from the physical space.
This method handles the unregistration of Pymunk objects. It is crucial for
maintaining simulation performance and preventing memory leaks or unexpected
physical interactions after a Mobject has been removed from the scene.
Parameters
----------
items
The Pymunk objects (Body, Shape, or Constraint) to be removed from
the simulation space.
"""
self.space.remove(*items)
[docs]
def _add_body2space(self, mob: Mobject) -> None:
"""Registers the physical body and shapes of a Mobject into the simulation space.
If the body is static, only the shapes are added to the space. For dynamic
or kinematic bodies, both the body and its shapes are added. Additionally,
this method attaches the simulation updater to the Mobject to ensure its
visual transform is synchronized with the physical simulation in every frame.
Parameters
----------
mob
The Mobject containing `.body` and `.shapes` attributes to be integrated
into the physical world.
"""
if mob.body is self.space.static_body:
self.space.add(*mob.shapes)
else:
self.space.add(mob.body)
self.space.add(*mob.shapes)
mob.add_updater(self.__simulate_updater)
mob.body.activate()
def __set_body(
self,
mob: Mobject,
body_type: int,
# body 相关
center_of_gravity: Tuple[float, float],
velocity: Tuple[float, float],
angular_velocity: float,
) -> None:
"""Initializes and configures the Pymunk physical body for a Mobject.
This internal method creates a `pymunk.Body` instance, sets its motion
type (Dynamic, Static, or Kinematic), and applies initial kinematic
properties such as center of mass, linear velocity, and angular velocity.
Parameters
----------
mob
The Mobject to which the physical body will be attached.
body_type
The Pymunk body type integer (e.g., `pymunk.Body.DYNAMIC`).
center_of_gravity
The center of mass position relative to the Mobject's center $(x, y)$.
velocity
The initial linear velocity vector $(v_x, v_y)$ of the body.
angular_velocity
The initial angular velocity (in radians per second).
"""
if not hasattr(mob, "body"):
mob.set(body=None)
if not hasattr(mob, "angle"):
mob.set(angle=0)
if body_type == pymunk.Body.DYNAMIC:
mob.body = pymunk.Body(body_type=pymunk.Body.DYNAMIC)
elif body_type == pymunk.Body.KINEMATIC:
mob.body = pymunk.Body(body_type=pymunk.Body.KINEMATIC)
else:
mob.body = pymunk.Body(body_type=pymunk.Body.STATIC)
mob.body.position = mob.get_x(), mob.get_y()
mob.body.center_of_gravity = center_of_gravity
mob.body.velocity = velocity
mob.body.angular_velocity = angular_velocity
[docs]
def set_body_and_shapes(
self,
mob: Mobject,
body_type: int,
is_solid: bool,
# shapes 相关
elasticity: float,
friction: float,
density: float,
sensor: bool,
surface_velocity: Tuple[float, float],
# body 相关
center_of_gravity: Tuple[float, float],
velocity: Tuple[float, float],
angular_velocity: float,
) -> None:
"""Sets up both the physical body and its collision shapes for a Mobject.
This method acts as a high-level initializer that configures the motion
properties (velocity, gravity center) and the physical material properties
(friction, elasticity) simultaneously, effectively binding a complete
physical identity to a visual Mobject.
Parameters
----------
mob
The Mobject to be initialized with physical properties.
body_type
The Pymunk body type (Dynamic, Static, or Kinematic).
is_solid
Whether the shapes are treated as solid objects or hollow boundaries.
elasticity
The coefficient of restitution. Controls how much energy is preserved after a collision.
friction
The friction coefficient. Controls how much the object resists sliding.
density
The density used to calculate mass and moment of inertia based on shape area.
sensor
If True, the shapes will trigger collision callbacks but won't cause physical bounces.
surface_velocity
A constant velocity applied to the surface of the shape (e.g., for conveyor belts).
center_of_gravity
The center of mass relative to the Mobject's center $(x, y)$.
velocity
The initial linear velocity vector $(v_x, v_y)$.
angular_velocity
The initial angular velocity in radians per second.
"""
self.__set_body(
mob,
body_type,
center_of_gravity=center_of_gravity,
velocity=velocity,
angular_velocity=angular_velocity,
)
self.__set_shape(
mob,
is_solid,
elasticity=elasticity,
friction=friction,
density=density,
sensor=sensor,
surface_velocity=surface_velocity,
)
self._add_body2space(mob)
[docs]
@staticmethod
def _set_collision_type(mob: Mobject, collision_type: int):
"""Sets the collision type ID for all physical shapes associated with a Mobject.
Collision types are user-defined integers used to categorize shapes. By
assigning types, you can define specific callback functions in a collision
handler to determine what happens when two shapes of certain types collide.
Parameters
----------
mob
The Mobject whose associated physical shapes will be updated.
collision_type
An integer ID representing the collision category.
(e.g., 1 for players, 2 for enemies).
"""
for shape in mob.shapes:
shape.collision_type = collision_type
[docs]
def _wildcard_collision_handler(
self,
collision_type_a: int,
begin: Callable[[pymunk.Arbiter, pymunk.Space, Dict], bool] = None,
pre_solve: Callable[[pymunk.Arbiter, pymunk.Space, Dict], bool] = None,
post_solve: Callable[[pymunk.Arbiter, pymunk.Space, Dict], None] = None,
separate: Callable[[pymunk.Arbiter, pymunk.Space, Dict], None] = None,
data: Dict[Any, Any] = None,
):
"""Registers a wildcard collision handler for a specific collision type.
This handler triggers whenever a shape with `collision_type_a` collides
with any other shape in the space, regardless of the other shape's
collision type. It is useful for global behaviors, such as playing a
sound whenever a specific object hits anything.
Parameters
----------
collision_type_a
The collision type ID to monitor.
begin
Called when two shapes first touch. Returning False ignores the collision.
pre_solve
Called every step while shapes are touching, before the collision solver
runs. Returning False ignores the collision for this step.
post_solve
Called every step while shapes are touching, after the collision solver
runs. Useful for retrieving collision impulse or kinetic energy.
separate
Called when two shapes stop touching.
data
A custom dictionary passed to all callback functions for state management.
Returns
-------
pymunk.CollisionHandler
The registered collision handler object.
"""
handler = self.space.add_wildcard_collision_handler(collision_type_a)
if begin:
handler.begin = begin
if pre_solve:
handler.pre_solve = pre_solve
if post_solve:
handler.post_solve = post_solve
if separate:
handler.separate = separate
if data:
handler.data.update(data)
return handler
[docs]
def _collision_detection_handler(
self,
collision_type_a: int,
collision_type_b: int,
begin: Callable[[pymunk.Arbiter, pymunk.Space, Dict], bool] = None,
pre_solve: Callable[[pymunk.Arbiter, pymunk.Space, Dict], bool] = None,
post_solve: Callable[[pymunk.Arbiter, pymunk.Space, Dict], None] = None,
separate: Callable[[pymunk.Arbiter, pymunk.Space, Dict], None] = None,
data: Dict[Any, Any] = None,
):
"""Registers a collision handler between two specific collision types.
This method defines custom callback logic for when a shape of type A
interacts with a shape of type B. It allows for fine-grained control over
physics responses, such as triggering events, modifying friction during
contact, or preventing specific objects from bouncing.
Parameters
----------
collision_type_a
The first specific collision type ID.
collision_type_b
The second specific collision type ID.
begin
Called when the two shapes first make contact. Must return True to
process the collision physically, or False to ignore it.
pre_solve
Called in each step while the shapes are touching, before the solver runs.
Useful for overriding collision parameters like friction or surface velocity.
post_solve
Called in each step while the shapes are touching, after the solver runs.
Commonly used to calculate collision impulses or impact energy.
separate
Called at the final step when the two shapes stop touching.
data
A custom dictionary for storing persistent state information accessible
within the callbacks.
Returns
-------
pymunk.CollisionHandler
The registered collision handler object.
"""
handler = self.space.add_collision_handler(collision_type_a, collision_type_b)
if begin:
handler.begin = begin
if pre_solve:
handler.pre_solve = pre_solve
if post_solve:
handler.post_solve = post_solve
if separate:
handler.separate = separate
if data:
handler.data.update(data)
return handler
# =============================== body ==================================
[docs]
@staticmethod
def apply_force_at_local_point(
mob: Mobject,
force: Tuple[float, float, float],
point: Tuple[float, float, float] = (0, 0, 0),
) -> None:
"""Applies a force to a Mobject's physical body at a point defined in local coordinates.
The force is applied relative to the body's current orientation. If the point
is not the center of gravity, it will also generate a torque, causing the
object to rotate.
Parameters
----------
mob
The Mobject whose physical body will receive the force.
force
The force vector $(f_x, f_y, f_z)$ to apply. Note that Pymunk
operates in 2D, so the z-component is typically ignored.
point
The offset from the body's center of gravity $(x, y, z)$ where
the force is applied, in local coordinates.
"""
mob.body.apply_force_at_local_point(force=force[:2], point=point[:2])
[docs]
@staticmethod
def apply_force_at_world_point(
mob: Mobject,
force: Tuple[float, float, float],
point: Tuple[float, float, float] = (0, 0, 0),
) -> None:
"""Applies a force to a Mobject's physical body at a point defined in world coordinates.
The force vector is applied at an absolute position in the scene. If the
point does not coincide with the body's center of gravity, it will generate
torque and cause the body to rotate. This is useful for external influences
that occur at specific scene locations.
Parameters
----------
mob
The Mobject whose physical body will receive the force.
force
The force vector $(f_x, f_y, f_z)$ to apply. Note that Pymunk
typically ignores the z-component.
point
The absolute position in the world (scene) coordinates where the
force is applied. Defaults to the origin $(0, 0, 0)$.
"""
mob.body.apply_force_at_world_point(force=force[:2], point=point[:2])
[docs]
@staticmethod
def apply_impulse_at_local_point(
mob: Mobject,
impulse: Tuple[float, float, float],
point: Tuple[float, float, float] = (0, 0, 0),
) -> None:
"""Applies an instantaneous impulse to a Mobject's physical body at a local point.
Impulses cause an immediate change in velocity (linear and angular) without
requiring time to elapse, simulating effects like a sudden hit or explosion.
The point is defined relative to the body's current position and orientation.
Parameters
----------
mob
The Mobject whose physical body will receive the impulse.
impulse
The impulse vector $(i_x, i_y, i_z)$ to apply. The z-component
is typically ignored in 2D physics.
point
The offset from the body's center of gravity $(x, y, z)$ where the
impulse is applied, in local coordinates.
"""
mob.body.apply_impulse_at_local_point(impulse=impulse[:2], point=point[:2])
[docs]
@staticmethod
def apply_impulse_at_world_point(
mob: Mobject,
impulse: tuple[float, float, float],
point: Tuple[float, float, float] = (0, 0, 0),
) -> None:
"""Applies an instantaneous impulse to a Mobject's physical body at a world coordinate.
The impulse vector is applied at an absolute position in the scene. This causes
an immediate change in the body's linear and angular velocity. If the
application point is offset from the body's center of mass, the body will
begin to rotate.
Parameters
----------
mob
The Mobject whose physical body will receive the impulse.
impulse
The impulse vector $(i_x, i_y, i_z)$ to apply. The z-component
is typically ignored in 2D physics.
point
The absolute position in world (scene) coordinates where the
impulse is applied. Defaults to the origin $(0, 0, 0)$.
"""
mob.body.apply_impulse_at_world_point(impulse=impulse[:2], point=point[:2])
[docs]
@staticmethod
def local_to_world(
mob: Mobject, point: Tuple[float, float, float] = (0, 0, 0)
) -> Tuple[float, float, float]:
world_pos = mob.body.local_to_world(point[:2])
return (*world_pos, 0)
[docs]
@staticmethod
def world_to_local(
mob: Mobject, point: Tuple[float, float, float] = (0, 0, 0)
) -> Tuple[float, float, float]:
local_pos = mob.body.world_to_local(point[:2])
return (*local_pos, 0)
[docs]
@staticmethod
def set_position_func(
mob: Mobject, callback: Callable[[pymunk.Body, float], None] = None
):
"""Assigns a custom position update callback to a Mobject's physical body.
By default, Pymunk updates a body's position based on its velocity. This
method allows you to override that behavior with custom logic. The callback
is executed during every physical simulation step.
Parameters
----------
mob
The Mobject whose physical body's position update logic will be customized.
callback
A function with the signature `def callback(body: pymunk.Body, dt: float)`.
If None, the default Pymunk position update logic is restored.
"""
if callback:
mob.body.position_func = callback
[docs]
@staticmethod
def set_velocity_func(
mob: Mobject,
callback: Callable[[Body, tuple[float, float], float, float], None] = None,
):
"""Assigns a custom velocity update callback to a Mobject's physical body.
This method overrides how Pymunk calculates velocity in each step. It is commonly
used to implement specialized physical effects such as custom air resistance (drag),
planetary gravity, or specific damping behaviors that differ from the global space settings.
Parameters
----------
mob
The Mobject whose physical body's velocity logic will be customized.
callback
A function with the signature:
`def callback(body: Body, gravity: Tuple[float, float], damping: float, dt: float)`
If None, the default Pymunk velocity update logic is restored.
"""
if callback:
mob.body.velocity_func = callback
[docs]
@staticmethod
def velocity_at_local_point(
mob: Mobject, point: Tuple[float, float, float] = (0, 0, 0)
) -> Tuple[float, float, float]:
velocity = mob.body.velocity_at_local_point(point[:2])
return (*velocity, 0)
[docs]
@staticmethod
def velocity_at_world_point(
mob: Mobject, point: Tuple[float, float, float] = (0, 0, 0)
) -> Tuple[float, float, float]:
velocity = mob.body.velocity_at_world_point(point[:2])
return (*velocity, 0)
# =============================== shape ==================================
def __set_shape(
self,
mob: Mobject,
is_solid: bool = True, # shapes 映射
# shapes 相关
elasticity: float = 0.8,
friction: float = 0.8,
density: float = 1.0,
sensor: bool = False,
surface_velocity: Tuple[float, float] = (0.0, 0.0),
) -> None:
"""Configures and attaches collision shapes to a Mobject's physical body.
This internal method defines the 'material' and 'boundary' properties of the
object. It determines how the object bounces, slides, and whether it
occupies solid space or acts merely as a trigger zone.
Parameters
----------
mob
The Mobject to assign physical shapes to. Must already have a `.body`.
is_solid
If True, the shape is treated as a solid object. If False, it may be
treated as a hollow boundary (depending on the geometry type).
elasticity
Coefficient of restitution (0.0 to 1.0+). Controls bounciness.
friction
Coefficient of friction (0.0 to 1.0+). Controls surface resistance.
density
Used to automatically calculate the mass and moment of inertia
based on the shape's area/volume.
sensor
If True, the shape detects collisions (triggers callbacks) but
produces no physical impact or bounce.
surface_velocity
The relative surface velocity. Useful for creating conveyor belt
effects or moving walkways.
"""
if not hasattr(mob, "shapes"):
mob.set(shapes=[])
if isinstance(mob, ImageMobject):
self.__calculate_img_shape(mob)
elif is_solid:
self.__calculate_solid_shape(mob)
else:
self.__calculate_hollow_shape(mob)
for shape in mob.shapes:
shape.elasticity = elasticity
shape.friction = friction
shape.density = density
shape.sensor = sensor
shape.surface_velocity = surface_velocity
def __calculate_solid_shape(self, mob: Mobject) -> None:
"""Generates Pymunk collision shapes for a solid Mobject.
This method maps Manim primitives (Circle, Line, Polygon, etc.) to their
corresponding Pymunk shape types. For complex shapes that are non-convex
(e.g., a Star or a generic path), it automatically performs convex
decomposition to ensure accurate physical interaction.
Parameters
----------
mob
The VMobject for which collision shapes are generated.
Note
----
Physical engines generally only support convex polygons. Non-convex
objects are decomposed into multiple convex sub-shapes attached to
the same body.
"""
stroke_width = (mob.stroke_width / 100) * (
config.frame_height / config.frame_width
)
if isinstance(mob, Circle):
mob.shapes = [
pymunk.Circle(body=mob.body, radius=mob.radius + stroke_width / 2)
]
elif isinstance(mob, Line):
center_x, center_y = mob.get_center()[:2]
start = mob.get_start()
end = mob.get_end()
local_a = (start[0] - center_x, start[1] - center_y)
local_b = (end[0] - center_x, end[1] - center_y)
mob.shapes = [pymunk.Segment(mob.body, local_a, local_b, stroke_width / 2)]
# Polygram, Star, RegularPolygon, VMobject,etc.
else:
local_points = self.__get_refined_points(mob, n_divisions=8)
if len(local_points) < 3:
return
# is convex?
hull = autogeometry.to_convex_hull(local_points, 0.001)
is_convex = len(hull) == len(local_points)
if is_convex:
# is convex
mob.shapes.append(
pymunk.Poly(mob.body, local_points, radius=stroke_width / 2)
)
else:
convex_hulls = self.__concave2convex_refined(
mob, n_divisions=8, tolerance=0.01
)
for hull_verts in convex_hulls:
mob.shapes.append(
pymunk.Poly(mob.body, hull_verts, radius=stroke_width / 2)
)
def __calculate_hollow_shape(self, mob: Mobject, n_divisions: int = 4) -> None:
"""Generates Pymunk collision shapes for a hollow Mobject (outline only).
Instead of a solid polygon, this method constructs a collision boundary using
multiple `pymunk.Segment` shapes that follow the Mobject's contour. This is
ideal for creating containers, cages, or hollow structures where other
physical objects can move inside.
Parameters
----------
mob
The VMobject whose stroke/outline will be used to create segments.
n_divisions
The subdivision level for Bezier curves. Higher values result in
smoother boundaries but may impact simulation performance.
"""
stroke_width = (mob.stroke_width / 100) * (
config.frame_height / config.frame_width
)
refined_points = self.__get_refined_points(mob, n_divisions)
# Convert to local coordinates relative to the center (required by the physics engine)
center = mob.get_center()
refined_points = [(p[0] - center[0], p[1] - center[1]) for p in refined_points]
n_pts = len(refined_points)
if n_pts < 2:
return
for j in range(n_pts):
p1 = refined_points[j]
p2 = refined_points[(j + 1) % n_pts]
# Filtering: Pymunk will report an error if the two points completely overlap.
if np.allclose(p1, p2, atol=1e-4):
continue
seg = pymunk.Segment(
mob.body, (p1[0], p1[1]), (p2[0], p2[1]), radius=stroke_width / 2
)
mob.shapes.append(seg)
def __calculate_img_shape(self, mob: ImageMobject) -> None:
"""Generates Pymunk collision shapes from ImageMobject pixel data.
This method analyzes the image's transparency (alpha channel) or pixel
contours to extract a representative convex polygon for physical interaction.
If contour extraction fails or the image is fully opaque, it falls back
to a standard rectangular bounding box.
Parameters
----------
mob
The ImageMobject to process for shape generation.
Notes
-----
For better performance and physical stability, complex image outlines are
often simplified into low-vertex count convex polygons.
"""
pixel_array = mob.pixel_array
polygons_verts = get_normalized_convex_polygons(
pixel_array,
base_px_width=512.0,
target_cell_size=4,
img_manim_w=mob.width,
img_manim_h=mob.height,
)
if polygons_verts:
# create polygons
for poly_verts in polygons_verts:
mob.shapes.append(pymunk.Poly(mob.body, poly_verts, radius=0.1))
else:
# Simple box
mob.shapes = [
pymunk.Poly.create_box(
mob.body, size=(mob.width, mob.height), radius=0.1
)
]
@staticmethod
def __get_refined_points(mob: Mobject, n_divisions: int) -> list:
"""Extracts subdivided sample points from a Mobject for precise collision shape generation.
This method performs adaptive sampling on the Bezier curves that define a
VMobject. By increasing the subdivision level, it approximates smooth curves
with a high-resolution sequence of linear segments. It also includes logic
to prune redundant points caused by floating-point precision errors.
Parameters
----------
mob
The VMobject to be sampled.
n_divisions
The subdivision level for each Bezier curve segment. A higher value
results in a denser point set and smoother physical boundaries.
Returns
-------
list
A list of subdivided $(x, y)$ coordinate tuples representing the
sampled path.
"""
all_points = []
for submob in mob.family_members_with_points():
pts = submob.points
if len(pts) == 0:
continue
# bezier divisions
for i in range(0, len(pts), 4):
bezier_segment = pts[i : i + 4]
if len(bezier_segment) < 4:
continue
sub_pts = subdivide_bezier(bezier_segment, n_divisions)
all_points.extend(sub_pts)
# 转为 2D 坐标并清洗重复点
unique_points = []
for p in all_points:
p_2d = (float(p[0]), float(p[1]))
if not unique_points or not np.allclose(p_2d, unique_points[-1], atol=1e-3):
unique_points.append(p_2d)
return unique_points
def __concave2convex_refined(
self, mob: Mobject, n_divisions: int, tolerance: float
):
"""Decomposes a concave polygon into multiple convex polygons for physics processing.
Since Pymunk and most physics engines only support convex shapes for collision
detection, this method takes complex non-convex VMobjects (like stars or
generic paths) and breaks them down into a set of convex sub-polygons.
Parameters
----------
mob
The VMobject (e.g., a Star or complex polygon) to be decomposed.
n_divisions
The Bezier subdivision level used to sample the Mobject's contour points.
tolerance
The decomposition tolerance. Higher values simplify the resulting
convex shapes by merging smaller features.
Returns
-------
List[List[Tuple[float, float]]]
A list where each element is a list of vertices defining a
specific convex sub-polygon.
"""
# 1. 采样获取高质量点集
refined_points = self.__get_refined_points(mob, n_divisions)
# 2. 转换成相对于中心的局部坐标(物理引擎需要)
center = mob.get_center()
local_points = [(p[0] - center[0], p[1] - center[1]) for p in refined_points]
if len(local_points) < 3:
return []
try:
convex_hulls = autogeometry.convex_decomposition(local_points, tolerance)
return convex_hulls
except Exception as e:
manim_pymunk_logger.error(
"Decomposition failed, attempting to downgrade to convex hull. Please check if the SVG path is clockwise: {e}"
)
hull = autogeometry.to_convex_hull(local_points, tolerance)
return [hull]
[docs]
@staticmethod
def _add_shape_filter(
mob: Mobject,
group: int = 0,
categories: int = 4294967295,
mask: int = 4294967295,
):
"""Configures collision filtering for all shapes associated with a Mobject.
This method defines which objects can collide with each other using Pymunk's
filtering rules. It uses group IDs to ignore collisions between related
shapes and bitmasks (categories and masks) for complex layered filtering.
Parameters
----------
mob
The Mobject whose shapes will receive the collision filter.
group
Shapes in the same non-zero group do not collide. Useful for
ignoring collisions between parts of the same complex object.
categories
A bitmask representing the categories this shape belongs to.
Default is all categories (32 bits set to 1).
mask
A bitmask representing which categories this shape will collide with.
Default is all categories.
"""
shape_filter = pymunk.ShapeFilter(group, categories, mask)
for shape in mob.shapes:
shape.filter = shape_filter
[docs]
@staticmethod
def get_point_query_info(
mob: Mobject, point: Tuple[float, float, float] = (0, 0, 0)
) -> list:
"""Performs a spatial query to find the relationship between a point and a Mobject's shapes.
This method calculates how a specific point in space relates to the physical
boundaries of a Mobject. It is essential for determining if a point is inside
an object, how far it is from the surface, and the direction to the
closest surface point.
Parameters
----------
mob
The Mobject whose associated physical shapes will be queried.
point
A (x, y, z) coordinate representing the test location in the scene.
Note: Only the (x, y) components are used for the 2D physics engine.
Returns
-------
query_info_list
A list of tuples, where each tuple contains:
- distance: The distance from the point to the shape (negative if inside).
- gradient: A 3D vector representing the direction of the distance gradient.
- point: The closest point on the shape's surface to the query point.
- shape: The specific pymunk.Shape object that was queried.
"""
query_info_list = []
for shape in mob.shapes:
point_query_info = shape.point_query(point[:2])
query_info_list.append(
(
point_query_info.distance,
[*point_query_info.gradient, 0],
[*point_query_info.point, 0],
point_query_info.shape,
)
)
return query_info_list
[docs]
@staticmethod
def get_line_query(
mob: Mobject,
start: Tuple[float, float, float],
end: Tuple[float, float, float],
stroke_width: float,
) -> list:
"""Performs a segment query to detect intersections between a line and a Mobject's shapes.
This method simulates a 'laser beam' or thick line segment traveling from
'start' to 'end'. It identifies if and where this segment pierces the
Mobject's physical boundaries, considering the segment's thickness.
Parameters
----------
mob
The Mobject whose associated physical shapes will be checked for intersection.
start
The (x, y, z) starting point of the query segment.
end
The (x, y, z) ending point of the query segment.
stroke_width
The radius of the query segment. Effectively makes the 'laser' a
thick cylinder/capsule for detection.
Returns
-------
query_info_list
A list of tuples containing intersection data:
- alpha: A float (0.0 to 1.0) representing the normalized distance
along the segment where the hit occurred.
- normal: A 3D vector representing the surface normal at the impact point.
- point: The exact 3D coordinate of the intersection point.
- shape: The specific pymunk.Shape that was hit.
"""
query_info_list = []
for shape in mob.shapes:
line_query_info = shape.segment_query(
start=start[:2], end=end[:2], radius=stroke_width
)
query_info_list.append(
(
line_query_info.alpha,
[*line_query_info.normal, 0],
[*line_query_info.point, 0],
line_query_info.shape,
)
)
return query_info_list
[docs]
@staticmethod
def get_shapea_shapeb_info(shape_a: pymunk.Shape, shape_b: pymunk.Shape) -> list:
"""Retrieves detailed contact information between two specific physical shapes.
This method performs a low-level collision query to find the 'contact manifold'
between two shapes. It calculates the collision normal and the set of points
where the two shapes are touching or overlapping.
Parameters
----------
shape_a
The first pymunk.Shape to check for collision.
shape_b
The second pymunk.Shape to check for collision.
Returns
-------
contact_data
A list where the first element is the collision normal, followed by
tuples of contact point details:
- normal: A 3D vector representing the direction required to resolve
the collision (from shape_a to shape_b).
- point_a: The coordinate on the surface of shape_a involved in the contact.
- point_b: The coordinate on the surface of shape_b involved in the contact.
- distance: The penetration depth (negative if overlapping, positive if
separated within the collision margin).
"""
contactPointSet = shape_a.shapes_collide(shape_b)
normal = [*contactPointSet.normal, 0]
contactPoints = contactPointSet.points
contact_info = []
for contact_point in contactPoints:
contact_info.append(
(
[*contact_point.point_a, 0],
[*contact_point.point_b, 0],
contact_point.distance,
)
)
return [normal, *contact_info]