Extract CSG engine + device hotplug; resolve blend-formula question
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 09:05:55 +0000 (12:05 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 09:05:55 +0000 (12:05 +0300)
- New Csg utility (pure polygon-list functions: union/subtract/intersect
  over BSP clip/invert sequences). AbstractCompositeShape's three CSG
  methods are now one-line delegates; clonePolygons moved into Csg.
  Composite drops 95 lines and its dependency on BspTree.
- New gui/DeviceHotplug: head-tracker + SpaceMouse hot-plug managers,
  extracted from ViewPanel (which now holds a single field and two
  delegating getters). ViewPanel loses its HeadTrackingManager/
  SpaceMouseManager lifecycle code.
- Blend formulas: PROVEN bit-identical. SolidPolygon's
  ((255-a)*dest + a*src)>>8 and TexturedTriangle's lerp form
  dest + ((a*(src-dest) - dest)>>8) agree for all 16.6M (a,src,dest)
  inputs (exhaustive check). Documented at both span writers; no change.
- transformChildrenParallel's aggregator param: NOT dead as the review
  claimed — it feeds the serial-fallback paths. Restored with accurate
  javadoc (was nearly deleted on the review's say-so; the build caught
  it before commit).

Deliberately NOT split: ViewPanel's render-loop core (mailbox, present
gates, pending paints) and AbstractCompositeShape's parallel-transform
planner — both are concurrency-critical and deliberate; extraction
trades cohesion for race-condition risk with zero functional gain.

src/main/java/eu/svjatoslav/aukio/e3d/gui/DeviceHotplug.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/Csg.java [new file with mode: 0644]

diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeviceHotplug.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeviceHotplug.java
new file mode 100644 (file)
index 0000000..ab8c26d
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTracker;
+import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTrackingManager;
+import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceMouseManager;
+import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceNavigatorHid;
+
+/**
+ * Optional input-device hot-plug for a {@link ViewPanel}: starts the
+ * RayNeo head-tracking manager and the SpaceNavigator 6DOF-mouse manager
+ * (each auto-detects its device even when plugged in after startup), and
+ * exposes the currently connected device, if any.
+ *
+ * <p>Extracted from ViewPanel to keep the panel focused on the render
+ * pipeline. Either device can be disabled with
+ * {@code -De3d.headtrack=false} / {@code -De3d.spacemouse=false}.</p>
+ */
+final class DeviceHotplug {
+
+    /** Head tracker hot-plug manager, unless disabled via e3d.headtrack=false. */
+    private HeadTrackingManager headTrackingManager;
+
+    /** SpaceNavigator hot-plug manager, unless disabled via e3d.spacemouse=false. */
+    private SpaceMouseManager spaceMouseManager;
+
+    /**
+     * Starts both hot-plug managers for the given panel (respecting the
+     * disable properties).
+     *
+     * @param viewPanel the panel whose camera the devices will drive
+     */
+    DeviceHotplug(final ViewPanel viewPanel) {
+        if (!"false".equalsIgnoreCase(
+                System.getProperty("e3d.headtrack", "true"))) {
+            headTrackingManager = new HeadTrackingManager(viewPanel);
+            headTrackingManager.start();
+        }
+        if (!"false".equalsIgnoreCase(
+                System.getProperty("e3d.spacemouse", "true"))) {
+            spaceMouseManager = new SpaceMouseManager(viewPanel);
+            spaceMouseManager.start();
+        }
+    }
+
+    /**
+     * Returns the active SpaceNavigator device, or null when no 6DOF
+     * mouse is currently connected.
+     */
+    SpaceNavigatorHid getSpaceMouse() {
+        return spaceMouseManager == null ? null
+                : spaceMouseManager.getDevice();
+    }
+
+    /**
+     * Returns the active head tracker, or null when no glasses are
+     * currently connected.
+     */
+    HeadTracker getHeadTracker() {
+        return headTrackingManager == null ? null
+                : headTrackingManager.getTracker();
+    }
+
+    /** Stops both hot-plug managers; safe to call more than once. */
+    void stop() {
+        if (headTrackingManager != null) {
+            headTrackingManager.stop();
+            headTrackingManager = null;
+        }
+        if (spaceMouseManager != null) {
+            spaceMouseManager.stop();
+            spaceMouseManager = null;
+        }
+    }
+}
index 0a69725..04e9e61 100755 (executable)
@@ -16,9 +16,7 @@ import eu.svjatoslav.aukio.e3d.diag.Telemetry;
 import eu.svjatoslav.aukio.e3d.geometry.Point3D;
 import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadLookController;
 import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTracker;
-import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTrackingManager;
 import eu.svjatoslav.aukio.e3d.gui.headtrack.RayNeoHid;
-import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceMouseManager;
 import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceNavigatorHid;
 import eu.svjatoslav.aukio.e3d.gui.humaninput.InputManager;
 import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack;
@@ -103,11 +101,8 @@ public class ViewPanel extends Canvas {
     private final KeyboardFocusStack keyboardFocusStack;
     /** The camera representing the viewer's position and orientation. */
     private final Camera camera = new Camera();
-    /** Head tracker hot-plug manager, unless disabled via e3d.headtrack=false. */
-    private HeadTrackingManager headTrackingManager;
-
-    /** SpaceNavigator hot-plug manager, unless disabled via e3d.spacemouse=false. */
-    private SpaceMouseManager spaceMouseManager;
+    /** Optional input devices (head tracker, 6DOF mouse) with hot-plug. */
+    private DeviceHotplug deviceHotplug;
     /** The root shape collection containing all 3D shapes in the scene. */
     private final ShapeCollection rootShapeCollection = new ShapeCollection();
     /** The set of frame listeners notified before each frame. */
@@ -337,8 +332,7 @@ public class ViewPanel extends Canvas {
         keyboardFocusStack = new KeyboardFocusStack(this);
 
         initializeCanvas();
-        initializeHeadTracking();
-        initializeSpaceMouse();
+        deviceHotplug = new DeviceHotplug(this);
 
         // Set default ambient light for the scene
         lightingManager.setAmbientLight(new Color(50, 50, 50));
@@ -1322,40 +1316,12 @@ public class ViewPanel extends Canvas {
         return transformExecutor;
     }
 
-    /**
-     * Starts the head tracking hot-plug manager: RayNeo glasses are
-     * detected when plugged in (even after startup) and head look-around
-     * is enabled automatically. Disable with {@code -De3d.headtrack=false}.
-     */
-    private void initializeHeadTracking() {
-        if ("false".equalsIgnoreCase(
-                System.getProperty("e3d.headtrack", "true")))
-            return;
-        headTrackingManager = new HeadTrackingManager(this);
-        headTrackingManager.start();
-    }
-
-    /**
-     * Starts the SpaceNavigator hot-plug manager: the 6DOF mouse is
-     * detected when plugged in (even after startup) and cap deflection
-     * drives the camera automatically. Disable with
-     * {@code -De3d.spacemouse=false}.
-     */
-    private void initializeSpaceMouse() {
-        if ("false".equalsIgnoreCase(
-                System.getProperty("e3d.spacemouse", "true")))
-            return;
-        spaceMouseManager = new SpaceMouseManager(this);
-        spaceMouseManager.start();
-    }
-
     /**
      * Returns the active SpaceNavigator device, or null when no 6DOF
      * mouse is currently connected.
      */
     public SpaceNavigatorHid getSpaceMouse() {
-        return spaceMouseManager == null ? null
-                : spaceMouseManager.getDevice();
+        return deviceHotplug == null ? null : deviceHotplug.getSpaceMouse();
     }
 
     /**
@@ -1363,21 +1329,16 @@ public class ViewPanel extends Canvas {
      * currently connected.
      */
     public HeadTracker getHeadTracker() {
-        return headTrackingManager == null ? null
-                : headTrackingManager.getTracker();
+        return deviceHotplug == null ? null : deviceHotplug.getHeadTracker();
     }
 
     /**
      * Stops rendering of this view.
      */
     public void stop() {
-        if (headTrackingManager != null) {
-            headTrackingManager.stop();
-            headTrackingManager = null;
-        }
-        if (spaceMouseManager != null) {
-            spaceMouseManager.stop();
-            spaceMouseManager = null;
+        if (deviceHotplug != null) {
+            deviceHotplug.stop();
+            deviceHotplug = null;
         }
         renderThreadRunning = false;
         presentThreadRunning = false;
index 313b66b..3a3ca1d 100644 (file)
@@ -366,6 +366,10 @@ public class SolidPolygon extends AbstractCoordinateShape {
             final int greenWithAlpha = g * polygonAlpha;
             final int blueWithAlpha = b * polygonAlpha;
 
+            // Blend form ((255-a)*dest + a*src) >> 8. Proven bit-identical
+            // to TexturedTriangle's lerp form dest + ((a*(src-dest) - dest)
+            // >> 8) for every (a,src,dest) — do NOT "fix" one to match the
+            // other cosmetically; both are the same formula.
             for (int i = 0; i < width; i++) {
                 if (zw > depth[offset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
                     final int dest = pixels[offset];
index 76f4b29..643a930 100644 (file)
@@ -377,7 +377,11 @@ public class TexturedTriangle extends AbstractCoordinateShape {
                             depth[renderBufferOffset] = (float) zw;
                     } else if (srcAlpha != 0) {
                         // Translucent: blend, but do NOT write depth —
-                        // translucency must not occlude later fragments
+                        // translucency must not occlude later fragments.
+                        // Lerp form dest + ((a*(src-dest) - dest) >> 8):
+                        // algebraically ((255-a)*dest + a*src) >> 8 — proven
+                        // bit-identical to SolidPolygon's form for all
+                        // inputs, so the two span writers blend the same.
                         final int destPixel = renderBufferPixels[renderBufferOffset];
                         final int destR = (destPixel >> 16) & 0xff;
                         final int destG = (destPixel >> 8) & 0xff;
index fa0ca38..776d21b 100644 (file)
@@ -546,29 +546,8 @@ public class AbstractCompositeShape extends AbstractShape {
      * @see #intersect(AbstractCompositeShape)
      */
     public void union(final AbstractCompositeShape other) {
-
-        final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
-        final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
-
-        // Remove from self any polygons that are inside other (interior faces)
-        selfTree.clipTo(otherTree);
-
-        // Remove from other any polygons that are inside self (interior faces)
-        otherTree.clipTo(selfTree);
-
-        // Invert other to convert remaining polygons for the next clip step
-        otherTree.invert();
-
-        // Clip inverted other against self to remove back-facing coplanar polygons
-        otherTree.clipTo(selfTree);
-
-        // Invert back to restore correct polygon orientation
-        otherTree.invert();
-
-        // Merge other's remaining polygons into self's BSP tree
-        selfTree.addPolygons(otherTree.allPolygons());
-
-        replaceSolidPolygons(selfTree.allPolygons());
+        replaceSolidPolygons(Csg.union(extractSolidPolygons(),
+                other.extractSolidPolygons()));
         mergeNonPolygonChildrenFrom(other);
     }
 
@@ -594,39 +573,8 @@ public class AbstractCompositeShape extends AbstractShape {
      * @see #intersect(AbstractCompositeShape)
      */
     public void subtract(final AbstractCompositeShape other) {
-
-        final BspTree target = new BspTree(clonePolygons(extractSolidPolygons()));
-        final BspTree cutter = new BspTree(clonePolygons(other.extractSolidPolygons()));
-
-        // Invert target: convert "inside" to "outside" and vice versa
-        // This transforms the problem from "subtract B from A" to "intersect A's complement with B's complement"
-        target.invert();
-
-        // Clip target against cutter: removes parts of target that are INSIDE the cutter
-        // Since target is inverted, this removes parts that were OUTSIDE the original target
-        target.clipTo(cutter);
-
-        // Clip cutter against (inverted) target: removes parts of cutter outside the inverted target
-        // This keeps only cutter polygons that are inside the inverted target = outside original target
-        cutter.clipTo(target);
-
-        // Invert cutter to flip its inside/outside
-        cutter.invert();
-
-        // Clip inverted cutter against target: removes coplanar back-faces
-        cutter.clipTo(target);
-
-        // Invert cutter back to correct orientation
-        cutter.invert();
-
-        // Merge cutter's polygons into target's BSP tree
-        target.addPolygons(cutter.allPolygons());
-
-        // Invert target back to restore correct inside/outside orientation
-        // Result: the carved-out volume (target minus cutter)
-        target.invert();
-
-        replaceSolidPolygons(target.allPolygons());
+        replaceSolidPolygons(Csg.subtract(extractSolidPolygons(),
+                other.extractSolidPolygons()));
     }
 
     /**
@@ -651,51 +599,8 @@ public class AbstractCompositeShape extends AbstractShape {
      * @see #subtract(AbstractCompositeShape)
      */
     public void intersect(final AbstractCompositeShape other) {
-
-        final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
-        final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
-
-        // Invert self to convert "inside" to "outside"
-        // This transforms intersection into: keep parts that are "outside both inverted shapes"
-        selfTree.invert();
-
-        // Clip other against inverted self: keeps only parts of other that are INSIDE original self
-        // (because clipTo removes what's "outside" the BSP, and inverted self's "outside" = original self's "inside")
-        otherTree.clipTo(selfTree);
-
-        // Invert other (which now represents the intersection region)
-        otherTree.invert();
-
-        // Clip inverted self against (inverted intersection): removes parts outside the intersection
-        selfTree.clipTo(otherTree);
-
-        // Clip intersection result against inverted self: removes back-facing coplanar polygons
-        otherTree.clipTo(selfTree);
-
-        // Build final BSP tree from the clipped intersection polygons
-        selfTree.addPolygons(otherTree.allPolygons());
-
-        // Invert back to restore correct inside/outside orientation
-        selfTree.invert();
-
-        replaceSolidPolygons(selfTree.allPolygons());
-    }
-
-    /**
-     * Creates deep clones of all polygons in the list.
-     *
-     * <p>CSG operations modify polygons in-place via BSP tree operations.
-     * Cloning ensures the original polygon data is preserved.</p>
-     *
-     * @param polygons the polygons to clone
-     * @return a new list containing deep clones of all polygons
-     */
-    private List<SolidPolygon> clonePolygons(final List<SolidPolygon> polygons) {
-        final List<SolidPolygon> cloned = new ArrayList<>(polygons.size());
-        for (final SolidPolygon p : polygons) {
-            cloned.add(p.deepClone());
-        }
-        return cloned;
+        replaceSolidPolygons(Csg.intersect(extractSolidPolygons(),
+                other.extractSolidPolygons()));
     }
 
     /**
@@ -1184,8 +1089,12 @@ public class AbstractCompositeShape extends AbstractShape {
      * value.</p>
      *
      * @param transformPipe the transform stack (includes this composite's transform)
-     * @param aggregator    unused in the parallel path: per-task aggregators
-     *                      are merged by the coordinator's drain
+     * @param aggregator    the caller's aggregator, used ONLY when the fork
+     *                      bails out (too few chunks, or the frame task
+     *                      budget is exhausted) and the children fall back
+     *                      to a serial inline transform. The parallel fork
+     *                      itself queues into per-task aggregators merged
+     *                      by the coordinator's drain.
      * @param context       the rendering context (provides the coordinator)
      */
     private void transformChildrenParallel(final TransformStack transformPipe,
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/Csg.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/Csg.java
new file mode 100644 (file)
index 0000000..5eb8191
--- /dev/null
@@ -0,0 +1,160 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Pure CSG (Constructive Solid Geometry) boolean engine: union, subtract
+ * and intersect over lists of {@link SolidPolygon}s, built on BSP tree
+ * clip/invert sequences.
+ *
+ * <p>These are pure functions — they take polygon lists and return new
+ * polygon lists, touching no shape state. {@link AbstractCompositeShape}'s
+ * instance methods ({@code union/subtract/intersect}) delegate here and
+ * handle the child-registry bookkeeping themselves.</p>
+ *
+ * <p>All operations clone their inputs first (BSP operations mutate
+ * polygons in place), so caller lists are never modified.</p>
+ */
+public final class Csg {
+
+    private Csg() {
+        // utility class
+    }
+
+    /**
+     * Union of two polygon sets: every surface of both, interior faces
+     * removed.
+     *
+     * @param a first operand's polygons (not modified)
+     * @param b second operand's polygons (not modified)
+     * @return the union result polygons
+     */
+    public static List<SolidPolygon> union(final List<SolidPolygon> a,
+                                           final List<SolidPolygon> b) {
+        final BspTree selfTree = new BspTree(clonePolygons(a));
+        final BspTree otherTree = new BspTree(clonePolygons(b));
+
+        // Remove from self any polygons that are inside other (interior faces)
+        selfTree.clipTo(otherTree);
+
+        // Remove from other any polygons that are inside self (interior faces)
+        otherTree.clipTo(selfTree);
+
+        // Invert other to convert remaining polygons for the next clip step
+        otherTree.invert();
+
+        // Clip inverted other against self to remove back-facing coplanar polygons
+        otherTree.clipTo(selfTree);
+
+        // Invert back to restore correct polygon orientation
+        otherTree.invert();
+
+        // Merge other's remaining polygons into self's BSP tree
+        selfTree.addPolygons(otherTree.allPolygons());
+
+        return selfTree.allPolygons();
+    }
+
+    /**
+     * Subtraction {@code a - b}: the cutter volume carved out of the
+     * target.
+     *
+     * @param a target polygons (not modified)
+     * @param b cutter polygons (not modified)
+     * @return the difference result polygons
+     */
+    public static List<SolidPolygon> subtract(final List<SolidPolygon> a,
+                                              final List<SolidPolygon> b) {
+        final BspTree target = new BspTree(clonePolygons(a));
+        final BspTree cutter = new BspTree(clonePolygons(b));
+
+        // Invert target: convert "inside" to "outside" and vice versa
+        // This transforms the problem from "subtract B from A" to "intersect A's complement with B's complement"
+        target.invert();
+
+        // Clip target against cutter: removes parts of target that are INSIDE the cutter
+        // Since target is inverted, this removes parts that were OUTSIDE the original target
+        target.clipTo(cutter);
+
+        // Clip cutter against (inverted) target: removes parts of cutter outside the inverted target
+        // This keeps only cutter polygons that are inside the inverted target = outside original target
+        cutter.clipTo(target);
+
+        // Invert cutter to flip its inside/outside
+        cutter.invert();
+
+        // Clip inverted cutter against target: removes coplanar back-faces
+        cutter.clipTo(target);
+
+        // Invert cutter back to correct orientation
+        cutter.invert();
+
+        // Merge cutter's polygons into target's BSP tree
+        target.addPolygons(cutter.allPolygons());
+
+        // Invert target back to restore correct inside/outside orientation
+        // Result: the carved-out volume (target minus cutter)
+        target.invert();
+
+        return target.allPolygons();
+    }
+
+    /**
+     * Intersection of two polygon sets: only the overlapping volume
+     * remains.
+     *
+     * @param a first operand's polygons (not modified)
+     * @param b second operand's polygons (not modified)
+     * @return the intersection result polygons
+     */
+    public static List<SolidPolygon> intersect(final List<SolidPolygon> a,
+                                               final List<SolidPolygon> b) {
+        final BspTree selfTree = new BspTree(clonePolygons(a));
+        final BspTree otherTree = new BspTree(clonePolygons(b));
+
+        // Invert self to convert "inside" to "outside"
+        // This transforms intersection into: keep parts that are "outside both inverted shapes"
+        selfTree.invert();
+
+        // Clip other against inverted self: keeps only parts of other that are INSIDE original self
+        // (because clipTo removes what's "outside" the BSP, and inverted self's "outside" = original self's "inside")
+        otherTree.clipTo(selfTree);
+
+        // Invert other (which now represents the intersection region)
+        otherTree.invert();
+
+        // Clip inverted self against (inverted intersection): removes parts outside the intersection
+        selfTree.clipTo(otherTree);
+
+        // Clip intersection result against inverted self: removes back-facing coplanar polygons
+        otherTree.clipTo(selfTree);
+
+        // Build final BSP tree from the clipped intersection polygons
+        selfTree.addPolygons(otherTree.allPolygons());
+
+        // Invert back to restore correct inside/outside orientation
+        selfTree.invert();
+
+        return selfTree.allPolygons();
+    }
+
+    /**
+     * Deep clones of all polygons in the list: CSG operations modify
+     * polygons in-place via BSP tree operations, cloning preserves the
+     * originals.
+     */
+    private static List<SolidPolygon> clonePolygons(final List<SolidPolygon> polygons) {
+        final List<SolidPolygon> cloned = new ArrayList<>(polygons.size());
+        for (final SolidPolygon p : polygons) {
+            cloned.add(p.deepClone());
+        }
+        return cloned;
+    }
+}