Add CsgTest (9 tests) — first-ever coverage of the CSG/BSP machinery
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 09:23:16 +0000 (12:23 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 09:23:16 +0000 (12:23 +0300)
Covers: union/subtract/intersect for disjoint+overlapping boxes
(interior-face and containment invariants), determinism (bit-exact
repeat runs), input immutability, and degenerate empty operands.

The test immediately caught a real defect: Csg.intersect with an empty
operand returned the FIRST operand unchanged (classic BSP sequence
degenerates: clips against an empty tree are no-ops and the inverts
cancel). Csg now guards empty operands explicitly in all three ops.
A naive polygon-count assertion in the test itself was also corrected
to the meaningful centroid invariant (BSP splits boundary faces,
making net counts meaningless).

src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/Csg.java
src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/CsgTest.java [new file with mode: 0644]

index 5eb8191..bf0eb5b 100644 (file)
@@ -38,6 +38,11 @@ public final class Csg {
      */
     public static List<SolidPolygon> union(final List<SolidPolygon> a,
                                            final List<SolidPolygon> b) {
+        // Degenerate operands: the BSP sequence handles these, but the
+        // short-circuit is explicit (and skips the tree builds).
+        if (a.isEmpty()) return clonePolygons(b);
+        if (b.isEmpty()) return clonePolygons(a);
+
         final BspTree selfTree = new BspTree(clonePolygons(a));
         final BspTree otherTree = new BspTree(clonePolygons(b));
 
@@ -72,6 +77,8 @@ public final class Csg {
      */
     public static List<SolidPolygon> subtract(final List<SolidPolygon> a,
                                               final List<SolidPolygon> b) {
+        if (a.isEmpty() || b.isEmpty()) return clonePolygons(a);
+
         final BspTree target = new BspTree(clonePolygons(a));
         final BspTree cutter = new BspTree(clonePolygons(b));
 
@@ -116,6 +123,12 @@ public final class Csg {
      */
     public static List<SolidPolygon> intersect(final List<SolidPolygon> a,
                                                final List<SolidPolygon> b) {
+        // Degenerate operand: the classic BSP sequence returns A here
+        // (an empty tree classifies nothing as inside, so every clip is
+        // a no-op and the inverts cancel out) — but the intersection
+        // with an empty volume IS empty. Guard explicitly.
+        if (a.isEmpty() || b.isEmpty()) return List.of();
+
         final BspTree selfTree = new BspTree(clonePolygons(a));
         final BspTree otherTree = new BspTree(clonePolygons(b));
 
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/CsgTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/CsgTest.java
new file mode 100644 (file)
index 0000000..786e033
--- /dev/null
@@ -0,0 +1,172 @@
+/*
+ * 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.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonRectangularBox;
+import org.junit.Test;
+
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Unit tests for the {@link Csg} boolean engine (BSP-based union /
+ * subtract / intersect over polygon lists). This machinery previously
+ * had zero coverage anywhere — no unit test, no golden scene.
+ */
+public class CsgTest {
+
+    private static final double EPS = 1e-9;
+
+    /** Axis-aligned box as a CSG-ready polygon list. */
+    private static List<SolidPolygon> box(final double x1, final double y1,
+                                          final double z1, final double x2,
+                                          final double y2, final double z2) {
+        return new SolidPolygonRectangularBox(
+                new Point3D(x1, y1, z1), new Point3D(x2, y2, z2), Color.RED)
+                .extractSolidPolygons();
+    }
+
+    private static double[] centroid(final SolidPolygon polygon) {
+        double cx = 0, cy = 0, cz = 0;
+        for (final Vertex v : polygon.vertices) {
+            cx += v.coordinate.x;
+            cy += v.coordinate.y;
+            cz += v.coordinate.z;
+        }
+        final int n = polygon.vertices.size();
+        return new double[]{cx / n, cy / n, cz / n};
+    }
+
+    /** Strictly inside the box (boundary does NOT count). */
+    private static boolean strictlyInside(final double[] point,
+                                          final double x1, final double y1,
+                                          final double z1, final double x2,
+                                          final double y2, final double z2) {
+        return point[0] > x1 + EPS && point[0] < x2 - EPS
+                && point[1] > y1 + EPS && point[1] < y2 - EPS
+                && point[2] > z1 + EPS && point[2] < z2 - EPS;
+    }
+
+    /** Inside or on the box boundary. */
+    private static boolean insideOrOn(final double x, final double y,
+                                      final double z,
+                                      final double x1, final double y1,
+                                      final double z1, final double x2,
+                                      final double y2, final double z2) {
+        return x >= x1 - EPS && x <= x2 + EPS
+                && y >= y1 - EPS && y <= y2 + EPS
+                && z >= z1 - EPS && z <= z2 + EPS;
+    }
+
+    private static final List<SolidPolygon> A = box(0, 0, 0, 1, 1, 1);
+    private static final List<SolidPolygon> B = box(0.5, 0.5, 0.5, 1.5, 1.5, 1.5);
+    private static final List<SolidPolygon> FAR = box(10, 10, 10, 11, 11, 11);
+
+    @Test
+    public void unionOfDisjointBoxesKeepsAllFaces() {
+        final List<SolidPolygon> result = Csg.union(A, FAR);
+        assertEquals("6 + 6 faces, no interior to remove",
+                A.size() + FAR.size(), result.size());
+    }
+
+    @Test
+    public void unionOfOverlappingBoxesRemovesInteriorFaces() {
+        final List<SolidPolygon> result = Csg.union(A, B);
+        assertTrue("union of two boxes must produce geometry", !result.isEmpty());
+        // Note: no polygon-COUNT assertion — BSP splits boundary-crossing
+        // faces (raising the count) while removing interior fragments
+        // (lowering it); the net count says nothing. The interior-face
+        // invariant below is the meaningful one.
+        for (final SolidPolygon polygon : result) {
+            final double[] c = centroid(polygon);
+            assertTrue("union must not contain a face strictly inside A",
+                    !strictlyInside(c, 0, 0, 0, 1, 1, 1));
+            assertTrue("union must not contain a face strictly inside B",
+                    !strictlyInside(c, 0.5, 0.5, 0.5, 1.5, 1.5, 1.5));
+        }
+    }
+
+    @Test
+    public void subtractOfDisjointBoxIsIdentity() {
+        final List<SolidPolygon> result = Csg.subtract(A, FAR);
+        assertEquals(A.size(), result.size());
+    }
+
+    @Test
+    public void subtractLeavesNothingInsideTheCutter() {
+        final List<SolidPolygon> result = Csg.subtract(A, B);
+        assertTrue("carving a corner out of a box must leave geometry",
+                !result.isEmpty());
+        for (final SolidPolygon polygon : result) {
+            final double[] c = centroid(polygon);
+            assertTrue("difference must not contain faces strictly inside the cutter",
+                    !strictlyInside(c, 0.5, 0.5, 0.5, 1.5, 1.5, 1.5));
+        }
+    }
+
+    @Test
+    public void intersectOfOverlappingBoxesIsTheOverlapRegion() {
+        final List<SolidPolygon> result = Csg.intersect(A, B);
+        assertTrue("overlap of [0,1]³ and [0.5,1.5]³ must be non-empty",
+                !result.isEmpty());
+        for (final SolidPolygon polygon : result)
+            for (final Vertex v : polygon.vertices) {
+                assertTrue("every vertex must lie inside-or-on A",
+                        insideOrOn(v.coordinate.x, v.coordinate.y, v.coordinate.z,
+                                0, 0, 0, 1, 1, 1));
+                assertTrue("every vertex must lie inside-or-on B",
+                        insideOrOn(v.coordinate.x, v.coordinate.y, v.coordinate.z,
+                                0.5, 0.5, 0.5, 1.5, 1.5, 1.5));
+            }
+    }
+
+    @Test
+    public void intersectOfDisjointBoxesIsEmpty() {
+        assertTrue(Csg.intersect(A, FAR).isEmpty());
+    }
+
+    @Test
+    public void emptyOperandBehaves() {
+        assertEquals(A.size(), Csg.union(A, List.of()).size());
+        assertEquals(A.size(), Csg.subtract(A, List.of()).size());
+        assertTrue(Csg.intersect(A, List.of()).isEmpty());
+    }
+
+    @Test
+    public void resultsAreDeterministic() {
+        final List<SolidPolygon> first = Csg.union(A, B);
+        final List<SolidPolygon> second = Csg.union(A, B);
+        assertEquals(first.size(), second.size());
+        for (int i = 0; i < first.size(); i++) {
+            final List<Vertex> v1 = first.get(i).vertices;
+            final List<Vertex> v2 = second.get(i).vertices;
+            assertEquals(v1.size(), v2.size());
+            for (int j = 0; j < v1.size(); j++) {
+                assertEquals(v1.get(j).coordinate.x, v2.get(j).coordinate.x, 0.0);
+                assertEquals(v1.get(j).coordinate.y, v2.get(j).coordinate.y, 0.0);
+                assertEquals(v1.get(j).coordinate.z, v2.get(j).coordinate.z, 0.0);
+            }
+        }
+    }
+
+    /** The inputs are never mutated (Csg clones before BSP-ing). */
+    @Test
+    public void inputsAreNotMutated() {
+        final List<SolidPolygon> a = box(0, 0, 0, 1, 1, 1);
+        final double firstX = a.get(0).vertices.get(0).coordinate.x;
+        final int size = a.size();
+        Csg.subtract(a, B);
+        Csg.union(a, B);
+        Csg.intersect(a, B);
+        assertEquals(size, a.size());
+        assertEquals(firstX, a.get(0).vertices.get(0).coordinate.x, 0.0);
+    }
+}