Skip to main content

oxiphysics_softbody/rope/
types.rs

1//! Auto-generated module
2//!
3//! šŸ¤– Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use super::functions::*;
6use crate::constraint::{DistanceConstraint, SoftConstraint};
7use crate::particle::{SoftBody, SoftParticle};
8use crate::solver::XpbdSolver;
9use oxiphysics_core::math::{Real, Vec3};
10
11/// A rope / hair chain simulated with Verlet integration and PBD constraints.
12#[derive(Debug, Clone)]
13pub struct Rope {
14    /// Nodes that make up the chain.
15    pub nodes: Vec<RopeNode>,
16    /// Rest length of each segment between adjacent nodes.
17    pub segment_length: f64,
18    /// Stretch stiffness in \[0, 1\]; higher values resist elongation more.
19    pub stiffness: f64,
20    /// Bending stiffness in \[0, 1\]; higher values resist bending more.
21    pub bending_stiffness: f64,
22    /// Velocity damping coefficient.
23    pub damping: f64,
24    /// Visual radius of the rope (not used in physics).
25    pub radius: f64,
26}
27impl Rope {
28    /// Create a straight rope with `n_nodes` nodes starting at `start` and
29    /// extending in `direction` (which will be normalised) for `total_length`.
30    ///
31    /// All nodes begin with mass `mass_per_node`; none are fixed initially.
32    pub fn new_straight(
33        n_nodes: usize,
34        start: [f64; 3],
35        direction: [f64; 3],
36        total_length: f64,
37        mass_per_node: f64,
38    ) -> Self {
39        assert!(n_nodes >= 2, "A rope needs at least 2 nodes");
40        let segment_length = total_length / (n_nodes - 1) as f64;
41        let dir = normalize3(direction);
42        let mut nodes = Vec::with_capacity(n_nodes);
43        for i in 0..n_nodes {
44            let pos = add3(start, scale3(dir, segment_length * i as f64));
45            nodes.push(RopeNode::new(pos, mass_per_node));
46        }
47        Self {
48            nodes,
49            segment_length,
50            stiffness: 1.0,
51            bending_stiffness: 0.3,
52            damping: 0.01,
53            radius: 0.01,
54        }
55    }
56    /// Fix one end of the rope.
57    ///
58    /// * `end == 0` fixes node 0 (the start).
59    /// * `end == 1` fixes the last node.
60    pub fn fix_end(&mut self, end: usize) {
61        match end {
62            0 => self.nodes[0].fixed = true,
63            1 => {
64                let last = self.nodes.len() - 1;
65                self.nodes[last].fixed = true;
66            }
67            _ => panic!("end must be 0 (start) or 1 (end)"),
68        }
69    }
70    /// Apply gravitational acceleration to every free node by directly
71    /// displacing `prev_position` so that the Verlet step picks it up.
72    ///
73    /// Internally we shift `prev_position` backward by `a * dt^2` which adds
74    /// `a * dt^2` to the Verlet displacement `pos - prev_pos`.
75    pub fn apply_gravity(&mut self, dt: f64, gravity: [f64; 3]) {
76        let accel_disp = scale3(gravity, dt * dt);
77        for node in &mut self.nodes {
78            if node.fixed {
79                continue;
80            }
81            node.prev_position = sub3(node.prev_position, accel_disp);
82        }
83    }
84    /// Verlet integration step followed by constraint solving.
85    pub fn step(&mut self, dt: f64, gravity: [f64; 3]) {
86        self.apply_gravity(dt, gravity);
87        let damping_factor = 1.0 - self.damping;
88        for node in &mut self.nodes {
89            if node.fixed {
90                continue;
91            }
92            let vel = scale3(sub3(node.position, node.prev_position), 1.0 / dt);
93            let vel_damped = scale3(vel, damping_factor);
94            let new_pos = add3(node.position, scale3(vel_damped, dt));
95            node.prev_position = node.position;
96            node.position = new_pos;
97        }
98        self.solve_distance_constraints();
99        self.solve_bending_constraints();
100        for node in &mut self.nodes {
101            if node.fixed {
102                continue;
103            }
104            node.velocity = scale3(sub3(node.position, node.prev_position), 1.0 / dt);
105        }
106    }
107    /// Project distance (stretch) constraints using PBD.
108    pub fn solve_distance_constraints(&mut self) {
109        let n = self.nodes.len();
110        for i in 0..n - 1 {
111            let pi = self.nodes[i].position;
112            let pj = self.nodes[i + 1].position;
113            let delta = sub3(pj, pi);
114            let dist = len3(delta);
115            if dist < 1e-15 {
116                continue;
117            }
118            let error = dist - self.segment_length;
119            let correction = scale3(normalize3(delta), error * self.stiffness * 0.5);
120            let wi = if self.nodes[i].fixed { 0.0 } else { 1.0 };
121            let wj = if self.nodes[i + 1].fixed { 0.0 } else { 1.0 };
122            let total_w = wi + wj;
123            if total_w < 1e-15 {
124                continue;
125            }
126            if !self.nodes[i].fixed {
127                self.nodes[i].position =
128                    add3(self.nodes[i].position, scale3(correction, wi / total_w));
129            }
130            if !self.nodes[i + 1].fixed {
131                self.nodes[i + 1].position =
132                    sub3(self.nodes[i + 1].position, scale3(correction, wj / total_w));
133            }
134        }
135    }
136    /// Project bending constraints using consecutive triplets (i-1, i, i+1).
137    pub fn solve_bending_constraints(&mut self) {
138        let n = self.nodes.len();
139        if n < 3 {
140            return;
141        }
142        for i in 1..n - 1 {
143            let pa = self.nodes[i - 1].position;
144            let pb = self.nodes[i].position;
145            let pc = self.nodes[i + 1].position;
146            let mid = scale3(add3(pa, pc), 0.5);
147            let delta = sub3(pb, mid);
148            let correction = scale3(delta, self.bending_stiffness * 0.5);
149            if !self.nodes[i - 1].fixed {
150                self.nodes[i - 1].position =
151                    add3(self.nodes[i - 1].position, scale3(correction, 0.25));
152            }
153            if !self.nodes[i].fixed {
154                self.nodes[i].position = sub3(self.nodes[i].position, scale3(correction, 0.5));
155            }
156            if !self.nodes[i + 1].fixed {
157                self.nodes[i + 1].position =
158                    add3(self.nodes[i + 1].position, scale3(correction, 0.25));
159            }
160        }
161    }
162    /// Sum of inter-node distances (current total length of the rope).
163    pub fn total_length(&self) -> f64 {
164        self.nodes
165            .windows(2)
166            .map(|w| len3(sub3(w[1].position, w[0].position)))
167            .sum()
168    }
169    /// Discrete curvature at node `i` (must satisfy `1 <= i <= n-2`).
170    ///
171    /// Computed as `|t2 - t1| / segment_length` where `t1` and `t2` are the
172    /// unit tangents of the two adjacent segments.
173    ///
174    /// Returns 0.0 for boundary nodes or degenerate segments.
175    pub fn curvature_at(&self, i: usize) -> f64 {
176        let n = self.nodes.len();
177        if i == 0 || i >= n - 1 {
178            return 0.0;
179        }
180        let t1 = normalize3(sub3(self.nodes[i].position, self.nodes[i - 1].position));
181        let t2 = normalize3(sub3(self.nodes[i + 1].position, self.nodes[i].position));
182        let dt = sub3(t2, t1);
183        if self.segment_length < 1e-15 {
184            0.0
185        } else {
186            len3(dt) / self.segment_length
187        }
188    }
189}
190impl Rope {
191    /// Resolve collisions between rope nodes and a sphere centred at `center`
192    /// with `radius`. Nodes inside the sphere are pushed to the surface.
193    ///
194    /// Returns the list of collisions that were resolved.
195    pub fn collide_sphere(&mut self, center: [f64; 3], radius: f64) -> Vec<RopeCollision> {
196        let mut collisions = Vec::new();
197        for i in 0..self.nodes.len() {
198            if self.nodes[i].fixed {
199                continue;
200            }
201            let diff = sub3(self.nodes[i].position, center);
202            let dist = len3(diff);
203            if dist < radius && dist > 1e-15 {
204                let normal = normalize3(diff);
205                let penetration = radius - dist;
206                self.nodes[i].position = add3(center, scale3(normal, radius));
207                collisions.push(RopeCollision {
208                    node_index: i,
209                    penetration,
210                    normal,
211                });
212            }
213        }
214        collisions
215    }
216    /// Resolve collisions between rope nodes and an infinite plane defined by
217    /// `plane_point` and `plane_normal` (must be unit length).
218    ///
219    /// Nodes below the plane are pushed onto it.
220    pub fn collide_plane(
221        &mut self,
222        plane_point: [f64; 3],
223        plane_normal: [f64; 3],
224    ) -> Vec<RopeCollision> {
225        let mut collisions = Vec::new();
226        for i in 0..self.nodes.len() {
227            if self.nodes[i].fixed {
228                continue;
229            }
230            let to_node = sub3(self.nodes[i].position, plane_point);
231            let dist = dot3(to_node, plane_normal);
232            if dist < 0.0 {
233                self.nodes[i].position = sub3(self.nodes[i].position, scale3(plane_normal, dist));
234                collisions.push(RopeCollision {
235                    node_index: i,
236                    penetration: -dist,
237                    normal: plane_normal,
238                });
239            }
240        }
241        collisions
242    }
243    /// Self-collision detection and response.
244    ///
245    /// Checks all non-adjacent node pairs separated by more than `skip_adjacent`
246    /// segments. If two nodes are closer than `min_distance`, they are pushed
247    /// apart symmetrically.
248    pub fn resolve_self_collision(&mut self, min_distance: f64, skip_adjacent: usize) {
249        let n = self.nodes.len();
250        for i in 0..n {
251            if self.nodes[i].fixed {
252                continue;
253            }
254            for j in (i + skip_adjacent + 1)..n {
255                if self.nodes[j].fixed {
256                    continue;
257                }
258                let diff = sub3(self.nodes[j].position, self.nodes[i].position);
259                let dist = len3(diff);
260                if dist < min_distance && dist > 1e-15 {
261                    let normal = normalize3(diff);
262                    let overlap = min_distance - dist;
263                    let half = overlap * 0.5;
264                    self.nodes[i].position = sub3(self.nodes[i].position, scale3(normal, half));
265                    self.nodes[j].position = add3(self.nodes[j].position, scale3(normal, half));
266                }
267            }
268        }
269    }
270    /// Rope–surface interaction: slide rope along a cylinder surface.
271    ///
272    /// The cylinder axis runs from `axis_start` to `axis_end` with `radius`.
273    /// Nodes inside the cylinder are projected onto its surface.
274    pub fn collide_cylinder(
275        &mut self,
276        axis_start: [f64; 3],
277        axis_end: [f64; 3],
278        radius: f64,
279    ) -> Vec<RopeCollision> {
280        let axis = sub3(axis_end, axis_start);
281        let axis_len = len3(axis);
282        if axis_len < 1e-15 {
283            return Vec::new();
284        }
285        let axis_dir = normalize3(axis);
286        let mut collisions = Vec::new();
287        for i in 0..self.nodes.len() {
288            if self.nodes[i].fixed {
289                continue;
290            }
291            let to_node = sub3(self.nodes[i].position, axis_start);
292            let proj = dot3(to_node, axis_dir);
293            if proj < 0.0 || proj > axis_len {
294                continue;
295            }
296            let closest_on_axis = add3(axis_start, scale3(axis_dir, proj));
297            let radial = sub3(self.nodes[i].position, closest_on_axis);
298            let radial_dist = len3(radial);
299            if radial_dist < radius && radial_dist > 1e-15 {
300                let normal = normalize3(radial);
301                let penetration = radius - radial_dist;
302                self.nodes[i].position = add3(closest_on_axis, scale3(normal, radius));
303                collisions.push(RopeCollision {
304                    node_index: i,
305                    penetration,
306                    normal,
307                });
308            }
309        }
310        collisions
311    }
312    /// Compute the catenary shape for a rope of length `rope_length` suspended
313    /// between two endpoints under gravity.
314    ///
315    /// Returns the positions of `n_nodes` evenly spaced nodes along the
316    /// catenary curve in the XY plane (gravity in -Y). The first and last
317    /// positions are `start` and `end` respectively.
318    ///
319    /// This is an approximate catenary using iterative Newton's method on the
320    /// catenary parameter `a`.
321    pub fn catenary_positions(
322        start: [f64; 3],
323        end: [f64; 3],
324        rope_length: f64,
325        n_nodes: usize,
326    ) -> Vec<[f64; 3]> {
327        assert!(n_nodes >= 2, "Need at least 2 nodes");
328        let dx = end[0] - start[0];
329        let dy = end[1] - start[1];
330        let dz = end[2] - start[2];
331        let horiz_dist = (dx * dx + dz * dz).sqrt();
332        let vert_diff = dy;
333        let span = (dx * dx + dy * dy + dz * dz).sqrt();
334        if rope_length <= span + 1e-12 {
335            let mut positions = Vec::with_capacity(n_nodes);
336            for i in 0..n_nodes {
337                let t = i as f64 / (n_nodes - 1) as f64;
338                positions.push([start[0] + dx * t, start[1] + dy * t, start[2] + dz * t]);
339            }
340            return positions;
341        }
342        let mut a = 1.0_f64;
343        for _ in 0..50 {
344            let rhs = (rope_length * rope_length - vert_diff * vert_diff).sqrt();
345            let arg = horiz_dist / (2.0 * a);
346            let f = 2.0 * a * arg.sinh() - rhs;
347            let df = 2.0 * arg.sinh() - horiz_dist * arg.cosh() / a;
348            if df.abs() < 1e-15 {
349                break;
350            }
351            a -= f / df;
352            if a < 1e-6 {
353                a = 1e-6;
354            }
355        }
356        let x0 = horiz_dist * 0.5 - a * ((vert_diff / rope_length).atanh()).clamp(-10.0, 10.0);
357        let y0 = start[1] - a * ((0.0 - x0) / a).cosh();
358        let horiz_dir = if horiz_dist > 1e-15 {
359            [dx / horiz_dist, 0.0, dz / horiz_dist]
360        } else {
361            [1.0, 0.0, 0.0]
362        };
363        let mut positions = Vec::with_capacity(n_nodes);
364        for i in 0..n_nodes {
365            let t = i as f64 / (n_nodes - 1) as f64;
366            let x_local = horiz_dist * t;
367            let y_cat = a * ((x_local - x0) / a).cosh() + y0;
368            positions.push([
369                start[0] + horiz_dir[0] * x_local,
370                y_cat,
371                start[2] + horiz_dir[2] * x_local,
372            ]);
373        }
374        positions
375    }
376    /// Detect whether the rope is taut (stretched to near its rest length).
377    ///
378    /// Returns `true` if `total_length() >= rest_length * (1.0 - tolerance)`.
379    pub fn is_taut(&self, tolerance: f64) -> bool {
380        let rest = self.segment_length * (self.nodes.len() - 1) as f64;
381        self.total_length() >= rest * (1.0 - tolerance)
382    }
383    /// Apply a constraint that limits the maximum stretch per segment.
384    ///
385    /// If any segment exceeds `max_stretch_ratio * segment_length`, the nodes
386    /// are pulled back. This is a refinement pass that can be called after
387    /// the standard distance constraints.
388    pub fn enforce_max_stretch(&mut self, max_stretch_ratio: f64) {
389        let max_len = self.segment_length * max_stretch_ratio;
390        let n = self.nodes.len();
391        let max_iters = n * 10;
392        for _iter in 0..max_iters {
393            let mut converged = true;
394            for i in 0..n - 1 {
395                let diff = sub3(self.nodes[i + 1].position, self.nodes[i].position);
396                let dist = len3(diff);
397                if dist > max_len && dist > 1e-15 {
398                    converged = false;
399                    let correction_dir = normalize3(diff);
400                    let excess = dist - max_len;
401                    let half = excess * 0.5;
402                    if !self.nodes[i].fixed {
403                        self.nodes[i].position =
404                            add3(self.nodes[i].position, scale3(correction_dir, half));
405                    }
406                    if !self.nodes[i + 1].fixed {
407                        self.nodes[i + 1].position =
408                            sub3(self.nodes[i + 1].position, scale3(correction_dir, half));
409                    }
410                }
411            }
412            if converged {
413                break;
414            }
415        }
416    }
417    /// Compute the tension at each segment as the force magnitude.
418    ///
419    /// Returns a vector of length `n_nodes - 1` with the estimated tension
420    /// in each segment, based on the displacement from rest length and stiffness.
421    pub fn segment_tensions(&self) -> Vec<f64> {
422        let n = self.nodes.len();
423        let mut tensions = Vec::with_capacity(n - 1);
424        for i in 0..n - 1 {
425            let diff = sub3(self.nodes[i + 1].position, self.nodes[i].position);
426            let dist = len3(diff);
427            let stretch = (dist - self.segment_length).max(0.0);
428            tensions.push(self.stiffness * stretch / self.segment_length.max(1e-15));
429        }
430        tensions
431    }
432    /// Maximum tension across all segments.
433    pub fn max_tension(&self) -> f64 {
434        self.segment_tensions().into_iter().fold(0.0_f64, f64::max)
435    }
436    /// Compute kinetic energy of the rope: 0.5 * sum(m * |v|^2).
437    pub fn kinetic_energy(&self) -> f64 {
438        self.nodes
439            .iter()
440            .filter(|n| !n.fixed)
441            .map(|n| 0.5 * n.mass * dot3(n.velocity, n.velocity))
442            .sum()
443    }
444    /// Compute gravitational potential energy relative to `ref_y`.
445    pub fn potential_energy(&self, gravity_y: f64, ref_y: f64) -> f64 {
446        self.nodes
447            .iter()
448            .filter(|n| !n.fixed)
449            .map(|n| n.mass * gravity_y.abs() * (n.position[1] - ref_y))
450            .sum()
451    }
452    /// Compute the catenary tension distribution along the rope.
453    ///
454    /// For a rope hanging under gravity, the tension at each node is given by
455    /// the catenary equation.  Requires the first node to be fixed (the anchor).
456    ///
457    /// `T(s) = w * a`  where `a` is the catenary parameter, `w` is the weight
458    /// per unit length, and `s` is the arc-length from the lowest point.
459    ///
460    /// In this discrete approximation, the horizontal tension component
461    /// `H = w * a` is assumed constant and computed from the total weight
462    /// and the horizontal span.
463    ///
464    /// # Arguments
465    /// * `gravity_y` – gravitational acceleration magnitude (m/s²).
466    ///
467    /// Returns a vector of tension magnitudes at each node (N).
468    pub fn compute_catenary_tension(&self, gravity_y: f64) -> Vec<f64> {
469        let n = self.nodes.len();
470        if n < 2 {
471            return vec![0.0; n];
472        }
473        let m_avg: f64 = self.nodes.iter().map(|nd| nd.mass).sum::<f64>() / n as f64;
474        let w = m_avg * gravity_y.abs() / self.segment_length.max(1e-15);
475        let x_start = self.nodes[0].position[0];
476        let x_end = self.nodes[n - 1].position[0];
477        let span = (x_end - x_start).abs();
478        let y_start = self.nodes[0].position[1];
479        let y_end = self.nodes[n - 1].position[1];
480        let y_min = self
481            .nodes
482            .iter()
483            .map(|nd| nd.position[1])
484            .fold(f64::INFINITY, f64::min);
485        let sag = ((y_start + y_end) / 2.0 - y_min).max(1e-6);
486        let a = span * span / (8.0 * sag).max(1e-15);
487        let h = w * a;
488        let x_mid = (x_start + x_end) / 2.0;
489        self.nodes
490            .iter()
491            .map(|nd| {
492                let s = (nd.position[0] - x_mid).abs();
493                (h * h + (w * s) * (w * s)).sqrt()
494            })
495            .collect()
496    }
497    /// Compute the dynamic bending stiffness of the rope.
498    ///
499    /// The effective dynamic bending stiffness accounts for both material
500    /// bending resistance and the tension-induced geometric stiffness:
501    /// ```text
502    /// EI_eff = EI_material + T * L²
503    /// ```
504    /// where `T` is the average tension, `L` is the segment length, and
505    /// `EI_material = E * I` is the cross-section bending rigidity.
506    ///
507    /// # Arguments
508    /// * `youngs_modulus` – E (Pa).
509    /// * `radius`         – cross-section radius (m) for a circular rod.
510    /// * `gravity_y`      – gravitational acceleration magnitude (m/s²).
511    ///
512    /// Returns the effective bending stiffness (N·m²).
513    pub fn compute_dynamic_stiffness(
514        &self,
515        youngs_modulus: f64,
516        radius: f64,
517        gravity_y: f64,
518    ) -> f64 {
519        let area_moment = std::f64::consts::PI * radius.powi(4) / 4.0;
520        let ei_material = youngs_modulus * area_moment;
521        let tensions = self.compute_catenary_tension(gravity_y);
522        let avg_tension = if tensions.is_empty() {
523            0.0
524        } else {
525            tensions.iter().sum::<f64>() / tensions.len() as f64
526        };
527        let l = self.segment_length;
528        ei_material + avg_tension * l * l
529    }
530    /// Apply a twist constraint along the rope to limit relative twist between segments.
531    ///
532    /// Each pair of adjacent nodes has an associated twist angle (the rotation about
533    /// the segment tangent).  This constraint projects the positions to bring the
534    /// twist angle within `[-max_twist, max_twist]`.
535    ///
536    /// In the discrete approximation, twist is estimated from the rotation of an
537    /// arbitrary perpendicular frame.  When the estimated twist exceeds the limit,
538    /// a corrective displacement is applied orthogonal to the tangent.
539    ///
540    /// # Arguments
541    /// * `max_twist` – maximum allowed twist per segment (radians).
542    /// * `compliance` – XPBD compliance for the twist correction (m²/N).  0 = rigid.
543    /// * `dt`         – current time step (s).
544    pub fn apply_twist_constraint(&mut self, max_twist: f64, compliance: f64, dt: f64) {
545        let n = self.nodes.len();
546        if n < 3 {
547            return;
548        }
549        for i in 0..n - 2 {
550            let t0 = normalize3(sub3(self.nodes[i + 1].position, self.nodes[i].position));
551            let t1 = normalize3(sub3(self.nodes[i + 2].position, self.nodes[i + 1].position));
552            let cross = [
553                t0[1] * t1[2] - t0[2] * t1[1],
554                t0[2] * t1[0] - t0[0] * t1[2],
555                t0[0] * t1[1] - t0[1] * t1[0],
556            ];
557            let sin_angle = len3(cross);
558            let cos_angle = dot3(t0, t1).clamp(-1.0, 1.0);
559            let angle = sin_angle.atan2(cos_angle);
560            if angle.abs() <= max_twist {
561                continue;
562            }
563            let excess = angle - angle.signum() * max_twist;
564            let w_sum = if !self.nodes[i + 1].fixed && !self.nodes[i + 2].fixed {
565                2.0
566            } else if !self.nodes[i + 1].fixed || !self.nodes[i + 2].fixed {
567                1.0
568            } else {
569                continue;
570            };
571            let alpha_tilde = compliance / (dt * dt).max(1e-30);
572            let delta = excess / (w_sum + alpha_tilde);
573            let perp = normalize3(cross);
574            let correction = scale3(perp, delta * 0.5);
575            if !self.nodes[i + 1].fixed {
576                self.nodes[i + 1].position = add3(self.nodes[i + 1].position, correction);
577            }
578            if !self.nodes[i + 2].fixed {
579                self.nodes[i + 2].position = sub3(self.nodes[i + 2].position, correction);
580            }
581        }
582    }
583}
584/// A single elastic segment connecting two particles.
585#[derive(Debug, Clone)]
586pub struct RopeSegment {
587    /// Position of the first endpoint.
588    pub p0: [f64; 3],
589    /// Position of the second endpoint.
590    pub p1: [f64; 3],
591    /// Natural (rest) length of the segment.
592    pub rest_length: f64,
593    /// Mass of the segment (equally split to each endpoint for accumulation).
594    pub mass: f64,
595    /// Stretch stiffness (spring constant, N/m).
596    pub k_stretch: f64,
597}
598impl RopeSegment {
599    /// Create a new segment.
600    pub fn new(p0: [f64; 3], p1: [f64; 3], rest_length: f64, mass: f64, k_stretch: f64) -> Self {
601        Self {
602            p0,
603            p1,
604            rest_length,
605            mass,
606            k_stretch,
607        }
608    }
609    /// Spring force on p0 (first return value) and p1 (second return value).
610    ///
611    /// Uses Hooke's law: F = k * (d - rest) * dir
612    /// where dir points from p0 to p1.
613    pub fn elastic_force(&self) -> ([f64; 3], [f64; 3]) {
614        let diff = sub3(self.p1, self.p0);
615        let d = len3(diff);
616        if d < 1e-15 {
617            return ([0.0; 3], [0.0; 3]);
618        }
619        let dir = normalize3(diff);
620        let stretch = d - self.rest_length;
621        let f_mag = self.k_stretch * stretch;
622        let force = scale3(dir, f_mag);
623        (force, scale3(force, -1.0))
624    }
625}
626/// Kelvin–Voigt viscoelastic segment connecting two particles.
627///
628/// The constitutive model is `σ = E ε + η dε/dt` where:
629/// - `E` is the elastic (Young's) modulus (spring constant).
630/// - `Ī·` is the viscosity coefficient (damper constant).
631/// - `ε` is the engineering strain `(l - lā‚€) / lā‚€`.
632#[derive(Debug, Clone)]
633pub struct ViscoelasticSegment {
634    /// Index of the first node.
635    pub node_a: usize,
636    /// Index of the second node.
637    pub node_b: usize,
638    /// Natural (rest) length lā‚€ (m).
639    pub rest_length: f64,
640    /// Elastic modulus times cross-section area EĀ·A (N).
641    pub ea: f64,
642    /// Viscous damping coefficient Ī·Ā·A (NĀ·s).
643    pub eta_a: f64,
644    /// Previous strain (for finite-difference strain rate).
645    pub prev_strain: f64,
646}
647impl ViscoelasticSegment {
648    /// Create a new segment between nodes `a` and `b`.
649    pub fn new(node_a: usize, node_b: usize, rest_length: f64, ea: f64, eta_a: f64) -> Self {
650        Self {
651            node_a,
652            node_b,
653            rest_length,
654            ea,
655            eta_a,
656            prev_strain: 0.0,
657        }
658    }
659    /// Compute the current engineering strain.
660    pub fn strain(&self, positions: &[[f64; 3]]) -> f64 {
661        let pa = positions[self.node_a];
662        let pb = positions[self.node_b];
663        let l = len3(sub3(pb, pa));
664        (l - self.rest_length) / self.rest_length.max(1e-15)
665    }
666    /// Compute the Kelvin–Voigt force on node_a (and equal-and-opposite on node_b).
667    ///
668    /// `dt` is the time step (used to estimate strain rate).
669    /// Returns `(force_on_a, force_on_b)`.
670    pub fn kelvin_voigt_force(&mut self, positions: &[[f64; 3]], dt: f64) -> ([f64; 3], [f64; 3]) {
671        let pa = positions[self.node_a];
672        let pb = positions[self.node_b];
673        let diff = sub3(pb, pa);
674        let l = len3(diff);
675        if l < 1e-15 {
676            return ([0.0; 3], [0.0; 3]);
677        }
678        let dir = normalize3(diff);
679        let eps = (l - self.rest_length) / self.rest_length.max(1e-15);
680        let eps_dot = (eps - self.prev_strain) / dt.max(1e-15);
681        self.prev_strain = eps;
682        let sigma = self.ea * eps + self.eta_a * eps_dot;
683        let fa = scale3(dir, sigma);
684        (fa, scale3(fa, -1.0))
685    }
686    /// Elastic potential energy stored in this segment.
687    pub fn elastic_energy(&self, positions: &[[f64; 3]]) -> f64 {
688        let eps = self.strain(positions);
689        0.5 * self.ea * eps * eps * self.rest_length
690    }
691}
692/// A single node in a [`Rope`] chain.
693#[derive(Debug, Clone)]
694pub struct RopeNode {
695    /// Current world-space position.
696    pub position: [f64; 3],
697    /// Position at the previous time step (used by Verlet integration).
698    pub prev_position: [f64; 3],
699    /// Current velocity (derived from Verlet; also used to apply impulses).
700    pub velocity: [f64; 3],
701    /// Mass of the node in kg.
702    pub mass: f64,
703    /// When `true` the node is kinematically fixed and will not move.
704    pub fixed: bool,
705}
706impl RopeNode {
707    fn new(position: [f64; 3], mass: f64) -> Self {
708        Self {
709            position,
710            prev_position: position,
711            velocity: [0.0; 3],
712            mass,
713            fixed: false,
714        }
715    }
716}
717/// Result of a rope–sphere collision check.
718#[derive(Debug, Clone)]
719pub struct RopeCollision {
720    /// Index of the colliding node.
721    pub node_index: usize,
722    /// Penetration depth (positive = overlapping).
723    pub penetration: f64,
724    /// Surface normal at the contact point (points away from the sphere centre).
725    pub normal: [f64; 3],
726}
727/// A rope built from [`ViscoelasticSegment`]s.
728#[derive(Debug, Clone)]
729pub struct ViscoelasticRope {
730    /// Node positions.
731    pub positions: Vec<[f64; 3]>,
732    /// Node velocities.
733    pub velocities: Vec<[f64; 3]>,
734    /// Node masses.
735    pub masses: Vec<f64>,
736    /// Fixed flags.
737    pub fixed: Vec<bool>,
738    /// Viscoelastic segments.
739    pub segments: Vec<ViscoelasticSegment>,
740}
741impl ViscoelasticRope {
742    /// Build a straight viscoelastic rope with `n` nodes.
743    pub fn new_straight(
744        n: usize,
745        start: [f64; 3],
746        direction: [f64; 3],
747        seg_len: f64,
748        mass_per_node: f64,
749        ea: f64,
750        eta_a: f64,
751    ) -> Self {
752        assert!(n >= 2);
753        let dir = normalize3(direction);
754        let positions: Vec<[f64; 3]> = (0..n)
755            .map(|i| add3(start, scale3(dir, seg_len * i as f64)))
756            .collect();
757        let velocities = vec![[0.0; 3]; n];
758        let masses = vec![mass_per_node; n];
759        let fixed = vec![false; n];
760        let segments: Vec<ViscoelasticSegment> = (0..n - 1)
761            .map(|i| ViscoelasticSegment::new(i, i + 1, seg_len, ea, eta_a))
762            .collect();
763        Self {
764            positions,
765            velocities,
766            masses,
767            fixed,
768            segments,
769        }
770    }
771    /// One semi-implicit Euler step under gravity.
772    pub fn step(&mut self, dt: f64, gravity: [f64; 3]) {
773        let n = self.positions.len();
774        let mut forces = vec![[0.0_f64; 3]; n];
775        let positions_snap = self.positions.clone();
776        for seg in &mut self.segments {
777            let (fa, fb) = seg.kelvin_voigt_force(&positions_snap, dt);
778            forces[seg.node_a] = add3(forces[seg.node_a], fa);
779            forces[seg.node_b] = add3(forces[seg.node_b], fb);
780        }
781        for (i, f) in forces.iter().enumerate().take(n) {
782            if self.fixed[i] {
783                continue;
784            }
785            let inv_m = 1.0 / self.masses[i].max(1e-30);
786            let a = [
787                f[0] * inv_m + gravity[0],
788                f[1] * inv_m + gravity[1],
789                f[2] * inv_m + gravity[2],
790            ];
791            self.velocities[i] = add3(self.velocities[i], scale3(a, dt));
792            self.positions[i] = add3(self.positions[i], scale3(self.velocities[i], dt));
793        }
794    }
795    /// Total elastic energy of all segments.
796    pub fn total_elastic_energy(&self) -> f64 {
797        self.segments
798            .iter()
799            .map(|s| s.elastic_energy(&self.positions))
800            .sum()
801    }
802    /// Total kinetic energy.
803    pub fn total_kinetic_energy(&self) -> f64 {
804        self.positions
805            .iter()
806            .enumerate()
807            .filter(|(i, _)| !self.fixed[*i])
808            .map(|(i, _)| 0.5 * self.masses[i] * dot3(self.velocities[i], self.velocities[i]))
809            .sum()
810    }
811}
812/// A rope represented as a chain of particles connected by XPBD distance
813/// constraints.
814///
815/// This is the original implementation.  For Verlet-based hair simulation
816/// see [`Rope`] and [`HairSystem`].
817#[derive(Debug, Clone)]
818pub struct XpbdRope {
819    /// The underlying soft body.
820    pub body: SoftBody,
821    /// Distance constraints along the chain.
822    pub constraints: Vec<DistanceConstraint>,
823}
824impl XpbdRope {
825    /// Create a straight rope between `start` and `end`.
826    ///
827    /// * `num_segments` – number of segments (one fewer than particles).
828    /// * `mass_per_segment` – mass of each particle (except the first, which
829    ///   is fixed by default).
830    /// * `compliance` – XPBD compliance for distance constraints.
831    pub fn new(
832        start: Vec3,
833        end: Vec3,
834        num_segments: usize,
835        mass_per_segment: Real,
836        compliance: Real,
837    ) -> Self {
838        assert!(num_segments >= 1, "Rope needs at least one segment");
839        let num_particles = num_segments + 1;
840        let mut particles = Vec::with_capacity(num_particles);
841        for i in 0..num_particles {
842            let t = i as Real / num_segments as Real;
843            let pos = start + (end - start) * t;
844            if i == 0 {
845                particles.push(SoftParticle::new_static(pos));
846            } else {
847                particles.push(SoftParticle::new(pos, mass_per_segment));
848            }
849        }
850        let body = SoftBody::from_particles(particles);
851        let mut constraints = Vec::with_capacity(num_segments);
852        for i in 0..num_segments {
853            constraints.push(DistanceConstraint::from_particles(
854                i,
855                i + 1,
856                &body.particles,
857                compliance,
858            ));
859        }
860        Self { body, constraints }
861    }
862    /// Number of particles in the rope.
863    pub fn num_particles(&self) -> usize {
864        self.body.particles.len()
865    }
866    /// Number of segments (distance constraints).
867    pub fn num_segments(&self) -> usize {
868        self.constraints.len()
869    }
870}
871/// A single hair strand: a [`Rope`] with an associated display colour.
872#[derive(Debug, Clone)]
873pub struct HairStrand {
874    /// The underlying rope simulation.
875    pub rope: Rope,
876    /// RGB colour in \[0, 1\] range.
877    pub color: [f64; 3],
878}
879impl HairStrand {
880    /// Create a hair strand hanging straight down from `root`.
881    ///
882    /// The strand has `n_nodes` nodes and a rest length of `length`.
883    pub fn new(root: [f64; 3], length: f64, n_nodes: usize) -> Self {
884        let direction = [0.0, -1.0, 0.0];
885        let mut rope = Rope::new_straight(n_nodes, root, direction, length, 1.0 / n_nodes as f64);
886        rope.fix_end(0);
887        Self {
888            rope,
889            color: [0.6, 0.4, 0.2],
890        }
891    }
892}
893/// A Verlet-integrated rope with per-segment rest lengths.
894///
895/// This is the flat-array representation (positions / velocities / masses /
896/// rest_lengths) requested for the new API.  The original [`Rope`] (node-based)
897/// is kept for backward compatibility.
898#[derive(Debug, Clone)]
899pub struct RopeVerlet {
900    /// Particle positions.
901    pub particles: Vec<[f64; 3]>,
902    /// Particle velocities.
903    pub velocities: Vec<[f64; 3]>,
904    /// Particle masses (kg).
905    pub masses: Vec<f64>,
906    /// Rest length of each segment (length = particles.len() - 1).
907    pub rest_lengths: Vec<f64>,
908    /// Whether each particle is fixed (pinned).
909    pub fixed: Vec<bool>,
910}
911impl RopeVerlet {
912    /// Create a straight rope with `n` particles, starting at `start` and
913    /// extending in `direction` (normalised internally).
914    ///
915    /// All particles have mass `mass_per_segment` and none are fixed initially.
916    pub fn new(
917        n: usize,
918        start: [f64; 3],
919        direction: [f64; 3],
920        segment_length: f64,
921        mass_per_segment: f64,
922    ) -> Self {
923        assert!(n >= 2, "Rope needs at least 2 particles");
924        let dir = normalize3(direction);
925        let mut particles = Vec::with_capacity(n);
926        for i in 0..n {
927            particles.push(add3(start, scale3(dir, segment_length * i as f64)));
928        }
929        let velocities = vec![[0.0_f64; 3]; n];
930        let masses = vec![mass_per_segment; n];
931        let rest_lengths = vec![segment_length; n - 1];
932        let fixed = vec![false; n];
933        Self {
934            particles,
935            velocities,
936            masses,
937            rest_lengths,
938            fixed,
939        }
940    }
941    /// Total arc length (sum of current inter-particle distances).
942    pub fn total_length(&self) -> f64 {
943        self.particles
944            .windows(2)
945            .map(|w| len3(sub3(w[1], w[0])))
946            .sum()
947    }
948    /// One Verlet + PBD step under gravity with velocity damping.
949    ///
950    /// `k_stretch` is a PBD stiffness in \[0, 1\];
951    /// `damping` is a velocity damping coefficient per step.
952    pub fn step(&mut self, dt: f64, gravity: [f64; 3], k_stretch: f64, damping: f64) {
953        let n = self.particles.len();
954        for i in 0..n {
955            if self.fixed[i] {
956                continue;
957            }
958            self.velocities[i] = add3(self.velocities[i], scale3(gravity, dt));
959            self.velocities[i] = scale3(self.velocities[i], 1.0 - damping);
960            self.particles[i] = add3(self.particles[i], scale3(self.velocities[i], dt));
961        }
962        self.apply_constraint_iterations(k_stretch, 1);
963    }
964    /// Run `n_iters` PBD distance constraint iterations with stiffness `k`.
965    pub fn apply_constraint_iterations(&mut self, k: f64, n_iters: usize) {
966        let n = self.particles.len();
967        for _ in 0..n_iters {
968            for seg in 0..n - 1 {
969                let pi = self.particles[seg];
970                let pj = self.particles[seg + 1];
971                let diff = sub3(pj, pi);
972                let dist = len3(diff);
973                if dist < 1e-15 {
974                    continue;
975                }
976                let rest = self.rest_lengths[seg];
977                let error = dist - rest;
978                let correction = scale3(normalize3(diff), error * k * 0.5);
979                let wi = if self.fixed[seg] { 0.0 } else { 1.0 };
980                let wj = if self.fixed[seg + 1] { 0.0 } else { 1.0 };
981                let total_w = wi + wj;
982                if total_w < 1e-15 {
983                    continue;
984                }
985                if !self.fixed[seg] {
986                    self.particles[seg] = add3(pi, scale3(correction, wi / total_w));
987                }
988                if !self.fixed[seg + 1] {
989                    self.particles[seg + 1] = sub3(pj, scale3(correction, wj / total_w));
990                }
991            }
992        }
993    }
994    /// Compute the catenary SAG parameter `a` for the rope in its current shape.
995    ///
996    /// Fits to y = a * cosh(x/a) + c in the vertical plane.  Returns `a`
997    /// (positive) or 0.0 if the rope is straight.
998    ///
999    /// This is an approximate Newton solve on the horizontal span and total length.
1000    pub fn catenary_shape(&self) -> Vec<f64> {
1001        let n = self.particles.len();
1002        if n < 2 {
1003            return vec![0.0];
1004        }
1005        let start = self.particles[0];
1006        let end = self.particles[n - 1];
1007        let dx = end[0] - start[0];
1008        let dy = end[1] - start[1];
1009        let dz = end[2] - start[2];
1010        let horiz_dist = (dx * dx + dz * dz).sqrt();
1011        let vert_diff = dy;
1012        let rope_length = self.total_length();
1013        let span = (dx * dx + dy * dy + dz * dz).sqrt();
1014        if rope_length <= span + 1e-12 || horiz_dist < 1e-12 {
1015            return vec![0.0];
1016        }
1017        let mut a = horiz_dist.max(1e-6);
1018        for _ in 0..50 {
1019            let rhs = (rope_length * rope_length - vert_diff * vert_diff).sqrt();
1020            let arg = horiz_dist / (2.0 * a);
1021            let f = 2.0 * a * arg.sinh() - rhs;
1022            let df = 2.0 * arg.sinh() - horiz_dist * arg.cosh() / a;
1023            if df.abs() < 1e-15 {
1024                break;
1025            }
1026            a -= f / df;
1027            if a < 1e-6 {
1028                a = 1e-6;
1029            }
1030        }
1031        vec![a]
1032    }
1033}
1034/// A collection of [`HairStrand`]s that are stepped together.
1035#[derive(Debug, Clone)]
1036pub struct HairSystem {
1037    /// All strands in the system.
1038    pub strands: Vec<HairStrand>,
1039}
1040impl HairSystem {
1041    /// Create an empty hair system.
1042    pub fn new() -> Self {
1043        Self {
1044            strands: Vec::new(),
1045        }
1046    }
1047    /// Add a strand to the system.
1048    pub fn add_strand(&mut self, strand: HairStrand) {
1049        self.strands.push(strand);
1050    }
1051    /// Step all strands forward by `dt` under `gravity`.
1052    pub fn step(&mut self, dt: f64, gravity: [f64; 3]) {
1053        for strand in &mut self.strands {
1054            strand.rope.step(dt, gravity);
1055        }
1056    }
1057    /// Number of strands currently in the system.
1058    pub fn strand_count(&self) -> usize {
1059        self.strands.len()
1060    }
1061}
1062/// Material frame associated with a single rod segment.
1063///
1064/// The Cosserat (special) Kirchhoff rod model attaches a right-handed
1065/// orthonormal frame (d1, d2, d3) to every segment:
1066/// - `d3` is the tangent (unit) to the centreline.
1067/// - `d1`, `d2` are two directors that track twist around the tangent.
1068#[derive(Debug, Clone, Copy)]
1069pub struct MaterialFrame {
1070    /// First director (spans the cross-section).
1071    pub d1: [f64; 3],
1072    /// Second director (spans the cross-section, perpendicular to d1).
1073    pub d2: [f64; 3],
1074    /// Tangent director (along the centreline).
1075    pub d3: [f64; 3],
1076}
1077impl MaterialFrame {
1078    /// Construct a frame from the segment tangent.
1079    ///
1080    /// `d3` is set to the normalised tangent; `d1` and `d2` are built
1081    /// with a stable Gram-Schmidt complement.
1082    pub fn from_tangent(tangent: [f64; 3]) -> Self {
1083        let d3 = normalize3(tangent);
1084        let d1 = Self::perpendicular_to(d3);
1085        let d2 = cross3(d3, d1);
1086        Self { d1, d2, d3 }
1087    }
1088    /// Rotate the frame by angle `phi` around `d3` (twist).
1089    pub fn twist(&self, phi: f64) -> Self {
1090        let cos_phi = phi.cos();
1091        let sin_phi = phi.sin();
1092        let d1_new = [
1093            cos_phi * self.d1[0] + sin_phi * self.d2[0],
1094            cos_phi * self.d1[1] + sin_phi * self.d2[1],
1095            cos_phi * self.d1[2] + sin_phi * self.d2[2],
1096        ];
1097        let d2_new = cross3(self.d3, d1_new);
1098        Self {
1099            d1: d1_new,
1100            d2: d2_new,
1101            d3: self.d3,
1102        }
1103    }
1104    /// Build a stable unit vector perpendicular to `n`.
1105    fn perpendicular_to(n: [f64; 3]) -> [f64; 3] {
1106        let ax: [f64; 3] = if n[0].abs() <= n[1].abs() && n[0].abs() <= n[2].abs() {
1107            [1.0, 0.0, 0.0]
1108        } else if n[1].abs() <= n[2].abs() {
1109            [0.0, 1.0, 0.0]
1110        } else {
1111            [0.0, 0.0, 1.0]
1112        };
1113        let d = dot3(ax, n);
1114        normalize3([ax[0] - d * n[0], ax[1] - d * n[1], ax[2] - d * n[2]])
1115    }
1116}
1117/// Discrete Kirchhoff (Cosserat) rod with per-segment material frames.
1118///
1119/// The rod stores a sequence of positions and one material frame per *segment*
1120/// (one fewer than nodes).  Bending and twisting energies are computed from
1121/// the discrete Darboux vector at each interior node.
1122#[derive(Debug, Clone)]
1123pub struct KirchhoffRod {
1124    /// Node positions along the centreline.
1125    pub positions: Vec<[f64; 3]>,
1126    /// Material frame for each segment (length = positions.len() - 1).
1127    pub frames: Vec<MaterialFrame>,
1128    /// Rest segment length (uniform).
1129    pub rest_length: f64,
1130    /// Bending stiffness EI (N·m²).
1131    pub bending_stiffness: f64,
1132    /// Twisting stiffness GJ (N·m²).
1133    pub twisting_stiffness: f64,
1134}
1135impl KirchhoffRod {
1136    /// Build a straight rod with `n` nodes and uniform rest length `seg_len`.
1137    ///
1138    /// All frames are initialised from the tangent of the straight line.
1139    pub fn new_straight(
1140        n: usize,
1141        start: [f64; 3],
1142        direction: [f64; 3],
1143        seg_len: f64,
1144        bending_stiffness: f64,
1145        twisting_stiffness: f64,
1146    ) -> Self {
1147        assert!(n >= 2, "rod needs at least 2 nodes");
1148        let dir = normalize3(direction);
1149        let positions: Vec<[f64; 3]> = (0..n)
1150            .map(|i| add3(start, scale3(dir, seg_len * i as f64)))
1151            .collect();
1152        let frames: Vec<MaterialFrame> = (0..n - 1)
1153            .map(|_| MaterialFrame::from_tangent(dir))
1154            .collect();
1155        Self {
1156            positions,
1157            frames,
1158            rest_length: seg_len,
1159            bending_stiffness,
1160            twisting_stiffness,
1161        }
1162    }
1163    /// Discrete Darboux (curvature-twist) vector at interior node `i`.
1164    ///
1165    /// `i` must satisfy `1 <= i <= n-2`.  Returns `[kappa1, kappa2, tau]`
1166    /// in the material frame of segment `i-1`.
1167    pub fn darboux_vector(&self, i: usize) -> [f64; 3] {
1168        let n = self.positions.len();
1169        if i == 0 || i >= n - 1 {
1170            return [0.0; 3];
1171        }
1172        let f_prev = &self.frames[i - 1];
1173        let f_next = &self.frames[i];
1174        let inv_l = 1.0 / self.rest_length.max(1e-15);
1175        let kappa1 = dot3(f_prev.d2, f_next.d3) * inv_l;
1176        let kappa2 = dot3(f_prev.d3, f_next.d1) * inv_l;
1177        let tau = dot3(f_prev.d1, f_next.d2) * inv_l;
1178        [kappa1, kappa2, tau]
1179    }
1180    /// Bending energy for the entire rod (sum over interior nodes).
1181    ///
1182    /// `E_bend = 0.5 * EI * Ī£ (κ₁² + κ₂²) * l`
1183    pub fn bending_energy(&self) -> f64 {
1184        let n = self.positions.len();
1185        let mut e = 0.0;
1186        for i in 1..n - 1 {
1187            let dv = self.darboux_vector(i);
1188            e += dv[0] * dv[0] + dv[1] * dv[1];
1189        }
1190        0.5 * self.bending_stiffness * e * self.rest_length
1191    }
1192    /// Twisting energy for the entire rod.
1193    ///
1194    /// `E_twist = 0.5 * GJ * Ī£ τ² * l`
1195    pub fn twisting_energy(&self) -> f64 {
1196        let n = self.positions.len();
1197        let mut e = 0.0;
1198        for i in 1..n - 1 {
1199            let dv = self.darboux_vector(i);
1200            e += dv[2] * dv[2];
1201        }
1202        0.5 * self.twisting_stiffness * e * self.rest_length
1203    }
1204    /// Total elastic energy (bending + twisting).
1205    pub fn total_elastic_energy(&self) -> f64 {
1206        self.bending_energy() + self.twisting_energy()
1207    }
1208    /// Update material frames from current node positions.
1209    ///
1210    /// The tangent of each segment is recomputed; `d1` and `d2` are
1211    /// transported parallel from the previous frame (approximate parallel
1212    /// transport by projection and Gram-Schmidt re-orthogonalisation).
1213    pub fn update_frames(&mut self) {
1214        let n = self.positions.len();
1215        for s in 0..n - 1 {
1216            let tangent = normalize3(sub3(self.positions[s + 1], self.positions[s]));
1217            let d1_old = self.frames[s].d1;
1218            let proj = dot3(d1_old, tangent);
1219            let d1_proj = sub3(d1_old, scale3(tangent, proj));
1220            let d1_new = if len3(d1_proj) > 1e-14 {
1221                normalize3(d1_proj)
1222            } else {
1223                MaterialFrame::perpendicular_to(tangent)
1224            };
1225            let d2_new = cross3(tangent, d1_new);
1226            self.frames[s] = MaterialFrame {
1227                d1: d1_new,
1228                d2: d2_new,
1229                d3: tangent,
1230            };
1231        }
1232    }
1233}
1234/// Convenience solver wrapper for [`XpbdRope`] simulation.
1235#[derive(Debug)]
1236pub struct RopeSolver {
1237    /// Inner XPBD solver.
1238    pub solver: XpbdSolver,
1239}
1240impl RopeSolver {
1241    /// Create a rope solver with the given number of substeps.
1242    pub fn new(num_substeps: usize) -> Self {
1243        Self {
1244            solver: XpbdSolver::new(num_substeps),
1245        }
1246    }
1247    /// Step the rope simulation forward by `dt`.
1248    pub fn step(&mut self, rope: &mut XpbdRope, dt: Real) {
1249        let mut boxed: Vec<Box<dyn SoftConstraint>> = rope
1250            .constraints
1251            .iter()
1252            .cloned()
1253            .map(|c| Box::new(c) as Box<dyn SoftConstraint>)
1254            .collect();
1255        self.solver.solve(&mut rope.body, &mut boxed, dt);
1256    }
1257}