diff --git a/Flax.sln.DotSettings b/Flax.sln.DotSettings index c91a48bfe..7ce410aa7 100644 --- a/Flax.sln.DotSettings +++ b/Flax.sln.DotSettings @@ -306,6 +306,7 @@ True True True + True True True True diff --git a/Source/Editor/SceneGraph/Actors/AnimatedModelNode.cs b/Source/Editor/SceneGraph/Actors/AnimatedModelNode.cs index 2fd35ead3..f9c3559fb 100644 --- a/Source/Editor/SceneGraph/Actors/AnimatedModelNode.cs +++ b/Source/Editor/SceneGraph/Actors/AnimatedModelNode.cs @@ -1,5 +1,7 @@ // Copyright (c) 2012-2021 Wojciech Figat. All rights reserved. +using System.Collections.Generic; +using FlaxEditor.GUI.ContextMenu; using FlaxEngine; namespace FlaxEditor.SceneGraph.Actors @@ -16,5 +18,377 @@ namespace FlaxEditor.SceneGraph.Actors : base(actor) { } + + /// + public override void OnContextMenu(ContextMenu contextMenu) + { + base.OnContextMenu(contextMenu); + + var actor = (AnimatedModel)Actor; + if (actor && actor.SkinnedModel) + { + var b = contextMenu.AddButton("Create ragdoll", OnCreateRagdoll); + b.TooltipText = "Adds ragdoll actor and setups the ragdoll physical structure based on skeleton bones hierarchy."; + } + } + + private void OnCreateRagdoll() + { + // Settings + var minBoneSize = 20.0f; // The minimum size for the bone bounds to be used for physical bodies generation + var minValidSize = 0.0001f; // The minimum size of the bone bounds to be included (used to skip too small, degenerated or invalid bones) + var collisionMargin = 1.01f; // The scale of the collision body dimensions (relative to the visual dimensions of the bones) + + var actor = (AnimatedModel)Actor; + var model = actor.SkinnedModel; + if (!model || model.WaitForLoaded()) + { + Editor.LogError("Missing or not loaded model."); + return; + } + var bones = model.Bones; + var nodes = model.Nodes; + actor.GetCurrentPose(out var localNodesPose); + if (bones.Length == 0 || localNodesPose.Length == 0) + { + Editor.LogError("Empty skeleton."); + return; + } + var skinningMatrices = new Matrix[bones.Length]; + for (int boneIndex = 0; boneIndex < bones.Length; boneIndex++) + { + ref var bone = ref bones[boneIndex]; + skinningMatrices[boneIndex] = bone.OffsetMatrix * localNodesPose[bone.NodeIndex]; + } + + // Get vertex data for each mesh + var meshes = model.LODs[0].Meshes; + var meshesData = new SkinnedMesh.Vertex0[meshes.Length][]; + var bonesVertices = new List[bones.Length]; + var indicesLimit = new Int4(bones.Length - 1); + for (int i = 0; i < meshes.Length; i++) + { + meshesData[i] = meshes[i].DownloadVertexBuffer0(); + + var meshData = meshes[i].DownloadVertexBuffer0(); + for (int j = 0; j < meshData.Length; j++) + { + ref var v = ref meshData[j]; + var weights = (Vector4)v.BlendWeights; + var indices = Int4.Min((Int4)v.BlendIndices, indicesLimit); + + // Find the bone with the highest influence on the vertex + var maxWeightIndex = 0; + for (int l = 0; l < 4; l++) + { + if (weights[l] > weights[maxWeightIndex]) + maxWeightIndex = l; + } + var maxWeightBone = indices[maxWeightIndex]; + + // Skin vertex position with the current pose + Vector3.Transform(ref v.Position, ref skinningMatrices[indices[0]], out Vector3 pos0); + Vector3.Transform(ref v.Position, ref skinningMatrices[indices[1]], out Vector3 pos1); + Vector3.Transform(ref v.Position, ref skinningMatrices[indices[2]], out Vector3 pos2); + Vector3.Transform(ref v.Position, ref skinningMatrices[indices[3]], out Vector3 pos3); + v.Position = pos0 * weights[0] + pos1 * weights[1] + pos2 * weights[2] + pos3 * weights[3]; + + // Add vertex to the bone list + ref var boneVertices = ref bonesVertices[maxWeightBone]; + if (boneVertices == null) + boneVertices = new List(); + boneVertices.Add(v); + } + } + + // Find small bones and try to merge them into parent (from back to end because the bones are always ordered from children to parents) + var bonesMergedSizes = new float[bones.Length]; + for (int boneIndex = bones.Length - 1; boneIndex >= 0; boneIndex--) + { + ref var bone = ref bones[boneIndex]; + ref var boneVertices = ref bonesVertices[boneIndex]; + if (boneVertices == null) + continue; // Skip not used bones + + // Compute bounds of the vertices using this bone (in local space of the actor) + var boneBounds = new BoundingBox(boneVertices[0].Position, boneVertices[0].Position); + for (int i = 1; i < boneVertices.Count; i++) + { + var pos = boneVertices[i].Position; + Vector3.Min(ref boneBounds.Minimum, ref pos, out boneBounds.Minimum); + Vector3.Max(ref boneBounds.Maximum, ref pos, out boneBounds.Maximum); + } + var boneBoxSize = (boneBounds.Size * 0.5f).Length; + var boneMergedSize = bonesMergedSizes[boneIndex] += boneBoxSize; + if (boneMergedSize < minBoneSize && boneMergedSize >= minValidSize) + { + if (bone.ParentIndex != -1) + { + // Merge it into parent + bonesMergedSizes[bone.ParentIndex] += boneMergedSize; + ref var parentVertices = ref bonesVertices[bone.ParentIndex]; + if (parentVertices == null) + parentVertices = boneVertices; + else + parentVertices.AddRange(boneVertices); + } + boneVertices = null; + } + } + + // Calculate the final sizes for the bone shapes + var bonesBounds = new BoundingBox[bones.Length]; + for (int boneIndex = 0; boneIndex < bones.Length; ++boneIndex) + { + ref var boneVertices = ref bonesVertices[boneIndex]; + var boneBounds = BoundingBox.Zero; + if (boneVertices != null) + { + boneBounds = new BoundingBox(boneVertices[0].Position, boneVertices[0].Position); + for (int i = 1; i < boneVertices.Count; i++) + { + var pos = boneVertices[i].Position; + Vector3.Min(ref boneBounds.Minimum, ref pos, out boneBounds.Minimum); + Vector3.Max(ref boneBounds.Maximum, ref pos, out boneBounds.Maximum); + } + } + bonesBounds[boneIndex] = boneBounds; + } + + // In case of problematic skeleton find the first bone to be sued as a root + int forcedRootBoneIndex = -1, firstParentBoneIndex = -1; + for (int boneIndex = 0; boneIndex < bones.Length; ++boneIndex) + { + if (bonesMergedSizes[boneIndex] > minBoneSize) + { + var parentIndex = bones[boneIndex].ParentIndex; + if (parentIndex == -1) + break; // The root node has a body + + if (firstParentBoneIndex == -1) + { + firstParentBoneIndex = parentIndex; // Cache the first parent for case sof multiple roots + } + else if (parentIndex == firstParentBoneIndex) + { + forcedRootBoneIndex = parentIndex; // In case of multiple roots use their parent as a root + break; + } + } + } + + // TODO: code above /\ could be async, then code below \/ could be executed on main thread to be safe + + // TODO: add undo support + + // Spawn ragdoll actor + var ragdoll = new Ragdoll + { + StaticFlags = StaticFlags.None, + Name = "Ragdoll", + Parent = actor, + }; + + // Spawn physical bodies for bones + var boneBodies = new RigidBody[bones.Length]; + for (int boneIndex = 0; boneIndex < bones.Length; ++boneIndex) + { + ref var boneVertices = ref bonesVertices[boneIndex]; + if (boneVertices == null || boneVertices.Count == 0) + continue; + var boneBounds = bonesBounds[boneIndex]; + if (bonesMergedSizes[boneIndex] < minBoneSize && boneIndex != forcedRootBoneIndex) + continue; + ref var bone = ref bones[boneIndex]; + ref var node = ref nodes[bone.NodeIndex]; + + // Calculate bone orientation based on the variance of the vertices + var covarianceMatrix = CalculateCovarianceMatrix(boneVertices); + var direction = ComputeEigenVector(ref covarianceMatrix); + var boneOrientation = Quaternion.FromDirection(direction); + + // Spawn body + var body = new RigidBody + { + StaticFlags = StaticFlags.None, + Name = node.Name, + LocalPosition = boneBounds.Center, + LocalOrientation = boneOrientation, + Parent = ragdoll, + }; + boneBodies[boneIndex] = body; + var boneTransform = body.LocalTransform; + + // Find the bounding box of the vertices in the local space of the bone + var boneLocalBounds = BoundingBox.Zero; + for (int i = 0; i < boneVertices.Count; i++) + { + var pos = boneTransform.WorldToLocal(boneVertices[i].Position); + Vector3.Min(ref boneLocalBounds.Minimum, ref pos, out boneLocalBounds.Minimum); + Vector3.Max(ref boneLocalBounds.Maximum, ref pos, out boneLocalBounds.Maximum); + } + + // Add collision shape + var boneLocalBoundsSize = boneLocalBounds.Size; +#if false + var collider = new BoxCollider + { + Name = "Box", + Size = boneLocalBoundsSize * collisionMargin, + }; +#elif false + var collider = new SphereCollider + { + Name = "Sphere", + Radius = boneLocalBoundsSize.MaxValue * 0.5f * collisionMargin, + }; +#elif true + var collider = new CapsuleCollider + { + Name = "Capsule", + }; + if (boneLocalBoundsSize.X > boneLocalBoundsSize.Y && boneLocalBoundsSize.X > boneLocalBoundsSize.Z) + { + collider.Height = boneLocalBoundsSize.X * collisionMargin; + collider.Radius = Mathf.Max(boneLocalBoundsSize.Y, boneLocalBoundsSize.Z) * 0.5f * collisionMargin; + } + else if (boneLocalBoundsSize.Y > boneLocalBoundsSize.X && boneLocalBoundsSize.Y > boneLocalBoundsSize.Z) + { + collider.LocalOrientation = Quaternion.Euler(0, 0, 90); + collider.Height = boneLocalBoundsSize.Y * collisionMargin; + collider.Radius = Mathf.Max(boneLocalBoundsSize.X, boneLocalBoundsSize.Z) * 0.5f * collisionMargin; + } + else + { + collider.LocalOrientation = Quaternion.Euler(0, 90, 0); + collider.Height = boneLocalBoundsSize.Z * collisionMargin; + collider.Radius = Mathf.Max(boneLocalBoundsSize.X, boneLocalBoundsSize.Y) * 0.5f * collisionMargin; + } + collider.Height = Mathf.Max(collider.Height - collider.Radius * 2.0f, 0.0f); +#endif + collider.StaticFlags = StaticFlags.None; + collider.Parent = body; + + // Crate joint with parent body + int parentBoneIndex = bone.ParentIndex; + while (parentBoneIndex != -1) + { + if (boneBodies[parentBoneIndex] != null) + break; + parentBoneIndex = bones[parentBoneIndex].ParentIndex; + } + if (parentBoneIndex != -1) + { + var parentBody = boneBodies[parentBoneIndex]; + var jointPose = localNodesPose[bone.NodeIndex]; +#if false + var joint = new FixedJoint(); +#else + var joint = new D6Joint + { + LimitSwing = new LimitConeRange + { + YLimitAngle = 45.0f, + ZLimitAngle = 45.0f, + }, + LimitTwist = new LimitAngularRange + { + Lower = -15.0f, + Upper = 15.0f, + }, + }; + joint.SetMotion(D6JointAxis.X, D6JointMotion.Locked); + joint.SetMotion(D6JointAxis.Y, D6JointMotion.Locked); + joint.SetMotion(D6JointAxis.Z, D6JointMotion.Locked); + joint.SetMotion(D6JointAxis.SwingY, D6JointMotion.Limited); + joint.SetMotion(D6JointAxis.SwingZ, D6JointMotion.Limited); + joint.SetMotion(D6JointAxis.Twist, D6JointMotion.Limited); +#endif + joint.StaticFlags = StaticFlags.None; + joint.EnableCollision = false; +#if true + // Child -> Parent + joint.Name = "Joint"; + joint.Target = parentBody; + joint.Parent = body; + //joint.Orientation = Quaternion.FromDirection(Vector3.Normalize(parentBody.Position - body.Position)); +#else + // Parent -> Child + joint.Name = "Joint to " + body.Name; + joint.Target = body; + joint.Parent = parentBody; + //joint.Orientation = Quaternion.FromDirection(Vector3.Normalize(body.Position - parentBody.Position)); +#endif + joint.SetJointLocation(actor.Transform.LocalToWorld(jointPose.TranslationVector)); + joint.SetJointOrientation(actor.Transform.Orientation * Quaternion.RotationMatrix(jointPose)); + } + } + + TreeNode.ExpandAll(true); + Editor.Instance.Scene.MarkSceneEdited(Root?.ParentScene); + } + + private static unsafe Matrix CalculateCovarianceMatrix(List vertices) + { + // [Reference: https://en.wikipedia.org/wiki/Covariance_matrix] + + // Calculate average point + var avg = Vector3.Zero; + for (int i = 0; i < vertices.Count; i++) + avg += vertices[i].Position; + avg /= vertices.Count; + + // Calculate distance to average for every point + var errors = new Vector3[vertices.Count]; + for (int i = 0; i < vertices.Count; i++) + errors[i] = vertices[i].Position - avg; + + var covariance = Matrix.Identity; + var cj = stackalloc float[3]; + for (int j = 0; j < 3; j++) + { + for (int k = 0; k < 3; k++) + { + // Average of the squared errors sum + for (int i = 0; i < vertices.Count; i++) + { + var error = errors[i]; + cj[k] += error[j] * error[k]; + } + cj[k] /= vertices.Count; + } + + var row = new Vector4(cj[0], cj[1], cj[2], 0.0f); + switch (j) + { + case 0: + covariance.Row1 = row; + break; + case 1: + covariance.Row2 = row; + break; + case 2: + covariance.Row3 = row; + break; + } + } + return covariance; + } + + private static Vector3 ComputeEigenVector(ref Matrix matrix) + { + // [Reference: http://en.wikipedia.org/wiki/Power_iteration] + var bk = new Vector3(0, 0, 1); + for (int i = 0; i < 32; ++i) + { + float bkLength = bk.Length; + if (bkLength > 0.0f) + { + Vector3.Transform(ref bk, ref matrix, out Vector3 bkA); + bk = bkA / bkLength; + } + } + return bk.Normalized; + } } } diff --git a/Source/Engine/Level/Actors/Ragdoll.cpp b/Source/Engine/Level/Actors/Ragdoll.cpp new file mode 100644 index 000000000..756d126d1 --- /dev/null +++ b/Source/Engine/Level/Actors/Ragdoll.cpp @@ -0,0 +1,236 @@ +// Copyright (c) 2012-2021 Wojciech Figat. All rights reserved. + +#include "Ragdoll.h" +#include "AnimatedModel.h" +#include "Engine/Level/Scene/Scene.h" +#include "Engine/Physics/Actors/RigidBody.h" +#include "Engine/Serialization/Serialization.h" + +Ragdoll::Ragdoll(const SpawnParams& params) + : Actor(params) +{ +} + +float Ragdoll::GetTotalMass() const +{ + float result = 0.0f; + for (auto child : Children) + { + const auto rigidBody = Cast(child); + if (!rigidBody || !rigidBody->IsActiveInHierarchy()) + continue; + result += rigidBody->GetMass(); + } + return result; +} + +float Ragdoll::InitBone(RigidBody* rigidBody, int32& nodeIndex, Transform& localOffset) +{ + // Bones with 0 weight are non-simulated (kinematic) + float weight = BonesWeight; + BonesWeights.TryGet(rigidBody->GetName(), weight); + rigidBody->SetIsKinematic(weight < ANIM_GRAPH_BLEND_THRESHOLD); + nodeIndex = _animatedModel->SkinnedModel->FindNode(rigidBody->GetName()); + if (nodeIndex != -1 && !_bonesOffsets.TryGet(rigidBody, localOffset)) + { + // Calculate the skeleton node local position of the bone + auto& node = _animatedModel->GraphInstance.NodesPose[nodeIndex]; + Transform nodeT; + node.Decompose(nodeT); + localOffset = nodeT.WorldToLocal(rigidBody->GetLocalTransform()); + _bonesOffsets[rigidBody] = localOffset; + } + return weight; +} + +void Ragdoll::OnFixedUpdate() +{ + if (!_animatedModel || !_animatedModel->SkinnedModel) + return; + PROFILE_CPU(); + + // Synchronize non-simulated bones + for (auto child : Children) + { + auto rigidBody = Cast(child); + if (!rigidBody || !rigidBody->IsActiveInHierarchy()) + continue; + Transform localOffset; + int32 nodeIndex; + const float weight = InitBone(rigidBody, nodeIndex, localOffset); + if (nodeIndex != -1 && weight < ANIM_GRAPH_BLEND_THRESHOLD) + { + // Bone is animation driven + auto& node = _animatedModel->GraphInstance.NodesPose[nodeIndex]; + Transform nodeT; + node.Decompose(nodeT); + rigidBody->SetLocalTransform(nodeT.LocalToWorld(localOffset)); + } + } + + // Synchronize simulated bones with skeleton if Anim Graph is disabled + if (!_animatedModel->AnimationGraph || _animatedModel->UpdateMode == AnimatedModel::AnimationUpdateMode::Never) + { + // Get current pose + Array currentPose; + _animatedModel->GetCurrentPose(currentPose); + + // Convert pose into local-bone pose + auto& skeleton = _animatedModel->SkinnedModel->Skeleton; + AnimGraphImpulse localPose; + localPose.Nodes.Resize(skeleton.Nodes.Count()); + for (int32 nodeIndex = 0; nodeIndex < skeleton.Nodes.Count(); nodeIndex++) + { + Transform t; + currentPose[nodeIndex].Decompose(t); + const int32 parentIndex = skeleton.Nodes[nodeIndex].ParentIndex; + if (parentIndex != -1) + { + Transform parent; + currentPose[parentIndex].Decompose(parent); + t = parent.WorldToLocal(t); + } + localPose.Nodes[nodeIndex] = t; + } + + // Override simulated bones in local pose + OnAnimationUpdating(&localPose); + + // Convert into skeleton pose + for (int32 nodeIndex = 0; nodeIndex < skeleton.Nodes.Count(); nodeIndex++) + { + const int32 parentIndex = skeleton.Nodes[nodeIndex].ParentIndex; + if (parentIndex != -1) + localPose.Nodes[parentIndex].LocalToWorld(localPose.Nodes[nodeIndex], localPose.Nodes[nodeIndex]); + localPose.Nodes[nodeIndex].GetWorld(currentPose[nodeIndex]); + } + + // Set current pose + _animatedModel->SetCurrentPose(currentPose); + } +} + +void Ragdoll::OnAnimationUpdating(AnimGraphImpulse* localPose) +{ + if (!_animatedModel || !_animatedModel->SkinnedModel) + return; + PROFILE_CPU(); + + // Synchronize simulated bones + auto& skeleton = _animatedModel->SkinnedModel->Skeleton; + for (auto child : Children) + { + const auto rigidBody = Cast(child); + if (!rigidBody || !rigidBody->IsActiveInHierarchy()) + continue; + Transform localOffset; + int32 nodeIndex; + const float weight = InitBone(rigidBody, nodeIndex, localOffset); + if (nodeIndex != -1 && weight > ANIM_GRAPH_BLEND_THRESHOLD) + { + // Calculate node transformation based on the rigidbody transform and inverted local offset + Transform nodeT, rigidbodyT = rigidBody->GetLocalTransform(); + nodeT.Scale = rigidbodyT.Scale / localOffset.Scale; + const Quaternion localOffsetOrientInv = localOffset.Orientation.Conjugated(); + Quaternion::Multiply(rigidbodyT.Orientation, localOffsetOrientInv, nodeT.Orientation); + nodeT.Orientation.Normalize(); + nodeT.Translation = rigidbodyT.Translation - (nodeT.Orientation * (localOffset.Translation * nodeT.Scale)); + + if (weight < 1.0f - ANIM_GRAPH_BLEND_THRESHOLD) + { + // Blend between simulated and animated state + Transform::Lerp(localPose->GetNodeModelTransformation(skeleton, nodeIndex), nodeT, weight, nodeT); + } + + // Bone is physics driven + localPose->SetNodeModelTransformation(skeleton, nodeIndex, nodeT); + } + } +} + +#if USE_EDITOR + +#include "Engine/Debug/DebugDraw.h" +#include "Engine/Physics/Joints/Joint.h" +#include "Engine/Physics/Colliders/Collider.h" + +void Ragdoll::OnDebugDrawSelected() +{ + // Draw whole skeleton + for (auto child : Children) + { + auto rigidBody = Cast(child); + if (!rigidBody || !rigidBody->IsActiveInHierarchy()) + continue; + for (auto grandChild : rigidBody->Children) + { + if (grandChild->Is() || grandChild->Is()) + grandChild->OnDebugDrawSelected(); + } + } + + // Base + Actor::OnDebugDrawSelected(); +} + +#endif + +void Ragdoll::OnEnable() +{ + GetScene()->Ticking.FixedUpdate.AddTick(this); + + // Initialize bones + if (_animatedModel) + { + if (_animatedModel->GraphInstance.NodesPose.IsEmpty()) + _animatedModel->PreInitSkinningData(); + for (auto child : Children) + { + const auto rigidBody = Cast(child); + if (rigidBody && rigidBody->IsActiveInHierarchy()) + { + Transform localOffset; + int32 nodeIndex; + InitBone(rigidBody, nodeIndex, localOffset); + } + } + } + + Actor::OnEnable(); +} + +void Ragdoll::OnDisable() +{ + Actor::OnDisable(); + + _bonesOffsets.Clear(); + GetScene()->Ticking.FixedUpdate.RemoveTick(this); +} + +void Ragdoll::OnParentChanged() +{ + Actor::OnParentChanged(); + + // Update for new parent + if (_animatedModel) + { + _animatedModel->GraphInstance.LocalPoseOverride.Unbind(this); + } + _animatedModel = Cast(_parent); + if (_animatedModel) + { + _animatedModel->GraphInstance.LocalPoseOverride.Bind(this); + } +} + +void Ragdoll::OnTransformChanged() +{ + // Force to be linked into parent + _localTransform = Transform::Identity; + + // Base + Actor::OnTransformChanged(); + + _box = BoundingBox(_transform.Translation); + _sphere = BoundingSphere(_transform.Translation, 0.0f); +} diff --git a/Source/Engine/Level/Actors/Ragdoll.h b/Source/Engine/Level/Actors/Ragdoll.h new file mode 100644 index 000000000..b79426fbc --- /dev/null +++ b/Source/Engine/Level/Actors/Ragdoll.h @@ -0,0 +1,57 @@ +// Copyright (c) 2012-2021 Wojciech Figat. All rights reserved. + +#pragma once + +#include "../Actor.h" +#include "Engine/Core/Collections/Dictionary.h" + +/// +/// Actor that synchronizes Animated Model skeleton pose with physical bones bodies simulated with physics. Child rigidbodies are used for per-bone simulation - rigidbodies names must match skeleton bone name and should be ordered based on importance in the skeleton tree (parents first). +/// +API_CLASS() class FLAXENGINE_API Ragdoll : public Actor +{ +DECLARE_SCENE_OBJECT(Ragdoll); +API_AUTO_SERIALIZATION(); +private: + + AnimatedModel* _animatedModel = nullptr; + Dictionary _bonesOffsets; + +public: + + /// + /// The default bones weight where 0 means fully animated bone and 1 means fully simulate bones. Can be used to control all bones simulation mode but is overriden by per-bone BonesWeights. + /// + API_FIELD(Attributes="EditorOrder(10), EditorDisplay(\"Ragdoll\"), Limit(0, 1)") + float BonesWeight = 1.0f; + + /// + /// The per-bone weights for ragdoll simulation. Key is bone name, value is the blend weight where 0 means fully animated bone and 1 means fully simulated bone. Can be used to control per-bone simulation. + /// + API_FIELD(Attributes="EditorOrder(20), EditorDisplay(\"Ragdoll\")") + Dictionary BonesWeights; + +public: + + /// + /// Calculates the total mass of all ragdoll bodies. + /// + API_PROPERTY() float GetTotalMass() const; + +private: + + float InitBone(RigidBody* rigidBody, int32& nodeIndex, Transform& localPose); + void OnFixedUpdate(); + void OnAnimationUpdating(struct AnimGraphImpulse* localPose); + +public: + + // [Actor] + void OnEnable() override; + void OnDisable() override; + void OnParentChanged() override; + void OnTransformChanged() override; +#if USE_EDITOR + void OnDebugDrawSelected() override; +#endif +};