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}