Source code for simvx.core.nodes_3d.path

"""Path3D and PathFollow3D -- 3D path-following nodes."""

from __future__ import annotations

from ..descriptors import Property, Signal
from ..math.types import Quat, Vec3
from .node3d import Node3D


[docs] class Path3D(Node3D): """Holds a Curve3D for path-following, positioned in 3D 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 Curve3D self.curve: Curve3D = Curve3D()
[docs] def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]: """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 [] # Offset by node's global position gp = self.world_position lines: list[tuple[Vec3, Vec3]] = [] prev = Vec3(pts[0].x + gp.x, pts[0].y + gp.y, pts[0].z + gp.z) for i in range(1, len(pts)): cur = Vec3(pts[i].x + gp.x, pts[i].y + gp.y, pts[i].z + gp.z) lines.append((prev, cur)) prev = cur return lines
[docs] class PathFollow3D(Node3D): """Moves along a parent Path3D's curve based on progress. Must be a child of a Path3D node. Updates position and 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") tilt = Property(0.0, hint="Bank angle around forward axis (radians)") 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 Curve3D from the parent Path3D, or None.""" if isinstance(self.parent, Path3D): 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, forward = curve.sample_baked_with_rotation(self._progress) if self.h_offset != 0.0 or self.v_offset != 0.0: up = Vec3(0, 1, 0) right = forward.cross(up).normalized() if right.length() < 1e-6: right = forward.cross(Vec3(0, 0, 1)).normalized() local_up = right.cross(forward).normalized() pos = pos + right * self.h_offset + local_up * self.v_offset self.position = pos if self.rotates and forward.length() > 1e-10: up = Vec3(0, 1, 0) look_rot = Quat.look_at(forward, up) if self.tilt != 0.0: tilt_rot = Quat.from_axis_angle(Vec3(0, 0, -1), self.tilt) look_rot = look_rot * tilt_rot self.rotation = look_rot