feat(headtrack): move head tracking into engine, add IMU diagnostics master
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Wed, 9 Sep 2026 19:07:15 +0000 (22:07 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Wed, 9 Sep 2026 19:07:15 +0000 (22:07 +0300)
- drop product-side headtrack classes; the engine now auto-enables
  hot-plug head tracking for every app (Workspace delegates
  getHeadTracker() to ViewPanel)
- add --headtest and --headdebug diagnostic modes to Main: live
  IMU angle dump and a periodic stream-health line (frames, tick,
  dt, max gyro, calibrated angles)
- fix terminal fg/bg channel order in TerminalPanel cell caching;
  add PtySession.clear()/fillBox()
- document the head tracking boundary exception in doc/index.org

doc/index.org
src/main/java/eu/svjatoslav/sixth/bridge/pty/PtySession.java
src/main/java/eu/svjatoslav/sixth/core/Main.java
src/main/java/eu/svjatoslav/sixth/workspace/TerminalPanel.java
src/main/java/eu/svjatoslav/sixth/workspace/Workspace.java

index 1c97181..8b4185e 100644 (file)
@@ -79,10 +79,13 @@ time is limited.
   (~GuiComponent~, ~TextCanvas~, ~TextEditComponent~) that games can
   also use (in-game consoles, interactive screens).
 + *Sixth* is the concrete product built on that library: the virtual
-  workspace you actually work in. All application-integration bridges
-  (PTY/terminal, future VNC client, window capture) live here — the
-  engine must never gain native dependencies or application-domain
-  concepts.
+  workspace you actually work in. Application-integration bridges
+  (PTY/terminal, future VNC client, window capture) live here.
+  Exception to the "no native code in the engine" rule: XR glasses
+  head tracking (RayNeo IMU over hidraw, ~eu.svjatoslav.sixth.e3d.gui.headtrack~)
+  lives in the ENGINE, so every app and demo gets look-around head
+  tracking automatically when glasses are plugged in — including
+  hot-plug at runtime.
 + *Sixth 3D Demos* remains the capability gallery and regression
   harness. Workspace features are prototyped as demos, but the real
   thing lives in Sixth.
index 30ec7a2..e444a87 100644 (file)
@@ -110,7 +110,16 @@ public class PtySession {
                 emulator.accept(chunk, count);
                 final ContentListener listener = contentListener;
                 if (listener != null)
-                    listener.contentChanged();
+                    try {
+                        listener.contentChanged();
+                    } catch (final Throwable t) {
+                        // The renderer can briefly reallocate canvas
+                        // internals during a window resize, racing this
+                        // writer thread. A listener failure must never
+                        // kill the reader loop — the next output batch
+                        // repaints anyway.
+                        t.printStackTrace();
+                    }
             }
         } catch (final IOException e) {
             // PTY closed: shell exited or session stopped
index 64d0504..2e19b0c 100644 (file)
@@ -29,6 +29,12 @@ public class Main {
         if (args.length > 0 && "--selftest".equals(args[0]))
             System.exit(runSelfTest(workspace));
 
+        if (args.length > 0 && "--headtest".equals(args[0]))
+            System.exit(runHeadTest(workspace));
+
+        if (args.length > 0 && "--headdebug".equals(args[0]))
+            startHeadDebugLogger(workspace);
+
         Runtime.getRuntime().addShutdownHook(
                 new Thread(workspace::close));
     }
@@ -99,6 +105,77 @@ public class Main {
         return 0;
     }
 
+    /**
+     * Prints head tracker diagnostics every 2 seconds (frame flow, tick,
+     * dt, max gyro excursion, fused angles) so a live look-around test
+     * can be verified from the process log afterwards.
+     */
+    private static void startHeadDebugLogger(final Workspace workspace) {
+        final Thread logger = new Thread(() -> {
+            while (true) {
+                final var tracker = workspace.getHeadTracker();
+                System.out.println("HEADTRACK "
+                        + (tracker == null ? "no device"
+                                : tracker.getDebugString()));
+                try {
+                    Thread.sleep(2000);
+                } catch (final InterruptedException e) {
+                    return;
+                }
+            }
+        }, "head-debug-logger");
+        logger.setDaemon(true);
+        logger.start();
+    }
+
+    /**
+     * Prints head tracker and camera angles for 30 seconds so axis/sign
+     * conventions can be verified against real head movement. Exit 0 if
+     * the tracker produced data at all.
+     */
+    private static int runHeadTest(final Workspace workspace)
+            throws InterruptedException {
+        final var tracker = workspace.getHeadTracker();
+        if (tracker == null) {
+            System.out.println("HEADTEST FAIL: no glasses detected");
+            return 1;
+        }
+        // frame listeners do not run under xvfb (no render loop), so do
+        // the boot recenter here and compute what HeadLookController
+        // would apply, in-line
+        while (!tracker.isCalibrated())
+            Thread.sleep(50);
+        Thread.sleep(1000); // let the filter settle
+        tracker.recenter();
+        final var camera = workspace.getViewFrame().getViewPanel()
+                .getCamera();
+        final double[] baseAngles = camera.getTransform().getRotation()
+                .toAngles();
+        System.out.println("wait for calibration, then: turn head LEFT, "
+                + "watch yaw; nod DOWN, watch pitch (30s)");
+        final long deadline = System.currentTimeMillis() + 30_000;
+        boolean sawMovement = false;
+        while (System.currentTimeMillis() < deadline) {
+            final double headYaw = tracker.getLookYaw();
+            final double headPitch = tracker.getLookPitch();
+            // frame listeners do not run under xvfb (no render loop), so
+            // compute what HeadLookController would apply, in-line
+            final double camYaw = baseAngles[0] - headYaw;
+            final double camPitch = baseAngles[1] + headPitch;
+            if (Math.abs(headYaw) > 0.05 || Math.abs(headPitch) > 0.05)
+                sawMovement = true;
+            System.out.printf("head yaw %+7.1f pitch %+7.1f deg | "
+                            + "camera yaw %+7.3f pitch %+7.3f rad%n",
+                    Math.toDegrees(headYaw), Math.toDegrees(headPitch),
+                    camYaw, camPitch);
+            Thread.sleep(200);
+        }
+        workspace.close();
+        System.out.println(sawMovement ? "HEADTEST PASS (movement seen)"
+                : "HEADTEST FAIL (no head movement detected)");
+        return sawMovement ? 0 : 1;
+    }
+
     private static boolean waitFor(final TerminalPanel terminal,
                                    final String needle)
             throws InterruptedException {
index 71a9f2f..239690b 100644 (file)
@@ -21,8 +21,8 @@ import java.awt.event.KeyEvent;
  * {@link PtySession}) rendered onto a {@link TextCanvas}.
  *
  * <p>Click the panel to focus it; keystrokes are translated to terminal
- * input bytes and written to the PTY. ESC releases focus (engine focus
- * model, same as the text editor).</p>
+ * input bytes and written to the PTY. Shift+ESC releases focus (plain
+ * ESC goes to the shell — terminal programs need it).</p>
  *
  * <p>Terminal output arrives on the PTY reader thread, which copies the
  * screen buffer into the canvas and requests a repaint. This is the same
@@ -159,11 +159,14 @@ public class TerminalPanel extends GuiComponent {
     @Override
     public boolean keyPressed(final KeyEvent event,
                               final ViewPanel viewPanel) {
-        // Focus model: Ctrl+ESC releases focus (GuiComponent convention
+        // Focus model: Shift+ESC releases focus (GuiComponent convention
         // uses plain ESC, but terminal programs need ESC themselves —
-        // mc uses ESC for dialogs and ESC 0 for F10).
+        // mc uses ESC for dialogs and ESC 0 for F10. Terminal apps cannot
+        // distinguish Shift+ESC from plain ESC on the wire, so no program
+        // loses a binding; Ctrl+ESC was avoided because desktops commonly
+        // intercept it).
         if (event.getKeyChar() == '\u001B') {
-            if (event.isControlDown())
+            if (event.isShiftDown())
                 return super.keyPressed(event, viewPanel);
             session.send("\u001B");
             return true;
index d2376b6..d220892 100644 (file)
@@ -81,7 +81,7 @@ public class Workspace {
         editor.setText("Sixth workspace\n\n"
                 + "Click a panel to focus it.\n"
                 + "Type into it. ESC releases focus\n"
-                + "(in the terminal: Ctrl+ESC).\n\n"
+                + "(in the terminal: Shift+ESC).\n\n"
                 + "The panel on the right is a real\n"
                 + "bash shell running on a PTY.\n"
                 + "Try: ls, mc, htop");
@@ -104,6 +104,14 @@ public class Workspace {
         return terminalPanel;
     }
 
+    public eu.svjatoslav.sixth.e3d.gui.headtrack.HeadTracker getHeadTracker() {
+        return viewFrame.getViewPanel().getHeadTracker();
+    }
+
+    public ViewFrame getViewFrame() {
+        return viewFrame;
+    }
+
     /**
      * Shuts down background processes (the shell).
      */