10652c5f76d93188358f57fe2121376533bf4173
[sixth-3d.git] /
1 /*
2  * Sixth 3D engine. Author: Svjatoslav Agejenko.
3  * This project is released under Creative Commons Zero (CC0) license.
4  */
5 package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base;
6
7 import eu.svjatoslav.sixth.e3d.geometry.Box;
8 import eu.svjatoslav.sixth.e3d.geometry.BspTree;
9 import eu.svjatoslav.sixth.e3d.geometry.Frustum;
10 import eu.svjatoslav.sixth.e3d.geometry.Point3D;
11 import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
12 import eu.svjatoslav.sixth.e3d.gui.ViewSpaceTracker;
13 import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
14 import eu.svjatoslav.sixth.e3d.math.Transform;
15 import eu.svjatoslav.sixth.e3d.math.TransformStack;
16 import eu.svjatoslav.sixth.e3d.math.Vertex;
17 import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
18 import eu.svjatoslav.sixth.e3d.renderer.raster.RenderAggregator;
19 import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractShape;
20 import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.Line;
21 import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
22 import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
23 import eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.TexturedPolygonTessellator;
24
25 import java.util.ArrayList;
26 import java.util.Iterator;
27 import java.util.List;
28
29 /**
30  * A composite shape that groups multiple sub-shapes into a single logical unit.
31  *
32  * <p>Use {@code AbstractCompositeShape} to build complex 3D objects by combining
33  * primitive shapes (lines, polygons, textured polygons) into a group that can be
34  * positioned, rotated, and manipulated as one entity. Sub-shapes can be organized
35  * into named groups for selective visibility toggling.</p>
36  *
37  * <p><b>Usage example - creating a custom composite shape:</b></p>
38  * <pre>{@code
39  * // Create a composite shape at position (0, 0, 200)
40  * AbstractCompositeShape myObject = new AbstractCompositeShape(
41  *     new Point3D(0, 0, 200)
42  * );
43  *
44  * // Add sub-shapes
45  * myObject.addShape(new Line(
46  *     new Point3D(-50, 0, 0), new Point3D(50, 0, 0),
47  *     Color.RED, 2.0
48  * ));
49  *
50  * // Add shapes to a named group for toggling visibility
51  * myObject.addShape(labelShape, "labels");
52  * myObject.hideGroup("labels");  // hide all shapes in "labels" group
53  * myObject.showGroup("labels");  // show them again
54  *
55  * // Add to scene
56  * viewPanel.getRootShapeCollection().addShape(myObject);
57  * }</pre>
58  *
59  * <p><b>Level-of-detail tessellation:</b></p>
60  * <p>Textured polygons within the composite shape are automatically tessellated into smaller
61  * triangles based on distance from the viewer. This provides perspective-correct texture
62  * mapping without requiring hardware support. The tessellation factor adapts dynamically.</p>
63  *
64  * <p><b>Extending this class:</b></p>
65  * <p>Override {@link #beforeTransformHook} to customize shape appearance or behavior
66  * on each frame (e.g., animations, dynamic geometry updates).</p>
67  *
68  * @see SubShape wrapper for individual sub-shapes with group and visibility support
69  * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractShape the base shape class
70  * @see eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.TexturedPolygonTessellator the level-of-detail polygon tessellator
71  */
72 public class AbstractCompositeShape extends AbstractShape {
73     /**
74      * Source-of-truth registry of all sub-shapes added to this composite.
75      *
76      * <p>Each sub-shape is wrapped with its group identifier and visibility state.
77      * Shapes are stored in insertion order and remain in this collection even when
78      * hidden (visibility state toggles instead of removal).</p>
79      *
80      * <p><b>Performance note:</b> This list is NOT processed for every frame.
81      * Instead, it serves as the authoritative source from which {@link #cachedRenderList}
82      * is compiled whenever the cache becomes invalid (see {@link #cacheNeedsRebuild}).
83      * Only modifications to this registry (add/remove/show/hide) trigger cache rebuild.</p>
84      *
85      * @see #cachedRenderList the frame-optimized cache derived from this registry
86      * @see #cacheNeedsRebuild the flag controlling when the cache is rebuilt
87      */
88     private final List<SubShape> subShapesRegistry = new ArrayList<>();
89
90     /**
91      * Tracks the distance and angle between the camera and this shape to compute
92      * an appropriate tessellation factor for level-of-detail adjustments.
93      */
94     private final ViewSpaceTracker viewSpaceTracker;
95
96     /**
97      * The current tessellation factor used for tessellating textured polygons into smaller
98      * triangles for perspective-correct rendering. Higher values produce more triangles
99      * for distant objects; lower values for nearby objects. Updated dynamically based
100      * on view-space analysis.
101      * <p>
102      * TODO: move this to TexturedTriangle. LOD must be computed per textured triangle, not per-shape.
103      */
104     double currentTessellationFactor = 5;
105
106     /**
107      * Frame-optimized cache of shapes ready for rendering, derived from {@link #subShapesRegistry}.
108      *
109      * <p>This list is processed during every frame in the {@link #transform} method.
110      * It contains:</p>
111      * <ul>
112      *   <li>Non-textured shapes (Line, SolidPolygon) - passed through directly</li>
113      *   <li>Textured polygons - tessellated into smaller triangles based on current LOD factor</li>
114      * </ul>
115      *
116      * <p><b>Caching strategy:</b> Regenerating this list involves texture tessellation which
117      * is expensive. The list is rebuilt only when {@link #cacheNeedsRebuild} is true,
118      * avoiding per-frame reconstruction overhead.</p>
119      *
120      * @see #subShapesRegistry the source registry this cache is derived from
121      * @see #cacheNeedsRebuild the flag that triggers cache regeneration
122      */
123     private List<AbstractShape> cachedRenderList = new ArrayList<>();
124
125     /**
126      * Flag indicating whether {@link #cachedRenderList} needs to be rebuilt from {@link #subShapesRegistry}.
127      *
128      * <p>Set to {@code true} when:</p>
129      * <ul>
130      *   <li>A shape is added via {@link #addShape}</li>
131      *   <li>A shape is removed via {@link #removeGroup}</li>
132      *   <li>Group visibility changes via {@link #showGroup} or {@link #hideGroup}</li>
133      *   <li>The tessellation factor changes significantly (determined by {@link #isRetessellationNeeded})</li>
134      * </ul>
135      *
136      * <p>Set to {@code false} after {@link #retessellate} completes the cache rebuild.</p>
137      *
138      * <p>This flag enables the performance optimization of avoiding per-frame list
139      * reconstruction - the registry is only re-processed when something actually changed.</p>
140      *
141      * @see #subShapesRegistry the source data that may need reprocessing
142      * @see #cachedRenderList the cache that gets rebuilt when this flag is true
143      */
144     private boolean cacheNeedsRebuild = true;
145
146     /**
147      * Flag indicating this composite is the root scene container (ShapeCollection's root).
148      *
149      * <p>Root composites have different behavior for LOD-based tessellation:</p>
150      * <ul>
151      *   <li>Root position equals camera position, so distance to camera is always 0</li>
152      *   <li>ViewSpaceTracker cannot compute meaningful tessellation factor for root</li>
153      *   <li>Root uses fixed {@link #currentTessellationFactor} and skips LOD-based retessellation checks</li>
154      *   <li>Root still performs N-gon triangulation when {@link #cacheNeedsRebuild} is true</li>
155      * </ul>
156      *
157      * <p>Set via {@link #setRootComposite(boolean)} by ShapeCollection.</p>
158      */
159     private boolean isRootComposite = false;
160
161     /**
162      * The position and orientation transform for this composite shape.
163      * Applied to all sub-shapes during the rendering transform pass.
164      */
165     private Transform transform;
166
167     /**
168      * Creates a composite shape at the world origin with no rotation.
169      */
170     public AbstractCompositeShape() {
171         this(new Transform());
172     }
173
174     /**
175      * Creates a composite shape at the specified location with no rotation.
176      *
177      * @param location the position in world space
178      */
179     public AbstractCompositeShape(final Point3D location) {
180         this(new Transform(location));
181     }
182
183     /**
184      * Creates a composite shape with the specified transform (position and orientation).
185      *
186      * @param transform the initial transform defining position and rotation
187      */
188     public AbstractCompositeShape(final Transform transform) {
189         this.transform = transform;
190         viewSpaceTracker = new ViewSpaceTracker();
191     }
192
193     /**
194      * Adds a sub-shape to this composite shape without a group identifier.
195      *
196      * @param shape the shape to add
197      */
198     public void addShape(final AbstractShape shape) {
199         addShape(shape, null);
200     }
201
202     /**
203      * Adds a sub-shape to this composite shape with an optional group identifier.
204      *
205      * <p>Grouped shapes can be shown, hidden, or removed together using
206      * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.</p>
207      *
208      * @param shape   the shape to add
209      * @param groupId the group identifier, or {@code null} for ungrouped shapes
210      */
211     public void addShape(final AbstractShape shape, final String groupId) {
212         subShapesRegistry.add(new SubShape(shape, groupId, true));
213         cacheNeedsRebuild = true;
214     }
215
216     /**
217      * This method should be overridden by anyone wanting to customize the shape
218      * before it is rendered.
219      *
220      * @param transformPipe the current transform stack
221      * @param context       the rendering context for the current frame
222      */
223     public void beforeTransformHook(final TransformStack transformPipe,
224                                     final RenderingContext context) {
225     }
226
227     /**
228      * Returns the world-space position of this composite shape.
229      *
230      * @return the translation component of this shape's transform
231      */
232     public Point3D getLocation() {
233         return transform.getTranslation();
234     }
235
236     /**
237      * Returns the axis-aligned bounding box encompassing all sub-shapes.
238      *
239      * <p>The bounding box is computed by aggregating the bounds of all visible
240      * sub-shapes, then transforming the result by this composite's own transform.</p>
241      *
242      * <p><b>Caching:</b> The bounding box is recomputed whenever
243      * {@link #cacheNeedsRebuild} is true (shapes added/removed/visibility changed).
244      * For nested composites, the bounds include their local transform offset.</p>
245      *
246      * @return the axis-aligned bounding box in this composite's local coordinates
247      */
248     @Override
249     public Box getBoundingBox() {
250         if (cachedBoundingBox == null || cacheNeedsRebuild) {
251             if (subShapesRegistry.isEmpty()) {
252                 return super.getBoundingBox();
253             }
254
255             double minX = Double.MAX_VALUE;
256             double maxX = Double.MIN_VALUE;
257             double minY = Double.MAX_VALUE;
258             double maxY = Double.MIN_VALUE;
259             double minZ = Double.MAX_VALUE;
260             double maxZ = Double.MIN_VALUE;
261
262             for (final SubShape subShape : subShapesRegistry) {
263                 if (!subShape.isVisible()) {
264                     continue;
265                 }
266
267                 final AbstractShape shape = subShape.getShape();
268                 final Box shapeBounds = shape.getBoundingBox();
269
270                 // Get bounds and apply sub-shape's transform if it's a composite
271                 Point3D shapeMin = new Point3D(shapeBounds.getMinX(), shapeBounds.getMinY(), shapeBounds.getMinZ());
272                 Point3D shapeMax = new Point3D(shapeBounds.getMaxX(), shapeBounds.getMaxY(), shapeBounds.getMaxZ());
273
274                 // If sub-shape is a composite, apply its transform to the bounds
275                 if (shape instanceof AbstractCompositeShape) {
276                     final Transform subTransform = ((AbstractCompositeShape) shape).getTransform();
277                     final Point3D subTranslation = subTransform.getTranslation();
278                     shapeMin.add(subTranslation);
279                     shapeMax.add(subTranslation);
280                 }
281
282                 minX = Math.min(minX, shapeMin.x);
283                 maxX = Math.max(maxX, shapeMax.x);
284                 minY = Math.min(minY, shapeMin.y);
285                 maxY = Math.max(maxY, shapeMax.y);
286                 minZ = Math.min(minZ, shapeMin.z);
287                 maxZ = Math.max(maxZ, shapeMax.z);
288             }
289
290             if (minX == Double.MAX_VALUE) {
291                 // No visible shapes
292                 return super.getBoundingBox();
293             }
294
295             cachedBoundingBox = new Box(
296                     new Point3D(minX, minY, minZ),
297                     new Point3D(maxX, maxY, maxZ)
298             );
299         }
300         return cachedBoundingBox;
301     }
302
303     /**
304      * Returns the sub-shapes registry (source of truth for all sub-shapes).
305      *
306      * <p>This is the authoritative list of all sub-shapes including hidden ones.
307      * For per-frame rendering, use {@link #cachedRenderList} instead (accessed internally).</p>
308      *
309      * @return the registry list of all sub-shapes with their group and visibility metadata
310      * @see #cachedRenderList the frame-optimized cache derived from this registry
311      */
312     public List<SubShape> getSubShapesRegistry() {
313         return subShapesRegistry;
314     }
315
316     /**
317      * Extracts all SolidPolygon instances from this composite shape.
318      *
319      * <p>Recursively traverses the shape hierarchy and collects all
320      * SolidPolygon instances. Used for CSG operations where polygons
321      * are needed directly without conversion.</p>
322      *
323      * @return list of SolidPolygon instances from this shape hierarchy
324      */
325     public List<SolidPolygon> extractSolidPolygons() {
326         final List<SolidPolygon> result = new ArrayList<>();
327         for (final SubShape subShape : subShapesRegistry) {
328             final AbstractShape shape = subShape.getShape();
329             if (shape instanceof SolidPolygon) {
330                 result.add((SolidPolygon) shape);
331             } else if (shape instanceof AbstractCompositeShape) {
332                 result.addAll(((AbstractCompositeShape) shape).extractSolidPolygons());
333             }
334         }
335         return result;
336     }
337
338     /**
339      * Returns the view-space tracker that monitors the distance
340      * and angle between the camera and this shape for level-of-detail adjustments.
341      *
342      * @return the view-space tracker for this shape
343      */
344     public ViewSpaceTracker getViewSpaceTracker() {
345         return viewSpaceTracker;
346     }
347
348     /**
349      * Hides all sub-shapes belonging to the specified group.
350      * Hidden shapes are not rendered but remain in the collection.
351      *
352      * @param groupIdentifier the group to hide
353      * @see #showGroup(String)
354      * @see #removeGroup(String)
355      */
356     public void hideGroup(final String groupIdentifier) {
357         for (final SubShape subShape : subShapesRegistry) {
358             if (subShape.matchesGroup(groupIdentifier)) {
359                 subShape.setVisible(false);
360                 cacheNeedsRebuild = true;
361             }
362         }
363     }
364
365     /**
366      * Determines whether textured polygons need to be re-tessellated based on tessellation factor change.
367      * <p>
368      * Re-tessellation is needed if the tessellation state is marked outdated, or if the ratio between
369      * the larger and smaller tessellation factor exceeds 1.5x. This threshold prevents frequent
370      * re-tessellation for minor view changes while ensuring significant LOD changes trigger updates.
371      *
372      * @param proposedNewTessellationFactor the tessellation factor computed from current view distance
373      * @param currentTessellationFactor     the tessellation factor currently in use
374      * @return {@code true} if re-tessellation should be performed
375      */
376     private boolean isRetessellationNeeded(final double proposedNewTessellationFactor, final double currentTessellationFactor) {
377
378         if (cacheNeedsRebuild)
379             return true;
380
381         // retessellate if there is significant difference between proposed and current tessellation factor
382         final double larger = Math.max(proposedNewTessellationFactor, currentTessellationFactor);
383         final double smaller = Math.min(proposedNewTessellationFactor, currentTessellationFactor);
384
385         return (larger / smaller) > 1.5d;
386     }
387
388     /**
389      * Permanently removes all sub-shapes belonging to the specified group.
390      *
391      * @param groupIdentifier the group to remove
392      * @see #hideGroup(String)
393      */
394     public void removeGroup(final String groupIdentifier) {
395         final java.util.Iterator<SubShape> iterator = subShapesRegistry
396                 .iterator();
397
398         while (iterator.hasNext()) {
399             final SubShape subShape = iterator.next();
400             if (subShape.matchesGroup(groupIdentifier)) {
401                 iterator.remove();
402                 cacheNeedsRebuild = true;
403             }
404         }
405     }
406
407     /**
408      * Returns all sub-shapes belonging to the specified group.
409      *
410      * @param groupIdentifier the group identifier to match
411      * @return list of matching sub-shapes
412      */
413     public List<SubShape> getGroup(final String groupIdentifier) {
414         final List<SubShape> result = new ArrayList<>();
415         for (int i = 0; i < subShapesRegistry.size(); i++) {
416             final SubShape subShape = subShapesRegistry.get(i);
417             if (subShape.matchesGroup(groupIdentifier))
418                 result.add(subShape);
419         }
420         return result;
421     }
422
423     /**
424      * Checks if re-slicing is needed and performs it if so.
425      *
426      * <p>For root composites, skips LOD-based checks since distance to camera is 0.
427      * Only retessellates when {@link #cacheNeedsRebuild} is true (shapes added/removed/visibility changed).</p>
428      *
429      * <p>For normal composites, checks both cache validity and LOD-based tessellation factor changes.</p>
430      *
431      * @param context the rendering context for logging
432      */
433     private void retessellateIfNeeded(final RenderingContext context) {
434
435         if (isRootComposite) {
436             if (cacheNeedsRebuild)
437                 retessellate(context);
438             return;
439         }
440
441         final double proposedTessellationFactor = viewSpaceTracker.proposeTessellationFactor();
442
443         if (isRetessellationNeeded(proposedTessellationFactor, currentTessellationFactor)) {
444             currentTessellationFactor = proposedTessellationFactor;
445             retessellate(context);
446         }
447     }
448
449     /**
450      * Paint solid elements of this composite shape into given color.
451      *
452      * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
453      *
454      * @param color the color to apply to all solid sub-shapes
455      */
456     public void setColor(final Color color) {
457         for (final SubShape subShape : getSubShapesRegistry()) {
458             final AbstractShape shape = subShape.getShape();
459
460             if (shape instanceof SolidPolygon) {
461                 ((SolidPolygon) shape).setColor(color);
462             } else if (shape instanceof Line) {
463                 ((Line) shape).color = color;
464             } else if (shape instanceof AbstractCompositeShape) {
465                 ((AbstractCompositeShape) shape).setColor(color);
466             }
467         }
468     }
469
470     /**
471      * Assigns a group identifier to all sub-shapes that currently have no group.
472      *
473      * @param groupIdentifier the group to assign to ungrouped shapes
474      */
475     public void setGroupForUngrouped(final String groupIdentifier) {
476         for (final SubShape subShape : subShapesRegistry)
477             if (subShape.isUngrouped())
478                 subShape.setGroup(groupIdentifier);
479     }
480
481     @Override
482     public void setMouseInteractionController(
483             final MouseInteractionController mouseInteractionController) {
484         super.setMouseInteractionController(mouseInteractionController);
485
486         for (final SubShape subShape : subShapesRegistry)
487             subShape.getShape().setMouseInteractionController(
488                     mouseInteractionController);
489
490         cacheNeedsRebuild = true;
491     }
492
493     /**
494      * Marks this composite as the root scene container.
495      *
496      * <p>Root composites skip LOD-based tessellation factor checks since their position
497      * equals the camera position (distance = 0). They use a fixed tessellation factor
498      * and only retessellate when shapes are added, removed, or visibility changes.</p>
499      *
500      * <p>Called by {@code ShapeCollection} to configure its root composite.</p>
501      *
502      * @param isRoot {@code true} if this is the root composite, {@code false} otherwise
503      */
504     public void setRootComposite(final boolean isRoot) {
505         this.isRootComposite = isRoot;
506     }
507
508     /**
509      * Returns this composite's transform (position and orientation).
510      *
511      * @return the transform object
512      */
513     public Transform getTransform() {
514         return transform;
515     }
516
517     /**
518      * Sets the transform for this composite shape.
519      *
520      * @param transform the new transform
521      * @return this composite shape (for chaining)
522      */
523     public AbstractCompositeShape setTransform(final Transform transform) {
524         this.transform = transform;
525         return this;
526     }
527
528     /**
529      * Sets the cache rebuild flag, forcing {@link #cachedRenderList} to be regenerated.
530      *
531      * <p>Used by {@code ShapeCollection} to trigger retessellate when clearing the scene
532      * or for other advanced use cases.</p>
533      *
534      * @param needsRebuild {@code true} to force cache rebuild on next frame
535      */
536     public void setCacheNeedsRebuild(final boolean needsRebuild) {
537         this.cacheNeedsRebuild = needsRebuild;
538     }
539
540     /**
541      * Enables or disables shading for all SolidTriangle and SolidPolygon sub-shapes.
542      * When enabled, shapes use the global lighting manager from the rendering
543      * context to calculate flat shading based on light sources.
544      *
545      * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
546      *
547      * @param shadingEnabled {@code true} to enable shading, {@code false} to disable
548      * @return this composite shape (for chaining)
549      */
550     public AbstractCompositeShape setShadingEnabled(final boolean shadingEnabled) {
551         for (final SubShape subShape : getSubShapesRegistry()) {
552             final AbstractShape shape = subShape.getShape();
553             if (shape instanceof SolidPolygon) {
554                 ((SolidPolygon) shape).setShadingEnabled(shadingEnabled);
555             } else if (shape instanceof AbstractCompositeShape) {
556                 ((AbstractCompositeShape) shape).setShadingEnabled(shadingEnabled);
557             }
558         }
559         return this;
560     }
561
562     /**
563      * Enables or disables backface culling for all SolidPolygon and TexturedTriangle sub-shapes.
564      *
565      * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
566      *
567      * @param backfaceCulling {@code true} to enable backface culling, {@code false} to disable
568      * @return this composite shape (for chaining)
569      */
570     public AbstractCompositeShape setBackfaceCulling(final boolean backfaceCulling) {
571         for (final SubShape subShape : getSubShapesRegistry()) {
572             final AbstractShape shape = subShape.getShape();
573             if (shape instanceof SolidPolygon) {
574                 ((SolidPolygon) shape).setBackfaceCulling(backfaceCulling);
575             } else if (shape instanceof TexturedTriangle) {
576                 ((TexturedTriangle) shape).setBackfaceCulling(backfaceCulling);
577             } else if (shape instanceof AbstractCompositeShape) {
578                 ((AbstractCompositeShape) shape).setBackfaceCulling(backfaceCulling);
579             }
580         }
581         return this;
582     }
583
584     /**
585      * Performs an in-place union with another composite shape.
586      *
587      * <p>This shape's SolidPolygon children are replaced with the union result.
588      * Non-SolidPolygon children from both shapes are preserved and combined.</p>
589      *
590      * <p><b>CSG Operation:</b> Union combines two shapes into one, keeping all
591      * geometry from both. Uses BSP tree algorithms for robust boolean operations.</p>
592      *
593      * <p><b>Child handling:</b></p>
594      * <ul>
595      *   <li>SolidPolygon children from both shapes â†’ replaced with union result</li>
596      *   <li>Non-SolidPolygon children from this shape â†’ preserved</li>
597      *   <li>Non-SolidPolygon children from other shape â†’ added to this shape</li>
598      *   <li>Nested AbstractCompositeShape children â†’ preserved unchanged (not recursively processed)</li>
599      * </ul>
600      *
601      * @param other the shape to union with
602      * @see #subtract(AbstractCompositeShape)
603      * @see #intersect(AbstractCompositeShape)
604      */
605     public void union(final AbstractCompositeShape other) {
606
607         final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
608         final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
609
610         // Remove from self any polygons that are inside other (interior faces)
611         selfTree.clipTo(otherTree);
612
613         // Remove from other any polygons that are inside self (interior faces)
614         otherTree.clipTo(selfTree);
615
616         // Invert other to convert remaining polygons for the next clip step
617         otherTree.invert();
618
619         // Clip inverted other against self to remove back-facing coplanar polygons
620         otherTree.clipTo(selfTree);
621
622         // Invert back to restore correct polygon orientation
623         otherTree.invert();
624
625         // Merge other's remaining polygons into self's BSP tree
626         selfTree.addPolygons(otherTree.allPolygons());
627
628         replaceSolidPolygons(selfTree.allPolygons());
629         mergeNonPolygonChildrenFrom(other);
630     }
631
632     /**
633      * Performs an in-place subtraction with another composite shape.
634      *
635      * <p>This shape's SolidPolygon children are replaced with the difference result.
636      * The other shape acts as a "cutter" that carves out volume from this shape.</p>
637      *
638      * <p><b>CSG Operation:</b> Subtract removes the volume of the second shape
639      * from the first shape. Useful for creating holes, cavities, and cutouts.</p>
640      *
641      * <p><b>Child handling:</b></p>
642      * <ul>
643      *   <li>SolidPolygon children from this shape â†’ replaced with difference result</li>
644      *   <li>Non-SolidPolygon children from this shape â†’ preserved</li>
645      *   <li>All children from other shape â†’ discarded (other is just a cutter)</li>
646      *   <li>Nested AbstractCompositeShape children â†’ preserved unchanged</li>
647      * </ul>
648      *
649      * @param other the shape to subtract (the cutter)
650      * @see #union(AbstractCompositeShape)
651      * @see #intersect(AbstractCompositeShape)
652      */
653     public void subtract(final AbstractCompositeShape other) {
654
655         final BspTree target = new BspTree(clonePolygons(extractSolidPolygons()));
656         final BspTree cutter = new BspTree(clonePolygons(other.extractSolidPolygons()));
657
658         // Invert target: convert "inside" to "outside" and vice versa
659         // This transforms the problem from "subtract B from A" to "intersect A's complement with B's complement"
660         target.invert();
661
662         // Clip target against cutter: removes parts of target that are INSIDE the cutter
663         // Since target is inverted, this removes parts that were OUTSIDE the original target
664         target.clipTo(cutter);
665
666         // Clip cutter against (inverted) target: removes parts of cutter outside the inverted target
667         // This keeps only cutter polygons that are inside the inverted target = outside original target
668         cutter.clipTo(target);
669
670         // Invert cutter to flip its inside/outside
671         cutter.invert();
672
673         // Clip inverted cutter against target: removes coplanar back-faces
674         cutter.clipTo(target);
675
676         // Invert cutter back to correct orientation
677         cutter.invert();
678
679         // Merge cutter's polygons into target's BSP tree
680         target.addPolygons(cutter.allPolygons());
681
682         // Invert target back to restore correct inside/outside orientation
683         // Result: the carved-out volume (target minus cutter)
684         target.invert();
685
686         replaceSolidPolygons(target.allPolygons());
687     }
688
689     /**
690      * Performs an in-place intersection with another composite shape.
691      *
692      * <p>This shape's SolidPolygon children are replaced with the intersection result.
693      * Only the overlapping volume between the two shapes remains.</p>
694      *
695      * <p><b>CSG Operation:</b> Intersect keeps only the volume where both shapes
696      * overlap. Useful for creating shapes constrained by multiple boundaries.</p>
697      *
698      * <p><b>Child handling:</b></p>
699      * <ul>
700      *   <li>SolidPolygon children from this shape â†’ replaced with intersection result</li>
701      *   <li>Non-SolidPolygon children from this shape â†’ preserved</li>
702      *   <li>All children from other shape â†’ discarded</li>
703      *   <li>Nested AbstractCompositeShape children â†’ preserved unchanged</li>
704      * </ul>
705      *
706      * @param other the shape to intersect with
707      * @see #union(AbstractCompositeShape)
708      * @see #subtract(AbstractCompositeShape)
709      */
710     public void intersect(final AbstractCompositeShape other) {
711
712         final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
713         final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
714
715         // Invert self to convert "inside" to "outside"
716         // This transforms intersection into: keep parts that are "outside both inverted shapes"
717         selfTree.invert();
718
719         // Clip other against inverted self: keeps only parts of other that are INSIDE original self
720         // (because clipTo removes what's "outside" the BSP, and inverted self's "outside" = original self's "inside")
721         otherTree.clipTo(selfTree);
722
723         // Invert other (which now represents the intersection region)
724         otherTree.invert();
725
726         // Clip inverted self against (inverted intersection): removes parts outside the intersection
727         selfTree.clipTo(otherTree);
728
729         // Clip intersection result against inverted self: removes back-facing coplanar polygons
730         otherTree.clipTo(selfTree);
731
732         // Build final BSP tree from the clipped intersection polygons
733         selfTree.addPolygons(otherTree.allPolygons());
734
735         // Invert back to restore correct inside/outside orientation
736         selfTree.invert();
737
738         replaceSolidPolygons(selfTree.allPolygons());
739     }
740
741     /**
742      * Creates deep clones of all polygons in the list.
743      *
744      * <p>CSG operations modify polygons in-place via BSP tree operations.
745      * Cloning ensures the original polygon data is preserved.</p>
746      *
747      * @param polygons the polygons to clone
748      * @return a new list containing deep clones of all polygons
749      */
750     private List<SolidPolygon> clonePolygons(final List<SolidPolygon> polygons) {
751         final List<SolidPolygon> cloned = new ArrayList<>(polygons.size());
752         for (final SolidPolygon p : polygons) {
753             cloned.add(p.deepClone());
754         }
755         return cloned;
756     }
757
758     /**
759      * Replaces this shape's SolidPolygon children with new polygons.
760      *
761      * <p>Preserves all non-SolidPolygon children (Lines, nested composites, etc.).</p>
762      *
763      * @param newPolygons the polygons to replace with
764      */
765     private void replaceSolidPolygons(final List<SolidPolygon> newPolygons) {
766         // Remove all direct SolidPolygon children from this shape
767         final Iterator<SubShape> iterator = subShapesRegistry.iterator();
768         while (iterator.hasNext()) {
769             final SubShape subShape = iterator.next();
770             if (subShape.getShape() instanceof SolidPolygon) {
771                 iterator.remove();
772             }
773         }
774
775         // Add all result polygons as new children
776         for (final SolidPolygon polygon : newPolygons) {
777             addShape(polygon);
778         }
779
780         cacheNeedsRebuild = true;
781     }
782
783     /**
784      * Merges non-SolidPolygon children from another shape into this shape.
785      *
786      * <p>Copies all non-SolidPolygon children (Lines, nested composites, etc.)
787      * from the other shape, preserving their group identifiers.</p>
788      *
789      * @param other the shape to merge non-polygon children from
790      */
791     private void mergeNonPolygonChildrenFrom(final AbstractCompositeShape other) {
792         if (other == null) {
793             return;
794         }
795
796         for (final SubShape otherSubShape : other.subShapesRegistry) {
797             final AbstractShape otherShape = otherSubShape.getShape();
798             if (!(otherShape instanceof SolidPolygon)) {
799                 addShape(otherShape, otherSubShape.getGroupIdentifier());
800             }
801         }
802
803         cacheNeedsRebuild = true;
804     }
805
806     /**
807      * Makes all sub-shapes belonging to the specified group visible.
808      *
809      * @param groupIdentifier the group to show
810      * @see #hideGroup(String)
811      */
812     public void showGroup(final String groupIdentifier) {
813         for (int i = 0; i < subShapesRegistry.size(); i++) {
814             final SubShape subShape = subShapesRegistry.get(i);
815             if (subShape.matchesGroup(groupIdentifier)) {
816                 subShape.setVisible(true);
817                 cacheNeedsRebuild = true;
818             }
819         }
820     }
821
822     /**
823      * Retessellates all textured polygons, triangulates N-vertex solid polygons,
824      * and rebuilds the cached render list.
825      * Logs the operation to the debug log buffer if available.
826      *
827      * @param context the rendering context for logging, may be {@code null}
828      */
829     private void retessellate(final RenderingContext context) {
830         cacheNeedsRebuild = false;
831
832         final List<AbstractShape> result = new ArrayList<>();
833
834         final TexturedPolygonTessellator tessellator = new TexturedPolygonTessellator(currentTessellationFactor);
835         int texturedPolygonCount = 0;
836         int solidPolygonCount = 0;
837         int triangulatedPolygonCount = 0;
838         int otherShapeCount = 0;
839
840         for (int i = 0; i < subShapesRegistry.size(); i++) {
841             final SubShape subShape = subShapesRegistry.get(i);
842             if (!subShape.isVisible())
843                 continue;
844
845             final AbstractShape shape = subShape.getShape();
846
847             if (shape instanceof TexturedTriangle) {
848                 tessellator.tessellate((TexturedTriangle) shape);
849                 texturedPolygonCount++;
850             } else if (shape instanceof SolidPolygon polygon) {
851                 final int vertexCount = polygon.getVertexCount();
852
853                 if (vertexCount == 3) {
854                     result.add(polygon);
855                     solidPolygonCount++;
856                 } else {
857                     triangulateSolidPolygon(polygon, result);
858                     triangulatedPolygonCount++;
859                 }
860             } else {
861                 result.add(shape);
862                 otherShapeCount++;
863             }
864         }
865
866         result.addAll(tessellator.getResult());
867
868         cachedRenderList = result;
869
870         if (context != null && context.debugLogBuffer != null) {
871             context.debugLogBuffer.log("retessellate: " + getClass().getSimpleName()
872                     + " tessellationFactor=" + String.format("%.2f", currentTessellationFactor)
873                     + " texturedPolygons=" + texturedPolygonCount
874                     + " solidPolygons=" + solidPolygonCount
875                     + " triangulatedPolygons=" + triangulatedPolygonCount
876                     + " otherShapes=" + otherShapeCount
877                     + " resultingTexturedPolygons=" + tessellator.getResult().size());
878         }
879     }
880
881     /**
882      * Triangulates a convex solid polygon using fan triangulation.
883      *
884      * <p>Fan triangulation creates N-2 triangles from an N-vertex polygon by using
885      * vertex 0 as the anchor and connecting it to each adjacent pair of vertices.</p>
886      *
887      * <p>Properties (color, shading, backface culling, mouse interaction) are
888      * propagated to each resulting triangle to ensure consistent behavior.</p>
889      *
890      * @param polygon the polygon to triangulate (must have at least 4 vertices)
891      * @param result  the list to add the resulting triangles to
892      */
893     private void triangulateSolidPolygon(final SolidPolygon polygon,
894                                          final List<AbstractShape> result) {
895
896         final Color color = polygon.getColor();
897         final boolean shadingEnabled = polygon.isShadingEnabled();
898         final boolean backfaceCulling = polygon.isBackfaceCullingEnabled();
899         final MouseInteractionController mouseController = polygon.mouseInteractionController;
900
901         final List<Vertex> vertices = polygon.vertices;
902         final Vertex v0 = vertices.get(0);
903
904         for (int i = 1; i < vertices.size() - 1; i++) {
905             final Vertex v1 = vertices.get(i);
906             final Vertex v2 = vertices.get(i + 1);
907
908             final SolidPolygon triangle = new SolidPolygon(
909                     v0.coordinate, v1.coordinate, v2.coordinate, color);
910
911             triangle.setShadingEnabled(shadingEnabled);
912             triangle.setBackfaceCulling(backfaceCulling);
913             triangle.setMouseInteractionController(mouseController);
914
915             result.add(triangle);
916         }
917     }
918
919     @Override
920     public void transform(final TransformStack transformPipe,
921                           final RenderAggregator aggregator, final RenderingContext context) {
922
923         // Add the current composite shape transform to the end of the transform
924         // pipeline.
925         transformPipe.addTransform(transform);
926
927         // FRUSTUM CULLING: Check if this composite's bounds are visible
928         // Root composite skips this check (its bounds are always the full scene)
929         // Non-root composites check their aggregated bounds against the frustum
930         if (context.frustum != null && !isRootComposite) {
931             // Count this composite for culling statistics (before frustum test)
932             if (context.cullingStatistics != null) {
933                 context.cullingStatistics.totalComposites++;
934             }
935
936             final Box localBounds = getBoundingBox();
937
938             // Transform all 8 corners of the bounding box to view space
939             final double minX = localBounds.getMinX();
940             final double maxX = localBounds.getMaxX();
941             final double minY = localBounds.getMinY();
942             final double maxY = localBounds.getMaxY();
943             final double minZ = localBounds.getMinZ();
944             final double maxZ = localBounds.getMaxZ();
945
946             final double[] xs = {minX, maxX};
947             final double[] ys = {minY, maxY};
948             final double[] zs = {minZ, maxZ};
949
950             double viewMinX = Double.MAX_VALUE;
951             double viewMaxX = -Double.MAX_VALUE;
952             double viewMinY = Double.MAX_VALUE;
953             double viewMaxY = -Double.MAX_VALUE;
954             double viewMinZ = Double.MAX_VALUE;
955             double viewMaxZ = -Double.MAX_VALUE;
956
957             for (int i = 0; i < 8; i++) {
958                 final double x = xs[(i & 1)];
959                 final double y = ys[(i >> 1) & 1];
960                 final double z = zs[(i >> 2) & 1];
961
962                 final Point3D corner = transformPointToViewSpace(x, y, z, transformPipe);
963
964                 viewMinX = Math.min(viewMinX, corner.x);
965                 viewMaxX = Math.max(viewMaxX, corner.x);
966                 viewMinY = Math.min(viewMinY, corner.y);
967                 viewMaxY = Math.max(viewMaxY, corner.y);
968                 viewMinZ = Math.min(viewMinZ, corner.z);
969                 viewMaxZ = Math.max(viewMaxZ, corner.z);
970             }
971
972             final Box viewSpaceBounds = new Box(
973                     new Point3D(viewMinX, viewMinY, viewMinZ),
974                     new Point3D(viewMaxX, viewMaxY, viewMaxZ)
975             );
976
977             final Frustum frustum = context.frustum;
978             final boolean visible = frustum.intersectsAABB(viewSpaceBounds);
979
980             if (!visible) {
981                 // Entire composite outside frustum - skip processing all children
982                 if (context.cullingStatistics != null) {
983                     context.cullingStatistics.culledComposites++;
984                 }
985                 transformPipe.dropTransform();
986                 return;
987             }
988         }
989
990         viewSpaceTracker.analyze(transformPipe, context);
991
992         beforeTransformHook(transformPipe, context);
993
994         retessellateIfNeeded(context);
995
996         // transform rendered subshapes
997         for (final AbstractShape shape : cachedRenderList)
998             shape.transform(transformPipe, aggregator, context);
999
1000         transformPipe.dropTransform();
1001     }
1002
1003     /**
1004      * Transforms a point to view space using the current transform stack.
1005      * Helper method for frustum culling that transforms bounding box corners.
1006      *
1007      * @param x             the X coordinate in local space
1008      * @param y             the Y coordinate in local space
1009      * @param z             the Z coordinate in local space
1010      * @param transformPipe the current transform stack
1011      * @return the transformed point in view space
1012      */
1013     private Point3D transformPointToViewSpace(final double x, final double y, final double z,
1014                                               final TransformStack transformPipe) {
1015         final Point3D input = new Point3D(x, y, z);
1016         final Point3D result = new Point3D();
1017         transformPipe.transform(input, result);
1018         return result;
1019     }
1020
1021 }