CadFlow · Pan Engine · Post-Mortem

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.

Roller input rate
200 Hz
events per second
osascript throughput
~12 Hz
commands per second
Rate gap
16×
unbridgeable in C#

This document covers the full technical chain, every approach tried, and what a real solution requires.

Background

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:

All four symptoms share one root cause: the command delivery pipeline is too slow and non-deterministic for real-time control.

Technical breakdown
Technical breakdown · 01

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.

your code
C# / Loupedeck plugin (.NET) ~0 ms
OS boundary
Process spawn — fork + exec + dyld 20–80 ms
interpreter
osascript — AppleScript runtime +5–15 ms
IPC bus
Apple Events — async message queue non-deterministic
mediator
System Events.app — broker process +dispatch queue
accessibility
macOS AXUIElement — rate-limited burst throttle
app thread
AutoCAD UI thread — command parser +parse + redraw
result
Viewport moves total: 40–120 ms
Technical breakdown · 02

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.

Process spawn (fork/exec/dyld)
20–80 ms
AppleScript interpreter
5–15 ms
Apple Events IPC
variable
System Events broker
3–8 ms
Accessibility API throttle
burst-limited
AutoCAD parse + redraw
2–5 ms
Technical breakdown · 03

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.

Roller input: ~100–200 Hz  ·  osascript throughput: ~12 Hz  ·  gap: 16×
This ratio cannot be improved with C# optimizations alone.
Roller input rate
~200 Hz
osascript throughput
~12 Hz
What we tried
Attempts 01 – 05

Five approaches, five lessons

01
Process.Start per tickFailed

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.

02
Persistent stdin pipeSilent failure

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.

03
Native CGEvent helperKilled by OS

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.

04
In-flight drop flagPartial

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.

05
Pending delta accumulatorCurrent best

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.

Resolution
Resolution

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.

ApproachLatencyBypassesFeasibility
AutoLISP named pipe reactor~1 msEntire osascript + Accessibility chainHigh — .lsp file loaded in AutoCAD
ObjectARX plugin~0 msEverything — direct viewport APIMedium — ObjectARX SDK + Xcode
Signed + notarized CGEvent binary~1 msosascript, Apple Events, System EventsMedium — Apple Developer ($99/yr)
VIEWCTR manipulation via script~5 msKeyboard emulation layerNeeds 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.

Reference

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 { }
        }
    }
}