using SharpDX; using T3.Core.Utils; namespace Lib.io.dmx { // ------------------------------------------------------------------------ // Type aliases – keep System.Numerics types distinct from SharpDX types. // ------------------------------------------------------------------------ using Vec3 = Vector3; using Quat = Quaternion; [Guid("c9d7cd19-7fc6-4491-8dfa-3808725c7857")] public sealed class PointsToDmxLights : Instance { #region Output Slots [Output(Guid = "8DC2DB32-D7A3-4B3A-A000-93C3107D19E4", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public readonly Slot> Result = new(new List(20)); [Output(Guid = "da7deb8c-4218-4cae-9ec5-fd7c2e6f4c35")] public readonly Slot VisualizeLights = new(); #endregion #region Enums public enum AxisModes { Disabled, X, Y, Z } public enum RotationOrderModes { PanThenTilt, TiltThenPan } public enum ForwardVectorModes { X, Y, Z, NegX, NegY, NegZ } public enum TestMode { Disabled = 0, ZPositive, ZNegative, XPositive, XNegative, YPositive, YNegative } #endregion #region Private Fields & Constants private readonly List _resultItems = new(128); private const int UniverseSize = 512; private BufferWithViews _visualizeBuffer; private Point[] _visualizationPoints = Array.Empty(); // Stores the previous pan/tilt (radians) per fixture – used by shortest‑path logic. private readonly List _lastPanTiltPerFixture = new(); private Point[] _points = Array.Empty(); private Point[] _referencePoints = Array.Empty(); private readonly List _pointChannelValues = new(); private readonly StructuredBufferReadAccess _pointsBufferReader = new(); private readonly StructuredBufferReadAccess _referencePointsBufferReader = new(); // Cached forward axis – may be overridden by TestMode. private Vec3 _cachedForwardAxis = Vec3.UnitZ; #endregion #region Constructor public PointsToDmxLights() { Result.UpdateAction = Update; } #endregion #region Input Slots // Buffers [Input(Guid = "61b48e46-c3d1-46e3-a470-810d55f30aa6")] public readonly InputSlot EffectedPoints = new(); [Input(Guid = "2bea2ccb-89f2-427b-bd9a-95c7038b715e")] public readonly InputSlot ReferencePoints = new(); // General behaviour [Input(Guid = "1348ed7c-79f8-48c6-ac00-e60fb40050db")] public readonly InputSlot FixtureChannelSize = new(); [Input(Guid = "7449cd05-54be-484b-854a-d2143340f925")] public readonly InputSlot FitInUniverse = new(); [Input(Guid = "850af6c3-d9ef-492c-9cfb-e2589ae5b9ac")] public readonly InputSlot FillUniverse = new(); [Input(Guid = "23F23213-68E2-45F5-B452-4A86289004C0")] public readonly InputSlot DebugToLog = new(); // Test‑Mode dropdown (debug only) [Input(Guid = "D8A90C30-4E5B-4F0B-BFA7-09DAF3A4C71F", MappedType = typeof(TestMode))] public readonly InputSlot TestModeSelect = new(); // POSITION [Input(Guid = "df04fce0-c6e5-4039-b03f-e651fc0ec4a9")] public readonly InputSlot GetPosition = new(); [Input(Guid = "628d96a8-466b-4148-9658-7786833ec989", MappedType = typeof(AxisModes))] public readonly InputSlot PositionMeasureAxis = new(); [Input(Guid = "78a7e683-f4e7-4826-8e39-c8de08e50e5e")] public readonly InputSlot InvertPositionDirection = new(); [Input(Guid = "8880c101-403f-46e0-901e-20ec2dd333e9")] public readonly InputSlot PositionDistanceRange = new(); [Input(Guid = "fc3ec0d6-8567-4d5f-9a63-5c69fb5988cb")] public readonly InputSlot PositionChannel = new(); [Input(Guid = "658a19df-e51b-45b4-9f91-cb97a891255a")] public readonly InputSlot PositionFineChannel = new(); // ROTATION [Input(Guid = "4922acd8-ab83-4394-8118-c555385c2ce9")] public readonly InputSlot GetRotation = new(); [Input(Guid = "032F3617-E1F3-4B41-A3BE-61DD63B9F3BA", MappedType = typeof(ForwardVectorModes))] public readonly InputSlot ForwardVector = new(); [Input(Guid = "9c235473-346b-4861-9844-4b584e09f58a", MappedType = typeof(RotationOrderModes))] public readonly InputSlot RotationOrder = new(); [Input(Guid = "49fefbdb-2652-43db-ae52-ebc2df3e2856")] public readonly InputSlot InvertX = new(); [Input(Guid = "6d8fc457-0c80-4736-8c25-cc48f07cbbfd")] public readonly InputSlot InvertY = new(); [Input(Guid = "0c57cdd5-e450-4425-954f-c9e4256f83e1")] public readonly InputSlot InvertZ = new(); [Input(Guid = "1f532994-fb0e-44e4-8a80-7917e1851eae", MappedType = typeof(AxisModes))] public readonly InputSlot PanAxis = new(); [Input(Guid = "7bf3e057-b9eb-43d2-8e1a-64c1c3857ca1")] public readonly InputSlot InvertPan = new(); [Input(Guid = "1f877cf6-10d9-4d0b-b087-974bd6855e0a", MappedType = typeof(AxisModes))] public readonly InputSlot TiltAxis = new(); [Input(Guid = "f85ecf9f-0c3d-4c10-8ba7-480aa2c7a667")] public readonly InputSlot InvertTilt = new(); [Input(Guid = "e96655be-6bc7-4ca4-bf74-079a07570d74")] public readonly InputSlot ShortestPathPanTilt = new(); [Input(Guid = "f50da250-606d-4a15-a25e-5458f540e527")] public readonly InputSlot PanRange = new(); [Input(Guid = "9000c279-73e4-4de8-a1f8-c3914eaaf533")] public readonly InputSlot PanChannel = new(); [Input(Guid = "4d4b3425-e6ad-4834-a8a7-06c9f9c2b909")] public readonly InputSlot PanFineChannel = new(); [Input(Guid = "6e8b4125-0e8c-430b-897d-2231bb4c8f6f")] public readonly InputSlot TiltRange = new(); [Input(Guid = "47d7294f-6f73-4e21-ac9a-0fc0817283fb")] public readonly InputSlot TiltChannel = new(); [Input(Guid = "4a40e022-d206-447c-bda3-d534f231c816")] public readonly InputSlot TiltFineChannel = new(); [Input(Guid = "C9D7CD19-7FC6-4491-8DFA-3808725C7859")] public readonly InputSlot PanOffset = new(); [Input(Guid = "C9D7CD19-7FC6-4491-8DFA-3808725C7860")] public readonly InputSlot TiltOffset = new(); // VISUALIZATION SETTINGS [Input(Guid = "294B0515-B9F2-446A-8A97-01E3C8B715C0", MappedType = typeof(AxisModes))] public readonly InputSlot VisPanAxis = new(); [Input(Guid = "F98E8F19-C234-453D-9492-369F6B08035D", MappedType = typeof(AxisModes))] public readonly InputSlot VisTiltAxis = new(); // COLOUR [Input(Guid = "5cdc69f7-45ec-4eec-bfb6-960d6245dafb")] public readonly InputSlot GetColor = new(); [Input(Guid = "cf2c3308-8f3f-442d-a563-b419f12e7ad1")] public readonly InputSlot RgbToCmy = new(); [Input(Guid = "013cc355-91d6-4ea6-b9f7-f1817b89e4a3")] public readonly InputSlot RedChannel = new(); [Input(Guid = "970769f4-116f-418d-87a7-cda28e44d063")] public readonly InputSlot GreenChannel = new(); [Input(Guid = "d755342b-9a9e-4c78-8376-81579d8c0909")] public readonly InputSlot BlueChannel = new(); [Input(Guid = "f13edebd-b44f-49e9-985e-7e3feb886fea")] public readonly InputSlot AlphaChannel = new(); [Input(Guid = "8ceece78-9a08-4c7b-8fea-740e8e5929a6")] public readonly InputSlot WhiteChannel = new(); [Input(Guid = "5E96A7A3-5340-43F2-96B9-9972A69421E5")] public readonly InputSlot Is16BitColor = new(); // FEATURES (F1 / F2) [Input(Guid = "91c78090-be10-4203-827e-d2ef1b93317e")] public readonly InputSlot GetF1 = new(); [Input(Guid = "bec9e5a6-40a9-49b2-88bd-01a4ea03d28c")] public readonly InputSlot GetF1ByPixel = new(); [Input(Guid = "b7061834-66aa-4f7f-91f9-10ebfe16713f")] public readonly InputSlot F1Channel = new(); [Input(Guid = "1cb93e97-0161-4a77-bbc7-ff30c1972cf8")] public readonly InputSlot GetF2 = new(); [Input(Guid = "b8080f4e-4542-4e20-9844-8028bbaf223f")] public readonly InputSlot GetF2ByPixel = new(); [Input(Guid = "d77be0d1-5fb9-4d26-9e4a-e16497e4759c")] public readonly InputSlot F2Channel = new(); // CUSTOM VARIABLES [Input(Guid = "b2c3d4e5-f6a7-8901-bcde-f23456789012")] public readonly InputSlot> CustomVariableChannels = new(); [Input(Guid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890")] public readonly InputSlot> CustomVariableValues = new(); #endregion #region Main Update Method private void Update(EvaluationContext context) { var pointBuffer = EffectedPoints.GetValue(context); var referencePointBuffer = ReferencePoints.GetValue(context); if (pointBuffer == null || pointBuffer.Buffer == null || pointBuffer.Srv == null) { Log.Warning("EffectedPoints buffer is not connected or invalid.", this); Result.Value?.Clear(); VisualizeLights.Value = null; _lastPanTiltPerFixture.Clear(); return; } // Asynchronously read the structured buffers _pointsBufferReader.InitiateRead( pointBuffer.Buffer, pointBuffer.Srv.Description.Buffer.ElementCount, pointBuffer.Buffer.Description.StructureByteStride, OnPointsReadComplete); _pointsBufferReader.Update(); if (referencePointBuffer != null && referencePointBuffer.Buffer != null && referencePointBuffer.Srv != null) { _referencePointsBufferReader.InitiateRead( referencePointBuffer.Buffer, referencePointBuffer.Srv.Description.Buffer.ElementCount, referencePointBuffer.Buffer.Description.StructureByteStride, OnReferencePointsReadComplete); _referencePointsBufferReader.Update(); } else { _referencePoints = Array.Empty(); } // Process when we actually have points if (_points != null && _points.Length > 0) { if (_visualizationPoints.Length != _points.Length) _visualizationPoints = new Point[_points.Length]; ApplyTestMode(context); UpdateChannelData(context, _points); Result.Value = new List(_resultItems); UpdateVisualizationBuffer(); VisualizeLights.Value = _visualizeBuffer; } else { Result.Value?.Clear(); VisualizeLights.Value = null; _lastPanTiltPerFixture.Clear(); } } #endregion #region Test‑Mode handling private void ApplyTestMode(EvaluationContext context) { var mode = (TestMode)TestModeSelect.GetValue(context); switch (mode) { case TestMode.Disabled: _cachedForwardAxis = ResolveForwardFromInput(context); break; case TestMode.ZPositive: _cachedForwardAxis = Vec3.UnitZ; break; case TestMode.ZNegative: _cachedForwardAxis = -Vec3.UnitZ; break; case TestMode.XPositive: _cachedForwardAxis = Vec3.UnitX; break; case TestMode.XNegative: _cachedForwardAxis = -Vec3.UnitX; break; case TestMode.YPositive: _cachedForwardAxis = Vec3.UnitY; break; case TestMode.YNegative: _cachedForwardAxis = -Vec3.UnitY; break; } } private Vec3 ResolveForwardFromInput(EvaluationContext context) { var mode = (ForwardVectorModes)ForwardVector.GetValue(context); return mode switch { ForwardVectorModes.X => Vec3.UnitX, ForwardVectorModes.Y => Vec3.UnitY, ForwardVectorModes.Z => Vec3.UnitZ, ForwardVectorModes.NegX => -Vec3.UnitX, ForwardVectorModes.NegY => -Vec3.UnitY, ForwardVectorModes.NegZ => -Vec3.UnitZ, _ => Vec3.UnitZ, }; } #endregion #region Buffer‑Read Callbacks private void OnPointsReadComplete(StructuredBufferReadAccess.ReadRequestItem readItem, IntPtr dataPointer, DataStream dataStream) { int count = readItem.ElementCount; if (_points.Length != count) _points = new Point[count]; using (dataStream) { dataStream.ReadRange(_points, 0, count); } } private void OnReferencePointsReadComplete(StructuredBufferReadAccess.ReadRequestItem readItem, IntPtr dataPointer, DataStream dataStream) { int count = readItem.ElementCount; if (_referencePoints.Length != count) _referencePoints = new Point[count]; using (dataStream) { dataStream.ReadRange(_referencePoints, 0, count); } } #endregion #region Channel‑Data Generation private void UpdateChannelData(EvaluationContext context, Point[] points) { int fixtureChannelSize = FixtureChannelSize.GetValue(context); int effectedPointsCount = points.Length; bool debugToLog = DebugToLog.GetValue(context); // Determine fixture → pixel mapping (reference based) int fixtureCount; int pixelsPerFixture; bool useReferencePoints = _referencePoints.Length > 0; if (useReferencePoints) { fixtureCount = _referencePoints.Length; if (fixtureCount == 0 || effectedPointsCount % fixtureCount != 0) { Log.Warning( $"Effected points count ({effectedPointsCount}) is not a multiple of reference points count ({fixtureCount}). " + "Falling back to 1‑to‑1 mapping.", this); fixtureCount = effectedPointsCount; pixelsPerFixture = 1; useReferencePoints = false; } else { pixelsPerFixture = effectedPointsCount / fixtureCount; } } else { fixtureCount = effectedPointsCount; pixelsPerFixture = 1; } // Ensure state list size matches fixture count while (_lastPanTiltPerFixture.Count < fixtureCount) _lastPanTiltPerFixture.Add(new Vector2(float.NaN, float.NaN)); bool fitInUniverse = FitInUniverse.GetValue(context); bool fillUniverse = FillUniverse.GetValue(context); _resultItems.Clear(); _pointChannelValues.Clear(); if (fixtureChannelSize <= 0) { if (effectedPointsCount > 0) Log.Warning("FixtureChannelSize is 0 or less, no DMX output generated.", this); return; } // Pre‑allocate the per‑fixture DMX channel buffer _pointChannelValues.Capacity = fixtureChannelSize; for (int i = 0; i < fixtureChannelSize; i++) _pointChannelValues.Add(0); // Process each fixture for (int fixtureIdx = 0; fixtureIdx < fixtureCount; fixtureIdx++) { bool logThisFixture = debugToLog && fixtureIdx == 0; // Reset per‑fixture channel array for (int i = 0; i < fixtureChannelSize; i++) _pointChannelValues[i] = 0; int firstPixelIdx = fixtureIdx * pixelsPerFixture; Point transformPoint = points[firstPixelIdx]; Point referencePoint = useReferencePoints ? _referencePoints[fixtureIdx] : transformPoint; if (logThisFixture) Log.Debug("--- Fixture 0 Debug ---", this); // Process transformations Vec3 finalVisPos; Quat finalVisOrientation = ProcessTransformations(context, transformPoint, referencePoint, useReferencePoints, fixtureIdx, logThisFixture, out finalVisPos); // Store visualisation data for (int p = 0; p < pixelsPerFixture; ++p) { int curIdx = firstPixelIdx + p; if (curIdx < _visualizationPoints.Length) { Point currentPoint = points[curIdx]; currentPoint.Position = finalVisPos; currentPoint.Orientation = finalVisOrientation; _visualizationPoints[curIdx] = currentPoint; } } // Handle color, features, and custom variables HandleColorAndFeatures(context, points, transformPoint, firstPixelIdx, pixelsPerFixture); HandleCustomVariables(context); // Universe fit if (fitInUniverse) { int remaining = UniverseSize - (_resultItems.Count % UniverseSize); if (fixtureChannelSize > remaining) { for (int i = 0; i < remaining; i++) _resultItems.Add(0); } } // Append fixture's DMX channel list _resultItems.AddRange(_pointChannelValues); } // Universe fill if (fillUniverse) { int remainder = _resultItems.Count % UniverseSize; if (remainder != 0) { int toAdd = UniverseSize - remainder; for (int i = 0; i < toAdd; i++) _resultItems.Add(0); } } } #endregion #region Transformations (Position & Rotation) private Quat ProcessTransformations(EvaluationContext context, Point transformPoint, Point referencePoint, bool useReferencePoints, int fixtureIdx, bool shouldLog, out Vec3 finalVisPosition) { bool getRot = GetRotation.GetValue(context); bool getPos = GetPosition.GetValue(context); // Rotation Quat finalOrientation = transformPoint.Orientation; if (getRot) finalOrientation = ProcessRotation(context, transformPoint, referencePoint, useReferencePoints, fixtureIdx, shouldLog); // Position finalVisPosition = transformPoint.Position; if (getPos) finalVisPosition = ProcessPosition(context, transformPoint, referencePoint, useReferencePoints, shouldLog); else if (getRot) finalVisPosition = referencePoint.Position; return finalOrientation; } #endregion #region Position Handling private Vec3 ProcessPosition(EvaluationContext context, Point point, Point referencePoint, bool calculateRelativePosition, bool shouldLog) { int channel = PositionChannel.GetValue(context); int fineChannel = PositionFineChannel.GetValue(context); AxisModes axis = (AxisModes)PositionMeasureAxis.GetValue(context); if (channel <= 0 || axis == AxisModes.Disabled) return point.Position; bool invert = InvertPositionDirection.GetValue(context); Vector2 range = PositionDistanceRange.GetValue(context); if (Math.Abs(range.Y - range.X) < 1e-4f) { Log.Warning("PositionDistanceRange min and max are too close – will output 0.", this); SetDmxValue(0f, channel, fineChannel, range.X, range.Y, shouldLog, "Position"); return point.Position; } Vec3 pos = point.Position; Vec3 refPos = calculateRelativePosition ? referencePoint.Position : Vec3.Zero; float distance = axis switch { AxisModes.X => pos.X - refPos.X, AxisModes.Y => pos.Y - refPos.Y, AxisModes.Z => pos.Z - refPos.Z, _ => 0f, }; if (invert) distance = -distance; float clampedDist = Math.Clamp(distance, range.X, range.Y); SetDmxValue(clampedDist, channel, fineChannel, range.X, range.Y, shouldLog, "Position"); // Compute position for visualization Vec3 resultPosition = point.Position; float finalDist = clampedDist; switch (axis) { case AxisModes.X: resultPosition.X = refPos.X + finalDist; break; case AxisModes.Y: resultPosition.Y = refPos.Y + finalDist; break; case AxisModes.Z: resultPosition.Z = refPos.Z + finalDist; break; } return resultPosition; } #endregion #region Rotation Handling private Quat ProcessRotation(EvaluationContext context, Point point, Point referencePoint, bool calculateRelativeRotation, int fixtureIdx, bool shouldLog) { // Axis configuration AxisModes panAxis = (AxisModes)PanAxis.GetValue(context); AxisModes tiltAxis = (AxisModes)TiltAxis.GetValue(context); // Validate axes first if (!ValidateAxes(panAxis, tiltAxis)) return point.Orientation; int panChannel = PanChannel.GetValue(context); int panFineChannel = PanFineChannel.GetValue(context); int tiltChannel = TiltChannel.GetValue(context); int tiltFineChannel = TiltFineChannel.GetValue(context); bool panEnabled = panAxis != AxisModes.Disabled && panChannel > 0; bool tiltEnabled = tiltAxis != AxisModes.Disabled && tiltChannel > 0; if (shouldLog) Log.Debug($"Processing Rotation for Fixture. PanEnabled: {panEnabled}, TiltEnabled: {tiltEnabled}, Pan16Bit: {panFineChannel > 0}, Tilt16Bit: {tiltFineChannel > 0}", this); Vec3 direction; bool useLookAt = false; if (calculateRelativeRotation) { if ((point.Position - referencePoint.Position).LengthSquared() > 0.0001f) { useLookAt = true; } } if (useLookAt) { Vec3 worldDir = point.Position - referencePoint.Position; if (worldDir.LengthSquared() < 1e-6f) worldDir = Vec3.UnitZ; else worldDir = Vec3.Normalize(worldDir); Quat refRot = referencePoint.Orientation; if (float.IsNaN(refRot.X) || float.IsNaN(refRot.Y) || float.IsNaN(refRot.Z) || float.IsNaN(refRot.W)) { if (shouldLog) Log.Warning("Reference rotation is invalid. Using Identity.", this); refRot = Quat.Identity; } Quat invRef = Quat.Inverse(refRot); direction = Vec3.Transform(worldDir, invRef); if (InvertX.GetValue(context)) direction.X = -direction.X; if (InvertY.GetValue(context)) direction.Y = -direction.Y; if (InvertZ.GetValue(context)) direction.Z = -direction.Z; direction = Vec3.Normalize(direction); if (shouldLog) Log.Debug($"LookAt Mode. WorldDir: {worldDir}, LocalDir: {direction}", this); } else { Quat active = ComputeActiveRotation(point.Orientation, referencePoint.Orientation, calculateRelativeRotation); if (shouldLog) Log.Debug($"Active Quaternion: {active}", this); direction = ExtractDirection(active, _cachedForwardAxis, InvertX.GetValue(context), InvertY.GetValue(context), InvertZ.GetValue(context)); if (shouldLog) Log.Debug($"Extracted Direction Vector: {direction}", this); } // Raw pan/tilt from direction - IK LOGIC THAT WORKS FOR DMX var (rawPan, rawTilt) = ComputePanTiltAngles(direction, panAxis, tiltAxis, shouldLog); if (shouldLog) Log.Debug($"Computed raw angles from direction - Pan: {rawPan * 180f / MathF.PI:F2} deg ({rawPan:F4} rad), Tilt: {rawTilt * 180f / MathF.PI:F2} deg ({rawTilt:F4} rad)", this); // Apply pan and tilt offsets float panOffsetRad = (PanOffset.GetValue(context) - 90f) * MathF.PI / 180f; float tiltOffsetRad = (TiltOffset.GetValue(context) - 90f) * MathF.PI / 180f; rawPan += panOffsetRad; rawTilt += tiltOffsetRad; if (shouldLog) Log.Debug($"Angles after applying offsets - Pan: {rawPan * 180f / MathF.PI:F2} deg ({rawPan:F4} rad), Tilt: {rawTilt * 180f / MathF.PI:F2} deg ({rawTilt:F4} rad)", this); // Apply ranges, inversion, shortest‑path and write DMX float finalPan = 0f, finalTilt = 0f; float bestPan = rawPan, bestTilt = rawTilt; bool useShortestPath = ShortestPathPanTilt.GetValue(context); if (useShortestPath) { Vector2 lastState = _lastPanTiltPerFixture[fixtureIdx]; (bestPan, bestTilt) = GetOptimizedPanTilt(rawPan, rawTilt, context, lastState); _lastPanTiltPerFixture[fixtureIdx] = new Vector2(bestPan, bestTilt); } else { bestPan = FitToRange(rawPan, PanRange.GetValue(context), false, float.NaN); bestTilt = FitToRange(rawTilt, TiltRange.GetValue(context), false, float.NaN); _lastPanTiltPerFixture[fixtureIdx] = new Vector2(float.NaN, float.NaN); } if (panEnabled) { finalPan = ApplyPanRangeAndWrite(bestPan, panChannel, panFineChannel, PanRange.GetValue(context), InvertPan.GetValue(context), shouldLog); } else { var state = _lastPanTiltPerFixture[fixtureIdx]; _lastPanTiltPerFixture[fixtureIdx] = new Vector2(float.NaN, state.Y); } if (tiltEnabled) { finalTilt = ApplyTiltRangeAndWrite(bestTilt, tiltChannel, tiltFineChannel, TiltRange.GetValue(context), InvertTilt.GetValue(context), shouldLog); } else { var state = _lastPanTiltPerFixture[fixtureIdx]; _lastPanTiltPerFixture[fixtureIdx] = new Vector2(state.X, float.NaN); } // --- Visualization Calculation START --- float visPan = finalPan; if (panEnabled && InvertPan.GetValue(context)) { Vector2 range = PanRange.GetValue(context); float min = range.X * MathF.PI / 180f; float max = range.Y * MathF.PI / 180f; visPan = min + max - finalPan; } float visTilt = finalTilt; if (tiltEnabled && InvertTilt.GetValue(context)) { Vector2 range = TiltRange.GetValue(context); float min = range.X * MathF.PI / 180f; float max = range.Y * MathF.PI / 180f; visTilt = min + max - finalTilt; } float panAngleForViz = visPan - panOffsetRad; float tiltAngleForViz = visTilt - tiltOffsetRad; // Get visualization axes (fall back to DMX axes if Disabled) AxisModes visPanAxis = (AxisModes)VisPanAxis.GetValue(context); if (visPanAxis == AxisModes.Disabled) visPanAxis = panAxis; AxisModes visTiltAxis = (AxisModes)VisTiltAxis.GetValue(context); if (visTiltAxis == AxisModes.Disabled) visTiltAxis = tiltAxis; // Create Pan and Tilt Quaternions using the corrected physical angles Quat panQuat = panEnabled ? Quat.CreateFromAxisAngle(GetAxisVector(visPanAxis), panAngleForViz) : Quat.Identity; Quat tiltQuat = tiltEnabled ? Quat.CreateFromAxisAngle(GetAxisVector(visTiltAxis), tiltAngleForViz) : Quat.Identity; // --- Visualization Calculation END --- // Re‑assemble final rotation (relative to neutral orientation) Quat resultRotation = (RotationOrderModes)RotationOrder.GetValue(context) == RotationOrderModes.TiltThenPan ? tiltQuat * panQuat : panQuat * tiltQuat; // Apply reference orientation if relative mode is active (ABSOLUTE FINAL ORIENTATION) Quat finalOrientation = resultRotation; if (calculateRelativeRotation) finalOrientation = referencePoint.Orientation * resultRotation; if (shouldLog) Log.Debug($"Re-assembled final orientation: {finalOrientation}", this); return finalOrientation; } #region Rotation Helper Methods private bool ValidateAxes(AxisModes pan, AxisModes tilt) { if (pan == AxisModes.Disabled && tilt == AxisModes.Disabled) { Log.Warning("Both Pan and Tilt axes are disabled – rotation will be ignored.", this); return false; } if (pan != AxisModes.Disabled && pan == tilt) { Log.Warning($"Pan and Tilt axes cannot be identical ({pan}). Skipping rotation.", this); return false; } Vec3 panVec = GetAxisVector(pan); Vec3 tiltVec = GetAxisVector(tilt); if (panVec != Vec3.Zero && tiltVec != Vec3.Zero && Vec3.Cross(panVec, tiltVec).LengthSquared() < 1e-6f) { Log.Warning($"Pan ({pan}) and Tilt ({tilt}) axes are collinear – rotation undefined.", this); return false; } return true; } private Quat ComputeActiveRotation(Quat current, Quat reference, bool relative) { if (!relative) return current; if (float.IsNaN(reference.X) || float.IsNaN(reference.Y) || float.IsNaN(reference.Z) || float.IsNaN(reference.W)) { Log.Warning("Reference rotation is invalid (NaN components). Falling back to absolute rotation.", this); return current; } return Quat.Inverse(reference) * current; } private Vec3 ExtractDirection(Quat rotation, Vec3 forwardAxis, bool invertX, bool invertY, bool invertZ) { Vec3 dir = Vec3.Transform(forwardAxis, rotation); if (invertX) dir.X = -dir.X; if (invertY) dir.Y = -dir.Y; if (invertZ) dir.Z = -dir.Z; return Vec3.Normalize(dir); } private static Vec3 GetAxisVector(AxisModes axis) => axis switch { AxisModes.X => Vec3.UnitX, AxisModes.Y => Vec3.UnitY, AxisModes.Z => Vec3.UnitZ, _ => Vec3.Zero, }; #endregion // IK LOGIC THAT WORKS FOR DMX (RESTORED/KEPT FROM PREVIOUS WORKING VERSION) private (float rawPan, float rawTilt) ComputePanTiltAngles(Vec3 direction, AxisModes panAxis, AxisModes tiltAxis, bool shouldLog) { Vec3 panVec = GetAxisVector(panAxis); Vec3 tiltVec = GetAxisVector(tiltAxis); float rawPan = 0f, rawTilt = 0f; // Handle cases where one or both axes are disabled if (panVec == Vec3.Zero && tiltVec == Vec3.Zero) { return (rawPan, rawTilt); } // If only one axis is enabled if (panVec == Vec3.Zero) // Only tilt enabled { // For tilt-only, we need to find the angle between the direction and the plane perpendicular to tilt axis Vec3 upVec = tiltVec; Vec3 forwardVec = FindOrthogonalVector(tiltVec); Vec3.Normalize(Vec3.Cross(upVec, forwardVec)); // Project direction onto the plane defined by forward and right vectors float forwardComponent = Vec3.Dot(direction, forwardVec); float upComponent = Vec3.Dot(direction, upVec); rawTilt = MathF.Atan2(upComponent, forwardComponent); return (rawPan, rawTilt); } if (tiltVec == Vec3.Zero) // Only pan enabled { // For pan-only, rotate around pan axis Vec3 forwardVec = _cachedForwardAxis; Vec3 rightVec = Vec3.Normalize(Vec3.Cross(panVec, forwardVec)); // Remove component along pan axis Vec3 directionInPlane = direction - panVec * Vec3.Dot(direction, panVec); if (directionInPlane.LengthSquared() > 1e-6f) { directionInPlane = Vec3.Normalize(directionInPlane); float rightComponent = Vec3.Dot(directionInPlane, rightVec); float forwardComponent = Vec3.Dot(directionInPlane, forwardVec); rawPan = MathF.Atan2(rightComponent, forwardComponent); } return (rawPan, rawTilt); } // Full 2-axis case - RESTORING ORIGINAL LOGIC // Create a coordinate system where: // - Z is the forward direction (cross product of tilt and pan axes) // - Y is the pan axis (rotation axis for pan) // - X is the tilt axis (rotation axis for tilt) Vec3 localForward = Vec3.Normalize(Vec3.Cross(tiltVec, panVec)); if (localForward.LengthSquared() < 1e-6f) { Log.Error("Pan and Tilt axes are collinear – cannot form a proper coordinate system.", this); return (rawPan, rawTilt); } // CORRECTED BASIS CONSTRUCTION // Right vector is Cross(Pan, Forward). For Pan=Y, Forward=Z -> YxZ = X. Vec3 localRight = Vec3.Normalize(Vec3.Cross(panVec, localForward)); // Up vector is Cross(Right, Forward). For Right=X, Forward=Z -> XxZ = -Y. Vec3 localUp = Vec3.Normalize(Vec3.Cross(localRight, localForward)); // Transform direction into local coordinate system float x = Vec3.Dot(direction, localRight); // Tilt axis component float y = Vec3.Dot(direction, localUp); // Pan axis component float z = Vec3.Dot(direction, localForward); // Forward component // Calculate pan angle (rotation around pan axis) rawPan = MathF.Atan2(x, z); // Calculate tilt angle (rotation around tilt axis) // Use atan2 for full range and better numerical stability float horizontalMagnitude = MathF.Sqrt(x * x + z * z); rawTilt = MathF.Atan2(y, horizontalMagnitude); if (shouldLog) { Log.Debug($"Local coords - X: {x:F3}, Y: {y:F3}, Z: {z:F3}", this); Log.Debug($"Calculated angles - Pan: {rawPan * 180f / MathF.PI:F1}°, Tilt: {rawTilt * 180f / MathF.PI:F1}°", this); } return (rawPan, rawTilt); } // Helper method to find an orthogonal vector private Vec3 FindOrthogonalVector(Vec3 vec) { // Try using unit X as candidate Vec3 candidate = Vec3.UnitX; if (Math.Abs(Vec3.Dot(vec, candidate)) > 0.9f) { // If too aligned, use unit Y candidate = Vec3.UnitY; } if (Math.Abs(Vec3.Dot(vec, candidate)) > 0.9f) { // If still too aligned, use unit Z candidate = Vec3.UnitZ; } // Make orthogonal using Gram-Schmidt return Vec3.Normalize(candidate - vec * Vec3.Dot(candidate, vec)); } private float ApplyPanRangeAndWrite(float panVal, int panChannel, int panFineChannel, Vector2 panRangeDegrees, bool invertPan, bool shouldLog) { if (panRangeDegrees.X >= panRangeDegrees.Y) { Log.Warning("Pan range min must be < max.", this); SetDmxValue(0f, panChannel, panFineChannel, 0f, 1f, shouldLog, "Pan"); return 0f; } float panMinRad = panRangeDegrees.X * MathF.PI / 180f; float panMaxRad = panRangeDegrees.Y * MathF.PI / 180f; if (invertPan) { panVal = panMaxRad + panMinRad - panVal; if (shouldLog) Log.Debug($"Pan (after inversion): {panVal * 180f / MathF.PI:F2} deg", this); } float finalPan = Math.Clamp(panVal, panMinRad, panMaxRad); if (shouldLog) Log.Debug($"Final Pan (clamped to {panRangeDegrees.X:F2}-{panRangeDegrees.Y:F2} deg): {finalPan * 180f / MathF.PI:F2} deg ({finalPan:F4} rad)", this); SetDmxValue(finalPan, panChannel, panFineChannel, panMinRad, panMaxRad, shouldLog, "Pan"); return finalPan; } private float ApplyTiltRangeAndWrite(float tiltVal, int tiltChannel, int tiltFineChannel, Vector2 tiltRangeDegrees, bool invertTilt, bool shouldLog) { if (tiltRangeDegrees.X >= tiltRangeDegrees.Y) { Log.Warning("Tilt range min must be < max.", this); SetDmxValue(0f, tiltChannel, tiltFineChannel, 0f, 1f, shouldLog, "Tilt"); return 0f; } float tiltMinRad = tiltRangeDegrees.X * MathF.PI / 180f; float tiltMaxRad = tiltRangeDegrees.Y * MathF.PI / 180f; if (invertTilt) { tiltVal = tiltMaxRad + tiltMinRad - tiltVal; if (shouldLog) Log.Debug($"Tilt (after inversion): {tiltVal * 180f / MathF.PI:F2} deg", this); } float finalTilt = Math.Clamp(tiltVal, tiltMinRad, tiltMaxRad); if (shouldLog) Log.Debug($"Final Tilt (clamped to {tiltRangeDegrees.X:F2}-{tiltRangeDegrees.Y:F2} deg): {finalTilt * 180f / MathF.PI:F2} deg ({finalTilt:F4} rad)", this); SetDmxValue(finalTilt, tiltChannel, tiltFineChannel, tiltMinRad, tiltMaxRad, shouldLog, "Tilt"); return finalTilt; } #endregion #region Colour / Feature Handling private void HandleColorAndFeatures(EvaluationContext context, Point[] points, Point transformPoint, int firstPixelIdx, int pixelsPerFixture) { bool getColor = GetColor.GetValue(context); bool getF1 = GetF1.GetValue(context); bool getF2 = GetF2.GetValue(context); bool f1ByPixel = GetF1ByPixel.GetValue(context); bool f2ByPixel = GetF2ByPixel.GetValue(context); bool useCmy = RgbToCmy.GetValue(context); bool is16BitColor = Is16BitColor.GetValue(context); int redChBase = RedChannel.GetValue(context); int greenChBase = GreenChannel.GetValue(context); int blueChBase = BlueChannel.GetValue(context); int whiteChBase = WhiteChannel.GetValue(context); int alphaChBase = AlphaChannel.GetValue(context); int f1ChBase = F1Channel.GetValue(context); int f2ChBase = F2Channel.GetValue(context); bool hasAnyPerPixelAttributes = (getColor && (redChBase > 0 || greenChBase > 0 || blueChBase > 0 || whiteChBase > 0 || alphaChBase > 0)) || (getF1 && f1ByPixel && f1ChBase > 0) || (getF2 && f2ByPixel && f2ChBase > 0); int overallMinPerPixelChannel = int.MaxValue; int overallMaxPerPixelChannel = int.MinValue; if (hasAnyPerPixelAttributes) { int channelsPerColorDmxValue = is16BitColor ? 2 : 1; int channelsPerFeatureDmxValue = 1; if (getColor) { if (redChBase > 0) { overallMinPerPixelChannel = Math.Min(overallMinPerPixelChannel, redChBase); overallMaxPerPixelChannel = Math.Max(overallMaxPerPixelChannel, redChBase + channelsPerColorDmxValue - 1); } if (greenChBase > 0) { overallMinPerPixelChannel = Math.Min(overallMinPerPixelChannel, greenChBase); overallMaxPerPixelChannel = Math.Max(overallMaxPerPixelChannel, greenChBase + channelsPerColorDmxValue - 1); } if (blueChBase > 0) { overallMinPerPixelChannel = Math.Min(overallMinPerPixelChannel, blueChBase); overallMaxPerPixelChannel = Math.Max(overallMaxPerPixelChannel, blueChBase + channelsPerColorDmxValue - 1); } if (whiteChBase > 0) { overallMinPerPixelChannel = Math.Min(overallMinPerPixelChannel, whiteChBase); overallMaxPerPixelChannel = Math.Max(overallMaxPerPixelChannel, whiteChBase + channelsPerColorDmxValue - 1); } if (alphaChBase > 0) { overallMinPerPixelChannel = Math.Min(overallMinPerPixelChannel, alphaChBase); overallMaxPerPixelChannel = Math.Max(overallMaxPerPixelChannel, alphaChBase + channelsPerColorDmxValue - 1); } } if (getF1 && f1ByPixel && f1ChBase > 0) { overallMinPerPixelChannel = Math.Min(overallMinPerPixelChannel, f1ChBase); overallMaxPerPixelChannel = Math.Max(overallMaxPerPixelChannel, f1ChBase + channelsPerFeatureDmxValue - 1); } if (getF2 && f2ByPixel && f2ChBase > 0) { overallMinPerPixelChannel = Math.Min(overallMinPerPixelChannel, f2ChBase); overallMaxPerPixelChannel = Math.Max(overallMaxPerPixelChannel, f2ChBase + channelsPerFeatureDmxValue - 1); } } int pixelChannelStride = 0; if (overallMinPerPixelChannel != int.MaxValue && overallMaxPerPixelChannel != int.MinValue) { pixelChannelStride = overallMaxPerPixelChannel - overallMinPerPixelChannel + 1; if (DebugToLog.GetValue(context)) Log.Debug($"Calculated pixelChannelStride: {pixelChannelStride} (min: {overallMinPerPixelChannel}, max: {overallMaxPerPixelChannel})", this); } else if (hasAnyPerPixelAttributes && DebugToLog.GetValue(context)) { Log.Warning("Per-pixel attributes are enabled, but all associated DMX channels are 0 or less. No per-pixel output will be generated.", this); } for (int pix = 0; pix < pixelsPerFixture; ++pix) { Point pt = points[firstPixelIdx + pix]; void WriteSinglePixelDmxValue(int baseChannel, float normalizedValue, bool is16BitChannel, string debugName) { if (baseChannel <= 0) return; if (pixelChannelStride == 0) return; int relativeOffsetFromOverallMin = baseChannel - overallMinPerPixelChannel; int actualDmxCoarseChannel = overallMinPerPixelChannel + (pix * pixelChannelStride) + relativeOffsetFromOverallMin; int actualDmxFineChannel = is16BitChannel ? actualDmxCoarseChannel + 1 : 0; SetDmxValue(normalizedValue, actualDmxCoarseChannel, actualDmxFineChannel, 0f, 1f, false, $"Pixel{pix}-{debugName}"); } if (getColor) { if (redChBase > 0 || greenChBase > 0 || blueChBase > 0 || whiteChBase > 0 || alphaChBase > 0) { float r = float.IsNaN(pt.Color.X) ? 0f : Math.Clamp(pt.Color.X, 0f, 1f); float g = float.IsNaN(pt.Color.Y) ? 0f : Math.Clamp(pt.Color.Y, 0f, 1f); float b = float.IsNaN(pt.Color.Z) ? 0f : Math.Clamp(pt.Color.Z, 0f, 1f); float a = float.IsNaN(pt.Color.W) ? 1f : Math.Clamp(pt.Color.W, 0f, 1f); if (useCmy) { r = 1f - r; g = 1f - g; b = 1f - b; } WriteSinglePixelDmxValue(redChBase, r, is16BitColor, "Red"); if (greenChBase > 0) WriteSinglePixelDmxValue(greenChBase, g, is16BitColor, "Green"); if (blueChBase > 0) WriteSinglePixelDmxValue(blueChBase, b, is16BitColor, "Blue"); if (whiteChBase > 0) { float w = Math.Min(r, Math.Min(g, b)); WriteSinglePixelDmxValue(whiteChBase, w, is16BitColor, "White"); } if (alphaChBase > 0) { WriteSinglePixelDmxValue(alphaChBase, a, is16BitColor, "Alpha"); } } } if (getF1 && f1ByPixel && f1ChBase > 0) { float f1 = float.IsNaN(pt.F1) ? 0f : Math.Clamp(pt.F1, 0f, 1f); WriteSinglePixelDmxValue(f1ChBase, f1, false, "F1"); } if (getF2 && f2ByPixel && f2ChBase > 0) { float f2 = float.IsNaN(pt.F2) ? 0f : Math.Clamp(pt.F2, 0f, 1f); WriteSinglePixelDmxValue(f2ChBase, f2, false, "F2"); } } // Fixture‑wide (non‑per‑pixel) F1/F2 if (getF1 && !f1ByPixel && f1ChBase > 0) { float f1Val = float.IsNaN(transformPoint.F1) ? 0f : Math.Clamp(transformPoint.F1, 0f, 1f); SetDmxValue(f1Val, f1ChBase, 0, 0f, 1f, DebugToLog.GetValue(context), "FixtureF1"); } if (getF2 && !f2ByPixel && f2ChBase > 0) { float f2Val = float.IsNaN(transformPoint.F2) ? 0f : Math.Clamp(transformPoint.F2, 0f, 1f); SetDmxValue(f2Val, f2ChBase, 0, 0f, 1f, DebugToLog.GetValue(context), "FixtureF2"); } } #endregion #region Custom Variable Handling private void HandleCustomVariables(EvaluationContext context) { const float customVarNormalizedMax = 255f; bool shouldLog = DebugToLog.GetValue(context); // Get the input lists var values = CustomVariableValues.GetValue(context); var channels = CustomVariableChannels.GetValue(context); // Validate inputs if (channels == null) { if (shouldLog) Log.Debug("CustomVariableChannels list is null, skipping custom variables.", this); return; } if (channels.Count == 0) { if (shouldLog) Log.Debug("CustomVariableChannels list is empty, skipping custom variables.", this); return; } // Create working copy of channels to avoid modifying the original var workingChannels = new List(channels); // Auto-resize values list to match channels size var workingValues = new List(); if (values != null) { workingValues = new List(values); } // Ensure values list size matches channels size if (workingValues.Count < workingChannels.Count) { // Extend values with default DMX value (128) int valuesToAdd = workingChannels.Count - workingValues.Count; for (int i = 0; i < valuesToAdd; i++) { workingValues.Add(128); // Default DMX value (middle of 0-255 range) } if (shouldLog) Log.Debug($"Auto-resized CustomVariableValues: extended from {values?.Count ?? 0} to {workingValues.Count} elements with default value 128", this); } else if (workingValues.Count > workingChannels.Count) { // Trim excess values to match channels size int excessCount = workingValues.Count - workingChannels.Count; workingValues.RemoveRange(workingChannels.Count, excessCount); if (shouldLog) Log.Debug($"Auto-trimmed CustomVariableValues: reduced from {values.Count} to {workingValues.Count} elements to match channels size", this); } // Process each channel-value pair for (int i = 0; i < workingChannels.Count; i++) { int channel = workingChannels[i]; int value = workingValues[i]; // Validate channel number if (channel <= 0) { if (shouldLog) Log.Debug($"Skipping custom variable at index {i}: invalid channel {channel}", this); continue; } // Clamp value to valid DMX range int clampedValue = Math.Clamp(value, 0, (int)customVarNormalizedMax); // Set the DMX value SetDmxValue(clampedValue, channel, 0, 0f, customVarNormalizedMax, shouldLog, $"CustomVar[{i}]"); } } #endregion #region DMX Helper Methods private void SetDmxValue(float value, int coarseChannel, int fineChannel, float inMin, float inMax, bool shouldLog, string name) { if (coarseChannel <= 0) { if (shouldLog) Log.Debug($"Skipping DMX write for {name}: Coarse Channel is 0 or less.", this); return; } int listCoarseIndex = coarseChannel - 1; if (fineChannel > 0) { int dmx16 = MapToDmx16(value, inMin, inMax); if (shouldLog) Log.Debug($"{name} DMX Channel: {coarseChannel}/{fineChannel} (16-bit), Input Value: {value:F4}, Mapped DMX (16‑bit): {dmx16}, Range: [{inMin:F4}, {inMax:F4}]", this); InsertOrSet(listCoarseIndex, (dmx16 >> 8) & 0xFF); InsertOrSet(fineChannel - 1, dmx16 & 0xFF); } else { float range = inMax - inMin; float normalized = Math.Clamp((value - inMin) / (range), 0f, 1f); int dmx8 = (int)Math.Round(normalized * 255.0f); if (shouldLog) Log.Debug($"{name} DMX Channel: {coarseChannel} (8-bit), Input Value: {value:F4}, Mapped DMX (8‑bit): {dmx8}, Range: [{inMin:F4}, {inMax:F4}]", this); InsertOrSet(listCoarseIndex, dmx8); } } private static int MapToDmx16(float value, float inMin, float inMax) { float range = inMax - inMin; if (Math.Abs(range) < 1e-4f) return 0; float normalized = (value - inMin) / range; return (int)Math.Round(Math.Clamp(normalized, 0f, 1f) * 65535f); } private void InsertOrSet(int index, int value) { if (index < 0) return; if (index >= _pointChannelValues.Count) { Log.Warning($"DMX channel list index {index + 1} out of range (list size {_pointChannelValues.Count}). " + $"Increase 'Fixture Channel Size' if you are using high channel numbers or 16-bit channels for multiple pixels.", this); return; } _pointChannelValues[index] = value; } #endregion #region Visualisation Buffer private void UpdateVisualizationBuffer() { if (_visualizationPoints == null || _visualizationPoints.Length == 0) { _visualizeBuffer = null; return; } int pointCount = _visualizationPoints.Length; int stride = Point.Stride; if (stride <= 0) return; Buffer buffer = null; ShaderResourceView srv = null; UnorderedAccessView uav = null; if (_visualizeBuffer != null) { buffer = _visualizeBuffer.Buffer; srv = _visualizeBuffer.Srv; uav = _visualizeBuffer.Uav; } ResourceManager.SetupStructuredBuffer(_visualizationPoints, stride * pointCount, stride, ref buffer); ResourceManager.CreateStructuredBufferSrv(buffer, ref srv); ResourceManager.CreateStructuredBufferUav(buffer, UnorderedAccessViewBufferFlags.None, ref uav); if (_visualizeBuffer == null) _visualizeBuffer = new BufferWithViews(); _visualizeBuffer.Buffer = buffer; _visualizeBuffer.Srv = srv; _visualizeBuffer.Uav = uav; } #endregion #region Shortest Path Optimization private (float pan, float tilt) GetOptimizedPanTilt(float rawPan, float rawTilt, EvaluationContext context, Vector2 lastState) { if (float.IsNaN(lastState.X) || float.IsNaN(lastState.Y)) { // No previous state, just use the normal path centered in range float pan = FitToRange(rawPan, PanRange.GetValue(context), false, float.NaN); float tilt = FitToRange(rawTilt, TiltRange.GetValue(context), false, float.NaN); return (pan, tilt); } // Candidate A: Normal path float panA = FitToRange(rawPan, PanRange.GetValue(context), true, lastState.X); float tiltA = FitToRange(rawTilt, TiltRange.GetValue(context), true, lastState.Y); // Candidate B: Flipped path (pan + 180, 180 - tilt) float panB = FitToRange(rawPan + MathF.PI, PanRange.GetValue(context), true, lastState.X); float tiltB = FitToRange(MathF.PI - rawTilt, TiltRange.GetValue(context), true, lastState.Y); // Calculate costs (weighted angular distance, penalizing out-of-range) float Cost(float p, float t, Vector2 pRange, Vector2 tRange) { float pMin = pRange.X * MathF.PI / 180f; float pMax = pRange.Y * MathF.PI / 180f; float tMin = tRange.X * MathF.PI / 180f; float tMax = tRange.Y * MathF.PI / 180f; float pDist = Math.Abs(p - lastState.X); float tDist = Math.Abs(t - lastState.Y); // Add heavy penalty if outside the valid range float pPenalty = (p < pMin || p > pMax) ? 1000f : 0f; float tPenalty = (t < tMin || t > tMax) ? 1000f : 0f; return pDist + tDist + pPenalty + tPenalty; } float costA = Cost(panA, tiltA, PanRange.GetValue(context), TiltRange.GetValue(context)); float costB = Cost(panB, tiltB, PanRange.GetValue(context), TiltRange.GetValue(context)); if (DebugToLog.GetValue(context)) { Log.Debug($"Path A: pan={panA*180/MathF.PI:F1}, tilt={tiltA*180/MathF.PI:F1}, cost={costA:F2}", this); Log.Debug($"Path B: pan={panB*180/MathF.PI:F1}, tilt={tiltB*180/MathF.PI:F1}, cost={costB:F2}", this); } return costB < costA ? (panB, tiltB) : (panA, tiltA); } private float FitToRange(float rawVal, Vector2 rangeDeg, bool useShortestPath, float lastValRad) { float val = rawVal; float minRad = rangeDeg.X * MathF.PI / 180f; float maxRad = rangeDeg.Y * MathF.PI / 180f; if (!useShortestPath || float.IsNaN(lastValRad)) { // Normalize to [-PI, PI] val = MathUtils.Fmod(val + MathF.PI, 2 * MathF.PI) - MathF.PI; // Shift to be close to the center of the allowed range float rangeCenterRad = (minRad + maxRad) / 2f; float turnsToCenter = MathF.Round((val - rangeCenterRad) / (2 * MathF.PI)); val -= turnsToCenter * 2 * MathF.PI; } else { // Shortest path logic: find the rotation multiple that gets closest to the last value // AND respects the range if possible. float baseVal = MathUtils.Fmod(val + MathF.PI, 2 * MathF.PI) - MathF.PI; // Calculate the "ideal" number of turns to be closest to lastVal float idealTurns = MathF.Round((lastValRad - baseVal) / (2 * MathF.PI)); float candidate = baseVal + idealTurns * 2 * MathF.PI; // If the candidate is inside the range, great. if (candidate >= minRad && candidate <= maxRad) { val = candidate; } else { // If not, check neighbors (idealTurns - 1, idealTurns + 1) to see if they are in range. // We prioritize "being in range" over "being closest to lastVal" to avoid penalties. float prev = baseVal + (idealTurns - 1) * 2 * MathF.PI; float next = baseVal + (idealTurns + 1) * 2 * MathF.PI; bool prevInRange = prev >= minRad && prev <= maxRad; bool nextInRange = next >= minRad && next <= maxRad; if (prevInRange && !nextInRange) val = prev; else if (!prevInRange && nextInRange) val = next; else if (prevInRange && nextInRange) { // Both in range? Pick closest. if (Math.Abs(prev - lastValRad) < Math.Abs(next - lastValRad)) val = prev; else val = next; } else { // None are in range. Stick with the closest one (candidate). val = candidate; } } } return val; } #endregion } }