Source code for simvx.core.physics_nodes

"""Physics/collision nodes: CharacterBody, CollisionShape, Area, ShapeCast (2D and 3D)."""


from __future__ import annotations

import logging
import math
import numpy as np

from .descriptors import Collision, Property, Signal
from .math.types import Vec2, Vec3
from .nodes_2d.node2d import Node2D
from .nodes_3d.node3d import Node3D
from .shapecast import ShapeCast2D, ShapeCast3D  # noqa: F401

log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Gizmo helper: generate circle line segments
# ---------------------------------------------------------------------------
_CIRCLE_SEGMENTS = 24


def _circle_lines_2d(cx: float, cy: float, radius: float, segments: int = _CIRCLE_SEGMENTS) -> list[tuple[Vec2, Vec2]]:
    """Return line segments approximating a circle in 2D."""
    lines: list[tuple[Vec2, Vec2]] = []
    step = math.tau / segments
    prev = Vec2(cx + radius, cy)
    for i in range(1, segments + 1):
        angle = i * step
        cur = Vec2(cx + radius * math.cos(angle), cy + radius * math.sin(angle))
        lines.append((prev, cur))
        prev = cur
    return lines


def _circle_lines_3d(
    center: Vec3, u: Vec3, v: Vec3, radius: float, segments: int = _CIRCLE_SEGMENTS,
) -> list[tuple[Vec3, Vec3]]:
    """Return line segments approximating a circle in 3D on the plane defined by u, v axes."""
    lines: list[tuple[Vec3, Vec3]] = []
    step = math.tau / segments
    prev = center + u * radius
    for i in range(1, segments + 1):
        angle = i * step
        cur = center + u * (radius * math.cos(angle)) + v * (radius * math.sin(angle))
        lines.append((prev, cur))
        prev = cur
    return lines

# ============================================================================
# Shared mixins to deduplicate 2D/3D logic
# ============================================================================

class _CharacterBodyBase:
    """Shared logic for CharacterBody2D and CharacterBody3D."""

    def _character_body_init(self):
        """Initialize contact tracking signals. Called from __init__."""
        self.body_entered = Signal()
        self.body_exited = Signal()
        self._prev_contacts: set = set()

    def _emit_contact_signals(self, current_contacts: set):
        """Compare current contacts with previous frame and emit enter/exit signals."""
        for body in current_contacts - self._prev_contacts:
            self.body_entered(body)
        for body in self._prev_contacts - current_contacts:
            self.body_exited(body)
        self._prev_contacts = current_contacts

    def is_on_floor(self) -> bool:
        """True if last collision normal aligns with floor_normal (dot > 0.7)."""
        if not self.last_collision:
            return False
        return self.last_collision.normal.dot(self.floor_normal) > 0.7

    def is_on_wall(self) -> bool:
        """True if last collision normal is mostly perpendicular to floor_normal."""
        if not self.last_collision:
            return False
        return abs(self.last_collision.normal.dot(self.floor_normal)) < 0.3

    def is_on_ceiling(self) -> bool:
        """True if last collision normal points opposite to floor_normal (dot < -0.7)."""
        if not self.last_collision:
            return False
        return self.last_collision.normal.dot(self.floor_normal) < -0.7


class _CollisionShapeBase:
    """Shared logic for CollisionShape2D and CollisionShape3D."""
    radius = Property(1.0, range=(0, 10000), hint="Collision radius")

    def overlaps_point(self, point) -> bool:
        """Test if a point is inside this shape."""
        diff = self.world_position - point
        if hasattr(diff, 'z'):
            return (diff.x**2 + diff.y**2 + diff.z**2) <= self.radius ** 2
        return (diff.x*diff.x + diff.y*diff.y) <= self.radius * self.radius


class _AreaBase:
    """Shared logic for Area2D and Area3D."""
    collision_layer = Property(1, hint="Collision layer bitmask")
    collision_mask = Property(1, hint="Collision mask bitmask")
    monitoring = Property(True, hint="Whether to detect overlaps")

    def _layers_match(self, other) -> bool:
        return bool(self.collision_mask & other.collision_layer)

    def _any_shape_overlaps(self, my_shapes, other_shapes) -> bool:
        return any(s.overlaps(o) for s in my_shapes for o in other_shapes)

    def get_overlapping_bodies(self) -> list:
        return list(self._overlapping_bodies)

    def get_overlapping_areas(self) -> list:
        return list(self._overlapping_areas)

    def has_overlapping_bodies(self) -> bool:
        return bool(self._overlapping_bodies)

    def has_overlapping_areas(self) -> bool:
        return bool(self._overlapping_areas)


# ============================================================================
# CharacterBody2D
# ============================================================================

[docs] class CharacterBody2D(_CharacterBodyBase, Node2D): """2D node with velocity, movement, and collision response. Uses CollisionShape2D children for circle or rectangle collision. Screen coordinates: Y-down, so floor normal defaults to (0, -1). """ collision_layer = Property(1, hint="Collision layer bitmask", group="Collision") collision_mask = Property(1, hint="Collision mask bitmask", group="Collision") max_slides = Property(4, range=(1, 16), hint="Max collision iterations per frame", group="Collision") def __init__(self, collision=None, **kwargs): super().__init__(**kwargs) self._character_body_init() self.velocity = Vec2() self.floor_normal = Vec2(0, -1) # Y-down screen coords: floor points up self.last_collision: Collision | None = None if collision is not None: if isinstance(collision, int | float): collision = CollisionShape2D(name="Collision", radius=collision) elif isinstance(collision, tuple | list | np.ndarray): collision = CollisionShape2D(name="Collision", extents=Vec2(collision)) self.collision = self.add_child(collision) else: self.collision = None
[docs] def move_and_slide(self, dt: float = 1.0): """Apply velocity with collision response. Resolves overlaps and slides along surfaces.""" # Substep at high velocities to prevent tunneling through thin walls. # If the frame displacement exceeds the collision radius, split into # multiple smaller steps so no single step overshoots a wall. speed_sq = self.velocity.x ** 2 + self.velocity.y ** 2 col_radius = self.collision.radius if self.collision else 8.0 disp = (speed_sq ** 0.5) * dt steps = max(1, int(disp / col_radius) + 1) if disp > col_radius else 1 sub_dt = dt / steps self.last_collision = None all_contacted: set[CharacterBody2D] = set() for _ in range(steps): self.position += self.velocity * sub_dt if not self.collision or not self._tree: continue for _ in range(self.max_slides): overlaps = self.get_overlapping() if not overlaps: break other = overlaps[0] all_contacted.add(other) pen = self.collision.get_penetration(other.collision) if pen is None: break normal, penetration = pen self.position += normal * penetration vdot = self.velocity.dot(normal) if vdot < 0: self.velocity -= normal * vdot self.last_collision = Collision( normal=normal, collider=other, position=other.collision.world_position + normal * other.collision.radius, depth=penetration, ) self._emit_contact_signals(all_contacted)
[docs] def get_overlapping(self, group: str = None, body_type: type = None) -> list[CharacterBody2D]: """Find all CharacterBody2D nodes whose collision overlaps ours.""" if not self.collision or not self._tree: return [] candidates = self._tree.get_group(group) if group else self._tree.root.find_all(CharacterBody2D) return [ b for b in candidates if b is not self and (body_type is None or isinstance(b, body_type)) and isinstance(b, CharacterBody2D) and b.collision and self.collision.overlaps(b.collision) ]
# ============================================================================ # 2D collision helpers (circle / AABB) # ============================================================================ def _circle_aabb_overlap(cp: Vec2, cr: float, rp: Vec2, re: Vec2) -> bool: """Circle (center *cp*, radius *cr*) vs AABB (center *rp*, half-extents *re*).""" cx, cy = float(cp[0]), float(cp[1]) dx = cx - max(float(rp[0] - re[0]), min(cx, float(rp[0] + re[0]))) dy = cy - max(float(rp[1] - re[1]), min(cy, float(rp[1] + re[1]))) return dx * dx + dy * dy <= cr * cr def _pen_cc(ap: Vec2, ar: float, bp: Vec2, br: float) -> tuple[Vec2, float] | None: """Circle-circle penetration: push *a* out of *b*.""" diff = ap - bp dist_sq = diff.x * diff.x + diff.y * diff.y radii = ar + br if dist_sq > radii * radii: return None if dist_sq < 1e-10: return (Vec2(0, -1), radii) dist = math.sqrt(dist_sq) return (diff * (1.0 / dist), radii - dist) def _pen_rr(ap: Vec2, ae: Vec2, bp: Vec2, be: Vec2) -> tuple[Vec2, float] | None: """Rect-rect (AABB) penetration: push *a* out of *b*.""" dx, dy = ap.x - bp.x, ap.y - bp.y ox = ae.x + be.x - abs(dx) oy = ae.y + be.y - abs(dy) if ox <= 0 or oy <= 0: return None if ox < oy: return (Vec2(1.0 if dx > 0 else -1.0, 0.0), ox) return (Vec2(0.0, 1.0 if dy > 0 else -1.0), oy) def _pen_circle_rect(cp: Vec2, cr: float, rp: Vec2, re: Vec2) -> tuple[Vec2, float] | None: """Penetration of circle into rect. Normal pushes circle out of rect.""" cx, cy = float(cp[0]), float(cp[1]) rx, ry, ex, ey = float(rp[0]), float(rp[1]), float(re[0]), float(re[1]) # Closest point on rect to circle centre clx = max(rx - ex, min(cx, rx + ex)) cly = max(ry - ey, min(cy, ry + ey)) dx, dy = cx - clx, cy - cly dist_sq = dx * dx + dy * dy if dist_sq > cr * cr: return None if dist_sq < 1e-10: # Circle centre inside rect — push to nearest edge to_l, to_r = cx - (rx - ex), (rx + ex) - cx to_t, to_b = cy - (ry - ey), (ry + ey) - cy m = min(to_l, to_r, to_t, to_b) if m == to_l: return (Vec2(-1, 0), to_l + cr) if m == to_r: return (Vec2(1, 0), to_r + cr) if m == to_t: return (Vec2(0, -1), to_t + cr) return (Vec2(0, 1), to_b + cr) dist = math.sqrt(dist_sq) return (Vec2(dx / dist, dy / dist), cr - dist) # ============================================================================ # CollisionShape2D # ============================================================================
[docs] class CollisionShape2D(_CollisionShapeBase, Node2D): """2D collision shape: circle (default) or axis-aligned rectangle. Circle mode (default): set ``radius``. Rectangle mode: set ``extents`` to a ``Vec2(half_width, half_height)``. When ``extents`` is non-zero the shape is treated as an AABB and ``radius`` is ignored for overlap tests. """ radius = Property(1.0, range=(0, 10000), hint="Collision radius (circle mode)", group="Collision") extents = Property(Vec2(), hint="Half-size (rect mode; zero = circle)", group="Collision") gizmo_colour = Property((0.2, 0.8, 0.2, 0.6), hint="Editor gizmo colour") @property def is_rect(self) -> bool: """True when extents are non-zero (rectangle mode).""" e = self.extents return float(e[0]) > 0 and float(e[1]) > 0 # -- overlap tests -------------------------------------------------------
[docs] def overlaps(self, other: CollisionShape2D) -> bool: """Test overlap with another CollisionShape2D (circle or rect).""" sr, orr = self.is_rect, other.is_rect sp, op = self.world_position, other.world_position if not sr and not orr: diff = sp - op radii = self.radius + other.radius return diff.x * diff.x + diff.y * diff.y <= radii * radii if sr and orr: se, oe = self.extents, other.extents return abs(sp.x - op.x) <= se.x + oe.x and abs(sp.y - op.y) <= se.y + oe.y # One circle, one rect if sr: return _circle_aabb_overlap(op, other.radius, sp, self.extents) return _circle_aabb_overlap(sp, self.radius, op, other.extents)
[docs] def overlaps_point(self, point: tuple[float, float] | np.ndarray) -> bool: """Test if a point is inside this shape.""" p = self.world_position if self.is_rect: e = self.extents return abs(float(point[0]) - p.x) <= e.x and abs(float(point[1]) - p.y) <= e.y diff = p - point return diff.x * diff.x + diff.y * diff.y <= self.radius * self.radius
# -- penetration for collision response ----------------------------------
[docs] def get_penetration(self, other: CollisionShape2D) -> tuple[Vec2, float] | None: """Return ``(normal, depth)`` to push **self** out of **other**, or ``None``.""" sr, orr = self.is_rect, other.is_rect sp, op = self.world_position, other.world_position if not sr and not orr: return _pen_cc(sp, self.radius, op, other.radius) if sr and orr: return _pen_rr(sp, self.extents, op, other.extents) if not sr and orr: return _pen_circle_rect(sp, self.radius, op, other.extents) # self=rect, other=circle → flip circle-rect result result = _pen_circle_rect(op, other.radius, sp, self.extents) if result is None: return None n, d = result return (Vec2(-n.x, -n.y), d)
# -- gizmo ---------------------------------------------------------------
[docs] def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]: """Return wireframe line segments for the collision shape in world space.""" p = self.world_position if self.is_rect: e = self.extents tl, tr = Vec2(p.x - e.x, p.y - e.y), Vec2(p.x + e.x, p.y - e.y) br, bl = Vec2(p.x + e.x, p.y + e.y), Vec2(p.x - e.x, p.y + e.y) return [(tl, tr), (tr, br), (br, bl), (bl, tl)] return _circle_lines_2d(p.x, p.y, float(self.radius))
# ============================================================================ # Area2D — Trigger zone with enter/exit signals # ============================================================================
[docs] class Area2D(_AreaBase, Node2D): """2D trigger zone that detects overlapping bodies and areas. Add CollisionShape2D children to define the detection region. Emits body_entered/body_exited when CharacterBody2D nodes overlap, and area_entered/area_exited for other Area2D nodes. """ collision_layer = Property(1, hint="Collision layer bitmask", group="Collision") collision_mask = Property(1, hint="Collision mask bitmask", group="Collision") monitoring = Property(True, hint="Whether to detect overlaps", group="Collision") gizmo_colour = Property((0.2, 0.6, 1.0, 0.5), hint="Editor gizmo colour") def __init__(self, **kwargs): super().__init__(**kwargs) self.body_entered = Signal() self.body_exited = Signal() self.area_entered = Signal() self.area_exited = Signal() self._overlapping_bodies: set[CharacterBody2D] = set() self._overlapping_areas: set[Area2D] = set()
[docs] def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]: """Return gizmo lines from child collision shapes, or a default diamond if none.""" lines: list[tuple[Vec2, Vec2]] = [] for child in self.children: if isinstance(child, CollisionShape2D) and hasattr(child, 'get_gizmo_lines'): lines.extend(child.get_gizmo_lines()) if not lines: p = self.world_position s = 15.0 corners = [Vec2(p.x, p.y - s), Vec2(p.x + s, p.y), Vec2(p.x, p.y + s), Vec2(p.x - s, p.y)] for i in range(4): lines.append((corners[i], corners[(i + 1) % 4])) return lines
[docs] def physics_process(self, dt: float): if not self.monitoring or not self._tree: return my_shapes = self.find_all(CollisionShape2D) if not my_shapes: return # Check bodies current_bodies: set[CharacterBody2D] = set() for body in self._tree.root.find_all(CharacterBody2D): if not self._layers_match(body) or not body.collision: continue if isinstance(body.collision, CollisionShape2D): body_shapes = [body.collision] else: body_shapes = body.find_all(CollisionShape2D) if self._any_shape_overlaps(my_shapes, body_shapes): current_bodies.add(body) for body in current_bodies - self._overlapping_bodies: self.body_entered(body) for body in self._overlapping_bodies - current_bodies: self.body_exited(body) self._overlapping_bodies = current_bodies # Check areas current_areas: set[Area2D] = set() for area in self._tree.root.find_all(Area2D): if area is self or not self._layers_match(area): continue other_shapes = area.find_all(CollisionShape2D) if self._any_shape_overlaps(my_shapes, other_shapes): current_areas.add(area) for area in current_areas - self._overlapping_areas: self.area_entered(area) for area in self._overlapping_areas - current_areas: self.area_exited(area) self._overlapping_areas = current_areas
# ============================================================================ # CharacterBody3D # ============================================================================
[docs] class CharacterBody3D(_CharacterBodyBase, Node3D): """3D node with velocity, movement, and collision response. Uses sphere-sphere collision via CollisionShape3D children. Y-up convention: floor normal defaults to (0, 1, 0). """ collision_layer = Property(1, hint="Collision layer bitmask", group="Collision") collision_mask = Property(1, hint="Collision mask bitmask", group="Collision") max_slides = Property(4, range=(1, 16), hint="Max collision iterations per frame", group="Collision") def __init__(self, collision=None, **kwargs): super().__init__(**kwargs) self._character_body_init() self.velocity: tuple[float, float, float] | np.ndarray = Vec3() self.floor_normal = Vec3(0, 1, 0) self.last_collision: Collision | None = None if collision is not None: if isinstance(collision, int | float): collision = CollisionShape3D(name="Collision", radius=collision) self.collision = self.add_child(collision) else: self.collision = None
[docs] def move_and_slide(self, dt: float = 1.0): """Apply velocity with collision response. Resolves overlaps and slides along surfaces.""" self.position += self.velocity * dt self.last_collision = None if not self.collision or not self._tree: self._emit_contact_signals(set()) return contacted: set[CharacterBody3D] = set() for _ in range(self.max_slides): overlaps = self.get_overlapping() if not overlaps: break other = overlaps[0] contacted.add(other) our_pos = self.collision.world_position their_pos = other.collision.world_position diff = our_pos - their_pos dist = diff.length() if dist < 1e-10: normal = Vec3(0, 1, 0) dist = 0.0 else: normal = diff * (1.0 / dist) penetration = (self.collision.radius + other.collision.radius) - dist if penetration <= 0: break self.position += normal * penetration vel = Vec3(self.velocity) if not isinstance(self.velocity, Vec3) else self.velocity vdot = vel.dot(normal) if vdot < 0: self.velocity = vel - normal * vdot contact = their_pos + normal * other.collision.radius self.last_collision = Collision( normal=normal, collider=other, position=contact, depth=penetration, ) self._emit_contact_signals(contacted)
[docs] def get_overlapping(self, group: str = None, body_type: type = None) -> list[CharacterBody3D]: if not self.collision or not self._tree: return [] candidates = self._tree.get_group(group) if group else self._tree.root.find_all(CharacterBody3D) return [ b for b in candidates if b is not self and (body_type is None or isinstance(b, body_type)) and isinstance(b, CharacterBody3D) and b.collision and self.collision.overlaps(b.collision) ]
# ============================================================================ # CollisionShape3D # ============================================================================
[docs] class CollisionShape3D(_CollisionShapeBase, Node3D): """Sphere collision shape. Attach as child of a CharacterBody3D.""" radius = Property(1.0, range=(0, 10000), hint="Collision radius", group="Collision") pickable = Property(False, hint="Receive input_event on mouse click") gizmo_colour = Property((0.2, 0.8, 0.2, 0.6), hint="Editor gizmo colour")
[docs] def overlaps(self, other: CollisionShape3D) -> bool: diff = self.world_position - other.world_position dist_sq = diff.x**2 + diff.y**2 + diff.z**2 radii = self.radius + other.radius return dist_sq <= radii * radii
[docs] def overlaps_point(self, point: tuple[float, float, float] | np.ndarray) -> bool: diff = self.world_position - point return (diff.x**2 + diff.y**2 + diff.z**2) <= self.radius ** 2
[docs] def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]: """Return wireframe line segments for the collision sphere (3 circles: XY, XZ, YZ).""" p = self.world_position r = float(self.radius) lines: list[tuple[Vec3, Vec3]] = [] lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 1, 0), r)) # XY lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 0, 1), r)) # XZ lines.extend(_circle_lines_3d(p, Vec3(0, 1, 0), Vec3(0, 0, 1), r)) # YZ return lines
# ============================================================================ # Area3D — Trigger zone with enter/exit signals # ============================================================================
[docs] class Area3D(_AreaBase, Node3D): """3D trigger zone that detects overlapping bodies and areas. Add CollisionShape3D children to define the detection region. Emits body_entered/body_exited when CharacterBody3D nodes overlap, and area_entered/area_exited for other Area3D nodes. """ collision_layer = Property(1, hint="Collision layer bitmask", group="Collision") collision_mask = Property(1, hint="Collision mask bitmask", group="Collision") monitoring = Property(True, hint="Whether to detect overlaps", group="Collision") gizmo_colour = Property((0.2, 0.6, 1.0, 0.5), hint="Editor gizmo colour") def __init__(self, **kwargs): super().__init__(**kwargs) self.body_entered = Signal() self.body_exited = Signal() self.area_entered = Signal() self.area_exited = Signal() self._overlapping_bodies: set[CharacterBody3D] = set() self._overlapping_areas: set[Area3D] = set()
[docs] def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]: """Return gizmo lines from child collision shapes, or a small axis cross if none.""" lines: list[tuple[Vec3, Vec3]] = [] for child in self.children: if isinstance(child, CollisionShape3D) and hasattr(child, 'get_gizmo_lines'): lines.extend(child.get_gizmo_lines()) if not lines: p = self.world_position s = 0.5 lines = [ (Vec3(p.x - s, p.y, p.z), Vec3(p.x + s, p.y, p.z)), (Vec3(p.x, p.y - s, p.z), Vec3(p.x, p.y + s, p.z)), (Vec3(p.x, p.y, p.z - s), Vec3(p.x, p.y, p.z + s)), ] return lines
[docs] def physics_process(self, dt: float): if not self.monitoring or not self._tree: return my_shapes = self.find_all(CollisionShape3D) if not my_shapes: return # Check bodies current_bodies: set[CharacterBody3D] = set() for body in self._tree.root.find_all(CharacterBody3D): if not self._layers_match(body) or not body.collision: continue body_shapes = ( [body.collision] if isinstance(body.collision, CollisionShape3D) else body.find_all(CollisionShape3D) ) if self._any_shape_overlaps(my_shapes, body_shapes): current_bodies.add(body) for body in current_bodies - self._overlapping_bodies: self.body_entered(body) for body in self._overlapping_bodies - current_bodies: self.body_exited(body) self._overlapping_bodies = current_bodies # Check areas current_areas: set[Area3D] = set() for area in self._tree.root.find_all(Area3D): if area is self or not self._layers_match(area): continue other_shapes = area.find_all(CollisionShape3D) if self._any_shape_overlaps(my_shapes, other_shapes): current_areas.add(area) for area in current_areas - self._overlapping_areas: self.area_entered(area) for area in self._overlapping_areas - current_areas: self.area_exited(area) self._overlapping_areas = current_areas