From: Svjatoslav Agejenko Date: Sun, 20 Sep 2026 09:23:16 +0000 (+0300) Subject: Add CsgTest (9 tests) — first-ever coverage of the CSG/BSP machinery X-Git-Tag: aukio-3d-1.0.0~3 X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=ed0a7e9dd0c9d9d9e7b637c829484fe579ecbde0;p=aukio-3d.git Add CsgTest (9 tests) — first-ever coverage of the CSG/BSP machinery 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). --- 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 index 5eb8191..bf0eb5b 100644 --- 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 @@ -38,6 +38,11 @@ public final class Csg { */ public static List union(final List a, final List 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 subtract(final List a, final List 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 intersect(final List a, final List 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 index 0000000..786e033 --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/CsgTest.java @@ -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 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 A = box(0, 0, 0, 1, 1, 1); + private static final List B = box(0.5, 0.5, 0.5, 1.5, 1.5, 1.5); + private static final List FAR = box(10, 10, 10, 11, 11, 11); + + @Test + public void unionOfDisjointBoxesKeepsAllFaces() { + final List 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 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 result = Csg.subtract(A, FAR); + assertEquals(A.size(), result.size()); + } + + @Test + public void subtractLeavesNothingInsideTheCutter() { + final List 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 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 first = Csg.union(A, B); + final List second = Csg.union(A, B); + assertEquals(first.size(), second.size()); + for (int i = 0; i < first.size(); i++) { + final List v1 = first.get(i).vertices; + final List 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 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); + } +}