Source code for simvx.core.nodes_2d.path
"""Path2D and PathFollow2D -- 2D path-following nodes."""
from __future__ import annotations
import math
from ..descriptors import Property, Signal
from ..math.types import Vec2
from .node2d import Node2D
[docs]
class Path2D(Node2D):
"""Holds a Curve2D for path-following, positioned in 2D space."""
gizmo_colour = Property((1.0, 0.8, 0.0, 0.7), hint="Editor gizmo colour")
def __init__(self, **kwargs):
super().__init__(**kwargs)
from ..math.types import Curve2D
self.curve: Curve2D = Curve2D()
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return connected line segments sampling the curve."""
if self.curve.point_count < 2:
return []
pts = self.curve.get_baked_points()
if len(pts) < 2:
return []
gp = self.world_position
lines: list[tuple[Vec2, Vec2]] = []
prev = Vec2(pts[0].x + gp.x, pts[0].y + gp.y)
for i in range(1, len(pts)):
cur = Vec2(pts[i].x + gp.x, pts[i].y + gp.y)
lines.append((prev, cur))
prev = cur
return lines
[docs]
class PathFollow2D(Node2D):
"""Moves along a parent Path2D's curve based on progress.
Must be a child of a Path2D node. Updates position (and optionally rotation)
each frame based on the current progress along the curve.
"""
h_offset = Property(0.0, hint="Horizontal offset from path")
v_offset = Property(0.0, hint="Vertical offset from path")
rotates = Property(True, hint="Auto-rotate to face path direction")
loop = Property(True, hint="Wrap around at end of path")
cubic_interp = Property(True, hint="Use cubic (vs linear) interpolation")
loop_completed = Signal()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._progress: float = 0.0
@property
def progress(self) -> float:
"""Distance along the curve (0 to curve length)."""
return self._progress
@progress.setter
def progress(self, value: float):
curve = self._get_curve()
if curve is None:
self._progress = value
return
curve_length = curve.get_baked_length()
if curve_length < 1e-10:
self._progress = 0.0
return
if self.loop:
if value > curve_length:
value = value % curve_length
self.loop_completed()
elif value < 0:
value = curve_length + (value % curve_length) if curve_length > 0 else 0.0
self.loop_completed()
else:
value = max(0.0, min(value, curve_length))
self._progress = value
@property
def progress_ratio(self) -> float:
"""Normalized progress (0.0 to 1.0)."""
curve = self._get_curve()
if curve is None:
return 0.0
curve_length = curve.get_baked_length()
if curve_length < 1e-10:
return 0.0
return self._progress / curve_length
@progress_ratio.setter
def progress_ratio(self, value: float):
curve = self._get_curve()
if curve is None:
return
curve_length = curve.get_baked_length()
self.progress = value * curve_length
def _get_curve(self):
"""Get the Curve2D from the parent Path2D, or None."""
if isinstance(self.parent, Path2D):
return self.parent.curve
return None
[docs]
def process(self, dt: float):
self._update_transform()
def _update_transform(self):
curve = self._get_curve()
if curve is None or curve.point_count < 2:
return
pos, angle = curve.sample_baked_with_rotation(self._progress)
if self.h_offset != 0.0 or self.v_offset != 0.0:
c, s = math.cos(angle), math.sin(angle)
pos = Vec2(
pos.x + c * self.h_offset - s * self.v_offset,
pos.y + s * self.h_offset + c * self.v_offset,
)
self.position = pos
if self.rotates:
self.rotation = angle