avian3d/dynamics/solver/
mod.rs

1//! Manages and solves contacts, joints, and other constraints.
2//!
3//! See [`SolverPlugin`].
4
5pub mod contact;
6pub mod joints;
7pub mod schedule;
8pub mod softness_parameters;
9pub mod xpbd;
10
11mod diagnostics;
12pub use diagnostics::SolverDiagnostics;
13
14use crate::prelude::*;
15use bevy::prelude::*;
16use schedule::SubstepSolverSet;
17
18use self::{
19    contact::ContactConstraint,
20    softness_parameters::{SoftnessCoefficients, SoftnessParameters},
21};
22
23/// Manages and solves contacts, joints, and other constraints.
24///
25/// Note that the [`ContactConstraints`] are currently generated by tbe [`NarrowPhasePlugin`].
26///
27/// # Implementation
28///
29/// The solver primarily uses TGS Soft, an impulse-based solver with substepping and [soft constraints](softness_parameters).
30/// Warm starting is used to improve convergence, along with a relaxation pass to reduce overshooting.
31///
32/// [Speculative collision](dynamics::ccd#speculative-collision) is used by default to prevent tunneling.
33/// Optional [sweep-based Continuous Collision Detection (CCD)](dynamics::ccd#swept-ccd) is handled by the [`CcdPlugin`].
34///
35/// [Joints](joints) and user constraints are currently solved using [Extended Position-Based Dynamics (XPBD)](xpbd).
36/// In the future, they may transition to an impulse-based approach as well.
37///
38/// # Steps
39///
40/// Below are the main steps of the `SolverPlugin`.
41///
42/// 1. Generate and prepare constraints (contact constraints are generated by the [`NarrowPhasePlugin`])
43/// 2. Substepping loop (runs the [`SubstepSchedule`] [`SubstepCount`] times)
44///     1. [Integrate velocities](super::integrator::IntegrationSet::Velocity)
45///     2. [Warm start](SubstepSolverSet::WarmStart)
46///     3. [Solve constraints with bias](SubstepSolverSet::SolveConstraints)
47///     4. [Integrate positions](super::integrator::IntegrationSet::Position)
48///     5. [Solve constraints without bias to relax velocities](SubstepSolverSet::Relax)
49///     6. [Solve XPBD constraints (joints)](SubstepSolverSet::SolveXpbdConstraints)
50///     7. [Solve user-defined constraints](SubstepSolverSet::SolveUserConstraints)
51///     8. [Update velocities after XPBD constraint solving.](SubstepSolverSet::XpbdVelocityProjection)
52/// 3. [Apply restitution](SolverSet::Restitution)
53/// 4. [Finalize positions by applying](SolverSet::ApplyTranslation) [`AccumulatedTranslation`]
54/// 5. [Store contact impulses for next frame's warm starting](SolverSet::StoreContactImpulses)
55pub struct SolverPlugin {
56    length_unit: Scalar,
57}
58
59impl Default for SolverPlugin {
60    fn default() -> Self {
61        Self::new_with_length_unit(1.0)
62    }
63}
64
65impl SolverPlugin {
66    /// Creates a [`SolverPlugin`] with the given approximate dimensions of most objects.
67    ///
68    /// The length unit will be used for initializing the [`PhysicsLengthUnit`]
69    /// resource unless it already exists.
70    pub fn new_with_length_unit(unit: Scalar) -> Self {
71        Self { length_unit: unit }
72    }
73}
74
75impl Plugin for SolverPlugin {
76    fn build(&self, app: &mut App) {
77        app.init_resource::<SolverConfig>()
78            .init_resource::<ContactSoftnessCoefficients>()
79            .init_resource::<ContactConstraints>();
80
81        if app
82            .world()
83            .get_resource::<PhysicsLengthUnit>()
84            .is_none_or(|unit| unit.0 == 1.0)
85        {
86            app.insert_resource(PhysicsLengthUnit(self.length_unit));
87        }
88
89        // Get the `PhysicsSchedule`, and panic if it doesn't exist.
90        let physics = app
91            .get_schedule_mut(PhysicsSchedule)
92            .expect("add PhysicsSchedule first");
93
94        physics.add_systems(update_contact_softness.before(PhysicsStepSet::NarrowPhase));
95
96        // Update previous rotations before the substepping loop.
97        physics.add_systems(
98            (|mut query: Query<(&Rotation, &mut PreviousRotation)>| {
99                for (rot, mut prev_rot) in &mut query {
100                    prev_rot.0 = *rot;
101                }
102            })
103            .in_set(SolverSet::PreSubstep),
104        );
105
106        // Finalize the positions of bodies by applying the `AccumulatedTranslation`.
107        // This runs after the substepping loop.
108        physics.add_systems(
109            apply_translation
110                .chain()
111                .in_set(SolverSet::ApplyTranslation),
112        );
113
114        // Apply restitution.
115        physics.add_systems(solve_restitution.in_set(SolverSet::Restitution));
116
117        // Store the current contact impulses for the next frame's warm starting.
118        physics.add_systems(store_contact_impulses.in_set(SolverSet::StoreContactImpulses));
119
120        // Get the `SubstepSchedule`, and panic if it doesn't exist.
121        let substeps = app
122            .get_schedule_mut(SubstepSchedule)
123            .expect("add SubstepSchedule first");
124
125        // Warm start the impulses.
126        // This applies the impulses stored from the previous substep,
127        // which improves convergence.
128        substeps.add_systems(warm_start.in_set(SubstepSolverSet::WarmStart));
129
130        // Solve velocities using a position bias.
131        substeps.add_systems(solve_contacts::<true>.in_set(SubstepSolverSet::SolveConstraints));
132
133        // Relax biased velocities and impulses.
134        // This reduces overshooting caused by warm starting.
135        substeps.add_systems(solve_contacts::<false>.in_set(SubstepSolverSet::Relax));
136
137        // Solve joints with XPBD.
138        substeps.add_systems(
139            (
140                |mut query: Query<
141                    (
142                        &AccumulatedTranslation,
143                        &mut PreSolveAccumulatedTranslation,
144                        &Rotation,
145                        &mut PreSolveRotation,
146                    ),
147                    Without<RigidBodyDisabled>,
148                >| {
149                    for (translation, mut pre_solve_translation, rotation, mut previous_rotation) in
150                        &mut query
151                    {
152                        pre_solve_translation.0 = translation.0;
153                        previous_rotation.0 = *rotation;
154                    }
155                },
156                xpbd::solve_constraint::<FixedJoint, 2>,
157                xpbd::solve_constraint::<RevoluteJoint, 2>,
158                #[cfg(feature = "3d")]
159                xpbd::solve_constraint::<SphericalJoint, 2>,
160                xpbd::solve_constraint::<PrismaticJoint, 2>,
161                xpbd::solve_constraint::<DistanceJoint, 2>,
162            )
163                .chain()
164                .in_set(SubstepSolverSet::SolveXpbdConstraints),
165        );
166
167        // Perform XPBD velocity updates after constraint solving.
168        substeps.add_systems(
169            (
170                xpbd::project_linear_velocity,
171                xpbd::project_angular_velocity,
172                joint_damping::<FixedJoint>,
173                joint_damping::<RevoluteJoint>,
174                #[cfg(feature = "3d")]
175                joint_damping::<SphericalJoint>,
176                joint_damping::<PrismaticJoint>,
177                joint_damping::<DistanceJoint>,
178            )
179                .chain()
180                .in_set(SubstepSolverSet::XpbdVelocityProjection),
181        );
182    }
183
184    fn finish(&self, app: &mut App) {
185        // Register timer and counter diagnostics for the solver.
186        app.register_physics_diagnostics::<SolverDiagnostics>();
187    }
188}
189
190// TODO: Where should this type be and which plugin should initialize it?
191/// A units-per-meter scaling factor that adjusts the engine's internal properties
192/// to the scale of the world.
193///
194/// For example, a 2D game might use pixels as units and have an average object size
195/// of around 100 pixels. By setting the length unit to `100.0`, the physics engine
196/// will interpret 100 pixels as 1 meter for internal thresholds, improving stability.
197///
198/// Note that this is *not* used to scale forces or any other user-facing inputs or outputs.
199/// Instead, the value is only used to scale some internal length-based tolerances, such as
200/// [`SleepingThreshold::linear`] and [`NarrowPhaseConfig::default_speculative_margin`],
201/// as well as the scale used for [debug rendering](PhysicsDebugPlugin).
202///
203/// Choosing the appropriate length unit can help improve stability and robustness.
204///
205/// Default: `1.0`
206///
207/// # Example
208///
209/// The [`PhysicsLengthUnit`] can be inserted as a resource like normal,
210/// but it can also be specified through the [`PhysicsPlugins`] plugin group.
211///
212/// ```no_run
213/// # #[cfg(feature = "2d")]
214/// use avian2d::prelude::*;
215/// use bevy::prelude::*;
216///
217/// # #[cfg(feature = "2d")]
218/// fn main() {
219///     App::new()
220///         .add_plugins((
221///             DefaultPlugins,
222///             // A 2D game with 100 pixels per meter
223///             PhysicsPlugins::default().with_length_unit(100.0),
224///         ))
225///         .run();
226/// }
227/// # #[cfg(not(feature = "2d"))]
228/// # fn main() {} // Doc test needs main
229/// ```
230#[derive(Resource, Clone, Debug, Deref, DerefMut, PartialEq, Reflect)]
231#[reflect(Resource)]
232pub struct PhysicsLengthUnit(pub Scalar);
233
234impl Default for PhysicsLengthUnit {
235    fn default() -> Self {
236        Self(1.0)
237    }
238}
239
240/// Configuration parameters for the constraint solver that handles
241/// things like contacts and joints.
242///
243/// These are tuned to give good results for most applications, but can
244/// be configured if more control over the simulation behavior is needed.
245#[derive(Resource, Clone, Debug, PartialEq, Reflect)]
246#[reflect(Resource)]
247pub struct SolverConfig {
248    /// The damping ratio used for contact stabilization.
249    ///
250    /// Lower values make contacts more compliant or "springy",
251    /// allowing more visible penetration before overlap has been
252    /// resolved and the contact has been stabilized.
253    ///
254    /// Consider using a higher damping ratio if contacts seem too soft.
255    /// Note that making the value too large can cause instability.
256    ///
257    /// Default: `10.0`.
258    pub contact_damping_ratio: Scalar,
259
260    /// Scales the frequency used for contacts. A higher frequency
261    /// makes contact responses faster and reduces visible springiness,
262    /// but can hurt stability.
263    ///
264    /// The solver computes the frequency using the time step and substep count,
265    /// and limits the maximum frequency to be at most half of the time step due to
266    /// [Nyquist's theorem](https://en.wikipedia.org/wiki/Nyquist%E2%80%93Shannon_sampling_theorem).
267    /// This factor scales the resulting frequency, which can lead to unstable behavior
268    /// if the factor is too large.
269    ///
270    /// Default: `1.5`
271    pub contact_frequency_factor: Scalar,
272
273    /// The maximum speed at which overlapping bodies are pushed apart by the solver.
274    ///
275    /// With a small value, overlap is resolved gently and gradually, while large values
276    /// can result in more snappy behavior.
277    ///
278    /// This is implicitly scaled by the [`PhysicsLengthUnit`].
279    ///
280    /// Default: `4.0`
281    pub max_overlap_solve_speed: Scalar,
282
283    /// The coefficient in the `[0, 1]` range applied to
284    /// [warm start](SubstepSolverSet::WarmStart) impulses.
285    ///
286    /// Warm starting uses the impulses from the previous frame as the initial
287    /// solution for the current frame. This helps the solver reach the desired
288    /// state much faster, meaning that *convergence* is improved.
289    ///
290    /// The coefficient should typically be set to `1.0`.
291    ///
292    /// Default: `1.0`
293    pub warm_start_coefficient: Scalar,
294
295    /// The minimum speed along the contact normal in units per second
296    /// for [restitution](Restitution) to be applied.
297    ///
298    /// An appropriate threshold should typically be small enough that objects
299    /// keep bouncing until the bounces are effectively unnoticeable,
300    /// but large enough that restitution is not applied unnecessarily,
301    /// improving performance and stability.
302    ///
303    /// This is implicitly scaled by the [`PhysicsLengthUnit`].
304    ///
305    /// Default: `1.0`
306    pub restitution_threshold: Scalar,
307
308    /// The number of iterations used for applying [restitution](Restitution).
309    ///
310    /// A higher number of iterations can result in more accurate bounces,
311    /// but it only makes a difference when there are more than one contact point.
312    ///
313    /// For example, with just one iteration, a cube falling flat on the ground
314    /// might bounce and rotate to one side, because the impulses are applied
315    /// to the corners sequentially, and some of the impulses are likely to be larger
316    /// than the others. With multiple iterations, the impulses are applied more evenly.
317    ///
318    /// Default: `1`
319    pub restitution_iterations: usize,
320}
321
322impl Default for SolverConfig {
323    fn default() -> Self {
324        Self {
325            contact_damping_ratio: 10.0,
326            contact_frequency_factor: 1.5,
327            max_overlap_solve_speed: 4.0,
328            warm_start_coefficient: 1.0,
329            restitution_threshold: 1.0,
330            restitution_iterations: 1,
331        }
332    }
333}
334
335/// The [`SoftnessCoefficients`] used for contacts.
336///
337/// **Note**: This resource is updated automatically and not intended to be modified manually.
338/// Use the [`SolverConfig`] resource instead for tuning contact behavior.
339#[derive(Resource, Clone, Copy, PartialEq, Reflect)]
340#[reflect(Resource)]
341pub struct ContactSoftnessCoefficients {
342    /// The [`SoftnessCoefficients`] used for contacts against dynamic bodies.
343    pub dynamic: SoftnessCoefficients,
344    /// The [`SoftnessCoefficients`] used for contacts against static or kinematic bodies.
345    pub non_dynamic: SoftnessCoefficients,
346}
347
348impl Default for ContactSoftnessCoefficients {
349    fn default() -> Self {
350        Self {
351            dynamic: SoftnessParameters::new(10.0, 30.0).compute_coefficients(1.0 / 60.0),
352            non_dynamic: SoftnessParameters::new(10.0, 60.0).compute_coefficients(1.0 / 60.0),
353        }
354    }
355}
356
357fn update_contact_softness(
358    mut coefficients: ResMut<ContactSoftnessCoefficients>,
359    solver_config: Res<SolverConfig>,
360    physics_time: Res<Time<Physics>>,
361    substep_time: Res<Time<Substeps>>,
362) {
363    if solver_config.is_changed() || physics_time.is_changed() || substep_time.is_changed() {
364        let dt = physics_time.delta_secs_f64() as Scalar;
365        let h = substep_time.delta_secs_f64() as Scalar;
366
367        // The contact frequency should at most be half of the time step due to Nyquist's theorem.
368        // https://en.wikipedia.org/wiki/Nyquist%E2%80%93Shannon_sampling_theorem
369        let max_hz = 1.0 / (dt * 2.0);
370        let hz = solver_config.contact_frequency_factor * max_hz.min(0.25 / h);
371
372        coefficients.dynamic = SoftnessParameters::new(solver_config.contact_damping_ratio, hz)
373            .compute_coefficients(h);
374
375        // TODO: Perhaps the non-dynamic softness should be configurable separately.
376        // Make contacts against static and kinematic bodies stiffer to avoid clipping through the environment.
377        coefficients.non_dynamic =
378            SoftnessParameters::new(solver_config.contact_damping_ratio, 2.0 * hz)
379                .compute_coefficients(h);
380    }
381}
382
383/// A resource that stores the contact constraints.
384#[derive(Resource, Default, Deref, DerefMut)]
385pub struct ContactConstraints(pub Vec<ContactConstraint>);
386
387/// Warm starts the solver by applying the impulses from the previous frame or substep.
388///
389/// See [`SubstepSolverSet::WarmStart`] for more information.
390fn warm_start(
391    mut bodies: Query<RigidBodyQuery, Without<RigidBodyDisabled>>,
392    mut constraints: ResMut<ContactConstraints>,
393    solver_config: Res<SolverConfig>,
394    mut diagnostics: ResMut<SolverDiagnostics>,
395) {
396    let start = crate::utils::Instant::now();
397
398    for constraint in constraints.iter_mut() {
399        debug_assert!(!constraint.points.is_empty());
400
401        let Ok([mut body1, mut body2]) = bodies.get_many_mut([constraint.body1, constraint.body2])
402        else {
403            continue;
404        };
405
406        let normal = constraint.normal;
407        let tangent_directions =
408            constraint.tangent_directions(body1.linear_velocity.0, body2.linear_velocity.0);
409
410        constraint.warm_start(
411            &mut body1,
412            &mut body2,
413            normal,
414            tangent_directions,
415            solver_config.warm_start_coefficient,
416        );
417    }
418
419    diagnostics.warm_start += start.elapsed();
420}
421
422/// Solves contacts by iterating through the given contact constraints
423/// and applying impulses to colliding rigid bodies.
424///
425/// This solve is done `iterations` times. With a substepped solver,
426/// `iterations` should typically be `1`, as substeps will handle the iteration.
427///
428/// If `use_bias` is `true`, the impulses will be boosted to account for overlap.
429/// The solver should often be run twice per frame or substep: first with the bias,
430/// and then without it to *relax* the velocities and reduce overshooting caused by
431/// [warm starting](SubstepSolverSet::WarmStart).
432///
433/// See [`SubstepSolverSet::SolveConstraints`] and [`SubstepSolverSet::Relax`] for more information.
434#[allow(clippy::too_many_arguments)]
435#[allow(clippy::type_complexity)]
436fn solve_contacts<const USE_BIAS: bool>(
437    mut bodies: Query<RigidBodyQuery, Without<RigidBodyDisabled>>,
438    mut constraints: ResMut<ContactConstraints>,
439    solver_config: Res<SolverConfig>,
440    length_unit: Res<PhysicsLengthUnit>,
441    time: Res<Time>,
442    mut diagnostics: ResMut<SolverDiagnostics>,
443) {
444    let start = crate::utils::Instant::now();
445
446    let delta_secs = time.delta_seconds_adjusted();
447    let max_overlap_solve_speed = solver_config.max_overlap_solve_speed * length_unit.0;
448
449    for constraint in &mut constraints.0 {
450        let Ok([mut body1, mut body2]) = bodies.get_many_mut([constraint.body1, constraint.body2])
451        else {
452            continue;
453        };
454
455        constraint.solve(
456            &mut body1,
457            &mut body2,
458            delta_secs,
459            USE_BIAS,
460            max_overlap_solve_speed,
461        );
462    }
463
464    if USE_BIAS {
465        diagnostics.solve_constraints += start.elapsed();
466    } else {
467        diagnostics.relax_velocities += start.elapsed();
468    }
469}
470
471/// Iterates through contact constraints and applies impulses to account for [`Restitution`].
472///
473/// Note that restitution with TGS Soft and speculative contacts may not be perfectly accurate.
474/// This is a tradeoff, but cheap CCD is often more important than perfect restitution.
475///
476/// The number of iterations can be increased with [`SolverConfig::restitution_iterations`]
477/// to apply restitution for multiple contact points more evenly.
478#[allow(clippy::too_many_arguments)]
479#[allow(clippy::type_complexity)]
480fn solve_restitution(
481    mut bodies: Query<RigidBodyQuery, Without<RigidBodyDisabled>>,
482    mut constraints: ResMut<ContactConstraints>,
483    solver_config: Res<SolverConfig>,
484    length_unit: Res<PhysicsLengthUnit>,
485    mut diagnostics: ResMut<SolverDiagnostics>,
486) {
487    let start = crate::utils::Instant::now();
488
489    // The restitution threshold determining the speed required for restitution to be applied.
490    let threshold = solver_config.restitution_threshold * length_unit.0;
491
492    for constraint in constraints.iter_mut() {
493        let restitution = constraint.restitution;
494
495        if restitution == 0.0 {
496            continue;
497        }
498
499        let Ok([mut body1, mut body2]) = bodies.get_many_mut([constraint.body1, constraint.body2])
500        else {
501            continue;
502        };
503
504        // Performing multiple iterations can result in more accurate restitution,
505        // but only if there are more than one contact point.
506        let restitution_iterations = if constraint.points.len() > 1 {
507            solver_config.restitution_iterations
508        } else {
509            1
510        };
511
512        for _ in 0..restitution_iterations {
513            constraint.apply_restitution(&mut body1, &mut body2, threshold);
514        }
515    }
516
517    diagnostics.apply_restitution += start.elapsed();
518}
519
520/// Copies contact impulses from [`ContactConstraints`] to the contacts in the [`ContactGraph`].
521/// They will be used for [warm starting](SubstepSolverSet::WarmStart).
522fn store_contact_impulses(
523    constraints: Res<ContactConstraints>,
524    mut contact_graph: ResMut<ContactGraph>,
525    mut diagnostics: ResMut<SolverDiagnostics>,
526) {
527    let start = crate::utils::Instant::now();
528
529    for constraint in constraints.iter() {
530        let Some(contact_pair) = contact_graph.get_mut(constraint.collider1, constraint.collider2)
531        else {
532            unreachable!(
533                "Contact pair between {} and {} not found in contact graph.",
534                constraint.collider1, constraint.collider2
535            );
536        };
537
538        let manifold = &mut contact_pair.manifolds[constraint.manifold_index];
539
540        for (contact, constraint_point) in manifold.points.iter_mut().zip(constraint.points.iter())
541        {
542            contact.normal_impulse = constraint_point.normal_part.impulse;
543            contact.tangent_impulse = constraint_point
544                .tangent_part
545                .as_ref()
546                .map_or(default(), |part| part.impulse);
547        }
548    }
549
550    diagnostics.store_impulses += start.elapsed();
551}
552
553/// Finalizes the positions of bodies by applying the [`AccumulatedTranslation`].
554#[allow(clippy::type_complexity)]
555fn apply_translation(
556    mut bodies: Query<
557        (
558            &RigidBody,
559            &mut Position,
560            &Rotation,
561            &PreviousRotation,
562            &mut AccumulatedTranslation,
563            &ComputedCenterOfMass,
564        ),
565        Changed<AccumulatedTranslation>,
566    >,
567    mut diagnostics: ResMut<SolverDiagnostics>,
568) {
569    let start = crate::utils::Instant::now();
570
571    for (rb, mut pos, rot, prev_rot, mut translation, center_of_mass) in &mut bodies {
572        if rb.is_static() {
573            continue;
574        }
575
576        // We must also account for the translation caused by rotations around the center of mass,
577        // as it may be offset from `Position`.
578        pos.0 += crate::utils::get_pos_translation(&translation, prev_rot, rot, center_of_mass);
579        translation.0 = Vector::ZERO;
580    }
581
582    diagnostics.finalize += start.elapsed();
583}
584
585/// Applies velocity corrections caused by joint damping.
586#[allow(clippy::type_complexity)]
587pub fn joint_damping<T: Joint>(
588    mut bodies: Query<
589        (
590            &RigidBody,
591            &mut LinearVelocity,
592            &mut AngularVelocity,
593            &ComputedMass,
594            Option<&Dominance>,
595        ),
596        RigidBodyActiveFilter,
597    >,
598    joints: Query<&T, Without<RigidBody>>,
599    time: Res<Time>,
600) {
601    let delta_secs = time.delta_seconds_adjusted();
602
603    for joint in &joints {
604        if let Ok(
605            [(rb1, mut lin_vel1, mut ang_vel1, mass1, dominance1), (rb2, mut lin_vel2, mut ang_vel2, mass2, dominance2)],
606        ) = bodies.get_many_mut(joint.entities())
607        {
608            let delta_omega =
609                (ang_vel2.0 - ang_vel1.0) * (joint.damping_angular() * delta_secs).min(1.0);
610
611            if rb1.is_dynamic() {
612                ang_vel1.0 += delta_omega;
613            }
614            if rb2.is_dynamic() {
615                ang_vel2.0 -= delta_omega;
616            }
617
618            let delta_v =
619                (lin_vel2.0 - lin_vel1.0) * (joint.damping_linear() * delta_secs).min(1.0);
620
621            let w1 = if rb1.is_dynamic() {
622                mass1.inverse()
623            } else {
624                0.0
625            };
626            let w2 = if rb2.is_dynamic() {
627                mass2.inverse()
628            } else {
629                0.0
630            };
631
632            if w1 + w2 <= Scalar::EPSILON {
633                continue;
634            }
635
636            let p = delta_v / (w1 + w2);
637
638            let dominance1 = dominance1.map_or(0, |dominance| dominance.0);
639            let dominance2 = dominance2.map_or(0, |dominance| dominance.0);
640
641            if rb1.is_dynamic() && (!rb2.is_dynamic() || dominance1 <= dominance2) {
642                lin_vel1.0 += p * mass1.inverse();
643            }
644            if rb2.is_dynamic() && (!rb1.is_dynamic() || dominance2 <= dominance1) {
645                lin_vel2.0 -= p * mass2.inverse();
646            }
647        }
648    }
649}