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