--- /dev/null
+/*
+ * 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);
+ }
+}