Skip to main content

viewport_lib/plugins/constraint/
plugin.rs

1//! ConstraintPlugin: spring-to-target, damping, and bounds constraints.
2
3use std::collections::HashMap;
4
5use crate::interaction::select::selection::NodeId;
6use crate::runtime::context::RuntimeStepContext;
7use crate::runtime::plugin::{RuntimePlugin, phase};
8
9/// A positional constraint applied to a scene node.
10#[derive(Debug, Clone)]
11pub enum Constraint {
12    /// Pull a node toward a fixed world-space target using a spring with damping.
13    ///
14    /// Uses semi-implicit Euler integration: velocity updated by spring and
15    /// damping forces, then position updated by velocity.
16    SpringTarget {
17        /// Target node.
18        node_id: NodeId,
19        /// World-space point the spring pulls toward.
20        target: glam::Vec3,
21        /// Spring stiffness in 1/s^2. Higher values pull more aggressively.
22        stiffness: f32,
23        /// Velocity damping coefficient in 1/s. Prevents oscillation.
24        damping: f32,
25    },
26    /// Drag a node's velocity to zero over time.
27    ///
28    /// Velocity is multiplied by `(1 - damping * dt)` each step. Requires an
29    /// initial velocity in the internal state (starts at zero).
30    Dampen {
31        /// Target node.
32        node_id: NodeId,
33        /// Damping coefficient in 1/s.
34        damping: f32,
35    },
36    /// Clamp a node's world-space position to stay inside an axis-aligned box.
37    ///
38    /// Velocity is zeroed on contact with a boundary.
39    ClampBounds {
40        /// Target node.
41        node_id: NodeId,
42        /// Minimum corner of the allowed region.
43        min: glam::Vec3,
44        /// Maximum corner of the allowed region.
45        max: glam::Vec3,
46    },
47}
48
49impl Constraint {
50    fn node_id(&self) -> NodeId {
51        match self {
52            Constraint::SpringTarget { node_id, .. }
53            | Constraint::Dampen { node_id, .. }
54            | Constraint::ClampBounds { node_id, .. } => *node_id,
55        }
56    }
57}
58
59/// A plugin that applies positional constraints to scene nodes.
60///
61/// Runs in the [`RuntimePhase::Animate`] phase. Spring and damping constraints
62/// maintain per-node velocity state internally.
63pub struct ConstraintPlugin {
64    constraints: Vec<Constraint>,
65    velocities: HashMap<NodeId, glam::Vec3>,
66}
67
68impl Default for ConstraintPlugin {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl ConstraintPlugin {
75    /// Create with no constraints.
76    pub fn new() -> Self {
77        Self {
78            constraints: Vec::new(),
79            velocities: HashMap::new(),
80        }
81    }
82
83    /// Add a constraint (builder style).
84    pub fn add(mut self, constraint: Constraint) -> Self {
85        self.constraints.push(constraint);
86        self
87    }
88
89    /// Add a constraint.
90    pub fn push(&mut self, constraint: Constraint) {
91        self.constraints.push(constraint);
92    }
93}
94
95impl RuntimePlugin for ConstraintPlugin {
96    fn priority(&self) -> i32 {
97        phase::ANIMATE + 50
98    }
99
100    fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
101        for constraint in &self.constraints {
102            let id = constraint.node_id();
103            let Some(node) = ctx.scene.node(id) else {
104                continue;
105            };
106            let world = node.world_transform();
107            let (scale, rotation, pos) = world.to_scale_rotation_translation();
108            let vel = self.velocities.entry(id).or_insert(glam::Vec3::ZERO);
109
110            let new_pos = match constraint {
111                Constraint::SpringTarget {
112                    target,
113                    stiffness,
114                    damping,
115                    ..
116                } => {
117                    // Semi-implicit Euler spring.
118                    let spring = (*target - pos) * *stiffness;
119                    let damp = *vel * *damping;
120                    *vel += (spring - damp) * ctx.dt;
121                    pos + *vel * ctx.dt
122                }
123                Constraint::Dampen { damping, .. } => {
124                    *vel *= (1.0 - damping * ctx.dt).max(0.0);
125                    pos + *vel * ctx.dt
126                }
127                Constraint::ClampBounds { min, max, .. } => {
128                    let clamped = pos.clamp(*min, *max);
129                    if clamped != pos {
130                        *vel = glam::Vec3::ZERO;
131                    }
132                    clamped
133                }
134            };
135
136            let new_t = glam::Affine3A::from_scale_rotation_translation(scale, rotation, new_pos);
137            ctx.writeback.set(id, new_t);
138        }
139    }
140}