/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Threading; using System.Threading.Tasks; using Opc.Ua; using Opc.Ua.RobotIntent; using Robotics.IntentEnabledRobot.Kinematics; namespace Robotics.IntentEnabledRobot.Simulation { /// /// Immutable observable state of the simulated arm. /// public sealed class SimulatedArmSnapshot { /// /// Initializes a snapshot. /// public SimulatedArmSnapshot( ArrayOf jointAngles, Pose3DDataType toolPose, ArrayOf jointFramePoses, double gripperOpening, bool hasObject, string toolName, ArrayOf heldPartPosition, ArrayOf stackSlotsFilled, string heldObjectClass = "") { JointAngles = jointAngles; ToolPose = toolPose; JointFramePoses = jointFramePoses; GripperOpening = gripperOpening; HasObject = hasObject; ToolName = toolName; HeldPartPosition = heldPartPosition; StackSlotsFilled = stackSlotsFilled; HeldObjectClass = heldObjectClass; } /// /// Gets joint angles in radians, ordered J1 through J6. /// public ArrayOf JointAngles { get; } /// /// Gets the tool-centre-point pose in the robot base frame. /// public Pose3DDataType ToolPose { get; } /// /// Gets the six joint-frame poses used to drive the USD chain. /// public ArrayOf JointFramePoses { get; } /// /// Gets the parallel gripper opening in metres. /// public double GripperOpening { get; } /// /// Gets a value indicating whether the simulated gripper carries an object. /// public bool HasObject { get; } /// /// Gets the fitted simulated tool name. /// public string ToolName { get; } /// /// Gets the visible position of the part currently carried by the gripper. /// public ArrayOf HeldPartPosition { get; } /// /// Gets the class label of the object the gripper carries, empty when it carries /// nothing. says that something is held; this says what, /// which is what a host needs to move the right item in its own world model. /// public string HeldObjectClass { get; } /// /// Gets a value for each pallet slot indicating whether that slot has been filled. /// public ArrayOf StackSlotsFilled { get; } } /// /// Provides deterministic simulation delays. /// public interface ISimulatedArmClock { /// /// Delays the simulated arm by the requested duration. /// ValueTask DelayAsync(TimeSpan delay, CancellationToken cancellationToken); } /// /// Executes simulation delays using the wall clock. /// public sealed class RealTimeSimulatedArmClock : ISimulatedArmClock { /// /// Gets the shared real-time clock. /// public static RealTimeSimulatedArmClock Shared { get; } = new(); /// public async ValueTask DelayAsync(TimeSpan delay, CancellationToken cancellationToken) { await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } } /// /// Executes Robot Intent structures against the simulated UR5e-style arm. /// public sealed class SimulatedArmExecutor : IIntentExecutor { /// /// Initializes a simulated executor with a new kinematics instance. /// public SimulatedArmExecutor() : this(new SimulatedArmKinematics()) { } /// /// Initializes a simulated executor. /// public SimulatedArmExecutor(SimulatedArmKinematics kinematics) : this(kinematics, RealTimeSimulatedArmClock.Shared) { } /// /// Initializes a simulated executor over a kinematics provider. /// public SimulatedArmExecutor(ISimulatedArmKinematics kinematics) : this(kinematics, RealTimeSimulatedArmClock.Shared) { } /// /// Initializes a simulated executor. /// public SimulatedArmExecutor(SimulatedArmKinematics kinematics, ISimulatedArmClock clock) : this((ISimulatedArmKinematics)kinematics, clock) { } /// /// Initializes a simulated executor over a kinematics provider. /// public SimulatedArmExecutor(ISimulatedArmKinematics kinematics, ISimulatedArmClock clock) { m_kinematics = kinematics ?? throw new ArgumentNullException(nameof(kinematics)); m_clock = clock ?? throw new ArgumentNullException(nameof(clock)); m_jointAngles = m_kinematics.InitialJointAngles.Span.ToArray(); if (m_jointAngles.Length != m_kinematics.AxisCount) { throw new ArgumentException( "The initial joint configuration does not match the kinematics axis count.", nameof(kinematics)); } PublishCurrentPoseLocked(); } /// /// Raised after the observable snapshot changes. /// public event EventHandler? SnapshotChanged; /// /// Gets how far below the tool centre point a grasped part hangs. /// /// /// A host that decides where a part should come to rest needs this to work back /// from the part's resting height to the tool pose that leaves it there. /// public const double HeldPartTcpOffset = 0.035; /// /// Resolves a Location NodeId to the position, in this arm's base frame, that a /// Pick or Place should travel to before actuating the gripper. Optional: leave it /// unset and both intents actuate the gripper where the arm already stands. /// public LocationPositionResolver? ResolveLocationPosition { get; set; } /// /// Resolves a Location NodeId to a complete tool pose. When set, this takes /// precedence over . /// /// /// A position-only resolver makes every move inherit whatever orientation the /// previous one left behind. A cell that knows how its tool should approach a bin /// or fixture supplies this instead, so one grasp cannot strand the next move at a /// yaw the arm cannot retract from. /// public LocationPoseResolver? ResolveLocationPose { get; set; } /// /// Resolves a Pick to the current pose of the selected workpiece. /// public PickPoseResolver? ResolvePickPose { get; set; } /// /// Notifies the host after a Pick attempt finishes, whether it succeeded or failed. /// public Action? PickAttemptFinished { get; set; } /// /// Gets or sets a host decision on whether the final approach to a Location should /// be a straight Cartesian descent. Optional: when unset, the executor uses a /// collision-checked joint path. /// /// /// A cell knows the difference between descending inside an open bin and descending /// onto a fixture. The shared executor does not. Letting the host state that /// difference keeps cell coordinates out of this type while allowing a vertical /// descent where crossing a bin wall on a joint interpolation would be wrong. /// public Func? PreferCartesianDescent { get; set; } /// /// Gets or sets an optional diagnostic sink for travel planning decisions. /// internal Action? Diagnostic { get; set; } /// /// Gets the latest observable state without exposing synchronization primitives. /// public SimulatedArmSnapshot CurrentSnapshot { get; private set; } /// public async ValueTask ExecuteAsync( IntentExecution execution, CancellationToken cancellationToken) { if (execution is null) { throw new ArgumentNullException(nameof(execution)); } if (execution.Intent is JointMoveIntentDataType or LinearMoveIntentDataType or CircularMoveIntentDataType or TrajectoryIntentDataType or CartesianPathIntentDataType or ForceIntentDataType) { InvalidateRecordedApproach(); } return execution.Intent switch { JointMoveIntentDataType joint => await ExecuteJointMoveAsync(joint, execution, cancellationToken) .ConfigureAwait(false), LinearMoveIntentDataType linear => await ExecuteLinearMoveAsync(linear, execution, cancellationToken) .ConfigureAwait(false), CircularMoveIntentDataType circular => await ExecuteCircularMoveAsync(circular, execution, cancellationToken) .ConfigureAwait(false), TrajectoryIntentDataType trajectory => await ExecuteTrajectoryAsync(trajectory, execution, cancellationToken) .ConfigureAwait(false), CartesianPathIntentDataType path => await ExecuteCartesianPathAsync(path, execution, cancellationToken) .ConfigureAwait(false), ForceIntentDataType force => await ExecuteForceAsync(force, execution, cancellationToken).ConfigureAwait(false), GraspIntentDataType grasp => await ExecuteGraspAsync(grasp, execution, cancellationToken).ConfigureAwait(false), ReleaseIntentDataType release => await ExecuteReleaseAsync(release, execution, cancellationToken) .ConfigureAwait(false), PickIntentDataType pick => await ExecutePickAsync(pick, execution, cancellationToken).ConfigureAwait(false), PlaceIntentDataType place => await ExecutePlaceAsync(place, execution, cancellationToken).ConfigureAwait(false), ToolChangeIntentDataType tool => await ExecuteToolChangeAsync(tool, execution, cancellationToken) .ConfigureAwait(false), WaitIntentDataType wait => await ExecuteWaitAsync(wait, execution, cancellationToken).ConfigureAwait(false), _ => IntentOutcome.Fail( IntentFailureEnum.CapabilityNotSupported, "The simulated arm does not support this intent.") }; } /// public bool CanCancel(IntentExecution execution) { if (execution is null) { throw new ArgumentNullException(nameof(execution)); } lock (m_lock) { return m_nonCancellableIntentId.Length == 0 || m_nonCancellableIntentId != execution.IntentId; } } private async ValueTask ExecuteJointMoveAsync( JointMoveIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { double[] start = GetJoints(); ArrayOf target; if (intent.HasJointTargets) { if (intent.JointTargets.Count != m_kinematics.AxisCount || !m_kinematics.IsWithinLimits(intent.JointTargets.Span)) { return IntentOutcome.Fail( IntentFailureEnum.JointLimit, "Joint targets exceed the simulated limits."); } target = ArrayOf.Create(intent.JointTargets.Span); } else if (m_kinematics.TrySelectNearest( intent.TargetPose, start, out SimulatedArmIkSolution? solution, out SimulatedArmKinematicFailure failure)) { target = solution.JointAngles; } else { return IntentOutcome.Fail( m_kinematics.MapFailure(failure), "The target pose cannot be reached."); } double distance = JointDistance(start, target.Span); var profile = new TrapezoidalVelocityProfile( distance, JointSpeed(intent.Constraints), DefaultJointAcceleration); IntentOutcome outcome = await FollowProfileAsync( profile, execution, fraction => SetJoints(m_kinematics.InterpolateJoints(start, target.Span, fraction).Span), DefaultJointAcceleration, cancellationToken).ConfigureAwait(false); return outcome.State == ExecutionStateEnum.Succeeded ? IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose) : outcome; } private ValueTask ExecuteLinearMoveAsync( LinearMoveIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { return MoveCartesianAsync(intent.Target, intent.Constraints, execution, cancellationToken); } private async ValueTask ExecuteCircularMoveAsync( CircularMoveIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { Pose3DDataType start = CurrentSnapshot.ToolPose; var via = new Pose3DDataType { FrameId = intent.ViaPoint.FrameId, Position = ArrayOf.Create(intent.ViaPoint.Position.Span), Orientation = ArrayOf.Create(start.Orientation.Span) }; double distance = ArcLength(start.Position.Span, via.Position.Span, intent.Target.Position.Span); var profile = new TrapezoidalVelocityProfile( distance, CartesianSpeed(intent.Constraints), CartesianAcceleration(intent.Constraints)); SimulatedArmKinematicFailure moveFailure = SimulatedArmKinematicFailure.None; IntentOutcome outcome = await FollowProfileAsync( profile, execution, fraction => { Pose3DDataType pose = InterpolateArc(start, via, intent.Target, fraction); if (m_kinematics.TrySelectNearestConfiguration( pose, CurrentSnapshot.JointAngles.Span, out SimulatedArmIkSolution? solution, out SimulatedArmKinematicFailure failure)) { SetPose(solution.JointAngles.Span); } else { moveFailure = failure; } }, CartesianAcceleration(intent.Constraints), cancellationToken, () => moveFailure != SimulatedArmKinematicFailure.None).ConfigureAwait(false); if (moveFailure != SimulatedArmKinematicFailure.None) { return IntentOutcome.Fail( m_kinematics.MapFailure(moveFailure), "The circular path is not feasible."); } return outcome.State == ExecutionStateEnum.Succeeded ? IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose) : outcome; } private async ValueTask ExecuteTrajectoryAsync( TrajectoryIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { if (intent.Points.IsEmpty) { return IntentOutcome.Fail(IntentFailureEnum.ParameterInvalid, "The trajectory has no points."); } double finalMs = intent.Points[^1].TimeFromStart; double elapsed = 0.0; int pointIndex = 0; while (elapsed < finalMs) { cancellationToken.ThrowIfCancellationRequested(); while (pointIndex + 1 < intent.Points.Count && intent.Points[pointIndex + 1].TimeFromStart <= elapsed) { pointIndex++; } TrajectoryPointDataType a = intent.Points[pointIndex]; TrajectoryPointDataType b = intent.Points[Math.Min(pointIndex + 1, intent.Points.Count - 1)]; double fraction = Math.Clamp( (elapsed - a.TimeFromStart) / Math.Max(1.0, b.TimeFromStart - a.TimeFromStart), 0.0, 1.0); SetJoints(m_kinematics.InterpolateJoints(a.Positions.Span, b.Positions.Span, fraction).Span); double goalDeviation = JointDistance( CurrentSnapshot.JointAngles.Span, intent.Points[^1].Positions.Span); execution.Progress.ReportProgress(Math.Clamp(elapsed / finalMs, 0.0, 1.0)); execution.Progress.ReportPose(CurrentSnapshot.ToolPose); execution.Progress.ReportTrajectoryDeviation(0.0, goalDeviation, elapsed, false); if (!await DelayTickAsync(cancellationToken).ConfigureAwait(false)) { break; } elapsed += TickSeconds * 1000.0; } SetJoints(intent.Points[^1].Positions.Span); execution.Progress.ReportProgress(1.0); execution.Progress.ReportPose(CurrentSnapshot.ToolPose); execution.Progress.ReportTrajectoryDeviation(0.0, 0.0, finalMs, true); return IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose); } private async ValueTask ExecuteCartesianPathAsync( CartesianPathIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { for (int i = 0; i < intent.Waypoints.Count; i++) { PathWaypointDataType waypoint = intent.Waypoints[i]; IntentOutcome outcome = await MoveCartesianAsync( waypoint.Pose, new MotionConstraintsDataType(), execution, cancellationToken).ConfigureAwait(false); if (outcome.State != ExecutionStateEnum.Succeeded) { return outcome; } } return IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose); } private async ValueTask ExecuteForceAsync( ForceIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { Pose3DDataType start = CurrentSnapshot.ToolPose; ReadOnlySpan direction = intent.Direction.Span; double norm = Math.Sqrt( (direction[0] * direction[0]) + (direction[1] * direction[1]) + (direction[2] * direction[2])); if (norm == 0.0) { return IntentOutcome.Fail(IntentFailureEnum.ParameterInvalid, "Force direction is zero."); } double directionX = direction[0] / norm; double directionY = direction[1] / norm; double directionZ = direction[2] / norm; var profile = new TrapezoidalVelocityProfile( intent.MaxDistance, DefaultCartesianSpeed * 0.35, DefaultCartesianAcceleration); bool contacted = false; SimulatedArmKinematicFailure moveFailure = SimulatedArmKinematicFailure.None; IntentOutcome outcome = await FollowProfileAsync( profile, execution, fraction => { double distance = fraction * intent.MaxDistance; Pose3DDataType pose = TranslatePose(start, directionX, directionY, directionZ, distance); contacted = IsContact(pose.Position.Span); if (m_kinematics.TrySelectNearestConfiguration( pose, CurrentSnapshot.JointAngles.Span, out SimulatedArmIkSolution? solution, out SimulatedArmKinematicFailure failure)) { SetPose(solution.JointAngles.Span); } else { moveFailure = failure; } }, DefaultCartesianAcceleration, cancellationToken, () => contacted || moveFailure != SimulatedArmKinematicFailure.None).ConfigureAwait(false); if (outcome.State != ExecutionStateEnum.Succeeded) { return outcome; } if (moveFailure != SimulatedArmKinematicFailure.None) { return IntentOutcome.Fail( m_kinematics.MapFailure(moveFailure), "The force path is not feasible."); } return contacted ? IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose) : IntentOutcome.Fail( IntentFailureEnum.ObjectNotFound, "No contact occurred before MaxDistance was exhausted."); } private async ValueTask ExecuteGraspAsync( GraspIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken, string objectClass = "") { SetNonCancellable(execution.IntentId); try { double width = intent.Width > 0.0 ? Math.Clamp(intent.Width, GripperClosed, GripperOpen) : GripperClosed; await MoveGripperAsync(width, execution, cancellationToken).ConfigureAwait(false); lock (m_lock) { m_hasObject = true; m_heldObjectClass = objectClass ?? string.Empty; PublishCurrentPoseLocked(); } SnapshotChanged?.Invoke(this, CurrentSnapshot); return IntentOutcome.Success; } finally { ClearNonCancellable(execution.IntentId); } } private async ValueTask ExecuteReleaseAsync( ReleaseIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { if (intent.HasTarget) { IntentOutcome motion = await MoveCartesianAsync( intent.Target, new MotionConstraintsDataType(), execution, cancellationToken).ConfigureAwait(false); if (motion.State != ExecutionStateEnum.Succeeded) { return motion; } } await MoveGripperAsync(GripperOpen, execution, cancellationToken).ConfigureAwait(false); lock (m_lock) { if (m_hasObject) { FillNextStackSlotLocked(); } m_hasObject = false; m_heldObjectClass = string.Empty; PublishCurrentPoseLocked(); } SnapshotChanged?.Invoke(this, CurrentSnapshot); return IntentOutcome.Success; } private async ValueTask ExecutePickAsync( PickIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { string objectClass = intent.ObjectClass ?? string.Empty; try { await m_clock.DelayAsync(TimeSpan.FromMilliseconds(80), cancellationToken).ConfigureAwait(false); if (!await MoveToLocationAsync( intent.Source, execution, cancellationToken, objectClass).ConfigureAwait(false)) { return Unreachable("Pick"); } IntentOutcome grasp = await ExecuteGraspAsync( new GraspIntentDataType { Force = intent.Force, Width = GripperClosed, Tool = intent.Tool }, execution, cancellationToken, objectClass).ConfigureAwait(false); if (grasp.State != ExecutionStateEnum.Succeeded) { return grasp; } return await RetractAfterActionAsync( "Pick", execution, cancellationToken).ConfigureAwait(false); } finally { PickAttemptFinished?.Invoke(objectClass); } } private async ValueTask ExecutePlaceAsync( PlaceIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { await m_clock.DelayAsync(TimeSpan.FromMilliseconds(80), cancellationToken).ConfigureAwait(false); if (!await MoveToLocationAsync(intent.Destination, execution, cancellationToken) .ConfigureAwait(false)) { return Unreachable("Place"); } IntentOutcome release = await ExecuteReleaseAsync( new ReleaseIntentDataType(), execution, cancellationToken).ConfigureAwait(false); if (release.State != ExecutionStateEnum.Succeeded) { return release; } return await RetractAfterActionAsync( "Place", execution, cancellationToken).ConfigureAwait(false); } private async ValueTask RetractAfterActionAsync( string action, IntentExecution execution, CancellationToken cancellationToken) { if (await RetractFromLastApproachAsync(execution, cancellationToken).ConfigureAwait(false)) { return IntentOutcome.Success; } return IntentOutcome.Fail( IntentFailureEnum.Unreachable, action + " completed its tool action but could not reverse the local approach."); } /// /// Reports an intent that could not be carried out because the arm could not get to /// the Location it named. /// private IntentOutcome Unreachable(string what) { return IntentOutcome.Fail( IntentFailureEnum.Unreachable, what + " could not reach the Location: " + LastTravelFailure); } /// /// Travels to the approach position a host resolved for a Location, so a Pick or a /// Place is a move followed by a gripper action rather than a gripper action alone. /// /// /// A Location arrives as a NodeId, which this executor cannot resolve on its own - /// it has no address space - so a host that knows its own cell supplies /// . The tool keeps its current orientation and /// only the position changes, because the current orientation belongs to a /// configuration the arm is already in and so keeps the inverse-kinematic solve /// well conditioned. /// /// A host with no resolver gets the gripper action where the arm stands: it has not /// told the executor where anything is, so there is nothing to travel to. But a /// Location that is resolved and cannot be reached fails the intent. It used /// to be best effort - the arm would stay where it was and close the gripper anyway, /// reporting success - which reads as "picked from the fixture" while the tool is /// still over the bin. A pick that never went anywhere is not a pick, and saying so /// is what lets a caller notice. /// /// /// /// true when the arm reached the Location, or when no resolver is configured. /// private async ValueTask MoveToLocationAsync( NodeId location, IntentExecution execution, CancellationToken cancellationToken, string objectClass = "") { if (location.IsNull) { return true; } if (!await RetractFromLastApproachAsync(execution, cancellationToken).ConfigureAwait(false)) { LastTravelFailure = "could not reverse the last local approach"; Diagnostic?.Invoke(LastTravelFailure); return false; } Pose3DDataType current = CurrentSnapshot.ToolPose; ArrayOf position; ArrayOf orientation; string frameId; if (objectClass.Length > 0 && ResolvePickPose != null && ResolvePickPose(location, objectClass, out Pose3DDataType pickPose)) { position = pickPose.Position; orientation = pickPose.Orientation; frameId = pickPose.FrameId ?? current.FrameId ?? string.Empty; } else if (ResolveLocationPose != null && ResolveLocationPose(location, out Pose3DDataType resolvedPose)) { position = resolvedPose.Position; orientation = resolvedPose.Orientation; frameId = resolvedPose.FrameId ?? current.FrameId ?? string.Empty; } else if (ResolveLocationPosition != null && ResolveLocationPosition(location, out position)) { orientation = current.Orientation; frameId = current.FrameId ?? string.Empty; } else { return true; } if (position.Count < 3 || orientation.Count < 4) { return true; } ReadOnlySpan targetPosition = position.Span; ReadOnlySpan currentPosition = current.Position.Span; ArrayOf currentOrientation = current.Orientation; bool sameWorkPosition = Distance2D(currentPosition, targetPosition) <= SameLocationToleranceMetres; // Travel over the cell rather than straight at the target. A single joint-space // move interpolates between two configurations, and the straight line between // "over the bin" and "over the fixture" dips: the arm sweeps a link through the // bench on the way, which is what makes it look like it is passing through the // table even when both ends of the move are clear. Lifting to a transit height, // crossing, and descending is both how a real cell moves and a set of legs whose // straight-line paths stay clear. double transitZ = Math.Max( Math.Max(currentPosition[2], targetPosition[2]), TransitHeightMetres); double[] lift = [currentPosition[0], currentPosition[1], transitZ]; double[] cross = [targetPosition[0], targetPosition[1], transitZ]; double[] descend = [targetPosition[0], targetPosition[1], targetPosition[2]]; // An empty gripper picking from any work area should come straight down on the // object. A loaded gripper placing onto the fixture may need the short, // collision-checked joint approach instead. The host preference still marks // bin/home locations as vertical for both directions. bool preferCartesianDescent = !CurrentSnapshot.HasObject || PreferCartesianDescent?.Invoke(location) == true; Diagnostic?.Invoke(string.Create( CultureInfo.InvariantCulture, $"start=({currentPosition[0]:F3},{currentPosition[1]:F3},{currentPosition[2]:F3}) " + $"target=({targetPosition[0]:F3},{targetPosition[1]:F3},{targetPosition[2]:F3}) " + $"cartesianDescent={preferCartesianDescent}")); if (sameWorkPosition) { Diagnostic?.Invoke("target is at the current work position; skipping cross-cell traverse"); bool localDescent = await MovePlannedCartesianAsync( descend, frameId, orientation, execution, recordRetractPath: true, cancellationToken).ConfigureAwait(false); if (!localDescent) { LastTravelFailure = "could not descend at the current work position"; Diagnostic?.Invoke(LastTravelFailure); } return localDescent; } // Lift in Cartesian space so the tool moves vertically away from the work. // Cross in joint space: a straight Cartesian line between the bin and fixture // passes over the base, where the tool sits on the shoulder axis and the inverse // kinematics are singular. Descend on a collision-checked joint path too: the // final work pose is reachable, but re-solving every point on the vertical line // can switch branches and reject the leg before it gets there. Raising the // pedestal, lowering the bench and moving both work areas out leaves 9-13 clear // candidates at the transit poses, so the arm can retract and traverse instead // of making one sweep through the table. bool retracted = await MoveToolToAsync( lift, frameId, currentOrientation, execution, cancellationToken).ConfigureAwait(false); if (!retracted) { // A low fixture pose can be reachable while the numerical solver has no // continuous Cartesian branch straight above it. The destination is still // a clear pose, so try the collision-checked joint path before refusing the // Pick or Place. retracted = await SwingToAsync( lift, frameId, currentOrientation, execution, cancellationToken).ConfigureAwait(false); } if (!retracted) { LastTravelFailure = "could not retract vertically from the work"; Diagnostic?.Invoke(LastTravelFailure); return false; } Diagnostic?.Invoke("retracted to the clear height"); if (!await SwingToAsync(cross, frameId, orientation, execution, cancellationToken) .ConfigureAwait(false)) { Diagnostic?.Invoke("direct clear-height traverse unavailable; trying bypass"); // The direct interpolation between work areas on opposite sides can sweep // through the shoulder axis even though both ends are clear. Route around // it at the same safe height, the way a motion planner would choose a // waypoint around a keep-out cylinder. double[] nearBypass = [-TransitBypassXMetres, TransitBypassYMetres, transitZ]; double[] farBypass = [TransitBypassXMetres, TransitBypassYMetres, transitZ]; bool bypassed = await SwingToAsync( nearBypass, frameId, orientation, execution, cancellationToken).ConfigureAwait(false); if (!bypassed) { bypassed = await MoveToolToAsync( nearBypass, frameId, orientation, execution, cancellationToken).ConfigureAwait(false); } Diagnostic?.Invoke(bypassed ? "reached the near clear-height bypass" : "could not reach the near clear-height bypass"); if (bypassed) { bypassed = await SwingToAsync( farBypass, frameId, orientation, execution, cancellationToken).ConfigureAwait(false); if (!bypassed) { bypassed = await MoveToolToAsync( farBypass, frameId, orientation, execution, cancellationToken).ConfigureAwait(false); } Diagnostic?.Invoke(bypassed ? "reached the far clear-height bypass" : "could not reach the far clear-height bypass"); } if (bypassed) { bypassed = await SwingToAsync( cross, frameId, orientation, execution, cancellationToken).ConfigureAwait(false); if (!bypassed) { bypassed = await MoveToolToAsync( cross, frameId, orientation, execution, cancellationToken).ConfigureAwait(false); } Diagnostic?.Invoke(bypassed ? "reached the far side from the bypass arc" : "could not reach the far side from the bypass arc"); } if (!bypassed) { LastTravelFailure = "could not traverse the cell around the base at the clear height"; Diagnostic?.Invoke(LastTravelFailure); return false; } } Diagnostic?.Invoke("traversed the cell at the clear height"); bool descended; if (preferCartesianDescent) { descended = await MovePlannedCartesianAsync( descend, frameId, orientation, execution, recordRetractPath: true, cancellationToken).ConfigureAwait(false); } else { // First establish a configuration locally above the fixture. Solving the // final pose from the far-side transit configuration can leave no connected // joint interpolation even though the pose itself has several clear // solutions. From 40 mm above, the short descent has the nearby branch a // real approach motion needs without standing so high above a tall stack // that the final branch disconnects again. double[] preApproach = [descend[0], descend[1], descend[2] + LocalApproachHeightMetres]; descended = await MoveToolToAsync( preApproach, frameId, orientation, execution, cancellationToken) .ConfigureAwait(false); if (descended) { double[] retractJointAngles = GetJoints(); descended = await MovePlannedCartesianAsync( descend, frameId, orientation, execution, recordRetractPath: true, cancellationToken) .ConfigureAwait(false); if (!descended) { descended = await SwingToAsync( descend, frameId, orientation, execution, cancellationToken) .ConfigureAwait(false); if (descended) { m_retractJointAngles = retractJointAngles; m_retractJointEndpoint = GetJoints(); } } } } if (!descended) { // The chosen path can still be unavailable because the numerical IK solver // switches branches along a Cartesian line, or because the direct joint // interpolation sweeps a link through an obstacle. Try the other path from // wherever the first attempt stopped before refusing the intent. descended = preferCartesianDescent ? await SwingToAsync(descend, frameId, orientation, execution, cancellationToken) .ConfigureAwait(false) : await MoveToolToAsync( descend, frameId, orientation, execution, cancellationToken, recordRetractPath: true) .ConfigureAwait(false); } if (!descended) { LastTravelFailure = "could not descend onto the work from the clear height"; Diagnostic?.Invoke(LastTravelFailure); return false; } Diagnostic?.Invoke("descended onto the work"); return true; } /// /// Replays the reverse of the last short fixture approach. /// /// /// The final fixture pose may require the yaw search to choose an orientation from /// which IK cannot independently solve a vertical retract. The path into that pose /// was already collision checked, so retaining its start configuration gives an /// exact, deterministic path back out instead of asking the numerical solver to /// rediscover one after the gripper action. /// private async ValueTask RetractFromLastApproachAsync( IntentExecution execution, CancellationToken cancellationToken) { List? sampledPath = m_retractCartesianPath; if (sampledPath != null) { m_retractCartesianPath = null; double[] current = GetJoints(); if (!SameJointConfiguration(current, sampledPath[^1])) { Diagnostic?.Invoke("discarded a stale Cartesian retract path"); return true; } double[] previous = current; for (int ii = sampledPath.Count - 2; ii >= 0; ii--) { cancellationToken.ThrowIfCancellationRequested(); double[] next = sampledPath[ii]; if (CurrentSnapshot.HasObject && !m_kinematics.ClearsPath(previous, next)) { return false; } SetJoints(next); previous = next; if (!await DelayTickAsync(cancellationToken).ConfigureAwait(false)) { return false; } } Diagnostic?.Invoke("replayed the last Cartesian approach in reverse"); return true; } double[]? target = m_retractJointAngles; if (target == null) { return true; } double[]? endpoint = m_retractJointEndpoint; m_retractJointAngles = null; m_retractJointEndpoint = null; double[] start = GetJoints(); if (endpoint == null || !SameJointConfiguration(start, endpoint)) { Diagnostic?.Invoke("discarded a stale joint retract path"); return true; } if (!m_kinematics.ClearsPath(start, target)) { return false; } double distance = JointDistance(start, target); var profile = new TrapezoidalVelocityProfile( distance, JointSpeed(new MotionConstraintsDataType()), DefaultJointAcceleration); IntentOutcome outcome = await FollowProfileAsync( profile, execution, fraction => SetJoints( m_kinematics.InterpolateJoints(start, target, fraction).Span), DefaultJointAcceleration, cancellationToken).ConfigureAwait(false); if (outcome.State == ExecutionStateEnum.Succeeded) { Diagnostic?.Invoke("reversed the last local approach"); return true; } return false; } /// /// Invalidates a recorded local approach when another motion changes the arm pose. /// private void InvalidateRecordedApproach() { m_retractCartesianPath = null; m_retractJointAngles = null; m_retractJointEndpoint = null; } /// /// Gets whether two joint configurations agree closely enough to replay a recorded /// path from the current pose. /// private static bool SameJointConfiguration(double[] left, double[] right) { if (left.Length != right.Length) { return false; } for (int ii = 0; ii < left.Length; ii++) { if (Math.Abs(left[ii] - right[ii]) > RetractEndpointToleranceRadians) { return false; } } return true; } /// /// Swings the tool to a position in joint space, so the arm rotates around its base /// rather than trying to carry the tool across the axis it stands on. /// private async ValueTask SwingToAsync( double[] position, string frameId, ArrayOf orientation, IntentExecution execution, CancellationToken cancellationToken) { double[] start = GetJoints(); if (!TrySolveWithYawSearch(position, frameId, orientation, start, out SimulatedArmIkSolution? solution)) { return false; } double distance = JointDistance(start, solution.JointAngles.Span); var profile = new TrapezoidalVelocityProfile( distance, JointSpeed(new MotionConstraintsDataType()), DefaultJointAcceleration); _ = await FollowProfileAsync( profile, execution, fraction => SetJoints( m_kinematics.InterpolateJoints(start, solution.JointAngles.Span, fraction).Span), DefaultJointAcceleration, cancellationToken).ConfigureAwait(false); return true; } /// /// Solves for a tool position, turning the tool about the vertical when the /// orientation it is holding does not work out. /// /// /// A parallel gripper coming straight down is free to choose its rotation about the /// tool axis - the jaws close on a part the same way whichever way round they are - /// but the cell never used that freedom: it reused whatever orientation the arm was /// left holding, so each Location got exactly one pose and one chance. That is fine /// until the solver has to satisfy clearance as well, at which point a single pose /// often has no answer while the same position a few degrees round has several. /// The requested orientation is tried first so nothing changes when it works, and /// the offsets are a fixed sequence so the same target always resolves the same way. /// private bool TrySolveWithYawSearch( double[] position, string frameId, ArrayOf orientation, double[] start, [NotNullWhen(true)] out SimulatedArmIkSolution? solution) { ReadOnlySpan requested = orientation.Span; foreach (double degrees in s_yawOffsetsDegrees) { var target = new Pose3DDataType { FrameId = frameId, Position = position.ToArrayOf(), Orientation = degrees == 0.0 ? orientation : TurnAboutVertical(requested, degrees).ToArrayOf() }; if (m_kinematics.TrySelectNearest( target, start, out solution, out SimulatedArmKinematicFailure _)) { return true; } } solution = null; return false; } /// /// Turns an orientation about the world vertical. /// private static double[] TurnAboutVertical(ReadOnlySpan orientation, double degrees) { double half = degrees * Math.PI / 360.0; double sin = Math.Sin(half); double cos = Math.Cos(half); return [ (cos * orientation[0]) - (sin * orientation[1]), (cos * orientation[1]) + (sin * orientation[0]), (cos * orientation[2]) + (sin * orientation[3]), (cos * orientation[3]) - (sin * orientation[2]) ]; } /// /// Gets why the last travel to a Location was refused, for the failure message. /// private string LastTravelFailure { get; set; } = string.Empty; /// /// Moves the tool centre point to one position along a straight line, keeping its /// orientation. /// /// /// The line is followed in Cartesian space and re-solved at each step rather than /// solved once and interpolated in joint space. Interpolating in joint space takes /// whatever route the two configurations happen to describe, and when the solver /// picks a different elbow branch for the far end that route swings a link through /// the bench - so with clearance enforced, every candidate gets refused and the arm /// simply stops. Re-solving along a straight line keeps each step next to the last /// one, which is also what the move looks like on a real cell. /// private async ValueTask MoveToolToAsync( double[] position, string frameId, ArrayOf orientation, IntentExecution execution, CancellationToken cancellationToken, bool recordRetractPath = false) { var target = new Pose3DDataType { FrameId = frameId, Position = position.ToArrayOf(), Orientation = orientation }; List? jointPath = recordRetractPath ? [GetJoints()] : null; IntentOutcome outcome = await MoveCartesianAsync( target, new MotionConstraintsDataType(), execution, cancellationToken, jointPath) .ConfigureAwait(false); bool succeeded = outcome.State == ExecutionStateEnum.Succeeded; if (recordRetractPath) { m_retractCartesianPath = succeeded && jointPath is { Count: > 1 } ? jointPath : null; } return succeeded; } /// /// Plans a complete, locally sampled Cartesian move before executing any of it. /// /// /// Solving while moving can switch into a branch that has no continuation near the /// target, even when another yaw has a clear path all the way down. This method /// evaluates every sample first, tries the same deterministic yaw spread used by /// direct moves, and executes only a sequence that reached the target. The samples /// are close enough that checking every configuration is the swept-path /// approximation; no unrelated joint interpolation is substituted for it. /// private async ValueTask MovePlannedCartesianAsync( double[] position, string frameId, ArrayOf orientation, IntentExecution execution, bool recordRetractPath, CancellationToken cancellationToken) { Pose3DDataType startPose = CurrentSnapshot.ToolPose; double[] startingJoints = GetJoints(); double[] requested = orientation.Span.ToArray(); foreach (double degrees in s_yawOffsetsDegrees) { ArrayOf candidateOrientation = degrees == 0.0 ? orientation : TurnAboutVertical(requested, degrees).ToArrayOf(); var target = new Pose3DDataType { FrameId = frameId, Position = position.ToArrayOf(), Orientation = candidateOrientation }; var path = new List(CartesianPlanningSamples + 1) { (double[])startingJoints.Clone() }; double[] reference = (double[])startingJoints.Clone(); bool complete = true; for (int step = 1; step <= CartesianPlanningSamples; step++) { double fraction = (double)step / CartesianPlanningSamples; Pose3DDataType pose = m_kinematics.InterpolateCartesian(startPose, target, fraction); if (!m_kinematics.TrySelectNearestConfiguration( pose, reference, out SimulatedArmIkSolution? solution, out SimulatedArmKinematicFailure _)) { complete = false; break; } reference = solution.JointAngles.Span.ToArray(); path.Add(reference); } if (!complete) { continue; } double distance = Distance(startPose.Position.Span, target.Position.Span); var profile = new TrapezoidalVelocityProfile( distance, DefaultCartesianSpeed, DefaultCartesianAcceleration); IntentOutcome outcome = await FollowProfileAsync( profile, execution, fraction => { int index = Math.Clamp( (int)Math.Round(fraction * (path.Count - 1)), 0, path.Count - 1); SetPose(path[index]); }, DefaultCartesianAcceleration, cancellationToken).ConfigureAwait(false); bool succeeded = outcome.State == ExecutionStateEnum.Succeeded; if (recordRetractPath) { m_retractCartesianPath = succeeded ? path : null; } return succeeded; } if (recordRetractPath) { m_retractCartesianPath = null; } return false; } private async ValueTask ExecuteToolChangeAsync( ToolChangeIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { SetNonCancellable(execution.IntentId); try { await m_clock.DelayAsync(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false); lock (m_lock) { m_toolName = intent.Tool.IsNull ? "none" : intent.Tool.ToString(); PublishCurrentPoseLocked(); } SnapshotChanged?.Invoke(this, CurrentSnapshot); return IntentOutcome.Success; } finally { ClearNonCancellable(execution.IntentId); } } private async ValueTask ExecuteWaitAsync( WaitIntentDataType intent, IntentExecution execution, CancellationToken cancellationToken) { double duration = intent.Duration > 0.0 ? intent.Duration : 100.0; double elapsed = 0.0; while (elapsed < duration) { cancellationToken.ThrowIfCancellationRequested(); execution.Progress.ReportProgress(Math.Clamp(elapsed / duration, 0.0, 1.0)); if (!await DelayTickAsync(cancellationToken).ConfigureAwait(false)) { break; } elapsed += TickSeconds * 1000.0; } execution.Progress.ReportProgress(1.0); return IntentOutcome.Success; } private async ValueTask MoveCartesianAsync( Pose3DDataType target, MotionConstraintsDataType constraints, IntentExecution execution, CancellationToken cancellationToken, List? jointPath = null) { Pose3DDataType start = CurrentSnapshot.ToolPose; double distance = Distance(start.Position.Span, target.Position.Span); var profile = new TrapezoidalVelocityProfile( distance, CartesianSpeed(constraints), CartesianAcceleration(constraints)); SimulatedArmKinematicFailure moveFailure = SimulatedArmKinematicFailure.None; IntentOutcome outcome = await FollowProfileAsync( profile, execution, fraction => { Pose3DDataType pose = m_kinematics.InterpolateCartesian(start, target, fraction); if (m_kinematics.TrySelectNearestConfiguration( pose, CurrentSnapshot.JointAngles.Span, out SimulatedArmIkSolution? solution, out SimulatedArmKinematicFailure failure)) { SetPose(solution.JointAngles.Span); jointPath?.Add(solution.JointAngles.Span.ToArray()); } else { moveFailure = failure; } }, CartesianAcceleration(constraints), cancellationToken, () => moveFailure != SimulatedArmKinematicFailure.None).ConfigureAwait(false); if (moveFailure != SimulatedArmKinematicFailure.None) { return IntentOutcome.Fail( m_kinematics.MapFailure(moveFailure), "The Cartesian path is not feasible."); } return outcome.State == ExecutionStateEnum.Succeeded ? IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose) : outcome; } private async ValueTask FollowProfileAsync( TrapezoidalVelocityProfile profile, IntentExecution execution, Action apply, double acceleration, CancellationToken cancellationToken, Func? stopEarly = null) { if (profile.Duration == 0.0) { apply(1.0); execution.Progress.ReportProgress(1.0); execution.Progress.ReportPose(CurrentSnapshot.ToolPose); return IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose); } double elapsed = 0.0; double totalDistance = Math.Max(profile.PositionAt(profile.Duration), double.Epsilon); while (elapsed < profile.Duration) { if (cancellationToken.IsCancellationRequested) { break; } double fraction = Math.Clamp(profile.PositionAt(elapsed) / totalDistance, 0.0, 1.0); apply(fraction); execution.Progress.ReportProgress(fraction); execution.Progress.ReportPose(CurrentSnapshot.ToolPose); if (stopEarly?.Invoke() == true) { break; } if (!await DelayTickAsync(cancellationToken).ConfigureAwait(false)) { break; } elapsed += TickSeconds; } if (cancellationToken.IsCancellationRequested && stopEarly?.Invoke() != true) { await ApplyAcceptedStopAsync( profile, execution, apply, acceleration, elapsed, totalDistance).ConfigureAwait(false); } if (!cancellationToken.IsCancellationRequested && stopEarly?.Invoke() != true) { apply(1.0); execution.Progress.ReportProgress(1.0); execution.Progress.ReportPose(CurrentSnapshot.ToolPose); } return IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose); } private async ValueTask ApplyAcceptedStopAsync( TrapezoidalVelocityProfile profile, IntentExecution execution, Action apply, double acceleration, double elapsed, double totalDistance) { StopModeEnum stopMode = execution.StopMode; if (stopMode is StopModeEnum.EndOfInstruction or StopModeEnum.EndOfCycle) { await FinishCurrentInstructionAsync(profile, execution, apply, elapsed, totalDistance) .ConfigureAwait(false); return; } double effectiveAcceleration = StopAcceleration(acceleration, stopMode); double currentPosition = profile.PositionAt(elapsed); double velocity = profile.VelocityAt(elapsed); double remaining = Math.Max(0.0, totalDistance - currentPosition); double stopDistance = Math.Min(remaining, (velocity * velocity) / (2.0 * effectiveAcceleration)); double duration = velocity / effectiveAcceleration; double stopElapsed = 0.0; while (stopElapsed < duration && stopDistance > 0.0) { double travelled = Math.Min( stopDistance, (velocity * stopElapsed) - (0.5 * effectiveAcceleration * stopElapsed * stopElapsed)); double fraction = Math.Clamp((currentPosition + travelled) / totalDistance, 0.0, 1.0); apply(fraction); execution.Progress.ReportProgress(fraction); execution.Progress.ReportPose(CurrentSnapshot.ToolPose); await DelayTickAsync(CancellationToken.None).ConfigureAwait(false); stopElapsed += TickSeconds; } double finalFraction = Math.Clamp((currentPosition + stopDistance) / totalDistance, 0.0, 1.0); apply(finalFraction); execution.Progress.ReportProgress(finalFraction); execution.Progress.ReportPose(CurrentSnapshot.ToolPose); } private async ValueTask FinishCurrentInstructionAsync( TrapezoidalVelocityProfile profile, IntentExecution execution, Action apply, double elapsed, double totalDistance) { double instructionElapsed = elapsed; while (instructionElapsed < profile.Duration) { double fraction = Math.Clamp(profile.PositionAt(instructionElapsed) / totalDistance, 0.0, 1.0); apply(fraction); execution.Progress.ReportProgress(fraction); execution.Progress.ReportPose(CurrentSnapshot.ToolPose); await DelayTickAsync(CancellationToken.None).ConfigureAwait(false); instructionElapsed += TickSeconds; } apply(1.0); execution.Progress.ReportProgress(1.0); execution.Progress.ReportPose(CurrentSnapshot.ToolPose); } private async ValueTask MoveGripperAsync( double targetOpening, IntentExecution execution, CancellationToken cancellationToken) { double start; lock (m_lock) { start = m_gripperOpening; } const int steps = 12; for (int i = 1; i <= steps; i++) { cancellationToken.ThrowIfCancellationRequested(); double fraction = (double)i / steps; lock (m_lock) { m_gripperOpening = start + ((targetOpening - start) * fraction); PublishCurrentPoseLocked(); } SnapshotChanged?.Invoke(this, CurrentSnapshot); execution.Progress.ReportProgress(fraction); await m_clock.DelayAsync(TimeSpan.FromMilliseconds(20), cancellationToken).ConfigureAwait(false); } } private async ValueTask DelayTickAsync(CancellationToken cancellationToken) { try { await m_clock.DelayAsync(TimeSpan.FromSeconds(TickSeconds), cancellationToken).ConfigureAwait(false); return true; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return false; } } private double[] GetJoints() { lock (m_lock) { return (double[])m_jointAngles.Clone(); } } private void SetJoints(ReadOnlySpan jointAngles) { lock (m_lock) { jointAngles.CopyTo(m_jointAngles); PublishCurrentPoseLocked(); } SnapshotChanged?.Invoke(this, CurrentSnapshot); } /// /// Publishes a new snapshot from the given joint angles. /// /// /// The tool pose is derived from the joint angles rather than taken from the pose that /// was commanded. The two differ by the inverse-kinematics residual, and publishing the /// commanded pose alongside joint frames computed from the solved joints let the tool /// centre point and the rendered arm disagree - the twin reported a pose the joints did /// not produce. /// /// The joint angles the arm has reached. private void SetPose(ReadOnlySpan jointAngles) { lock (m_lock) { jointAngles.CopyTo(m_jointAngles); SimulatedArmForwardPose forward = m_kinematics.Forward(m_jointAngles); CurrentSnapshot = new SimulatedArmSnapshot( ArrayOf.Create(m_jointAngles.AsSpan()), forward.ToolPose, forward.JointFramePoses, m_gripperOpening, m_hasObject, m_toolName, HeldPartPosition(forward.ToolPose), ArrayOf.Create(m_stackSlotsFilled.AsSpan()), m_heldObjectClass); } SnapshotChanged?.Invoke(this, CurrentSnapshot); } [MemberNotNull(nameof(CurrentSnapshot))] private void PublishCurrentPoseLocked() { SimulatedArmForwardPose pose = m_kinematics.Forward(m_jointAngles); CurrentSnapshot = new SimulatedArmSnapshot( ArrayOf.Create(m_jointAngles.AsSpan()), pose.ToolPose, pose.JointFramePoses, m_gripperOpening, m_hasObject, m_toolName, HeldPartPosition(pose.ToolPose), ArrayOf.Create(m_stackSlotsFilled.AsSpan()), m_heldObjectClass); } private void FillNextStackSlotLocked() { for (int ii = 0; ii < m_stackSlotsFilled.Length; ii++) { if (!m_stackSlotsFilled[ii]) { m_stackSlotsFilled[ii] = true; return; } } } private void SetNonCancellable(string intentId) { lock (m_lock) { m_nonCancellableIntentId = intentId; } } private void ClearNonCancellable(string intentId) { lock (m_lock) { if (m_nonCancellableIntentId == intentId) { m_nonCancellableIntentId = string.Empty; } } } private static Pose3DDataType TranslatePose(Pose3DDataType pose, double x, double y, double z, double distance) { ReadOnlySpan position = pose.Position.Span; return new Pose3DDataType { FrameId = pose.FrameId, Position = ArrayOf.Create([ position[0] + (x * distance), position[1] + (y * distance), position[2] + (z * distance) ]), Orientation = ArrayOf.Create(pose.Orientation.Span) }; } private Pose3DDataType InterpolateArc( Pose3DDataType start, Pose3DDataType via, Pose3DDataType target, double fraction) { if (!TryCircle(start.Position.Span, via.Position.Span, target.Position.Span, out Circle circle)) { return m_kinematics.InterpolateCartesian(start, target, fraction); } double angle = circle.StartAngle + (circle.SweepAngle * Math.Clamp(fraction, 0.0, 1.0)); return new Pose3DDataType { FrameId = target.FrameId, Position = ArrayOf.Create([ circle.CenterX + (circle.Radius * Math.Cos(angle) * circle.BasisUx) + (circle.Radius * Math.Sin(angle) * circle.BasisVx), circle.CenterY + (circle.Radius * Math.Cos(angle) * circle.BasisUy) + (circle.Radius * Math.Sin(angle) * circle.BasisVy), circle.CenterZ + (circle.Radius * Math.Cos(angle) * circle.BasisUz) + (circle.Radius * Math.Sin(angle) * circle.BasisVz) ]), Orientation = m_kinematics.InterpolateCartesian(start, target, fraction).Orientation }; } private static bool TryCircle( ReadOnlySpan start, ReadOnlySpan via, ReadOnlySpan end, out Circle circle) { double ax = via[0] - start[0]; double ay = via[1] - start[1]; double az = via[2] - start[2]; double bx = end[0] - start[0]; double by = end[1] - start[1]; double bz = end[2] - start[2]; double nx = (ay * bz) - (az * by); double ny = (az * bx) - (ax * bz); double nz = (ax * by) - (ay * bx); double n2 = (nx * nx) + (ny * ny) + (nz * nz); if (n2 < 1e-10) { circle = default; return false; } double a2 = (ax * ax) + (ay * ay) + (az * az); double b2 = (bx * bx) + (by * by) + (bz * bz); double cx = start[0] + (((b2 * ((ay * nz) - (az * ny))) - (a2 * ((by * nz) - (bz * ny)))) / (2.0 * n2)); double cy = start[1] + (((b2 * ((az * nx) - (ax * nz))) - (a2 * ((bz * nx) - (bx * nz)))) / (2.0 * n2)); double cz = start[2] + (((b2 * ((ax * ny) - (ay * nx))) - (a2 * ((bx * ny) - (by * nx)))) / (2.0 * n2)); double ux = start[0] - cx; double uy = start[1] - cy; double uz = start[2] - cz; double radius = Math.Sqrt((ux * ux) + (uy * uy) + (uz * uz)); ux /= radius; uy /= radius; uz /= radius; double nn = Math.Sqrt(n2); nx /= nn; ny /= nn; nz /= nn; double vx = (ny * uz) - (nz * uy); double vy = (nz * ux) - (nx * uz); double vz = (nx * uy) - (ny * ux); double endAngle = Math.Atan2( ((end[0] - cx) * vx) + ((end[1] - cy) * vy) + ((end[2] - cz) * vz), ((end[0] - cx) * ux) + ((end[1] - cy) * uy) + ((end[2] - cz) * uz)); double viaAngle = Math.Atan2( ((via[0] - cx) * vx) + ((via[1] - cy) * vy) + ((via[2] - cz) * vz), ((via[0] - cx) * ux) + ((via[1] - cy) * uy) + ((via[2] - cz) * uz)); if (endAngle < 0.0) { endAngle += 2.0 * Math.PI; } if (viaAngle < 0.0) { viaAngle += 2.0 * Math.PI; } double sweep = viaAngle < endAngle ? endAngle : endAngle - (2.0 * Math.PI); circle = new Circle(cx, cy, cz, ux, uy, uz, vx, vy, vz, radius, 0.0, sweep); return true; } private static double ArcLength(ReadOnlySpan start, ReadOnlySpan via, ReadOnlySpan end) { return TryCircle(start, via, end, out Circle circle) ? Math.Abs(circle.Radius * circle.SweepAngle) : Distance(start, end); } private static bool IsContact(ReadOnlySpan position) { if (Math.Abs(position[2] - BenchTopZ) > 0.005) { return false; } if (Math.Abs(position[0]) <= 0.7 && Math.Abs(position[1]) <= 0.45) { return true; } return IsInsideTarget(position, 0.41, -0.28, 0.04) || IsInsideTarget(position, 0.48, 0.26, 0.04) || IsInsideTarget(position, -0.25, 0.30, 0.035) || IsInsideTarget(position, -0.46, -0.26, 0.04); } private static bool IsInsideTarget(ReadOnlySpan position, double x, double y, double radius) { double dx = position[0] - x; double dy = position[1] - y; return ((dx * dx) + (dy * dy)) <= radius * radius; } private static double CartesianSpeed(MotionConstraintsDataType constraints) { double speed = constraints?.CartesianSpeed > 0.0 ? constraints.CartesianSpeed : DefaultCartesianSpeed; if (constraints?.SpeedFraction > 0.0) { speed *= Math.Clamp(constraints.SpeedFraction, 0.05, 1.0); } return speed; } private static double CartesianAcceleration(MotionConstraintsDataType constraints) { return constraints?.CartesianAcceleration > 0.0 ? constraints.CartesianAcceleration : DefaultCartesianAcceleration; } private static double JointSpeed(MotionConstraintsDataType constraints) { return constraints?.SpeedFraction > 0.0 ? DefaultJointSpeed * Math.Clamp(constraints.SpeedFraction, 0.05, 1.0) : DefaultJointSpeed; } private static double StopAcceleration(double acceleration, StopModeEnum stopMode) { double boundedAcceleration = Math.Max(acceleration, double.Epsilon); return stopMode switch { StopModeEnum.QuickStop => boundedAcceleration, StopModeEnum.ProcessStop => boundedAcceleration * 0.5, StopModeEnum.OnPath => boundedAcceleration * 0.25, _ => boundedAcceleration * 0.35 }; } private double JointDistance(ReadOnlySpan a, ReadOnlySpan b) { double sum = 0.0; for (int i = 0; i < m_kinematics.AxisCount; i++) { double delta = a[i] - b[i]; sum += delta * delta; } return Math.Sqrt(sum); } private static double Distance(ReadOnlySpan a, ReadOnlySpan b) { double dx = a[0] - b[0]; double dy = a[1] - b[1]; double dz = a[2] - b[2]; return Math.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); } private static double Distance2D(ReadOnlySpan a, ReadOnlySpan b) { double x = a[0] - b[0]; double y = a[1] - b[1]; return Math.Sqrt((x * x) + (y * y)); } private static ArrayOf HeldPartPosition(Pose3DDataType toolPose) { ReadOnlySpan position = toolPose.Position.Span; return ArrayOf.Create([ position[0], position[1], position[2] - HeldPartTcpOffset ]); } private const double DefaultCartesianSpeed = 0.25; /// /// How high the tool lifts to before crossing the cell, in the arm's base frame. /// Above the bin walls and above a full stack on the fixture, so a straight /// joint-space leg at this height clears the furniture between two work positions. /// private const double TransitHeightMetres = 0.32; private const double TransitBypassXMetres = 0.20; private const double TransitBypassYMetres = -0.35; private const double LocalApproachHeightMetres = 0.04; private const int CartesianPlanningSamples = 32; private const double RetractEndpointToleranceRadians = 1e-5; private const double SameLocationToleranceMetres = 0.01; /// /// The rotations about the vertical a Pick or Place may use when the orientation the /// arm is holding has no clear solution. Zero first, so a target that already works /// resolves exactly as before, then outwards in both directions. /// private static readonly double[] s_yawOffsetsDegrees = [ 0.0, 15.0, -15.0, 30.0, -30.0, 45.0, -45.0, 60.0, -60.0, 75.0, -75.0, 90.0, -90.0, 105.0, -105.0, 120.0, -120.0, 135.0, -135.0, 150.0, -150.0, 165.0, -165.0, 180.0 ]; private const double DefaultCartesianAcceleration = 0.7; private const double DefaultJointSpeed = 0.9; private const double DefaultJointAcceleration = 2.0; private const double TickSeconds = 0.02; private const double BenchTopZ = 0.829; private const double GripperOpen = 0.08; private const double GripperClosed = 0.018; private const int StackSlotCount = 8; private readonly System.Threading.Lock m_lock = new(); private readonly ISimulatedArmKinematics m_kinematics; private readonly ISimulatedArmClock m_clock; /// /// /// Home configuration, radians. This arm is mounted on a bench in both samples that /// use it, so the pose has to keep every joint above the work surface, and in the /// bin-picking cell it also has to aim the eye-in-hand camera: it is solved so the /// camera prim lands at the world position the Vision model declares for it /// (0.38, 0, 1.35) looking straight down, which puts the bin 0.50 m away and 1.8 /// degrees off the optical axis - matching the standoff the detections report. /// /// /// Two constraints on the solution are easy to miss and both were violated by /// earlier attempts: /// /// /// - It is the elbow-back branch. The elbow-forward solutions reach the same /// camera pose but park a link directly under the camera, and the frame comes /// back showing the arm's own upper arm instead of the bin. /// - The wrist stays 25 degrees clear of J4 and J6 lining up. Aiming a /// straight-down camera from a point on the base's own X-Z plane lands exactly /// on that singularity, so the camera roll is tilted 15 degrees to get off it. /// A singular home pose is not a cosmetic problem: the first IK solve of any /// motion away from home fails, so every intent returns Kinematics. /// /// private readonly double[] m_jointAngles; private double[]? m_retractJointAngles; private double[]? m_retractJointEndpoint; private List? m_retractCartesianPath; private double m_gripperOpening = GripperOpen; private bool m_hasObject; private string m_heldObjectClass = string.Empty; private readonly bool[] m_stackSlotsFilled = new bool[StackSlotCount]; private string m_toolName = "parallel-gripper"; private string m_nonCancellableIntentId = string.Empty; private readonly struct Circle { public Circle( double centerX, double centerY, double centerZ, double basisUx, double basisUy, double basisUz, double basisVx, double basisVy, double basisVz, double radius, double startAngle, double sweepAngle) { CenterX = centerX; CenterY = centerY; CenterZ = centerZ; BasisUx = basisUx; BasisUy = basisUy; BasisUz = basisUz; BasisVx = basisVx; BasisVy = basisVy; BasisVz = basisVz; Radius = radius; StartAngle = startAngle; SweepAngle = sweepAngle; } public double CenterX { get; } public double CenterY { get; } public double CenterZ { get; } public double BasisUx { get; } public double BasisUy { get; } public double BasisUz { get; } public double BasisVx { get; } public double BasisVy { get; } public double BasisVz { get; } public double Radius { get; } public double StartAngle { get; } public double SweepAngle { get; } } } }