Why smooth panning is hard on macOS
AutoCAD on macOS exposes no real-time viewport API. Every pan command must be injected as simulated keyboard input — routed through a chain of interpreters, message queues, and security layers that were never designed for high-frequency control.
This document covers the full technical chain, every approach tried, and what a real solution requires.
The problem
The Loupedeck CadFlow plugin sends pan commands to AutoCAD when the roller or scroller dial rotates. The goal is smooth, real-time viewport movement — like panning in Figma or a game engine.
Instead, what happens:
- Movement feels periodic and stepped rather than continuous
- After the dial stops, the view keeps moving for 1–3 seconds
- Occasionally the pan direction reverses unexpectedly
- Fast rolling causes large burst jumps
All four symptoms share one root cause: the command delivery pipeline is too slow and non-deterministic for real-time control.
The 8-layer stack
Every pan command travels through all of these layers in sequence. Every arrow is latency. None of it was designed for streaming high-frequency input.
Latency per layer
The single biggest cost is the process spawn — it occurs before a single byte of our command is interpreted. This alone makes 60 Hz delivery physically impossible.
The hard ceiling
The roller fires input events at up to 200 Hz. The osascript pipeline processes at most 10–15 Hz. Commands that arrive in the gap don't get dropped — they queue up in Apple Events and drain after the roller stops. This is why the view keeps moving. It's not a code bug — it's a rate mismatch.
This ratio cannot be improved with C# optimizations alone.
Five approaches, five lessons
Fire a new osascript process on every roller event — simplest possible approach.
Process.Start("/usr/bin/osascript", $"-e 'tell application...'");
Queue grows unboundedly. Each spawn takes 20–80 ms, events arrive every 5 ms. Hundreds of queued processes drain after the roller stops — causing 2–5 seconds of post-stop movement. Also caused polarity reversal via elapsed time normalization spikes on resume.
Start one osascript process and feed commands via stdin — keeping the Apple Events bridge warm.
Arguments = "-", // read from stdin RedirectStandardInput = true
osascript - reads stdin as one complete script and waits for EOF before executing anything. Every command was buffered and never ran. The roller did nothing.
Bypass AppleScript entirely. Compile a C binary using CGEventPost — direct kernel-level keystroke injection at ~1 ms latency.
CGEventRef down = CGEventCreateKeyboardEvent(NULL, 0, true); CGEventKeyboardSetUnicodeString(down, 1, &c); CGEventPost(kCGHIDEventTap, down);
macOS kills the process immediately. CGEventPost(kCGHIDEventTap) requires explicit Accessibility permission. An unsigned, non-notarized binary is terminated before posting a single event. Needs signing + notarization + Apple Developer account — not viable for a distributable plugin.
Use an Interlocked flag so only one osascript runs at a time. Drop all new events while one is in-flight.
if (Interlocked.CompareExchange(ref _inFlight, 1, 0) != 0)
return; // already sending — drop
Stopped runaway queue growth. Movement stopped closer to when the roller stopped. But too aggressive — large accumulated deltas sent as single jumps felt stepped and coarse. Slow rolling produced barely any movement.
Accumulate all roller ticks into shared _pendingDx / _pendingDy. When osascript finishes, drain the full pending delta in one call. Wipe pending on idle gap >60 ms.
lock (_pendingLock) { _pendingDx += -dx; }
TryFire();
lock (_pendingLock) {
dx = _pendingDx; _pendingDx = 0;
dy = _pendingDy; _pendingDy = 0;
}
No runaway queue. Stops cleanly on idle. No polarity reversal. Still capped at osascript's ~12 Hz — fast rolling feels coarse — but the best achievable within the current transport layer.
What a real fix looks like
No C# optimization can close the 16× gap. The fix requires bypassing the entire osascript → Apple Events → System Events chain.
| Approach | Latency | Bypasses | Feasibility |
|---|---|---|---|
| AutoLISP named pipe reactor | ~1 ms | Entire osascript + Accessibility chain | High — .lsp file loaded in AutoCAD |
| ObjectARX plugin | ~0 ms | Everything — direct viewport API | Medium — ObjectARX SDK + Xcode |
| Signed + notarized CGEvent binary | ~1 ms | osascript, Apple Events, System Events | Medium — Apple Developer ($99/yr) |
| VIEWCTR manipulation via script | ~5 ms | Keyboard emulation layer | Needs research |
The closest viable option without an Apple Developer account is an AutoLISP named pipe reactor — a small .lsp file AutoCAD loads at startup that opens a Unix domain socket and listens for pan commands. The C# plugin writes dx dy pairs directly to the socket, bypassing every layer above.
Current NudgeEngine.cs
Attempt 05 — the pending delta accumulator. NudgeXAdjustment and NudgeYAdjustment call FeedX / FeedY respectively and are unchanged.
namespace Loupedeck.CadFlow
{
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
internal sealed class NudgeEngine
{
public static readonly NudgeEngine Instance = new NudgeEngine();
private NudgeEngine() { }
private const double FineScale = 0.6;
private const double TurboCoeff = 1.5;
private const double Exponent = 1.65;
private const int MaxDelta = 12;
private const string AcadApp = "AutoCAD 2027";
private const int IdleResetMs = 60;
private int _pendingDx = 0;
private int _pendingDy = 0;
private readonly object _pendingLock = new object();
private double _accumX;
private double _accumY;
private DateTime _lastEventTime = DateTime.MinValue;
private int _inFlight = 0;
public void FeedX(int diff)
{
var now = DateTime.Now;
ResetIfIdle(now);
_lastEventTime = now;
_accumX += Velocity(diff);
int dx = (int)Math.Truncate(_accumX);
if (dx == 0) return;
_accumX -= dx;
lock (_pendingLock) { _pendingDx += -dx; }
TryFire();
}
public void FeedY(int diff)
{
var now = DateTime.Now;
ResetIfIdle(now);
_lastEventTime = now;
_accumY += Velocity(diff);
int dy = (int)Math.Truncate(_accumY);
if (dy == 0) return;
_accumY -= dy;
lock (_pendingLock) { _pendingDy += dy; }
TryFire();
}
private void ResetIfIdle(DateTime now)
{
if (_lastEventTime == DateTime.MinValue) return;
if ((now - _lastEventTime).TotalMilliseconds > IdleResetMs)
{
_accumX = 0; _accumY = 0;
lock (_pendingLock) { _pendingDx = 0; _pendingDy = 0; }
}
}
private void TryFire()
{
if (Interlocked.CompareExchange(ref _inFlight, 1, 0) != 0) return;
Task.Run(() =>
{
try
{
int dx, dy;
lock (_pendingLock)
{
dx = _pendingDx; _pendingDx = 0;
dy = _pendingDy; _pendingDy = 0;
}
if (dx != 0 || dy != 0) SendPan(dx, dy);
}
finally
{
Interlocked.Exchange(ref _inFlight, 0);
bool more;
lock (_pendingLock)
more = _pendingDx != 0 || _pendingDy != 0;
if (more) TryFire();
}
});
}
private static double Velocity(int raw)
{
int c = Math.Clamp(raw, -MaxDelta, MaxDelta);
double abs = Math.Abs(c);
return Math.Sign(c) * (FineScale + TurboCoeff * Math.Pow(abs, Exponent));
}
private static void SendPan(int dx, int dy)
{
string command = $"'_-pan 0,0 {dx},{dy} ";
string script =
$"tell application \"System Events\" to tell process \"{AcadApp}\" " +
$"to keystroke \"{command}\"";
try
{
var psi = new ProcessStartInfo
{
FileName = "/usr/bin/osascript",
ArgumentList = { "-e", script },
UseShellExecute = false,
CreateNoWindow = true,
};
using var p = Process.Start(psi);
p?.WaitForExit(80);
}
catch { }
}
}
}