feat(csg): add constructive solid geometry with BSP tree boolean operations
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 28 Mar 2026 12:47:12 +0000 (14:47 +0200)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 28 Mar 2026 12:47:12 +0000 (14:47 +0200)
Add a complete CSG system that enables boolean operations (union, subtract,
intersect) on 3D solids using BSP trees. The implementation includes:

- CSG class with union/subtract/intersect/inverse operations
- CSGNode BSP tree for spatial partitioning and polygon clipping
- CSGPlane for polygon classification and splitting
- CSGPolygon N-gon type for BSP operations
- PolygonType enum for vertex classification

Integration with existing shapes via:
- extractSolidPolygons() method on AbstractCompositeShape
- SolidPolygonMesh composite for rendering CSG results
- CSG.fromCompositeShape() factory for converting existing primitives

Also includes supporting refactorings: rename coordinates field to vertices
for consistency, simplified constructors for arrow shapes with auto-calculated
tip dimensions, and Point3D/Vertex enhancements for vector math.

21 files changed:
TODO.org
doc/Axis.png [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/e3d/csg/CSG.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/e3d/csg/CSGNode.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/e3d/csg/CSGPlane.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/e3d/csg/CSGPolygon.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/e3d/csg/PolygonType.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/e3d/geometry/Point3D.java
src/main/java/eu/svjatoslav/sixth/e3d/math/Vertex.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractCoordinateShape.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/Billboard.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/Line.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedPolygon.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/CanvasCharacter.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java
src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/slicer/Slicer.java

index 2327fa0..c904218 100644 (file)
--- a/TODO.org
+++ b/TODO.org
@@ -16,6 +16,8 @@ sources.
 Make dedicated tutorial about shading algorithm with screenshot and
 what are available parameters.
 
+** Document boolean operations
+
 * Add 3D mouse support
 :PROPERTIES:
 :CUSTOM_ID: add-3d-mouse-support
@@ -29,6 +31,7 @@ what are available parameters.
 :PROPERTIES:
 :CUSTOM_ID: add-more-math-formula-examples
 :END:
+
 ** Allow manual thread count specification in performance test demo
 :PROPERTIES:
 :CUSTOM_ID: allow-manual-thread-count-specification
@@ -80,6 +83,7 @@ the sweet spot.
 :PROPERTIES:
 :CUSTOM_ID: add-polygon-reduction-lod
 :END:
+
 ** Add object fading based on view distance
 :PROPERTIES:
 :CUSTOM_ID: add-object-fading-view-distance
@@ -90,6 +94,7 @@ Goal: make it easier to distinguish nearby objects from distant ones.
 :PROPERTIES:
 :CUSTOM_ID: add-csg-support
 :END:
+
 ** Add shadow casting
 :PROPERTIES:
 :CUSTOM_ID: add-shadow-casting
@@ -118,10 +123,12 @@ shadows.
   image resolution if needed to maintain desired FPS.
 
 ** Explore possibility for implementing better perspective correct textured polygons
-** Add arrow shape: cone + cylinder
+
 ** Add X, Y, Z axis indicators
 Will use different colored arrows + text label
 
+** Add collision detection (physics engine)
+
 * Add clickable vertexes
 :PROPERTIES:
 :CUSTOM_ID: add-clickable-vertexes
@@ -148,8 +155,3 @@ Add formula textbox display on top of 3D graph.
   http://blog.rogach.org/2015/08/how-to-create-your-own-simple-3d-render.html
 
 + Improve triangulation. Read: https://ianthehenry.com/posts/delaunay/
-
-** Fix camera rotation for voxel raytracer
-:PROPERTIES:
-:CUSTOM_ID: fix-camera-rotation-voxel-raytracer
-:END:
diff --git a/doc/Axis.png b/doc/Axis.png
new file mode 100644 (file)
index 0000000..d028e61
Binary files /dev/null and b/doc/Axis.png differ
diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/csg/CSG.java b/src/main/java/eu/svjatoslav/sixth/e3d/csg/CSG.java
new file mode 100644 (file)
index 0000000..2d26a98
--- /dev/null
@@ -0,0 +1,368 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.csg;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonMesh;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a solid for Constructive Solid Geometry (CSG) operations.
+ *
+ * <p>CSG allows combining 3D shapes using boolean operations:</p>
+ * <ul>
+ *   <li><b>Union:</b> Combine two shapes into one</li>
+ *   <li><b>Subtract:</b> Carve one shape out of another</li>
+ *   <li><b>Intersect:</b> Keep only the overlapping volume</li>
+ * </ul>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create shapes from existing composite shapes
+ * SolidPolygonCube cube = new SolidPolygonCube(new Point3D(0, 0, 0), 80, Color.RED);
+ * SolidPolygonSphere sphere = new SolidPolygonSphere(new Point3D(0, 0, 0), 96, 12, Color.BLUE);
+ *
+ * // Convert to CSG solids
+ * CSG cubeCSG = CSG.fromCompositeShape(cube);
+ * CSG sphereCSG = CSG.fromCompositeShape(sphere);
+ *
+ * // Perform boolean operation
+ * CSG result = cubeCSG.subtract(sphereCSG);
+ *
+ * // Render the result
+ * SolidPolygonMesh mesh = result.toMesh(new Color(255, 100, 100), new Point3D(0, 0, 0));
+ * shapes.addShape(mesh);
+ * }</pre>
+ *
+ * @see CSGNode the BSP tree node used internally
+ * @see CSGPolygon the N-gon polygon type used for BSP operations
+ * @see SolidPolygonMesh the renderable mesh created from CSG results
+ */
+public class CSG {
+
+    /**
+     * The list of polygons that make up this solid.
+     */
+    public final List<CSGPolygon> polygons = new ArrayList<>();
+
+    /**
+     * Creates an empty CSG solid.
+     */
+    public CSG() {
+    }
+
+    /**
+     * Creates a CSG solid from a list of CSG polygons.
+     *
+     * @param polygonList the polygons to include
+     * @return a new CSG solid
+     */
+    public static CSG fromPolygons(final List<CSGPolygon> polygonList) {
+        final CSG csg = new CSG();
+        csg.polygons.addAll(polygonList);
+        return csg;
+    }
+
+    /**
+     * Creates a CSG solid from a list of SolidPolygon triangles.
+     *
+     * <p>Each SolidPolygon is converted to a CSGPolygon (3-vertex N-gon).
+     * The color from each SolidPolygon is preserved.</p>
+     *
+     * @param solidPolygons the triangles to convert
+     * @return a new CSG solid
+     */
+    public static CSG fromSolidPolygons(final List<SolidPolygon> solidPolygons) {
+        final List<CSGPolygon> csgPolygons = new ArrayList<>(solidPolygons.size());
+
+        for (final SolidPolygon sp : solidPolygons) {
+            final List<Vertex> vertices = new ArrayList<>(3);
+            for (int i = 0; i < 3; i++) {
+                final Vertex v = new Vertex(sp.vertices[i].coordinate);
+                v.normal = sp.vertices[i].normal;
+                vertices.add(v);
+            }
+
+            final CSGPolygon csgPoly = new CSGPolygon(vertices, sp.getColor());
+            csgPolygons.add(csgPoly);
+        }
+
+        return fromPolygons(csgPolygons);
+    }
+
+    /**
+     * Creates a CSG solid from a composite shape.
+     *
+     * <p>Extracts all SolidPolygon triangles from the composite shape
+     * and converts them to CSGPolygons. This allows using shapes like
+     * {@code SolidPolygonCube}, {@code SolidPolygonSphere}, etc. with CSG operations.</p>
+     *
+     * @param shape the composite shape to convert
+     * @return a new CSG solid containing all triangles from the shape
+     */
+    public static CSG fromCompositeShape(final AbstractCompositeShape shape) {
+        return fromSolidPolygons(shape.extractSolidPolygons());
+    }
+
+    /**
+     * Creates a deep clone of this CSG solid.
+     *
+     * @return a new CSG solid with cloned polygons
+     */
+    public CSG clone() {
+        final CSG csg = new CSG();
+        for (final CSGPolygon p : polygons) {
+            csg.polygons.add(p.clone());
+        }
+        return csg;
+    }
+
+    /**
+     * Returns the list of polygons in this solid.
+     *
+     * @return the polygon list
+     */
+    public List<CSGPolygon> toPolygons() {
+        return polygons;
+    }
+
+    /**
+     * Performs a union operation with another CSG solid.
+     *
+     * <p>The result contains all points that are in either solid.</p>
+     *
+     * <h3>Algorithm:</h3>
+     * <pre>
+     * Union(A, B) = clip(A to outside B) + clip(B to outside A)
+     * </pre>
+     * <ol>
+     *   <li>Clip A's polygons to keep only parts outside B</li>
+     *   <li>Clip B's polygons to keep only parts outside A</li>
+     *   <li>Invert B, clip to A, invert again (keeps B's surface inside A)</li>
+     *   <li>Build final tree from all remaining polygons</li>
+     * </ol>
+     *
+     * @param csg the other solid to union with
+     * @return a new CSG solid representing the union
+     */
+    public CSG union(final CSG csg) {
+        // Create BSP trees from both solids
+        final CSGNode a = new CSGNode(clone().polygons);
+        final CSGNode b = new CSGNode(csg.clone().polygons);
+
+        // Remove from A any parts that are inside B
+        a.clipTo(b);
+
+        // Remove from B any parts that are inside A
+        b.clipTo(a);
+
+        // Invert B temporarily to capture B's interior surface that touches A
+        b.invert();
+        b.clipTo(a);
+        b.invert();
+
+        // Combine all polygons into A's tree
+        a.build(b.allPolygons());
+
+        return CSG.fromPolygons(a.allPolygons());
+    }
+
+    /**
+     * Performs a subtraction operation with another CSG solid.
+     *
+     * <p>The result contains all points that are in this solid but not in the other.
+     * This effectively carves the other solid out of this one.</p>
+     *
+     * <h3>Algorithm:</h3>
+     * <pre>
+     * Subtract(A, B) = A - B = clip(inverted A to B) inverted
+     * </pre>
+     * <ol>
+     *   <li>Invert A (turning solid into cavity, cavity into solid)</li>
+     *   <li>Clip inverted A to keep only parts inside B</li>
+     *   <li>Clip B to keep only parts inside inverted A</li>
+     *   <li>Invert B twice to get B's cavity surface</li>
+     *   <li>Combine and invert final result</li>
+     * </ol>
+     *
+     * <p>The inversion trick converts "subtract B from A" into "intersect A
+     * with the inverse of B", which the BSP algorithm handles naturally.</p>
+     *
+     * @param csg the solid to subtract
+     * @return a new CSG solid representing the difference
+     */
+    public CSG subtract(final CSG csg) {
+        // Create BSP trees from both solids
+        final CSGNode a = new CSGNode(clone().polygons);
+        final CSGNode b = new CSGNode(csg.clone().polygons);
+
+        // Invert A: what was solid becomes empty, what was empty becomes solid
+        // This transforms the problem into finding the intersection of inverted-A and B
+        a.invert();
+
+        // Remove from inverted-A any parts outside B (keep intersection)
+        a.clipTo(b);
+
+        // Remove from B any parts outside inverted-A (keep intersection)
+        b.clipTo(a);
+
+        // Capture B's interior surface
+        b.invert();
+        b.clipTo(a);
+        b.invert();
+
+        // Combine B's interior surface with A
+        a.build(b.allPolygons());
+
+        // Invert result to convert back from "intersection with inverse" to "subtraction"
+        a.invert();
+
+        return CSG.fromPolygons(a.allPolygons());
+    }
+
+    /**
+     * Performs an intersection operation with another CSG solid.
+     *
+     * <p>The result contains only the points that are in both solids.</p>
+     *
+     * <h3>Algorithm:</h3>
+     * <pre>
+     * Intersect(A, B) = clip(inverted A to outside B) inverted
+     * </pre>
+     * <ol>
+     *   <li>Invert A (swap inside/outside)</li>
+     *   <li>Clip inverted-A to B, keeping parts outside B</li>
+     *   <li>Invert B, clip to A (captures B's interior surface)</li>
+     *   <li>Clip B again to ensure proper boundaries</li>
+     *   <li>Combine and invert final result</li>
+     * </ol>
+     *
+     * <p>This uses the principle: A ∩ B = ¬(¬A ∪ ¬B)</p>
+     *
+     * @param csg the other solid to intersect with
+     * @return a new CSG solid representing the intersection
+     */
+    public CSG intersect(final CSG csg) {
+        // Create BSP trees from both solids
+        final CSGNode a = new CSGNode(clone().polygons);
+        final CSGNode b = new CSGNode(csg.clone().polygons);
+
+        // Invert A to transform intersection into a union-like operation
+        a.invert();
+
+        // Clip B to keep only parts inside inverted-A (outside original A)
+        b.clipTo(a);
+
+        // Invert B to capture its interior surface
+        b.invert();
+
+        // Clip A to keep only parts inside inverted-B (outside original B)
+        a.clipTo(b);
+
+        // Clip B again to ensure proper boundary handling
+        b.clipTo(a);
+
+        // Combine B's interior surface with A
+        a.build(b.allPolygons());
+
+        // Invert result to get the actual intersection
+        a.invert();
+
+        return CSG.fromPolygons(a.allPolygons());
+    }
+
+    /**
+     * Returns the inverse of this solid.
+     *
+     * <p>The inverse has all polygons flipped, effectively turning the solid inside-out.</p>
+     *
+     * @return a new CSG solid representing the inverse
+     */
+    public CSG inverse() {
+        final CSG csg = clone();
+        for (final CSGPolygon p : csg.polygons) {
+            p.flip();
+        }
+        return csg;
+    }
+
+    /**
+     * Converts this CSG solid to a renderable mesh with a uniform color.
+     *
+     * <p>All polygons are rendered with the specified color, ignoring
+     * any colors stored in the CSGPolygons.</p>
+     *
+     * @param color    the color to apply to all triangles
+     * @param location the position in 3D space for the mesh
+     * @return a renderable mesh containing triangles
+     */
+    public SolidPolygonMesh toMesh(final Color color, final Point3D location) {
+        final List<SolidPolygon> triangles = new ArrayList<>();
+
+        for (final CSGPolygon polygon : polygons) {
+            triangulatePolygon(polygon, color, triangles);
+        }
+
+        return new SolidPolygonMesh(triangles, location);
+    }
+
+    /**
+     * Triangulates a CSGPolygon using fan triangulation.
+     *
+     * <p>Fan triangulation works by selecting the first vertex as a central point
+     * and connecting it to each pair of consecutive vertices. For an N-gon,
+     * this produces (N-2) triangles:</p>
+     *
+     * <pre>
+     * Original N-gon:    v0-v1-v2-v3-v4...
+     * Triangles:         v0-v1-v2, v0-v2-v3, v0-v3-v4, ...
+     * </pre>
+     *
+     * <p>This method is suitable for convex polygons. For concave polygons,
+     * it may produce overlapping triangles, but CSG operations typically
+     * generate convex polygon fragments.</p>
+     *
+     * @param polygon   the polygon to triangulate (may have 3+ vertices)
+     * @param color     the color to apply to all resulting triangles
+     * @param triangles the list to add the resulting SolidPolygon triangles to
+     */
+    private void triangulatePolygon(final CSGPolygon polygon, final Color color,
+                                    final List<SolidPolygon> triangles) {
+        final int vertexCount = polygon.vertices.size();
+
+        // Skip degenerate polygons (less than 3 vertices cannot form a triangle)
+        if (vertexCount < 3) {
+            return;
+        }
+
+        // Use the first vertex as the "pivot" of the fan
+        final Point3D v0 = polygon.vertices.get(0).coordinate;
+
+        // Create triangles by connecting v0 to each consecutive pair of vertices
+        // For a polygon with vertices [v0, v1, v2, v3], we create:
+        // - Triangle 1: v0, v1, v2 (i=1)
+        // - Triangle 2: v0, v2, v3 (i=2)
+        for (int i = 1; i < vertexCount - 1; i++) {
+            final Point3D v1 = polygon.vertices.get(i).coordinate;
+            final Point3D v2 = polygon.vertices.get(i + 1).coordinate;
+
+            // Clone the points to avoid sharing references with the original polygon
+            final SolidPolygon triangle = new SolidPolygon(
+                    new Point3D(v0),
+                    new Point3D(v1),
+                    new Point3D(v2),
+                    color
+            );
+
+            triangles.add(triangle);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/csg/CSGNode.java b/src/main/java/eu/svjatoslav/sixth/e3d/csg/CSGNode.java
new file mode 100644 (file)
index 0000000..0766122
--- /dev/null
@@ -0,0 +1,359 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.csg;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A node in a Binary Space Partitioning (BSP) tree used for CSG operations.
+ *
+ * <p>BSP trees are the data structure that makes CSG boolean operations possible.
+ * Each node divides 3D space into two half-spaces using a plane, enabling
+ * efficient spatial queries and polygon clipping.</p>
+ *
+ * <h3>BSP Tree Structure:</h3>
+ * <pre>
+ *                 [Node: plane P]
+ *                /               \
+ *        [Front subtree]     [Back subtree]
+ *     (same side as P's     (opposite side
+ *        normal)             of P's normal)
+ * </pre>
+ *
+ * <h3>Key Properties:</h3>
+ * <ul>
+ *   <li><b>polygons:</b> Polygons coplanar with this node's partitioning plane</li>
+ *   <li><b>plane:</b> The partitioning plane that divides space</li>
+ *   <li><b>front:</b> Subtree for the half-space the plane normal points toward</li>
+ *   <li><b>back:</b> Subtree for the opposite half-space</li>
+ * </ul>
+ *
+ * <h3>CSG Algorithm Overview:</h3>
+ * <p>CSG boolean operations (union, subtraction, intersection) work by:</p>
+ * <ol>
+ *   <li>Building BSP trees from both input solids</li>
+ *   <li>Clipping each tree against the other (removing overlapping geometry)</li>
+ *   <li>Optionally inverting trees (for subtraction and intersection)</li>
+ *   <li>Collecting the resulting polygons</li>
+ * </ol>
+ *
+ * @see CSG the main CSG class that provides the boolean operation API
+ * @see CSGPlane the plane type used for spatial partitioning
+ * @see CSGPolygon the polygon type stored in BSP nodes
+ */
+public class CSGNode {
+
+    /**
+     * Polygons that lie on this node's partitioning plane.
+     *
+     * <p>These polygons are coplanar with the plane and are stored directly
+     * in this node rather than being pushed down to child nodes. This includes
+     * both polygons originally on this plane and polygons split by planes above
+     * that ended up coplanar here.</p>
+     */
+    public final List<CSGPolygon> polygons = new ArrayList<>();
+
+    /**
+     * The partitioning plane for this node.
+     *
+     * <p>This plane divides 3D space into two half-spaces: front (where the
+     * normal points) and back. All polygons in this node are coplanar with
+     * this plane. Child nodes contain polygons on their respective sides.</p>
+     *
+     * <p>Null for leaf nodes (empty subtrees).</p>
+     */
+    public CSGPlane plane;
+
+    /**
+     * The front child subtree.
+     *
+     * <p>Contains polygons that lie in the front half-space of this node's plane
+     * (the side the normal points toward). May be null if no polygons exist
+     * in the front half-space.</p>
+     */
+    public CSGNode front;
+
+    /**
+     * The back child subtree.
+     *
+     * <p>Contains polygons that lie in the back half-space of this node's plane
+     * (the side opposite the normal direction). May be null if no polygons exist
+     * in the back half-space.</p>
+     */
+    public CSGNode back;
+
+    /**
+     * Creates an empty BSP node with no plane or children.
+     *
+     * <p>This constructor creates a leaf node. The plane, front, and back
+     * fields will be populated when polygons are added via {@link #build(List)}.</p>
+     */
+    public CSGNode() {
+    }
+
+    /**
+     * Creates a BSP tree from a list of polygons.
+     *
+     * <p>Delegates to {@link #build(List)} to construct the tree.</p>
+     *
+     * @param polygons the polygons to partition into a BSP tree
+     */
+    public CSGNode(final List<CSGPolygon> polygons) {
+        build(polygons);
+    }
+
+    /**
+     * Creates a deep clone of this BSP tree.
+     *
+     * <p>Recursively clones all child nodes and polygons. The resulting tree
+     * is completely independent of the original.</p>
+     *
+     * @return a new CSGNode tree with cloned data
+     */
+    public CSGNode clone() {
+        final CSGNode node = new CSGNode();
+
+        // Clone the plane if present
+        node.plane = plane != null ? plane.clone() : null;
+
+        // Recursively clone child subtrees
+        node.front = front != null ? front.clone() : null;
+        node.back = back != null ? back.clone() : null;
+
+        // Clone each polygon in this node
+        for (final CSGPolygon p : polygons) {
+            node.polygons.add(p.clone());
+        }
+
+        return node;
+    }
+
+    /**
+     * Inverts this BSP tree, converting "inside" to "outside" and vice versa.
+     *
+     * <p>This operation is fundamental to CSG subtraction and intersection:</p>
+     * <ul>
+     *   <li>All polygon normals are flipped (reversing their facing direction)</li>
+     *   <li>All plane normals are flipped</li>
+     *   <li>Front and back subtrees are swapped</li>
+     * </ul>
+     *
+     * <p>After inversion:</p>
+     * <ul>
+     *   <li>What was solid becomes empty space</li>
+     *   <li>What was empty space becomes solid</li>
+     *   <li>Front/back relationships are reversed throughout the tree</li>
+     * </ul>
+     *
+     * <p>This is used in CSG subtraction where solid B "carves out" of solid A
+     * by inverting B, unioning, then inverting the result.</p>
+     */
+    public void invert() {
+        // Flip all polygons at this node
+        for (final CSGPolygon polygon : polygons) {
+            polygon.flip();
+        }
+
+        // Flip the partitioning plane
+        if (plane != null) {
+            plane.flip();
+        }
+
+        // Recursively invert child subtrees
+        if (front != null) {
+            front.invert();
+        }
+        if (back != null) {
+            back.invert();
+        }
+
+        // Swap front and back children since the half-spaces are now reversed
+        final CSGNode temp = front;
+        front = back;
+        back = temp;
+    }
+
+    /**
+     * Clips a list of polygons against this BSP tree.
+     *
+     * <p>This recursively removes the portions of the input polygons that lie
+     * inside the solid represented by this BSP tree. The result contains only
+     * the portions that are outside this solid.</p>
+     *
+     * <h3>Algorithm:</h3>
+     * <ol>
+     *   <li>At each node, split input polygons by the node's plane</li>
+     *   <li>Polygons in front go to front child for further clipping</li>
+     *   <li>Polygons in back go to back child for further clipping</li>
+     *   <li>Coplanar polygons are kept (they're on the surface)</li>
+     *   <li>If no back child exists, back polygons are discarded (they're inside)</li>
+     * </ol>
+     *
+     * <p>This is used during CSG operations to remove overlapping geometry.</p>
+     *
+     * @param polygons the polygons to clip against this BSP tree
+     * @return a new list containing only the portions outside this solid
+     */
+    public List<CSGPolygon> clipPolygons(final List<CSGPolygon> polygons) {
+        // Base case: if this is a leaf node, return copies of all polygons
+        if (plane == null) {
+            return new ArrayList<>(polygons);
+        }
+
+        // Split all input polygons by this node's plane
+        final List<CSGPolygon> frontList = new ArrayList<>();
+        final List<CSGPolygon> backList = new ArrayList<>();
+
+        for (final CSGPolygon polygon : polygons) {
+            // Split polygon into front/back/coplanar parts
+            // Note: coplanar polygons go into both front and back lists
+            plane.splitPolygon(polygon, frontList, backList, frontList, backList);
+        }
+
+        // Recursively clip front polygons against front subtree
+        List<CSGPolygon> resultFront = frontList;
+        if (front != null) {
+            resultFront = front.clipPolygons(frontList);
+        }
+
+        // Recursively clip back polygons against back subtree
+        List<CSGPolygon> resultBack = backList;
+        if (back != null) {
+            resultBack = back.clipPolygons(backList);
+        } else {
+            // No back child means this is a boundary - discard back polygons
+            // (they would be inside the solid we're clipping against)
+            resultBack = new ArrayList<>();
+        }
+
+        // Combine the clipped results
+        final List<CSGPolygon> result = new ArrayList<>(resultFront.size() + resultBack.size());
+        result.addAll(resultFront);
+        result.addAll(resultBack);
+        return result;
+    }
+
+    /**
+     * Clips this BSP tree against another BSP tree.
+     *
+     * <p>This removes from this tree all polygons that lie inside the solid
+     * represented by the other BSP tree. Used during CSG operations to
+     * eliminate overlapping geometry.</p>
+     *
+     * <p>The operation modifies this tree in place, replacing all polygons
+     * with their clipped versions.</p>
+     *
+     * @param bsp the BSP tree to clip against (the "cutter")
+     */
+    public void clipTo(final CSGNode bsp) {
+        // Clip all polygons at this node against the other BSP tree
+        final List<CSGPolygon> newPolygons = bsp.clipPolygons(polygons);
+        polygons.clear();
+        polygons.addAll(newPolygons);
+
+        // Recursively clip child subtrees
+        if (front != null) {
+            front.clipTo(bsp);
+        }
+        if (back != null) {
+            back.clipTo(bsp);
+        }
+    }
+
+    /**
+     * Collects all polygons from this BSP tree into a flat list.
+     *
+     * <p>Recursively traverses the entire tree and collects all polygons
+     * from all nodes. This is used after CSG operations to extract the
+     * final result as a simple polygon list.</p>
+     *
+     * @return a new list containing all polygons in this tree
+     */
+    public List<CSGPolygon> allPolygons() {
+        final List<CSGPolygon> result = new ArrayList<>(polygons);
+
+        // Recursively collect polygons from child subtrees
+        if (front != null) {
+            result.addAll(front.allPolygons());
+        }
+        if (back != null) {
+            result.addAll(back.allPolygons());
+        }
+
+        return result;
+    }
+
+    /**
+     * Builds or extends this BSP tree from a list of polygons.
+     *
+     * <p>This is the core BSP tree construction algorithm. It partitions
+     * space by selecting a splitting plane and recursively building subtrees.</p>
+     *
+     * <h3>Algorithm:</h3>
+     * <ol>
+     *   <li>If this node has no plane, use the first polygon's plane as the partitioning plane</li>
+     *   <li>For each polygon:
+     *     <ul>
+     *       <li>Coplanar polygons are stored in this node</li>
+     *       <li>Front polygons go to the front list</li>
+     *       <li>Back polygons go to the back list</li>
+     *       <li>Spanning polygons are split into front and back parts</li>
+     *     </ul>
+     *   </li>
+     *   <li>Recursively build front subtree with front polygons</li>
+     *   <li>Recursively build back subtree with back polygons</li>
+     * </ol>
+     *
+     * <h3>Calling Conventions:</h3>
+     * <ul>
+     *   <li>Can be called multiple times to add more polygons to an existing tree</li>
+     *   <li>Empty polygon list is a no-op</li>
+     *   <li>Creates child nodes as needed</li>
+     * </ul>
+     *
+     * @param polygonList the polygons to add to this BSP tree
+     */
+    public void build(final List<CSGPolygon> polygonList) {
+        // Base case: no polygons to add
+        if (polygonList.isEmpty()) {
+            return;
+        }
+
+        // Initialize the partitioning plane if this is a new node
+        // Use the first polygon's plane as the splitting plane
+        if (plane == null) {
+            plane = polygonList.get(0).plane.clone();
+        }
+
+        // Classify each polygon relative to this node's plane
+        final List<CSGPolygon> frontList = new ArrayList<>();
+        final List<CSGPolygon> backList = new ArrayList<>();
+
+        for (final CSGPolygon polygon : polygonList) {
+            // Split the polygon and distribute to appropriate lists:
+            // - coplanarFront/coplanarBack → this node's polygons list
+            // - front → frontList (for front subtree)
+            // - back → backList (for back subtree)
+            plane.splitPolygon(polygon, polygons, polygons, frontList, backList);
+        }
+
+        // Recursively build front subtree
+        if (!frontList.isEmpty()) {
+            if (front == null) {
+                front = new CSGNode();
+            }
+            front.build(frontList);
+        }
+
+        // Recursively build back subtree
+        if (!backList.isEmpty()) {
+            if (back == null) {
+                back = new CSGNode();
+            }
+            back.build(backList);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/csg/CSGPlane.java b/src/main/java/eu/svjatoslav/sixth/e3d/csg/CSGPlane.java
new file mode 100644 (file)
index 0000000..473608b
--- /dev/null
@@ -0,0 +1,239 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.csg;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents an infinite plane in 3D space using the Hesse normal form.
+ *
+ * <p>A plane is defined by a normal vector (perpendicular to the plane surface)
+ * and a scalar value 'w' representing the signed distance from the origin.
+ * The plane equation is: {@code normal.x * x + normal.y * y + normal.z * z = w}</p>
+ *
+ * <p>Planes are fundamental to BSP (Binary Space Partitioning) tree operations
+ * in CSG. They divide 3D space into two half-spaces:</p>
+ * <ul>
+ *   <li><b>Front half-space:</b> Points where {@code normal · point > w}</li>
+ *   <li><b>Back half-space:</b> Points where {@code normal · point < w}</li>
+ * </ul>
+ *
+ * <p>Planes are used to:</p>
+ * <ul>
+ *   <li>Define the surface orientation of {@link CSGPolygon} faces</li>
+ *   <li>Split polygons that cross BSP partition boundaries</li>
+ *   <li>Determine which side of a BSP node a polygon lies on</li>
+ * </ul>
+ *
+ * @see CSGPolygon polygons that reference their containing plane
+ * @see CSGNode BSP tree nodes that use planes for spatial partitioning
+ */
+public class CSGPlane {
+
+    /**
+     * Epsilon value used for floating-point comparisons.
+     *
+     * <p>When determining which side of a plane a point lies on, values within
+     * this threshold are considered coplanar (on the plane). This prevents
+     * numerical instability from causing infinite recursion or degenerate
+     * polygons during BSP operations.</p>
+     */
+    public static final double EPSILON = 0.01;
+
+    /**
+     * The unit normal vector perpendicular to the plane surface.
+     *
+     * <p>The direction of the normal determines which side is "front"
+     * and which is "back". The front is the side the normal points toward.</p>
+     */
+    public Point3D normal;
+
+    /**
+     * The signed distance from the origin to the plane along the normal.
+     *
+     * <p>This is equivalent to the dot product of the normal with any point
+     * on the plane. For a plane defined by point P and normal N:
+     * {@code w = N · P}</p>
+     */
+    public double w;
+
+    /**
+     * Creates a plane with the given normal and distance.
+     *
+     * @param normal the unit normal vector (caller must ensure it's normalized)
+     * @param w      the signed distance from origin to the plane
+     */
+    public CSGPlane(final Point3D normal, final double w) {
+        this.normal = normal;
+        this.w = w;
+    }
+
+    /**
+     * Creates a plane from three non-collinear points.
+     *
+     * <p>The plane passes through all three points. The normal is computed
+     * using the cross product of vectors (b-a) and (c-a), then normalized.
+     * The winding order of the points determines the normal direction:</p>
+     * <ul>
+     *   <li>Counter-clockwise (CCW) winding → normal points toward viewer</li>
+     *   <li>Clockwise (CW) winding → normal points away from viewer</li>
+     * </ul>
+     *
+     * @param a the first point on the plane
+     * @param b the second point on the plane
+     * @param c the third point on the plane
+     * @return a new CSGPlane passing through the three points
+     * @throws ArithmeticException if the points are collinear (cross product is zero)
+     */
+    public static CSGPlane fromPoints(final Point3D a, final Point3D b, final Point3D c) {
+        // Compute two edge vectors from point a
+        final Point3D edge1 = b.minus(a);
+        final Point3D edge2 = c.minus(a);
+
+        // Cross product gives the normal direction (perpendicular to both edges)
+        final Point3D n = edge1.cross(edge2).unit();
+
+        // Distance from origin is the projection of any point on the plane onto the normal
+        return new CSGPlane(n, n.dot(a));
+    }
+
+    /**
+     * Creates a deep clone of this plane.
+     *
+     * <p>The normal vector is cloned to avoid shared references.</p>
+     *
+     * @return a new CSGPlane with the same normal and distance
+     */
+    public CSGPlane clone() {
+        return new CSGPlane(new Point3D(normal.x, normal.y, normal.z), w);
+    }
+
+    /**
+     * Flips the plane orientation by negating the normal and distance.
+     *
+     * <p>This effectively swaps the front and back half-spaces. After flipping:</p>
+     * <ul>
+     *   <li>Points that were in front are now in back</li>
+     *   <li>Points that were in back are now in front</li>
+     *   <li>Coplanar points remain coplanar</li>
+     * </ul>
+     *
+     * <p>Used during CSG operations when inverting solids (converting "inside"
+     * to "outside" and vice versa).</p>
+     */
+    public void flip() {
+        normal = normal.negated();
+        w = -w;
+    }
+
+    /**
+     * Splits a polygon by this plane, classifying and potentially dividing it.
+     *
+     * <p>This is the core operation for BSP tree construction. The polygon is
+     * classified based on where its vertices lie relative to the plane:</p>
+     *
+     * <h3>Classification types:</h3>
+     * <ul>
+     *   <li><b>COPLANAR (0):</b> All vertices lie on the plane (within EPSILON)</li>
+     *   <li><b>FRONT (1):</b> All vertices are in the front half-space</li>
+     *   <li><b>BACK (2):</b> All vertices are in the back half-space</li>
+     *   <li><b>SPANNING (3):</b> Vertices are on both sides (polygon crosses the plane)</li>
+     * </ul>
+     *
+     * <h3>Destination lists:</h3>
+     * <ul>
+     *   <li><b>coplanarFront:</b> Coplanar polygons with same-facing normals</li>
+     *   <li><b>coplanarBack:</b> Coplanar polygons with opposite-facing normals</li>
+     *   <li><b>front:</b> Polygons entirely in front half-space</li>
+     *   <li><b>back:</b> Polygons entirely in back half-space</li>
+     * </ul>
+     *
+     * <h3>Spanning polygon handling:</h3>
+     * <p>When a polygon spans the plane, it is split into two polygons:</p>
+     * <ol>
+     *   <li>Vertices on the front side become a new polygon (added to front list)</li>
+     *   <li>Vertices on the back side become a new polygon (added to back list)</li>
+     *   <li>Intersection points are computed and added to both polygons</li>
+     * </ol>
+     *
+     * @param polygon       the polygon to classify and potentially split
+     * @param coplanarFront list to receive coplanar polygons with same-facing normals
+     * @param coplanarBack  list to receive coplanar polygons with opposite-facing normals
+     * @param front         list to receive polygons in the front half-space
+     * @param back          list to receive polygons in the back half-space
+     */
+    public void splitPolygon(final CSGPolygon polygon,
+                             final List<CSGPolygon> coplanarFront,
+                             final List<CSGPolygon> coplanarBack,
+                             final List<CSGPolygon> front,
+                             final List<CSGPolygon> back) {
+
+        PolygonType polygonType = PolygonType.COPLANAR;
+        final PolygonType[] types = new PolygonType[polygon.vertices.size()];
+
+        for (int i = 0; i < polygon.vertices.size(); i++) {
+            final Vertex v = polygon.vertices.get(i);
+            final double t = normal.dot(v.coordinate) - w;
+            final PolygonType type = (t < -EPSILON) ? PolygonType.BACK
+                    : (t > EPSILON) ? PolygonType.FRONT : PolygonType.COPLANAR;
+            polygonType = polygonType.combine(type);
+            types[i] = type;
+        }
+
+        switch (polygonType) {
+            case COPLANAR:
+                ((normal.dot(polygon.plane.normal) > 0) ? coplanarFront : coplanarBack).add(polygon);
+                break;
+
+            case FRONT:
+                front.add(polygon);
+                break;
+
+            case BACK:
+                back.add(polygon);
+                break;
+
+            case SPANNING:
+                final List<Vertex> f = new ArrayList<>();
+                final List<Vertex> b = new ArrayList<>();
+
+                for (int i = 0; i < polygon.vertices.size(); i++) {
+                    final int j = (i + 1) % polygon.vertices.size();
+                    final PolygonType ti = types[i];
+                    final PolygonType tj = types[j];
+                    final Vertex vi = polygon.vertices.get(i);
+                    final Vertex vj = polygon.vertices.get(j);
+
+                    if (ti.isFront()) {
+                        f.add(vi);
+                    }
+                    if (ti.isBack()) {
+                        b.add(ti == PolygonType.COPLANAR ? vi.clone() : vi);
+                    }
+                    if (ti != tj && ti != PolygonType.COPLANAR && tj != PolygonType.COPLANAR) {
+                        final double t = (w - normal.dot(vi.coordinate))
+                                / normal.dot(vj.coordinate.minus(vi.coordinate));
+                        final Vertex v = vi.interpolate(vj, t);
+                        f.add(v);
+                        b.add(v.clone());
+                    }
+                }
+
+                if (f.size() >= 3) {
+                    final CSGPolygon frontPoly = new CSGPolygon(f, polygon.color);
+                    front.add(frontPoly);
+                }
+                if (b.size() >= 3) {
+                    final CSGPolygon backPoly = new CSGPolygon(b, polygon.color);
+                    back.add(backPoly);
+                }
+                break;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/csg/CSGPolygon.java b/src/main/java/eu/svjatoslav/sixth/e3d/csg/CSGPolygon.java
new file mode 100644 (file)
index 0000000..9ba8ceb
--- /dev/null
@@ -0,0 +1,91 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.csg;
+
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * An N-gon polygon used for CSG BSP tree operations.
+ *
+ * <p>During BSP tree traversal, polygons may be split by planes, resulting
+ * in polygons with varying vertex counts (3 or more). The polygon stores
+ * its vertices, the plane it lies on, and material properties (color).</p>
+ *
+ * <p>The color is preserved through CSG operations - split polygons inherit
+ * the color from their parent.</p>
+ *
+ * @see CSG the main CSG solid class
+ * @see CSGPlane used for splitting polygons
+ */
+public class CSGPolygon {
+
+    /**
+     * The vertices defining this polygon's geometry.
+     * For CSG operations, this can be 3 or more vertices (N-gon).
+     */
+    public final List<Vertex> vertices;
+
+    /**
+     * The plane that contains this polygon.
+     * Cached for efficient BSP operations.
+     */
+    public final CSGPlane plane;
+
+    /**
+     * The color of this polygon.
+     * Preserved through CSG operations; split polygons inherit this color.
+     */
+    public Color color;
+
+    /**
+     * Creates a polygon with vertices and a color.
+     *
+     * @param vertices the vertices defining this polygon (must be at least 3)
+     * @param color    the color of this polygon
+     */
+    public CSGPolygon(final List<Vertex> vertices, final Color color) {
+        this.vertices = vertices;
+        this.color = color;
+        this.plane = CSGPlane.fromPoints(
+                vertices.get(0).coordinate,
+                vertices.get(1).coordinate,
+                vertices.get(2).coordinate
+        );
+    }
+
+    /**
+     * Creates a deep clone of this polygon.
+     *
+     * <p>Clones all vertices and preserves the color.</p>
+     *
+     * @return a new CSGPolygon with cloned data
+     */
+    public CSGPolygon clone() {
+        final List<Vertex> clonedVertices = new ArrayList<>(vertices.size());
+        for (final Vertex v : vertices) {
+            clonedVertices.add(v.clone());
+        }
+        return new CSGPolygon(clonedVertices, this.color);
+    }
+
+    /**
+     * Flips the orientation of this polygon.
+     *
+     * <p>Reverses the vertex order and negates vertex normals.
+     * Also flips the plane. Used during CSG operations when inverting solids.</p>
+     */
+    public void flip() {
+        Collections.reverse(vertices);
+        for (final Vertex v : vertices) {
+            v.flip();
+        }
+        plane.flip();
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/csg/PolygonType.java b/src/main/java/eu/svjatoslav/sixth/e3d/csg/PolygonType.java
new file mode 100644 (file)
index 0000000..c603a59
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.csg;
+
+/**
+ * Classification of a polygon's position relative to a plane.
+ * Used in CSG operations to determine how polygons should be split.
+ */
+enum PolygonType {
+    /** Polygon lies on the plane. */
+    COPLANAR,
+    /** Polygon is entirely in front of the plane. */
+    FRONT,
+    /** Polygon is entirely behind the plane. */
+    BACK,
+    /** Polygon straddles the plane (vertices on both sides). */
+    SPANNING;
+
+    /**
+     * Combines this type with another to compute the aggregate classification.
+     * When vertices are on both sides of a plane, the result is SPANNING.
+     *
+     * @param other the other polygon type to combine with
+     * @return the combined classification
+     */
+    PolygonType combine(final PolygonType other) {
+        if (this == other || other == COPLANAR) {
+            return this;
+        }
+        if (this == COPLANAR) {
+            return other;
+        }
+        // FRONT + BACK = SPANNING
+        return SPANNING;
+    }
+
+    /**
+     * Checks if this type represents a vertex in front of the plane.
+     *
+     * @return true if FRONT or COPLANAR (treated as front for classification)
+     */
+    boolean isFront() {
+        return this == FRONT || this == COPLANAR;
+    }
+
+    /**
+     * Checks if this type represents a vertex behind the plane.
+     *
+     * @return true if BACK or COPLANAR (treated as back for classification)
+     */
+    boolean isBack() {
+        return this == BACK || this == COPLANAR;
+    }
+}
\ No newline at end of file
index 7cfd8e0..c4e6b52 100755 (executable)
@@ -432,4 +432,115 @@ public class Point3D implements Cloneable {
         return this;
     }
 
+    // ========== Non-mutating vector operations (return new Point3D) ==========
+
+    /**
+     * Computes the dot product of this vector with another.
+     *
+     * @param other the other vector
+     * @return the dot product (scalar)
+     */
+    public double dot(final Point3D other) {
+        return x * other.x + y * other.y + z * other.z;
+    }
+
+    /**
+     * Computes the cross product of this vector with another.
+     * Returns a new vector perpendicular to both input vectors.
+     *
+     * @param other the other vector
+     * @return a new Point3D representing the cross product
+     */
+    public Point3D cross(final Point3D other) {
+        return new Point3D(
+                y * other.z - z * other.y,
+                z * other.x - x * other.z,
+                x * other.y - y * other.x
+        );
+    }
+
+    /**
+     * Returns a new point that is the sum of this point and another.
+     * Neither point is modified.
+     *
+     * @param other the point to add
+     * @return a new Point3D representing the sum
+     */
+    public Point3D plus(final Point3D other) {
+        return new Point3D(x + other.x, y + other.y, z + other.z);
+    }
+
+    /**
+     * Returns a new point that is this point minus another.
+     * Neither point is modified.
+     *
+     * @param other the point to subtract
+     * @return a new Point3D representing the difference
+     */
+    public Point3D minus(final Point3D other) {
+        return new Point3D(x - other.x, y - other.y, z - other.z);
+    }
+
+    /**
+     * Returns a new point with negated coordinates.
+     * This point is not modified.
+     *
+     * @return a new Point3D with negated coordinates
+     */
+    public Point3D negated() {
+        return new Point3D(-x, -y, -z);
+    }
+
+    /**
+     * Returns a new unit vector (normalized) in the same direction.
+     * This point is not modified.
+     *
+     * @return a new Point3D with unit length
+     */
+    public Point3D unit() {
+        final double len = getVectorLength();
+        if (len == 0) {
+            return new Point3D(0, 0, 0);
+        }
+        return new Point3D(x / len, y / len, z / len);
+    }
+
+    /**
+     * Returns a new point that is a linear interpolation between this point and another.
+     * When t=0, returns this point. When t=1, returns the other point.
+     *
+     * @param other the other point
+     * @param t     the interpolation parameter (0 to 1)
+     * @return a new Point3D representing the interpolated position
+     */
+    public Point3D lerp(final Point3D other, final double t) {
+        return new Point3D(
+                x + (other.x - x) * t,
+                y + (other.y - y) * t,
+                z + (other.z - z) * t
+        );
+    }
+
+    /**
+     * Returns a new point with coordinates multiplied by a factor.
+     * This point is not modified. Unlike {@link #scaleUp}, this returns a new instance.
+     *
+     * @param factor the scaling factor
+     * @return a new scaled Point3D
+     */
+    public Point3D times(final double factor) {
+        return new Point3D(x * factor, y * factor, z * factor);
+    }
+
+    /**
+     * Returns a new point with coordinates divided by a factor.
+     * This point is not modified. Unlike {@link #scaleDown}, this returns a new instance.
+     *
+     * @param factor the divisor
+     * @return a new scaled Point3D
+     */
+    public Point3D dividedBy(final double factor) {
+        return new Point3D(x / factor, y / factor, z / factor);
+    }
+
 }
index 20cea76..ba63d6c 100644 (file)
@@ -58,7 +58,14 @@ public class Vertex {
     /**
      * Texture coordinate for UV mapping (optional).
      */
-    public Point2D textureCoordinate; // TODO: is this proper term ?
+    public Point2D textureCoordinate;
+
+    /**
+     * Normal vector for this vertex (optional).
+     * Used by CSG operations for smooth interpolation during polygon splitting.
+     * Null for non-CSG usage; existing rendering code ignores this field.
+     */
+    public Point3D normal;
 
 
     /**
@@ -76,20 +83,20 @@ public class Vertex {
     /**
      * Creates a vertex at the specified position with no texture coordinate.
      *
-     * @param location the 3D position of this vertex
+     * @param coordinate the 3D position of this vertex
      */
-    public Vertex(final Point3D location) {
-        this(location, null);
+    public Vertex(final Point3D coordinate) {
+        this(coordinate, null);
     }
 
     /**
      * Creates a vertex at the specified position with an optional texture coordinate.
      *
-     * @param location          the 3D position of this vertex
+     * @param coordinate        the 3D position of this vertex
      * @param textureCoordinate the UV texture coordinate, or {@code null} for none
      */
-    public Vertex(final Point3D location, Point2D textureCoordinate) {
-        coordinate = location;
+    public Vertex(final Point3D coordinate, final Point2D textureCoordinate) {
+        this.coordinate = coordinate;
         transformedCoordinate = new Point3D();
         onScreenCoordinate = new Point2D();
         this.textureCoordinate = textureCoordinate;
@@ -120,4 +127,58 @@ public class Vertex {
         onScreenCoordinate.y = ((transformedCoordinate.y / transformedCoordinate.z) * renderContext.projectionScale);
         onScreenCoordinate.add(renderContext.centerCoordinate);
     }
+
+    // ========== CSG support methods ==========
+
+    /**
+     * Creates a deep copy of this vertex.
+     * Clones the coordinate, normal (if present), and texture coordinate (if present).
+     * The transformedCoordinate and onScreenCoordinate are not cloned (they are computed per-frame).
+     *
+     * @return a new Vertex with cloned data
+     */
+    public Vertex clone() {
+        final Vertex result = new Vertex(new Point3D(coordinate),
+                textureCoordinate != null ? new Point2D(textureCoordinate) : null);
+        if (normal != null) {
+            result.normal = new Point3D(normal);
+        }
+        return result;
+    }
+
+    /**
+     * Flips the orientation of this vertex by negating the normal vector.
+     * Called when the orientation of a polygon is flipped during CSG operations.
+     * If normal is null, this method does nothing.
+     */
+    public void flip() {
+        if (normal != null) {
+            normal = normal.negated();
+        }
+    }
+
+    /**
+     * Creates a new vertex between this vertex and another by linearly interpolating
+     * all properties using parameter t.
+     *
+     * <p>Interpolates: position, normal (if present), and texture coordinate (if present).</p>
+     *
+     * @param other the other vertex to interpolate towards
+     * @param t     the interpolation parameter (0 = this vertex, 1 = other vertex)
+     * @return a new Vertex representing the interpolated position
+     */
+    public Vertex interpolate(final Vertex other, final double t) {
+        final Vertex result = new Vertex(
+                coordinate.lerp(other.coordinate, t),
+                (textureCoordinate != null && other.textureCoordinate != null)
+                        ? new Point2D(
+                        textureCoordinate.x + (other.textureCoordinate.x - textureCoordinate.x) * t,
+                        textureCoordinate.y + (other.textureCoordinate.y - textureCoordinate.y) * t)
+                        : null
+        );
+        if (normal != null && other.normal != null) {
+            result.normal = normal.lerp(other.normal, t);
+        }
+        return result;
+    }
 }
index a75d798..3112fde 100644 (file)
@@ -63,7 +63,7 @@ public abstract class AbstractCoordinateShape extends AbstractShape {
      * Each vertex contains both the original world-space coordinate and
      * a transformed screen-space coordinate computed during {@link #transform}.
      */
-    public final Vertex[] coordinates;
+    public final Vertex[] vertices;
 
     /**
      * Average Z-depth of this shape in screen space after transformation.
@@ -76,12 +76,12 @@ public abstract class AbstractCoordinateShape extends AbstractShape {
      * Creates a shape with the specified number of vertices, each initialized
      * to the origin (0, 0, 0).
      *
-     * @param pointsCount the number of vertices in this shape
+     * @param vertexCount the number of vertices in this shape
      */
-    public AbstractCoordinateShape(final int pointsCount) {
-        coordinates = new Vertex[pointsCount];
-        for (int i = 0; i < pointsCount; i++)
-            coordinates[i] = new Vertex();
+    public AbstractCoordinateShape(final int vertexCount) {
+        vertices = new Vertex[vertexCount];
+        for (int i = 0; i < vertexCount; i++)
+            vertices[i] = new Vertex();
 
         shapeId = lastShapeId.getAndIncrement();
     }
@@ -89,10 +89,10 @@ public abstract class AbstractCoordinateShape extends AbstractShape {
     /**
      * Creates a shape from the given vertices.
      *
-     * @param vertexes the vertices defining this shape's geometry
+     * @param vertices the vertices defining this shape's geometry
      */
-    public AbstractCoordinateShape(final Vertex... vertexes) {
-        coordinates = vertexes;
+    public AbstractCoordinateShape(final Vertex... vertices) {
+        this.vertices = vertices;
 
         shapeId = lastShapeId.getAndIncrement();
     }
@@ -132,7 +132,7 @@ public abstract class AbstractCoordinateShape extends AbstractShape {
         double accumulatedZ = 0;
         boolean paint = true;
 
-        for (final Vertex geometryPoint : coordinates) {
+        for (final Vertex geometryPoint : vertices) {
             geometryPoint.calculateLocationRelativeToViewer(transforms, renderingContext);
 
             accumulatedZ += geometryPoint.transformedCoordinate.z;
@@ -142,7 +142,7 @@ public abstract class AbstractCoordinateShape extends AbstractShape {
         }
 
         if (paint) {
-            onScreenZ = accumulatedZ / coordinates.length;
+            onScreenZ = accumulatedZ / vertices.length;
             aggregator.queueShapeForRendering(this);
         }
     }
index ec38e5b..974e285 100644 (file)
@@ -77,7 +77,7 @@ public class Billboard extends AbstractCoordinateShape {
     public void paint(final RenderingContext targetRenderingArea) {
 
         // distance from camera/viewer to center of the texture
-        final double z = coordinates[0].transformedCoordinate.z;
+        final double z = vertices[0].transformedCoordinate.z;
 
         // compute forward oriented texture visible distance from center
         final double visibleHorizontalDistanceFromCenter = (targetRenderingArea.width
@@ -92,7 +92,7 @@ public class Billboard extends AbstractCoordinateShape {
 
         final TextureBitmap textureBitmap = texture.getZoomedBitmap(zoom);
 
-        final Point2D onScreenCoordinate = coordinates[0].onScreenCoordinate;
+        final Point2D onScreenCoordinate = vertices[0].onScreenCoordinate;
 
         // compute Y
         final int onScreenUncappedYStart = (int) (onScreenCoordinate.y - visibleVerticalDistanceFromCenter);
@@ -219,7 +219,7 @@ public class Billboard extends AbstractCoordinateShape {
      * @return the center position in world coordinates
      */
     public Point3D getLocation() {
-        return coordinates[0].coordinate;
+        return vertices[0].coordinate;
     }
 
 }
index 72c38fa..4b53da1 100644 (file)
@@ -22,9 +22,9 @@ import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
  * <p>
  * The rendering algorithm:
  * 1. For thin lines (below a threshold), draws single-pixel lines with alpha
- *    adjustment based on perspective.
+ * adjustment based on perspective.
  * 2. For thicker lines, creates four interpolators to define the line's
- *    rectangular area and fills it scanline by scanline.
+ * rectangular area and fills it scanline by scanline.
  * <p>
  * Note: The width is scaled by the LINE_WIDTH_MULTIPLIER and adjusted based on
  * the distance from the viewer (z-coordinate) to maintain a consistent visual size.
@@ -52,8 +52,8 @@ public class Line extends AbstractCoordinateShape {
      * @param parentLine the line to copy
      */
     public Line(final Line parentLine) {
-        this(parentLine.coordinates[0].coordinate.clone(),
-                parentLine.coordinates[1].coordinate.clone(),
+        this(parentLine.vertices[0].coordinate.clone(),
+                parentLine.vertices[1].coordinate.clone(),
                 new Color(parentLine.color), parentLine.width);
     }
 
@@ -164,8 +164,8 @@ public class Line extends AbstractCoordinateShape {
     private void drawSinglePixelHorizontalLine(final RenderingContext buffer,
                                                final int alpha) {
 
-        final Point2D onScreenPoint1 = coordinates[0].onScreenCoordinate;
-        final Point2D onScreenPoint2 = coordinates[1].onScreenCoordinate;
+        final Point2D onScreenPoint1 = vertices[0].onScreenCoordinate;
+        final Point2D onScreenPoint2 = vertices[1].onScreenCoordinate;
 
         int xStart = (int) onScreenPoint1.x;
         int xEnd = (int) onScreenPoint2.x;
@@ -202,21 +202,21 @@ public class Line extends AbstractCoordinateShape {
 
                 final int y = yBase + ((relativeX * lineHeight) / lineWidth);
                 if ((y >= buffer.renderMinY) && (y < buffer.renderMaxY)) {
-                        if ((y >= 0) && (y < buffer.height)) {
-                            int offset = (y * buffer.width) + x;
+                    if ((y >= 0) && (y < buffer.height)) {
+                        int offset = (y * buffer.width) + x;
 
-                            final int dest = pixels[offset];
-                            final int destR = (dest >> 16) & 0xff;
-                            final int destG = (dest >> 8) & 0xff;
-                            final int destB = dest & 0xff;
+                        final int dest = pixels[offset];
+                        final int destR = (dest >> 16) & 0xff;
+                        final int destG = (dest >> 8) & 0xff;
+                        final int destB = dest & 0xff;
 
-                            final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
-                            final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
-                            final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
+                        final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
+                        final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
+                        final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
 
-                            pixels[offset] = (newR << 16) | (newG << 8) | newB;
-                        }
+                        pixels[offset] = (newR << 16) | (newG << 8) | newB;
                     }
+                }
             }
         }
 
@@ -232,8 +232,8 @@ public class Line extends AbstractCoordinateShape {
     private void drawSinglePixelVerticalLine(final RenderingContext buffer,
                                              final int alpha) {
 
-        final Point2D onScreenPoint1 = coordinates[0].onScreenCoordinate;
-        final Point2D onScreenPoint2 = coordinates[1].onScreenCoordinate;
+        final Point2D onScreenPoint1 = vertices[0].onScreenCoordinate;
+        final Point2D onScreenPoint2 = vertices[1].onScreenCoordinate;
 
         int yStart = (int) onScreenPoint1.y;
         int yEnd = (int) onScreenPoint2.y;
@@ -267,25 +267,25 @@ public class Line extends AbstractCoordinateShape {
             final int y = yStart + relativeY;
 
             if ((y >= buffer.renderMinY) && (y < buffer.renderMaxY)) {
-                    if ((y >= 0) && (y < buffer.height)) {
+                if ((y >= 0) && (y < buffer.height)) {
 
-                        final int x = xBase + ((relativeY * lineWidth) / lineHeight);
-                        if ((x >= 0) && (x < buffer.width)) {
-                            int offset = (y * buffer.width) + x;
+                    final int x = xBase + ((relativeY * lineWidth) / lineHeight);
+                    if ((x >= 0) && (x < buffer.width)) {
+                        int offset = (y * buffer.width) + x;
 
-                            final int dest = pixels[offset];
-                            final int destR = (dest >> 16) & 0xff;
-                            final int destG = (dest >> 8) & 0xff;
-                            final int destB = dest & 0xff;
+                        final int dest = pixels[offset];
+                        final int destR = (dest >> 16) & 0xff;
+                        final int destG = (dest >> 8) & 0xff;
+                        final int destB = dest & 0xff;
 
-                            final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
-                            final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
-                            final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
+                        final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
+                        final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
+                        final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
 
-                            pixels[offset] = (newR << 16) | (newG << 8) | newB;
-                        }
+                        pixels[offset] = (newR << 16) | (newG << 8) | newB;
                     }
                 }
+            }
         }
     }
 
@@ -320,16 +320,16 @@ public class Line extends AbstractCoordinateShape {
     @Override
     public void paint(final RenderingContext buffer) {
 
-        final Point2D onScreenPoint1 = coordinates[0].onScreenCoordinate;
-        final Point2D onScreenPoint2 = coordinates[1].onScreenCoordinate;
+        final Point2D onScreenPoint1 = vertices[0].onScreenCoordinate;
+        final Point2D onScreenPoint2 = vertices[1].onScreenCoordinate;
 
         final double xp = onScreenPoint2.x - onScreenPoint1.x;
         final double yp = onScreenPoint2.y - onScreenPoint1.y;
 
         final double point1radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width)
-                / coordinates[0].transformedCoordinate.z;
+                / vertices[0].transformedCoordinate.z;
         final double point2radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width)
-                / coordinates[1].transformedCoordinate.z;
+                / vertices[1].transformedCoordinate.z;
 
         if ((point1radius < MINIMUM_WIDTH_THRESHOLD)
                 || (point2radius < MINIMUM_WIDTH_THRESHOLD)) {
index fb729c6..9d03c8f 100644 (file)
@@ -10,7 +10,6 @@ import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
 import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
 import eu.svjatoslav.sixth.e3d.math.Vertex;
 import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
-import eu.svjatoslav.sixth.e3d.renderer.raster.lighting.LightingManager;
 import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
 
 import static eu.svjatoslav.sixth.e3d.geometry.Polygon.pointWithinPolygon;
@@ -136,12 +135,12 @@ public class SolidPolygon extends AbstractCoordinateShape {
      *   <li>Scanline rasterization with alpha blending</li>
      * </ul>
      *
-     * @param context                     the rendering context
-     * @param onScreenPoint1              the first vertex in screen coordinates
-     * @param onScreenPoint2              the second vertex in screen coordinates
-     * @param onScreenPoint3              the third vertex in screen coordinates
-     * @param mouseInteractionController  optional controller for mouse events, or null
-     * @param color                       the fill color
+     * @param context                    the rendering context
+     * @param onScreenPoint1             the first vertex in screen coordinates
+     * @param onScreenPoint2             the second vertex in screen coordinates
+     * @param onScreenPoint3             the third vertex in screen coordinates
+     * @param mouseInteractionController optional controller for mouse events, or null
+     * @param color                      the fill color
      */
     public static void drawPolygon(final RenderingContext context,
                                    final Point2D onScreenPoint1, final Point2D onScreenPoint2,
@@ -207,9 +206,21 @@ public class SolidPolygon extends AbstractCoordinateShape {
         LineInterpolator b = polygonBoundary2;
         LineInterpolator c = polygonBoundary3;
         LineInterpolator t;
-        if (a.compareTo(b) > 0) { t = a; a = b; b = t; }
-        if (b.compareTo(c) > 0) { t = b; b = c; c = t; }
-        if (a.compareTo(b) > 0) { t = a; a = b; b = t; }
+        if (a.compareTo(b) > 0) {
+            t = a;
+            a = b;
+            b = t;
+        }
+        if (b.compareTo(c) > 0) {
+            t = b;
+            b = c;
+            c = t;
+        }
+        if (a.compareTo(b) > 0) {
+            t = a;
+            a = b;
+            b = t;
+        }
 
         for (int y = yTop; y < yBottom; y++)
             if (a.containsY(y)) {
@@ -287,9 +298,9 @@ public class SolidPolygon extends AbstractCoordinateShape {
      * @param result the point to store the normal vector in
      */
     private void calculateNormal(final Point3D result) {
-        final Point3D v1 = coordinates[0].coordinate;
-        final Point3D v2 = coordinates[1].coordinate;
-        final Point3D v3 = coordinates[2].coordinate;
+        final Point3D v1 = vertices[0].coordinate;
+        final Point3D v2 = vertices[1].coordinate;
+        final Point3D v3 = vertices[2].coordinate;
 
         final double ax = v2.x - v1.x;
         final double ay = v2.y - v1.y;
@@ -321,9 +332,9 @@ public class SolidPolygon extends AbstractCoordinateShape {
      * @param result the point to store the center in
      */
     private void calculateCenter(final Point3D result) {
-        final Point3D v1 = coordinates[0].coordinate;
-        final Point3D v2 = coordinates[1].coordinate;
-        final Point3D v3 = coordinates[2].coordinate;
+        final Point3D v1 = vertices[0].coordinate;
+        final Point3D v2 = vertices[1].coordinate;
+        final Point3D v3 = vertices[2].coordinate;
 
         result.x = (v1.x + v2.x + v3.x) / 3.0;
         result.y = (v1.y + v2.y + v3.y) / 3.0;
@@ -345,9 +356,9 @@ public class SolidPolygon extends AbstractCoordinateShape {
     @Override
     public void paint(final RenderingContext renderBuffer) {
 
-        final Point2D onScreenPoint1 = coordinates[0].onScreenCoordinate;
-        final Point2D onScreenPoint2 = coordinates[1].onScreenCoordinate;
-        final Point2D onScreenPoint3 = coordinates[2].onScreenCoordinate;
+        final Point2D onScreenPoint1 = vertices[0].onScreenCoordinate;
+        final Point2D onScreenPoint2 = vertices[1].onScreenCoordinate;
+        final Point2D onScreenPoint3 = vertices[2].onScreenCoordinate;
 
         if (backfaceCulling) {
             final double signedArea = (onScreenPoint2.x - onScreenPoint1.x)
index 477c30b..b5ba635 100644 (file)
@@ -11,7 +11,7 @@ import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
 import eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture;
 import eu.svjatoslav.sixth.e3d.renderer.raster.texture.TextureBitmap;
 
-import java.awt.Color;
+import java.awt.*;
 
 import static eu.svjatoslav.sixth.e3d.geometry.Polygon.pointWithinPolygon;
 
@@ -66,9 +66,9 @@ public class TexturedPolygon extends AbstractCoordinateShape {
      */
     private void computeTotalTextureDistance() {
         // compute total texture distance
-        totalTextureDistance = coordinates[0].textureCoordinate.getDistanceTo(coordinates[1].textureCoordinate);
-        totalTextureDistance += coordinates[0].textureCoordinate.getDistanceTo(coordinates[2].textureCoordinate);
-        totalTextureDistance += coordinates[1].textureCoordinate.getDistanceTo(coordinates[2].textureCoordinate);
+        totalTextureDistance = vertices[0].textureCoordinate.getDistanceTo(vertices[1].textureCoordinate);
+        totalTextureDistance += vertices[0].textureCoordinate.getDistanceTo(vertices[2].textureCoordinate);
+        totalTextureDistance += vertices[1].textureCoordinate.getDistanceTo(vertices[2].textureCoordinate);
     }
 
     /**
@@ -201,9 +201,9 @@ public class TexturedPolygon extends AbstractCoordinateShape {
     @Override
     public void paint(final RenderingContext renderBuffer) {
 
-        final Point2D projectedPoint1 = coordinates[0].onScreenCoordinate;
-        final Point2D projectedPoint2 = coordinates[1].onScreenCoordinate;
-        final Point2D projectedPoint3 = coordinates[2].onScreenCoordinate;
+        final Point2D projectedPoint1 = vertices[0].onScreenCoordinate;
+        final Point2D projectedPoint2 = vertices[1].onScreenCoordinate;
+        final Point2D projectedPoint3 = vertices[2].onScreenCoordinate;
 
         if (backfaceCulling) {
             final double signedArea = (projectedPoint2.x - projectedPoint1.x)
@@ -276,23 +276,35 @@ public class TexturedPolygon extends AbstractCoordinateShape {
         final PolygonBorderInterpolator polygonBorder3 = interp[2];
 
         polygonBorder1.setPoints(projectedPoint1, projectedPoint2,
-                coordinates[0].textureCoordinate,
-                coordinates[1].textureCoordinate);
+                vertices[0].textureCoordinate,
+                vertices[1].textureCoordinate);
         polygonBorder2.setPoints(projectedPoint1, projectedPoint3,
-                coordinates[0].textureCoordinate,
-                coordinates[2].textureCoordinate);
+                vertices[0].textureCoordinate,
+                vertices[2].textureCoordinate);
         polygonBorder3.setPoints(projectedPoint2, projectedPoint3,
-                coordinates[1].textureCoordinate,
-                coordinates[2].textureCoordinate);
+                vertices[1].textureCoordinate,
+                vertices[2].textureCoordinate);
 
         // Inline sort for 3 elements to avoid array allocation
         PolygonBorderInterpolator a = polygonBorder1;
         PolygonBorderInterpolator b = polygonBorder2;
         PolygonBorderInterpolator c = polygonBorder3;
         PolygonBorderInterpolator t;
-        if (a.compareTo(b) > 0) { t = a; a = b; b = t; }
-        if (b.compareTo(c) > 0) { t = b; b = c; c = t; }
-        if (a.compareTo(b) > 0) { t = a; a = b; b = t; }
+        if (a.compareTo(b) > 0) {
+            t = a;
+            a = b;
+            b = t;
+        }
+        if (b.compareTo(c) > 0) {
+            t = b;
+            b = c;
+            c = t;
+        }
+        if (a.compareTo(b) > 0) {
+            t = a;
+            a = b;
+            b = t;
+        }
 
         for (int y = yTop; y < yBottom; y++)
             if (a.containsY(y)) {
@@ -331,9 +343,9 @@ public class TexturedPolygon extends AbstractCoordinateShape {
      */
     private void showBorders(final RenderingContext renderBuffer) {
 
-        final Point2D projectedPoint1 = coordinates[0].onScreenCoordinate;
-        final Point2D projectedPoint2 = coordinates[1].onScreenCoordinate;
-        final Point2D projectedPoint3 = coordinates[2].onScreenCoordinate;
+        final Point2D projectedPoint1 = vertices[0].onScreenCoordinate;
+        final Point2D projectedPoint2 = vertices[1].onScreenCoordinate;
+        final Point2D projectedPoint3 = vertices[2].onScreenCoordinate;
 
         final int x1 = (int) projectedPoint1.x;
         final int y1 = (int) projectedPoint1.y;
index f394a29..20c2901 100644 (file)
@@ -40,11 +40,11 @@ public class ForwardOrientedTextBlock extends Billboard {
     /**
      * Creates a new forward-oriented text block at the given 3D position.
      *
-     * @param point           the 3D position where the text label is placed
-     * @param scale           the scale factor controlling the rendered size of the text
+     * @param point            the 3D position where the text label is placed
+     * @param scale            the scale factor controlling the rendered size of the text
      * @param maxUpscaleFactor the maximum mipmap upscale factor for the backing texture
-     * @param text            the text string to render
-     * @param textColor       the color of the rendered text
+     * @param text             the text string to render
+     * @param textColor        the color of the rendered text
      */
     public ForwardOrientedTextBlock(final Point3D point, final double scale,
                                     final int maxUpscaleFactor, final String text,
@@ -60,9 +60,9 @@ public class ForwardOrientedTextBlock extends Billboard {
      * defined in {@link TextCanvas}. Each character is drawn individually at the appropriate
      * horizontal offset using {@link TextCanvas#FONT}.</p>
      *
-     * @param text            the text string to render into the texture
+     * @param text             the text string to render into the texture
      * @param maxUpscaleFactor the maximum mipmap upscale factor for the texture
-     * @param textColor       the color of the rendered text
+     * @param textColor        the color of the rendered text
      * @return a new {@link Texture} containing the rendered text
      */
     public static Texture getTexture(final String text,
@@ -73,9 +73,10 @@ public class ForwardOrientedTextBlock extends Billboard {
                 * TextCanvas.FONT_CHAR_WIDTH_TEXTURE_PIXELS, TextCanvas.FONT_CHAR_HEIGHT_TEXTURE_PIXELS,
                 maxUpscaleFactor);
 
+        // Put blue background to test if texture has correct size
         // texture.graphics.setColor(Color.BLUE);
         // texture.graphics.fillRect(0, 0, texture.primaryBitmap.width,
-        // texture.primaryBitmap.width);
+        //    texture.primaryBitmap.width);
 
         texture.graphics.setFont(TextCanvas.FONT);
         texture.graphics.setColor(textColor.toAwtColor());
index f456590..d9bfe23 100644 (file)
@@ -183,6 +183,37 @@ public class AbstractCompositeShape extends AbstractShape {
         return originalSubShapes;
     }
 
+    /**
+     * Extracts all SolidPolygon triangles from this composite shape.
+     *
+     * <p>Recursively traverses the shape hierarchy and collects all
+     * {@link SolidPolygon} instances. Useful for CSG operations where
+     * you need the raw triangles from a composite shape like
+     * {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube}
+     * or {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonSphere}.</p>
+     *
+     * <p><b>Example:</b></p>
+     * <pre>{@code
+     * SolidPolygonCube cube = new SolidPolygonCube(new Point3D(0, 0, 0), 50, Color.RED);
+     * List<SolidPolygon> triangles = cube.extractSolidPolygons();
+     * CSG csg = CSG.fromSolidPolygons(triangles);
+     * }</pre>
+     *
+     * @return list of all SolidPolygon sub-shapes
+     */
+    public List<SolidPolygon> extractSolidPolygons() {
+        final List<SolidPolygon> result = new ArrayList<>();
+        for (final SubShape subShape : originalSubShapes) {
+            final AbstractShape shape = subShape.getShape();
+            if (shape instanceof SolidPolygon) {
+                result.add((SolidPolygon) shape);
+            } else if (shape instanceof AbstractCompositeShape) {
+                result.addAll(((AbstractCompositeShape) shape).extractSolidPolygons());
+            }
+        }
+        return result;
+    }
+
     /**
      * Returns the view-space tracker that monitors the distance
      * and angle between the camera and this shape for level-of-detail adjustments.
index 5fd74ab..d8a9236 100644 (file)
@@ -7,7 +7,6 @@ package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
 import eu.svjatoslav.sixth.e3d.geometry.Point3D;
 import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
 import eu.svjatoslav.sixth.e3d.math.Quaternion;
-import eu.svjatoslav.sixth.e3d.math.Transform;
 import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
 import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
 import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
@@ -48,26 +47,37 @@ import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCom
 public class SolidPolygonArrow extends AbstractCompositeShape {
 
     /**
-     * Constructs a 3D arrow pointing from start to end.
      *
-     * <p>The arrow consists of a cylindrical body extending from the start point
-     * towards the end, and a conical tip at the end point. If the distance between
-     * start and end is less than or equal to the tip length, only the cone tip
-     * is rendered.</p>
+     * Number of segments for arrow smoothness.
+     */
+    private static final int SEGMENTS = 12;
+
+    /**
+     * Arrow tip radius as a fraction of body radius (2.5x).
+     */
+    private static final double TIP_RADIUS_FACTOR = 2.5;
+
+    /**
+     * Arrow tip length as a fraction of body radius (5.0x).
+     */
+    private static final double TIP_LENGTH_FACTOR = 5.0;
+
+    /**
+     * Constructs a 3D arrow pointing from start to end with sensible defaults.
+     *
+     * <p>This simplified constructor automatically calculates the tip radius as
+     * 2.5 times the body radius, the tip length as 5 times the body radius, and
+     * uses 12 segments for smoothness. For custom tip dimensions or segment count,
+     * use the full constructor.</p>
      *
-     * @param startPoint  the origin point of the arrow (where the body starts)
-     * @param endPoint    the destination point of the arrow (where the tip points to)
-     * @param bodyRadius  the radius of the cylindrical body
-     * @param tipRadius   the radius of the cone base at the tip
-     * @param tipLength   the length of the conical tip
-     * @param segments    the number of segments for cylinder and cone smoothness.
-     *                    Higher values create smoother arrows. Minimum is 3.
-     * @param color       the fill color (RGBA; alpha controls transparency)
+     * @param startPoint the origin point of the arrow (where the body starts)
+     * @param endPoint   the destination point of the arrow (where the tip points to)
+     * @param bodyRadius the radius of the cylindrical body; tip dimensions are
+     *                   calculated automatically from this value
+     * @param color      the fill color (RGBA; alpha controls transparency)
      */
     public SolidPolygonArrow(final Point3D startPoint, final Point3D endPoint,
-                             final double bodyRadius, final double tipRadius,
-                             final double tipLength, final int segments,
-                             final Color color) {
+                             final double bodyRadius, final Color color) {
         super();
 
         // Calculate direction and distance
@@ -93,13 +103,13 @@ public class SolidPolygonArrow extends AbstractCompositeShape {
         final Matrix3x3 rotMatrix = rotation.toMatrix();
 
         // Calculate body length (distance minus tip)
-        final double bodyLength = Math.max(0, distance - tipLength);
+        final double bodyLength = Math.max(0, distance - bodyRadius * TIP_LENGTH_FACTOR);
 
         // Build the arrow components
         if (bodyLength > 0) {
-            addCylinderBody(startPoint, bodyRadius, bodyLength, segments, color, rotMatrix, nx, ny, nz);
+            addCylinderBody(startPoint, bodyRadius, bodyLength, SEGMENTS, color, rotMatrix, nx, ny, nz);
         }
-        addConeTip(endPoint, tipRadius, tipLength, segments, color, rotMatrix, nx, ny, nz);
+        addConeTip(endPoint, bodyRadius * TIP_RADIUS_FACTOR, bodyRadius * TIP_LENGTH_FACTOR, SEGMENTS, color, rotMatrix, nx, ny, nz);
 
         setBackfaceCulling(true);
     }
@@ -248,15 +258,15 @@ public class SolidPolygonArrow extends AbstractCompositeShape {
      * <p><b>Local coordinate system:</b> In local space, the cone points in -Y direction
      * (apex at lower Y). The base ring is at Y=0, and the apex is at Y=-length.</p>
      *
-     * @param endPoint   the position of the arrow tip (cone apex)
-     * @param radius     the radius of the cone base
-     * @param length     the length of the cone
-     * @param segments   the number of segments around the circumference
-     * @param color      the fill color
-     * @param rotMatrix  the rotation matrix to apply
-     * @param dirX       direction X component
-     * @param dirY       direction Y component
-     * @param dirZ       direction Z component
+     * @param endPoint  the position of the arrow tip (cone apex)
+     * @param radius    the radius of the cone base
+     * @param length    the length of the cone
+     * @param segments  the number of segments around the circumference
+     * @param color     the fill color
+     * @param rotMatrix the rotation matrix to apply
+     * @param dirX      direction X component
+     * @param dirY      direction Y component
+     * @param dirZ      direction Z component
      */
     private void addConeTip(final Point3D endPoint, final double radius,
                             final double length, final int segments,
diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java
new file mode 100644 (file)
index 0000000..3014ef7
--- /dev/null
@@ -0,0 +1,65 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+import java.util.List;
+
+/**
+ * A renderable mesh composed of SolidPolygon triangles.
+ *
+ * <p>This is a generic composite shape that holds a collection of triangles.
+ * It can be constructed from any source of triangles, such as CSG operation
+ * results or procedural geometry generation.</p>
+ *
+ * <p><b>Usage:</b></p>
+ * <pre>{@code
+ * // From CSG result
+ * CSG result = cubeCSG.subtract(sphereCSG);
+ * SolidPolygonMesh mesh = result.toMesh(new Point3D(0, 0, 0));
+ * mesh.setShadingEnabled(true);
+ * mesh.setBackfaceCulling(true);
+ * shapes.addShape(mesh);
+ *
+ * // From list of triangles
+ * List<SolidPolygon> triangles = ...;
+ * SolidPolygonMesh mesh = new SolidPolygonMesh(triangles, new Point3D(0, 0, 0));
+ * }</pre>
+ *
+ * @see SolidPolygon the triangle type for rendering
+ */
+public class SolidPolygonMesh extends AbstractCompositeShape {
+
+    private int triangleCount;
+
+    /**
+     * Creates a mesh from a list of SolidPolygon triangles.
+     *
+     * @param triangles the triangles to include in the mesh
+     * @param location   the position in 3D space
+     */
+    public SolidPolygonMesh(final List<SolidPolygon> triangles, final Point3D location) {
+        super(location);
+        this.triangleCount = 0;
+
+        for (final SolidPolygon triangle : triangles) {
+            addShape(triangle);
+            triangleCount++;
+        }
+    }
+
+    /**
+     * Returns the number of triangles in this mesh.
+     *
+     * @return the triangle count
+     */
+    public int getTriangleCount() {
+        return triangleCount;
+    }
+}
\ No newline at end of file
index a103d7c..59fdb2b 100644 (file)
@@ -8,7 +8,6 @@ import eu.svjatoslav.sixth.e3d.geometry.Point2D;
 import eu.svjatoslav.sixth.e3d.geometry.Point3D;
 import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
 import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
-import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
 
 import java.awt.*;
 
@@ -47,10 +46,10 @@ public class CanvasCharacter extends AbstractCoordinateShape {
     /**
      * Creates a canvas character at the specified location with given colors.
      *
-     * @param centerLocation   the center position in 3D space
-     * @param character        the character to render
-     * @param foregroundColor  the foreground (text) color
-     * @param backgroundColor  the background color
+     * @param centerLocation  the center position in 3D space
+     * @param character       the character to render
+     * @param foregroundColor the foreground (text) color
+     * @param backgroundColor the background color
      */
     public CanvasCharacter(final Point3D centerLocation, final char character,
                            final eu.svjatoslav.sixth.e3d.renderer.raster.Color foregroundColor,
@@ -64,25 +63,25 @@ public class CanvasCharacter extends AbstractCoordinateShape {
         this.backgroundColor = backgroundColor;
 
 
-        coordinates[0].coordinate = centerLocation;
+        vertices[0].coordinate = centerLocation;
 
         final double halfWidth = FONT_CHAR_WIDTH / 2d;
         final double halfHeight = FONT_CHAR_HEIGHT / 2d;
 
         // upper left
-        coordinates[1].coordinate = centerLocation.clone().translateX(-halfWidth)
+        vertices[1].coordinate = centerLocation.clone().translateX(-halfWidth)
                 .translateY(-halfHeight);
 
         // upper right
-        coordinates[2].coordinate = centerLocation.clone().translateX(halfWidth)
+        vertices[2].coordinate = centerLocation.clone().translateX(halfWidth)
                 .translateY(-halfHeight);
 
         // lower right
-        coordinates[3].coordinate = centerLocation.clone().translateX(halfWidth)
+        vertices[3].coordinate = centerLocation.clone().translateX(halfWidth)
                 .translateY(halfHeight);
 
         // lower left
-        coordinates[4].coordinate = centerLocation.clone().translateX(-halfWidth)
+        vertices[4].coordinate = centerLocation.clone().translateX(-halfWidth)
                 .translateY(halfHeight);
     }
 
@@ -144,6 +143,7 @@ public class CanvasCharacter extends AbstractCoordinateShape {
 
     /**
      * Paints the character on the screen.
+     *
      * @param renderingContext the rendering context
      */
     @Override
@@ -151,16 +151,16 @@ public class CanvasCharacter extends AbstractCoordinateShape {
 
         // Draw background rectangle first. It is composed of two triangles.
         drawPolygon(renderingContext,
-                coordinates[1].onScreenCoordinate,
-                coordinates[2].onScreenCoordinate,
-                coordinates[3].onScreenCoordinate,
+                vertices[1].onScreenCoordinate,
+                vertices[2].onScreenCoordinate,
+                vertices[3].onScreenCoordinate,
                 mouseInteractionController,
                 backgroundColor);
 
         drawPolygon(renderingContext,
-                coordinates[1].onScreenCoordinate,
-                coordinates[3].onScreenCoordinate,
-                coordinates[4].onScreenCoordinate,
+                vertices[1].onScreenCoordinate,
+                vertices[3].onScreenCoordinate,
+                vertices[4].onScreenCoordinate,
                 mouseInteractionController,
                 backgroundColor);
 
@@ -170,7 +170,7 @@ public class CanvasCharacter extends AbstractCoordinateShape {
         if (desiredFontSize >= MAX_FONT_SIZE)
             return;
 
-        final Point2D onScreenLocation = coordinates[0].onScreenCoordinate;
+        final Point2D onScreenLocation = vertices[0].onScreenCoordinate;
 
         // screen borders check
         if (onScreenLocation.x < 0)
index 96900ae..69ec6cf 100644 (file)
@@ -42,27 +42,64 @@ import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCom
  */
 public class WireframeArrow extends AbstractCompositeShape {
 
-    /**
-     * Constructs a 3D wireframe arrow pointing from start to end.
-     *
-     * <p>The arrow consists of a cylindrical body extending from the start point
-     * towards the end, and a conical tip at the end point. If the distance between
-     * start and end is less than or equal to the tip length, only the cone tip
-     * is rendered.</p>
-     *
-     * @param startPoint  the origin point of the arrow (where the body starts)
-     * @param endPoint    the destination point of the arrow (where the tip points to)
-     * @param bodyRadius  the radius of the cylindrical body
-     * @param tipRadius   the radius of the cone base at the tip
-     * @param tipLength   the length of the conical tip
-     * @param segments    the number of segments for cylinder and cone smoothness.
-     *                    Higher values create smoother arrows. Minimum is 3.
-     * @param appearance  the line appearance (color, width) used for all lines
-     */
-    public WireframeArrow(final Point3D startPoint, final Point3D endPoint,
-                          final double bodyRadius, final double tipRadius,
-                          final double tipLength, final int segments,
-                          final LineAppearance appearance) {
+/**
+ * Default number of segments for arrow smoothness.
+ */
+private static final int DEFAULT_SEGMENTS = 12;
+
+/**
+ * Default tip radius as a fraction of body radius (2.5x).
+ */
+private static final double TIP_RADIUS_FACTOR = 2.5;
+
+/**
+ * Default tip length as a fraction of body radius (5.0x).
+ */
+private static final double TIP_LENGTH_FACTOR = 5.0;
+
+/**
+ * Constructs a 3D wireframe arrow pointing from start to end with sensible defaults.
+ *
+ * <p>This simplified constructor automatically calculates the tip radius as
+ * 2.5 times the body radius, the tip length as 5 times the body radius, and
+ * uses 12 segments for smoothness. For custom tip dimensions or segment count,
+ * use the full constructor.</p>
+ *
+ * @param startPoint the origin point of the arrow (where the body starts)
+ * @param endPoint   the destination point of the arrow (where the tip points to)
+ * @param bodyRadius the radius of the cylindrical body; tip dimensions are
+ *                   calculated automatically from this value
+ * @param appearance the line appearance (color, width) used for all lines
+ */
+public WireframeArrow(final Point3D startPoint, final Point3D endPoint,
+                      final double bodyRadius, final LineAppearance appearance) {
+    this(startPoint, endPoint, bodyRadius,
+            bodyRadius * TIP_RADIUS_FACTOR,
+            bodyRadius * TIP_LENGTH_FACTOR,
+            DEFAULT_SEGMENTS, appearance);
+}
+
+/**
+ * Constructs a 3D wireframe arrow pointing from start to end with full control over all dimensions.
+ *
+ * <p>The arrow consists of a cylindrical body extending from the start point
+ * towards the end, and a conical tip at the end point. If the distance between
+ * start and end is less than or equal to the tip length, only the cone tip
+ * is rendered.</p>
+ *
+ * @param startPoint  the origin point of the arrow (where the body starts)
+ * @param endPoint    the destination point of the arrow (where the tip points to)
+ * @param bodyRadius  the radius of the cylindrical body
+ * @param tipRadius   the radius of the cone base at the tip
+ * @param tipLength   the length of the conical tip
+ * @param segments    the number of segments for cylinder and cone smoothness.
+ *                    Higher values create smoother arrows. Minimum is 3.
+ * @param appearance  the line appearance (color, width) used for all lines
+ */
+public WireframeArrow(final Point3D startPoint, final Point3D endPoint,
+                      final double bodyRadius, final double tipRadius,
+                      final double tipLength, final int segments,
+                      final LineAppearance appearance) {
         super();
 
         // Calculate direction and distance
index 20b80e9..c023f3f 100644 (file)
@@ -77,9 +77,21 @@ public class Slicer {
         BorderLine b = line2;
         BorderLine c = line3;
         BorderLine t;
-        if (a.getLength() > b.getLength()) { t = a; a = b; b = t; }
-        if (b.getLength() > c.getLength()) { t = b; b = c; c = t; }
-        if (a.getLength() > b.getLength()) { t = a; a = b; b = t; }
+        if (a.getLength() > b.getLength()) {
+            t = a;
+            a = b;
+            b = t;
+        }
+        if (b.getLength() > c.getLength()) {
+            t = b;
+            b = c;
+            c = t;
+        }
+        if (a.getLength() > b.getLength()) {
+            t = a;
+            a = b;
+            b = t;
+        }
 
         final BorderLine longestLine = c;
 
@@ -132,9 +144,9 @@ public class Slicer {
     public void slice(final TexturedPolygon originalPolygon) {
 
         considerSlicing(
-                originalPolygon.coordinates[0],
-                originalPolygon.coordinates[1],
-                originalPolygon.coordinates[2],
+                originalPolygon.vertices[0],
+                originalPolygon.vertices[1],
+                originalPolygon.vertices[2],
                 originalPolygon);
     }