776d21b9c55ca3d004ee2ac6f0dd5bad19321e4d
[aukio-3d.git] /
1 /*
2  * Aukio 3D engine. Author: Svjatoslav Agejenko.
3  * This project is released under Creative Commons Zero (CC0) license.
4  */
5 package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base;
6
7 import eu.svjatoslav.aukio.e3d.geometry.Box;
8 import eu.svjatoslav.aukio.e3d.renderer.raster.Frustum;
9 import eu.svjatoslav.aukio.e3d.geometry.Point3D;
10 import eu.svjatoslav.aukio.e3d.renderer.raster.RenderingContext;
11 import eu.svjatoslav.aukio.e3d.gui.ViewSpaceTracker;
12 import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
13 import eu.svjatoslav.aukio.e3d.math.Transform;
14 import eu.svjatoslav.aukio.e3d.math.TransformStack;
15 import eu.svjatoslav.aukio.e3d.renderer.raster.Vertex;
16 import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
17 import eu.svjatoslav.aukio.e3d.renderer.raster.ParallelTransformCoordinator;
18 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
19 import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator;
20 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape;
21 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line;
22 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
23 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
24
25 import java.util.ArrayList;
26 import java.util.Iterator;
27 import java.util.List;
28 import java.util.concurrent.atomic.AtomicInteger;
29
30 /**
31  * A composite shape that groups multiple sub-shapes into a single logical unit.
32  *
33  * <p>Use {@code AbstractCompositeShape} to build complex 3D objects by combining
34  * primitive shapes (lines, polygons, textured polygons) into a group that can be
35  * positioned, rotated, and manipulated as one entity. Sub-shapes can be organized
36  * into named groups for selective visibility toggling.</p>
37  *
38  * <p><b>Usage example - creating a custom composite shape:</b></p>
39  * <pre>{@code
40  * // Create a composite shape at position (0, 0, 200)
41  * AbstractCompositeShape myObject = new AbstractCompositeShape(
42  *     new Point3D(0, 0, 200)
43  * );
44  *
45  * // Add sub-shapes
46  * myObject.addShape(new Line(
47  *     new Point3D(-50, 0, 0), new Point3D(50, 0, 0),
48  *     Color.RED, 2.0
49  * ));
50  *
51  * // Add shapes to a named group for toggling visibility
52  * myObject.addShape(labelShape, "labels");
53  * myObject.hideGroup("labels");  // hide all shapes in "labels" group
54  * myObject.showGroup("labels");  // show them again
55  *
56  * // Add to scene
57  * viewPanel.getRootShapeCollection().addShape(myObject);
58  * }</pre>
59  *
60  * <p><b>Perspective-correct texturing:</b></p>
61  * <p>Textured polygons are rendered with Quake-style perspective-correct scanline
62  * mapping ({@code TexturedTriangle}), so no screen-size tessellation is needed.</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.aukio.e3d.renderer.raster.shapes.AbstractShape the base shape class
70  */
71 public class AbstractCompositeShape extends AbstractShape {
72     /**
73      * Source-of-truth registry of all sub-shapes added to this composite.
74      *
75      * <p>Each sub-shape is wrapped with its group identifier and visibility state.
76      * Shapes are stored in insertion order and remain in this collection even when
77      * hidden (visibility state toggles instead of removal).</p>
78      *
79      * <p><b>Performance note:</b> This list is NOT processed for every frame.
80      * Instead, it serves as the authoritative source from which {@link #cachedRenderList}
81      * is compiled whenever the cache becomes invalid (see {@link #cacheNeedsRebuild}).
82      * Only modifications to this registry (add/remove/show/hide) trigger cache rebuild.</p>
83      *
84      * @see #cachedRenderList the frame-optimized cache derived from this registry
85      * @see #cacheNeedsRebuild the flag controlling when the cache is rebuilt
86      */
87     private final List<SubShape> subShapesRegistry = new ArrayList<>();
88
89     /**
90      * Tracks the distance and angle between the camera and this shape.
91      * Used e.g. by TextCanvas for distance-based rendering mode selection.
92      */
93     private final ViewSpaceTracker viewSpaceTracker;
94
95     /**
96      * Frame-optimized cache of shapes ready for rendering, derived from {@link #subShapesRegistry}.
97      *
98      * <p>This list is processed during every frame in the {@link #transform} method.
99      * It contains:</p>
100      * <ul>
101      *   <li>Shapes passing through directly (Line, TexturedTriangle, ...)</li>
102      *   <li>Solid polygons with more than 3 vertices - fan-triangulated</li>
103      * </ul>
104      *
105      * <p><b>Caching strategy:</b> The list is rebuilt only when
106      * {@link #cacheNeedsRebuild} is true, avoiding per-frame reconstruction
107      * overhead.</p>
108      *
109      * @see #subShapesRegistry the source registry this cache is derived from
110      * @see #cacheNeedsRebuild the flag that triggers cache regeneration
111      */
112     private List<AbstractShape> cachedRenderList = new ArrayList<>();
113
114     /**
115      * Flag indicating whether {@link #cachedRenderList} needs to be rebuilt from {@link #subShapesRegistry}.
116      *
117      * <p>Set to {@code true} when:</p>
118      * <ul>
119      *   <li>A shape is added via {@link #addShape}</li>
120      *   <li>A shape is removed via {@link #removeGroup}</li>
121      *   <li>Group visibility changes via {@link #showGroup} or {@link #hideGroup}</li>
122      * </ul>
123      *
124      * <p>Set to {@code false} after {@link #rebuildRenderList} completes the cache rebuild.</p>
125      *
126      * <p>This flag enables the performance optimization of avoiding per-frame list
127      * reconstruction - the registry is only re-processed when something actually changed.</p>
128      *
129      * @see #subShapesRegistry the source data that may need reprocessing
130      * @see #cachedRenderList the cache that gets rebuilt when this flag is true
131      */
132     private boolean cacheNeedsRebuild = true;
133
134     /**
135      * Flag indicating this composite is the root scene container (ShapeCollection's root).
136      *
137      * <p>Set via {@link #setRootComposite(boolean)} by ShapeCollection.</p>
138      */
139     private boolean isRootComposite = false;
140
141     /**
142      * The position and orientation transform for this composite shape.
143      * Applied to all sub-shapes during the rendering transform pass.
144      */
145     private Transform transform;
146
147     /**
148      * Creates a composite shape at the world origin with no rotation.
149      */
150     public AbstractCompositeShape() {
151         this(new Transform());
152     }
153
154     /**
155      * Creates a composite shape at the specified location with no rotation.
156      *
157      * @param location the position in world space
158      */
159     public AbstractCompositeShape(final Point3D location) {
160         this(new Transform(location));
161     }
162
163     /**
164      * Creates a composite shape with the specified transform (position and orientation).
165      *
166      * @param transform the initial transform defining position and rotation
167      */
168     public AbstractCompositeShape(final Transform transform) {
169         this.transform = transform;
170         viewSpaceTracker = new ViewSpaceTracker();
171     }
172
173     /**
174      * Adds a sub-shape to this composite shape without a group identifier.
175      *
176      * @param shape the shape to add
177      */
178     public void addShape(final AbstractShape shape) {
179         addShape(shape, null);
180     }
181
182     /**
183      * Adds a sub-shape to this composite shape with an optional group identifier.
184      *
185      * <p>Grouped shapes can be shown, hidden, or removed together using
186      * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.</p>
187      *
188      * @param shape   the shape to add
189      * @param groupId the group identifier, or {@code null} for ungrouped shapes
190      */
191     public void addShape(final AbstractShape shape, final String groupId) {
192         subShapesRegistry.add(new SubShape(shape, groupId, true));
193         cacheNeedsRebuild = true;
194     }
195
196     /**
197      * This method should be overridden by anyone wanting to customize the shape
198      * before it is rendered.
199      *
200      * @param transformPipe the current transform stack
201      * @param context       the rendering context for the current frame
202      */
203     public void beforeTransformHook(final TransformStack transformPipe,
204                                     final RenderingContext context) {
205     }
206
207     /**
208      * Returns the world-space position of this composite shape.
209      *
210      * @return the translation component of this shape's transform
211      */
212     public Point3D getLocation() {
213         return transform.getTranslation();
214     }
215
216     /**
217      * Returns the axis-aligned bounding box encompassing all sub-shapes.
218      *
219      * <p>The bounding box is computed by aggregating the bounds of all visible
220      * sub-shapes, then transforming the result by this composite's own transform.</p>
221      *
222      * <p><b>Caching:</b> The bounding box is recomputed whenever
223      * {@link #cacheNeedsRebuild} is true (shapes added/removed/visibility changed).
224      * For nested composites, the bounds include their local transform offset.</p>
225      *
226      * @return the axis-aligned bounding box in this composite's local coordinates
227      */
228     @Override
229     public Box getBoundingBox() {
230         if (cachedBoundingBox == null || cacheNeedsRebuild) {
231             if (subShapesRegistry.isEmpty()) {
232                 return super.getBoundingBox();
233             }
234
235             double minX = Double.MAX_VALUE;
236             double maxX = -Double.MAX_VALUE;
237             double minY = Double.MAX_VALUE;
238             double maxY = -Double.MAX_VALUE;
239             double minZ = Double.MAX_VALUE;
240             double maxZ = -Double.MAX_VALUE;
241
242             for (final SubShape subShape : subShapesRegistry) {
243                 if (!subShape.isVisible()) {
244                     continue;
245                 }
246
247                 final AbstractShape shape = subShape.getShape();
248                 final Box shapeBounds = shape.getBoundingBox();
249
250                 // Get bounds and apply sub-shape's transform if it's a composite
251                 Point3D shapeMin = new Point3D(shapeBounds.getMinX(), shapeBounds.getMinY(), shapeBounds.getMinZ());
252                 Point3D shapeMax = new Point3D(shapeBounds.getMaxX(), shapeBounds.getMaxY(), shapeBounds.getMaxZ());
253                 if (shape instanceof AbstractCompositeShape) {
254                     final Transform subTransform = ((AbstractCompositeShape) shape).getTransform();
255                     final Point3D subTranslation = subTransform.getTranslation();
256                     shapeMin.add(subTranslation);
257                     shapeMax.add(subTranslation);
258                 }
259
260                 minX = Math.min(minX, shapeMin.x);
261                 maxX = Math.max(maxX, shapeMax.x);
262                 minY = Math.min(minY, shapeMin.y);
263                 maxY = Math.max(maxY, shapeMax.y);
264                 minZ = Math.min(minZ, shapeMin.z);
265                 maxZ = Math.max(maxZ, shapeMax.z);
266             }
267
268             if (minX == Double.MAX_VALUE) {
269                 // No visible shapes
270                 return super.getBoundingBox();
271             }
272
273             cachedBoundingBox = new Box(
274                     new Point3D(minX, minY, minZ),
275                     new Point3D(maxX, maxY, maxZ)
276             );
277         }
278         return cachedBoundingBox;
279     }
280
281     /**
282      * Returns the sub-shapes registry (source of truth for all sub-shapes).
283      *
284      * <p>This is the authoritative list of all sub-shapes including hidden ones.
285      * For per-frame rendering, use {@link #cachedRenderList} instead (accessed internally).</p>
286      *
287      * @return the registry list of all sub-shapes with their group and visibility metadata
288      * @see #cachedRenderList the frame-optimized cache derived from this registry
289      */
290     public List<SubShape> getSubShapesRegistry() {
291         return subShapesRegistry;
292     }
293
294     /**
295      * Extracts all SolidPolygon instances from this composite shape.
296      *
297      * <p>Recursively traverses the shape hierarchy and collects all
298      * SolidPolygon instances. Used for CSG operations where polygons
299      * are needed directly without conversion.</p>
300      *
301      * @return list of SolidPolygon instances from this shape hierarchy
302      */
303     public List<SolidPolygon> extractSolidPolygons() {
304         final List<SolidPolygon> result = new ArrayList<>();
305         for (final SubShape subShape : subShapesRegistry) {
306             final AbstractShape shape = subShape.getShape();
307             if (shape instanceof SolidPolygon) {
308                 result.add((SolidPolygon) shape);
309             } else if (shape instanceof AbstractCompositeShape) {
310                 result.addAll(((AbstractCompositeShape) shape).extractSolidPolygons());
311             }
312         }
313         return result;
314     }
315
316     /**
317      * Returns the view-space tracker that monitors the distance
318      * and angle between the camera and this shape for level-of-detail adjustments.
319      *
320      * @return the view-space tracker for this shape
321      */
322     public ViewSpaceTracker getViewSpaceTracker() {
323         return viewSpaceTracker;
324     }
325
326     /**
327      * Hides all sub-shapes belonging to the specified group.
328      * Hidden shapes are not rendered but remain in the collection.
329      *
330      * @param groupIdentifier the group to hide
331      * @see #showGroup(String)
332      * @see #removeGroup(String)
333      */
334     public void hideGroup(final String groupIdentifier) {
335         for (final SubShape subShape : subShapesRegistry) {
336             if (subShape.matchesGroup(groupIdentifier)) {
337                 subShape.setVisible(false);
338                 cacheNeedsRebuild = true;
339             }
340         }
341     }
342
343     /**
344      * Permanently removes all sub-shapes belonging to the specified group.
345      *
346      * @param groupIdentifier the group to remove
347      * @see #hideGroup(String)
348      */
349     public void removeGroup(final String groupIdentifier) {
350         final java.util.Iterator<SubShape> iterator = subShapesRegistry
351                 .iterator();
352
353         while (iterator.hasNext()) {
354             final SubShape subShape = iterator.next();
355             if (subShape.matchesGroup(groupIdentifier)) {
356                 iterator.remove();
357                 cacheNeedsRebuild = true;
358             }
359         }
360     }
361
362     /**
363      * Returns all sub-shapes belonging to the specified group.
364      *
365      * @param groupIdentifier the group identifier to match
366      * @return list of matching sub-shapes
367      */
368     public List<SubShape> getGroup(final String groupIdentifier) {
369         final List<SubShape> result = new ArrayList<>();
370         for (int i = 0; i < subShapesRegistry.size(); i++) {
371             final SubShape subShape = subShapesRegistry.get(i);
372             if (subShape.matchesGroup(groupIdentifier))
373                 result.add(subShape);
374         }
375         return result;
376     }
377
378     /**
379      * Rebuilds the cached render list if shapes were added, removed, or
380      * visibility changed since the last rebuild.
381      *
382      * @param context the rendering context for logging
383      */
384     private void rebuildRenderListIfNeeded(final RenderingContext context) {
385         if (cacheNeedsRebuild)
386             rebuildRenderList(context);
387     }
388
389     /**
390      * Paint solid elements of this composite shape into given color.
391      *
392      * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
393      *
394      * @param color the color to apply to all solid sub-shapes
395      */
396     public void setColor(final Color color) {
397         for (final SubShape subShape : getSubShapesRegistry()) {
398             final AbstractShape shape = subShape.getShape();
399
400             if (shape instanceof SolidPolygon) {
401                 ((SolidPolygon) shape).setColor(color);
402             } else if (shape instanceof Line) {
403                 ((Line) shape).color = color;
404             } else if (shape instanceof AbstractCompositeShape) {
405                 ((AbstractCompositeShape) shape).setColor(color);
406             }
407         }
408     }
409
410     /**
411      * Assigns a group identifier to all sub-shapes that currently have no group.
412      *
413      * @param groupIdentifier the group to assign to ungrouped shapes
414      */
415     public void setGroupForUngrouped(final String groupIdentifier) {
416         for (final SubShape subShape : subShapesRegistry)
417             if (subShape.isUngrouped())
418                 subShape.setGroup(groupIdentifier);
419     }
420
421     @Override
422     public void setMouseInteractionController(
423             final MouseInteractionController mouseInteractionController) {
424         super.setMouseInteractionController(mouseInteractionController);
425
426         for (final SubShape subShape : subShapesRegistry)
427             subShape.getShape().setMouseInteractionController(
428                     mouseInteractionController);
429
430         cacheNeedsRebuild = true;
431     }
432
433     /**
434      * Marks this composite as the root scene container.
435      *
436      * <p>Called by {@code ShapeCollection} to configure its root composite.</p>
437      *
438      * @param isRoot {@code true} if this is the root composite, {@code false} otherwise
439      */
440     public void setRootComposite(final boolean isRoot) {
441         this.isRootComposite = isRoot;
442     }
443
444     /**
445      * Returns this composite's transform (position and orientation).
446      *
447      * @return the transform object
448      */
449     public Transform getTransform() {
450         return transform;
451     }
452
453     /**
454      * Sets the transform for this composite shape.
455      *
456      * @param transform the new transform
457      * @return this composite shape (for chaining)
458      */
459     public AbstractCompositeShape setTransform(final Transform transform) {
460         this.transform = transform;
461         return this;
462     }
463
464 /**
465      * Sets the cache rebuild flag on this composite and all nested composites recursively.
466      *
467      * <p>Used by {@code ShapeCollection} to trigger a render-list rebuild when
468      * clearing the scene or for other advanced use cases.</p>
469      *
470      * @param needsRebuild {@code true} to force cache rebuild on next frame
471      */
472     public void setCacheNeedsRebuild(final boolean needsRebuild) {
473         this.cacheNeedsRebuild = needsRebuild;
474         // Propagate to nested composites
475         for (final SubShape subShape : subShapesRegistry) {
476             final AbstractShape shape = subShape.getShape();
477             if (shape instanceof AbstractCompositeShape composite) {
478                 composite.setCacheNeedsRebuild(needsRebuild);
479             }
480         }
481     }
482
483     /**
484      * Enables or disables shading for all SolidTriangle and SolidPolygon sub-shapes.
485      * When enabled, shapes use the global lighting manager from the rendering
486      * context to calculate flat shading based on light sources.
487      *
488      * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
489      *
490      * @param shadingEnabled {@code true} to enable shading, {@code false} to disable
491      * @return this composite shape (for chaining)
492      */
493     public AbstractCompositeShape setShadingEnabled(final boolean shadingEnabled) {
494         for (final SubShape subShape : getSubShapesRegistry()) {
495             final AbstractShape shape = subShape.getShape();
496             if (shape instanceof SolidPolygon) {
497                 ((SolidPolygon) shape).setShadingEnabled(shadingEnabled);
498             } else if (shape instanceof AbstractCompositeShape) {
499                 ((AbstractCompositeShape) shape).setShadingEnabled(shadingEnabled);
500             }
501         }
502         return this;
503     }
504
505     /**
506      * Enables or disables backface culling for all SolidPolygon and TexturedTriangle sub-shapes.
507      *
508      * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
509      *
510      * @param backfaceCulling {@code true} to enable backface culling, {@code false} to disable
511      * @return this composite shape (for chaining)
512      */
513     public AbstractCompositeShape setBackfaceCulling(final boolean backfaceCulling) {
514         for (final SubShape subShape : getSubShapesRegistry()) {
515             final AbstractShape shape = subShape.getShape();
516             if (shape instanceof SolidPolygon) {
517                 ((SolidPolygon) shape).setBackfaceCulling(backfaceCulling);
518             } else if (shape instanceof TexturedTriangle) {
519                 ((TexturedTriangle) shape).setBackfaceCulling(backfaceCulling);
520             } else if (shape instanceof AbstractCompositeShape) {
521                 ((AbstractCompositeShape) shape).setBackfaceCulling(backfaceCulling);
522             }
523         }
524         return this;
525     }
526
527     /**
528      * Performs an in-place union with another composite shape.
529      *
530      * <p>This shape's SolidPolygon children are replaced with the union result.
531      * Non-SolidPolygon children from both shapes are preserved and combined.</p>
532      *
533      * <p><b>CSG Operation:</b> Union combines two shapes into one, keeping all
534      * geometry from both. Uses BSP tree algorithms for robust boolean operations.</p>
535      *
536      * <p><b>Child handling:</b></p>
537      * <ul>
538      *   <li>SolidPolygon children from both shapes → replaced with union result</li>
539      *   <li>Non-SolidPolygon children from this shape → preserved</li>
540      *   <li>Non-SolidPolygon children from other shape → added to this shape</li>
541      *   <li>Nested AbstractCompositeShape children → preserved unchanged (not recursively processed)</li>
542      * </ul>
543      *
544      * @param other the shape to union with
545      * @see #subtract(AbstractCompositeShape)
546      * @see #intersect(AbstractCompositeShape)
547      */
548     public void union(final AbstractCompositeShape other) {
549         replaceSolidPolygons(Csg.union(extractSolidPolygons(),
550                 other.extractSolidPolygons()));
551         mergeNonPolygonChildrenFrom(other);
552     }
553
554     /**
555      * Performs an in-place subtraction with another composite shape.
556      *
557      * <p>This shape's SolidPolygon children are replaced with the difference result.
558      * The other shape acts as a "cutter" that carves out volume from this shape.</p>
559      *
560      * <p><b>CSG Operation:</b> Subtract removes the volume of the second shape
561      * from the first shape. Useful for creating holes, cavities, and cutouts.</p>
562      *
563      * <p><b>Child handling:</b></p>
564      * <ul>
565      *   <li>SolidPolygon children from this shape → replaced with difference result</li>
566      *   <li>Non-SolidPolygon children from this shape → preserved</li>
567      *   <li>All children from other shape → discarded (other is just a cutter)</li>
568      *   <li>Nested AbstractCompositeShape children → preserved unchanged</li>
569      * </ul>
570      *
571      * @param other the shape to subtract (the cutter)
572      * @see #union(AbstractCompositeShape)
573      * @see #intersect(AbstractCompositeShape)
574      */
575     public void subtract(final AbstractCompositeShape other) {
576         replaceSolidPolygons(Csg.subtract(extractSolidPolygons(),
577                 other.extractSolidPolygons()));
578     }
579
580     /**
581      * Performs an in-place intersection with another composite shape.
582      *
583      * <p>This shape's SolidPolygon children are replaced with the intersection result.
584      * Only the overlapping volume between the two shapes remains.</p>
585      *
586      * <p><b>CSG Operation:</b> Intersect keeps only the volume where both shapes
587      * overlap. Useful for creating shapes constrained by multiple boundaries.</p>
588      *
589      * <p><b>Child handling:</b></p>
590      * <ul>
591      *   <li>SolidPolygon children from this shape → replaced with intersection result</li>
592      *   <li>Non-SolidPolygon children from this shape → preserved</li>
593      *   <li>All children from other shape → discarded</li>
594      *   <li>Nested AbstractCompositeShape children → preserved unchanged</li>
595      * </ul>
596      *
597      * @param other the shape to intersect with
598      * @see #union(AbstractCompositeShape)
599      * @see #subtract(AbstractCompositeShape)
600      */
601     public void intersect(final AbstractCompositeShape other) {
602         replaceSolidPolygons(Csg.intersect(extractSolidPolygons(),
603                 other.extractSolidPolygons()));
604     }
605
606     /**
607      * Replaces this shape's SolidPolygon children with new polygons.
608      *
609      * <p>Preserves all non-SolidPolygon children (Lines, nested composites, etc.).</p>
610      *
611      * @param newPolygons the polygons to replace with
612      */
613     private void replaceSolidPolygons(final List<SolidPolygon> newPolygons) {
614         // Remove all direct SolidPolygon children from this shape
615         final Iterator<SubShape> iterator = subShapesRegistry.iterator();
616         while (iterator.hasNext()) {
617             final SubShape subShape = iterator.next();
618             if (subShape.getShape() instanceof SolidPolygon) {
619                 iterator.remove();
620             }
621         }
622
623         // Add all result polygons as new children
624         for (final SolidPolygon polygon : newPolygons) {
625             addShape(polygon);
626         }
627
628         cacheNeedsRebuild = true;
629     }
630
631     /**
632      * Merges non-SolidPolygon children from another shape into this shape.
633      *
634      * <p>Copies all non-SolidPolygon children (Lines, nested composites, etc.)
635      * from the other shape, preserving their group identifiers.</p>
636      *
637      * @param other the shape to merge non-polygon children from
638      */
639     private void mergeNonPolygonChildrenFrom(final AbstractCompositeShape other) {
640         if (other == null) {
641             return;
642         }
643
644         for (final SubShape otherSubShape : other.subShapesRegistry) {
645             final AbstractShape otherShape = otherSubShape.getShape();
646             if (!(otherShape instanceof SolidPolygon)) {
647                 addShape(otherShape, otherSubShape.getGroupIdentifier());
648             }
649         }
650
651         cacheNeedsRebuild = true;
652     }
653
654     /**
655      * Makes all sub-shapes belonging to the specified group visible.
656      *
657      * @param groupIdentifier the group to show
658      * @see #hideGroup(String)
659      */
660     public void showGroup(final String groupIdentifier) {
661         for (int i = 0; i < subShapesRegistry.size(); i++) {
662             final SubShape subShape = subShapesRegistry.get(i);
663             if (subShape.matchesGroup(groupIdentifier)) {
664                 subShape.setVisible(true);
665                 cacheNeedsRebuild = true;
666             }
667         }
668     }
669
670     /**
671      * Rebuilds the cached render list from the shape registry:
672      * textured triangles pass through as-is (perspective-correct scanline
673      * rendering needs no tessellation), N-vertex solid polygons are
674      * fan-triangulated, everything else passes through.
675      * Logs the operation to the debug log buffer if available.
676      *
677      * @param context the rendering context for logging, may be {@code null}
678      */
679     private void rebuildRenderList(final RenderingContext context) {
680         cacheNeedsRebuild = false;
681
682         final List<AbstractShape> result = new ArrayList<>();
683         int texturedPolygonCount = 0;
684         int solidPolygonCount = 0;
685         int triangulatedPolygonCount = 0;
686         int otherShapeCount = 0;
687
688         for (int i = 0; i < subShapesRegistry.size(); i++) {
689             final SubShape subShape = subShapesRegistry.get(i);
690             if (!subShape.isVisible())
691                 continue;
692
693             final AbstractShape shape = subShape.getShape();
694
695             if (shape instanceof TexturedTriangle) {
696                 result.add(shape);
697                 texturedPolygonCount++;
698             } else if (shape instanceof SolidPolygon polygon) {
699                 final int vertexCount = polygon.getVertexCount();
700
701                 if (vertexCount == 3) {
702                     result.add(polygon);
703                     solidPolygonCount++;
704                 } else {
705                     triangulateSolidPolygon(polygon, result);
706                     triangulatedPolygonCount++;
707                 }
708             } else {
709                 result.add(shape);
710                 otherShapeCount++;
711             }
712         }
713
714         cachedRenderList = postprocessRenderList(result);
715         renderListVersion++;
716         globalRenderListVersion.incrementAndGet();
717
718         if (context != null && context.debugLogBuffer != null) {
719             context.debugLogBuffer.log("rebuildRenderList: " + getClass().getSimpleName()
720                     + " texturedPolygons=" + texturedPolygonCount
721                     + " solidPolygons=" + solidPolygonCount
722                     + " triangulatedPolygons=" + triangulatedPolygonCount
723                     + " otherShapes=" + otherShapeCount);
724         }
725     }
726
727     /**
728      * Returns the global render list version: incremented every time ANY
729      * composite's render list is rebuilt. Used by derived structures
730      * (BSP trees, GI scene snapshots) to detect that they must rebuild.
731      *
732      * @return monotonically increasing global version
733      */
734     public static int getGlobalRenderListVersion() {
735         return globalRenderListVersion.get();
736     }
737
738     /**
739      * Collects the triangles of this composite's current render list,
740      * recursing into nested composites. These are the exact objects that
741      * get transformed and rendered: triangulated render-list polygons, or
742      * lightmapped wrappers for
743      * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}.
744      *
745      * <p>Render lists are built lazily during transform; composites that
746      * have not been transformed yet (or are frustum-culled) contribute
747      * nothing. Vertices are in each composite's local space — callers
748      * combining several composites should require identity transforms.</p>
749      *
750      * @param out list receiving the triangles
751      */
752     public void collectRenderTriangles(final List<AbstractCoordinateShape> out) {
753         if (cachedRenderList == null)
754             return;
755         for (final AbstractShape shape : cachedRenderList) {
756             if (shape instanceof SolidPolygon
757                     || shape instanceof eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle)
758                 out.add((AbstractCoordinateShape) shape);
759             else if (shape instanceof AbstractCompositeShape)
760                 ((AbstractCompositeShape) shape).collectRenderTriangles(out);
761         }
762     }
763
764     /**
765      * Hook: post-processes the freshly rebuilt render list before it becomes
766      * the rendering cache. The default implementation returns the list
767      * unchanged. Subclasses may replace the list — e.g.
768      * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}
769      * wraps its polygons into lightmapped triangles here.
770      *
771      * @param renderList the render list built from the shape registry
772      * @return the render list to cache and render
773      */
774     protected List<AbstractShape> postprocessRenderList(final List<AbstractShape> renderList) {
775         return renderList;
776     }
777
778     /**
779      * Triangulates a convex solid polygon using fan triangulation.
780      *
781      * <p>Fan triangulation creates N-2 triangles from an N-vertex polygon by using
782      * vertex 0 as the anchor and connecting it to each adjacent pair of vertices.</p>
783      *
784      * <p>Properties (color, shading, backface culling, mouse interaction) are
785      * propagated to each resulting triangle to ensure consistent behavior.</p>
786      *
787      * @param polygon the polygon to triangulate (must have at least 4 vertices)
788      * @param result  the list to add the resulting triangles to
789      */
790     private void triangulateSolidPolygon(final SolidPolygon polygon,
791                                          final List<AbstractShape> result) {
792
793         final Color color = polygon.getColor();
794         final boolean shadingEnabled = polygon.isShadingEnabled();
795         final boolean backfaceCulling = polygon.isBackfaceCullingEnabled();
796         final MouseInteractionController mouseController = polygon.mouseInteractionController;
797
798         final List<Vertex> vertices = polygon.vertices;
799         final Vertex v0 = vertices.get(0);
800
801         for (int i = 1; i < vertices.size() - 1; i++) {
802             final Vertex v1 = vertices.get(i);
803             final Vertex v2 = vertices.get(i + 1);
804
805             final SolidPolygon triangle = new SolidPolygon(
806                     v0.coordinate, v1.coordinate, v2.coordinate, color);
807
808             triangle.setShadingEnabled(shadingEnabled);
809             triangle.setBackfaceCulling(backfaceCulling);
810             triangle.setMouseInteractionController(mouseController);
811
812             result.add(triangle);
813         }
814     }
815
816     @Override
817     public void transform(final TransformStack transformPipe,
818                           final RenderAggregator aggregator, final RenderingContext context) {
819
820         // Add the current composite shape transform to the end of the transform
821         // pipeline.
822         transformPipe.addTransform(transform);
823
824         // FRUSTUM CULLING: Check if this composite's bounds are visible
825         // Root composite skips this check (its bounds are always the full scene)
826         // Non-root composites check their aggregated bounds against the frustum
827         if (context.frustum != null && !isRootComposite) {
828             // Count this composite for culling statistics (before frustum test)
829             if (context.cullingStatistics != null) {
830                 context.cullingStatistics.totalComposites.incrementAndGet();
831             }
832
833             final Box localBounds = getBoundingBox();
834
835             // Transform all 8 corners of the bounding box to view space
836             final double minX = localBounds.getMinX();
837             final double maxX = localBounds.getMaxX();
838             final double minY = localBounds.getMinY();
839             final double maxY = localBounds.getMaxY();
840             final double minZ = localBounds.getMinZ();
841             final double maxZ = localBounds.getMaxZ();
842
843             final double[] xs = {minX, maxX};
844             final double[] ys = {minY, maxY};
845             final double[] zs = {minZ, maxZ};
846
847             double viewMinX = Double.MAX_VALUE;
848             double viewMaxX = -Double.MAX_VALUE;
849             double viewMinY = Double.MAX_VALUE;
850             double viewMaxY = -Double.MAX_VALUE;
851             double viewMinZ = Double.MAX_VALUE;
852             double viewMaxZ = -Double.MAX_VALUE;
853
854             for (int i = 0; i < 8; i++) {
855                 final double x = xs[(i & 1)];
856                 final double y = ys[(i >> 1) & 1];
857                 final double z = zs[(i >> 2) & 1];
858
859                 final Point3D corner = transformPointToViewSpace(x, y, z, transformPipe);
860
861                 viewMinX = Math.min(viewMinX, corner.x);
862                 viewMaxX = Math.max(viewMaxX, corner.x);
863                 viewMinY = Math.min(viewMinY, corner.y);
864                 viewMaxY = Math.max(viewMaxY, corner.y);
865                 viewMinZ = Math.min(viewMinZ, corner.z);
866                 viewMaxZ = Math.max(viewMaxZ, corner.z);
867             }
868
869             final Box viewSpaceBounds = new Box(
870                     new Point3D(viewMinX, viewMinY, viewMinZ),
871                     new Point3D(viewMaxX, viewMaxY, viewMaxZ)
872             );
873
874             final Frustum frustum = context.frustum;
875             final boolean visible = frustum.intersectsAABB(viewSpaceBounds);
876
877             if (!visible) {
878                 // Entire composite outside frustum - skip processing all children
879                 if (context.cullingStatistics != null) {
880                     context.cullingStatistics.culledComposites.incrementAndGet();
881                 }
882                 transformPipe.dropTransform();
883                 return;
884             }
885         }
886
887         viewSpaceTracker.analyze(transformPipe, context);
888
889         beforeTransformHook(transformPipe, context);
890
891         rebuildRenderListIfNeeded(context);
892
893         // transform rendered subshapes
894         if (shouldForkTransform(context)) {
895             transformChildrenParallel(transformPipe, aggregator, context);
896         } else {
897             transformChildrenSerial(transformPipe, aggregator, context);
898         }
899
900         transformPipe.dropTransform();
901     }
902
903     /**
904      * Minimum number of children before the parallel fork is considered at
905      * all. A single child cannot be split; splitting happens in the child.
906      */
907     private static final int PARALLEL_TRANSFORM_MIN_SHAPES = 2;
908
909     /**
910      * Minimum total subtree weight (leaf primitives below this composite)
911      * before forking its transform into parallel chunks. Below this, the
912      * serial walk is cheaper than the fork overhead. Deliberately above
913      * one sphere's generated triangle count (~960 at 16 segments):
914      * measured 2026-09-04, forking those pays task overhead per chunk for
915      * negligible serial work (35k chunk tasks on a 3000-sphere scene were
916      * SLOWER than serial).
917      */
918     private static final int PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT = 2048;
919
920     /**
921      * Minimum weight per parallel chunk task. Keeps chunk granularity
922      * coarse enough that task dispatch overhead stays negligible.
923      */
924     private static final int PARALLEL_TASK_MIN_WEIGHT = 512;
925
926     /**
927      * Cached subtree weight from {@link #getTransformWeight}, valid for
928      * {@link #subtreeWeightCycle} only.
929      */
930     private int cachedSubtreeWeight;
931
932     /**
933      * Transform cycle id the cached subtree weight was computed on.
934      * Keyed on the globally unique cycle id, not the per-context frame
935      * number, so alternating between rendering contexts cannot produce
936      * stale cache hits.
937      */
938     private long subtreeWeightCycle = -1;
939
940     /**
941      * Transform cycle id the cached subtree weight was last RECOMPUTED on.
942      * Separate from {@link #subtreeWeightCycle} (last read): the refresh
943      * gate measures the age of the computation, not of the last access.
944      */
945     private long subtreeWeightComputeCycle = -1;
946
947     /**
948      * {@link #renderListVersion} at the last weight recomputation.
949      */
950     private int weightListVersion = -1;
951
952     /**
953      * Bumped whenever ANY composite rebuilds its render list. Lets the
954      * weight shortcut react to structural changes anywhere in the tree
955      * within one cycle, at O(1) per node per cycle — scanning direct
956      * children's versions instead costs O(leaves) per frame because leaf
957      * lists dominate (measured 2026-09-04: +3-4 ms/frame on a 400-sphere
958      * scene).
959      */
960     private static final AtomicInteger globalRenderListVersion = new AtomicInteger();
961
962     /**
963      * {@link #globalRenderListVersion} value seen at the last weight
964      * recomputation.
965      */
966     private int weightGlobalVersion = -1;
967
968     /**
969      * Bumped every time {@link #cachedRenderList} is rebuilt. Gates weight
970      * recomputation: while the list is unchanged, the cached weight is
971      * reused without re-walking the subtree.
972      */
973     private int renderListVersion;
974
975     /**
976      * Full subtree weight re-walks are O(total leaves below this node),
977      * which costs real milliseconds on big meshes. With an unchanged render
978      * list the cached weight is refreshed at most every this many cycles;
979      * structural changes (rebuilds) recompute immediately.
980      */
981     private static final long WEIGHT_REFRESH_CYCLES = 16;
982
983     /**
984      * Total transform weight of this composite: the sum of its children's
985      * weights, i.e. roughly the number of leaf primitives below it.
986      * Computed lazily; recomputed only when this node's render list was
987      * rebuilt or the cache is older than {@link #WEIGHT_REFRESH_CYCLES}
988      * cycles (children's internal rebuilds are picked up by the periodic
989      * refresh). Used solely for parallel fork load balancing, never for
990      * correctness, so brief staleness is harmless.
991      *
992      * <p>Thread safety: a composite's fork decision runs on exactly one
993      * thread per cycle. Chunk-thread reads are cycle-stamped cache hits
994      * published through the executor's happens-before edge.</p>
995      *
996      * @param renderingContext the rendering context (cycle identity)
997      * @return subtree transform weight, at least 1
998      */
999     @Override
1000     public int getTransformWeight(final RenderingContext renderingContext) {
1001         final long cycle = renderingContext.transformCycleId;
1002         if (subtreeWeightCycle == cycle) {
1003             return cachedSubtreeWeight;
1004         }
1005         final int globalVersion = globalRenderListVersion.get();
1006         if (weightListVersion == renderListVersion
1007                 && weightGlobalVersion == globalVersion
1008                 && cycle - subtreeWeightComputeCycle < WEIGHT_REFRESH_CYCLES) {
1009             // Nothing rebuilt anywhere and computation fresh: keep the
1010             // value, just re-stamp the read. O(1) per node per cycle.
1011             subtreeWeightCycle = cycle;
1012             return cachedSubtreeWeight;
1013         }
1014         int weight = 0;
1015         for (final AbstractShape child : cachedRenderList) {
1016             weight += child.getTransformWeight(renderingContext);
1017         }
1018         cachedSubtreeWeight = Math.max(1, weight);
1019         subtreeWeightCycle = cycle;
1020         subtreeWeightComputeCycle = cycle;
1021         weightListVersion = renderListVersion;
1022         weightGlobalVersion = globalVersion;
1023         return cachedSubtreeWeight;
1024     }
1025
1026     /**
1027      * Decides whether this composite forks its children's transform into
1028      * parallel chunks: enough children to split, and enough TOTAL weight
1029      * below it to amortize the fork overhead. Weight (not local child
1030      * count) is what matters: a deep narrow tree with two heavy children
1031      * forks just like a flat mesh with thousands of leaves.
1032      *
1033      * @param context the rendering context (provides the coordinator)
1034      * @return true when the parallel fork should be taken
1035      */
1036     private boolean shouldForkTransform(final RenderingContext context) {
1037         if (context.transformCoordinator == null) {
1038             return false;
1039         }
1040         if (cachedRenderList.size() < PARALLEL_TRANSFORM_MIN_SHAPES) {
1041             return false;
1042         }
1043         return getTransformWeight(context) >= PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT;
1044     }
1045
1046     /**
1047      * Target number of task chunks per available processor core.
1048      * More tasks than cores gives the pool load balancing across
1049      * shapes with uneven transform cost.
1050      */
1051     private static final int PARALLEL_TASKS_PER_CORE = 4;
1052
1053     /**
1054      * Transforms all children serially on the calling thread.
1055      *
1056      * @param transformPipe the transform stack (includes this composite's transform)
1057      * @param aggregator    the aggregator to queue visible shapes into
1058      * @param context       the rendering context
1059      */
1060     private void transformChildrenSerial(final TransformStack transformPipe,
1061                                          final RenderAggregator aggregator,
1062                                          final RenderingContext context) {
1063         for (final AbstractShape shape : cachedRenderList) {
1064             shape.transform(transformPipe, aggregator, context);
1065         }
1066     }
1067
1068     /**
1069      * Forks the children's transform into parallel chunk tasks on the
1070      * frame's {@link ParallelTransformCoordinator} and returns immediately
1071      * WITHOUT waiting for them.
1072      *
1073      * <p>Works at any nesting level: a heavy composite reached inside a
1074      * chunk task forks its own children into the same coordinator. This is
1075      * deadlock-safe because chunk tasks never block on other tasks; only
1076      * the orchestrating render thread waits (in the coordinator's drain).</p>
1077      *
1078      * <p>Stack snapshotting: this composite's transform is dropped from
1079      * {@code transformPipe} right after this method returns, long before
1080      * the chunk tasks run, so the pipe is copied HERE on the forking
1081      * thread. Each task then copies the snapshot for its own working stack.
1082      * The snapshot is never mutated after publication, so concurrent
1083      * copying by chunk tasks is safe.</p>
1084      *
1085      * <p>Thread-safety notes: sibling composites are exclusively owned by
1086      * one chunk, so their per-instance caches (render list, bounding
1087      * boxes, own transform's cached matrix) never race. The vertex
1088      * frameNumber cache is a benign race: every thread writes the same
1089      * value.</p>
1090      *
1091      * @param transformPipe the transform stack (includes this composite's transform)
1092      * @param aggregator    the caller's aggregator, used ONLY when the fork
1093      *                      bails out (too few chunks, or the frame task
1094      *                      budget is exhausted) and the children fall back
1095      *                      to a serial inline transform. The parallel fork
1096      *                      itself queues into per-task aggregators merged
1097      *                      by the coordinator's drain.
1098      * @param context       the rendering context (provides the coordinator)
1099      */
1100     private void transformChildrenParallel(final TransformStack transformPipe,
1101                                            final RenderAggregator aggregator,
1102                                            final RenderingContext context) {
1103         // Snapshot the render list reference. With the pipelined render
1104         // loop, the NEXT pass's tree walk can already be running while
1105         // this pass's chunk tasks are still queued (the drain happens in
1106         // the async continuation, not before the next walk). That walk
1107         // may rebuild this composite's render list, REASSIGNING
1108         // cachedRenderList to a new list of a different size. The chunk
1109         // ranges below are computed against this list instance, so the
1110         // chunk tasks must index this same instance — re-reading the
1111         // field inside the lambda raced with the rebuild and threw
1112         // IndexOutOfBoundsException. (The old list stays alive and valid
1113         // for this pass; each pass transforms into its own vertex slot.)
1114         final List<AbstractShape> renderList = cachedRenderList;
1115         final int size = renderList.size();
1116         final int totalWeight = getTransformWeight(context);
1117         final int processors = Runtime.getRuntime().availableProcessors();
1118         final int targetTasks = processors * PARALLEL_TASKS_PER_CORE;
1119         final int taskWeight = Math.max(PARALLEL_TASK_MIN_WEIGHT,
1120                 (totalWeight + targetTasks - 1) / targetTasks);
1121
1122         // Pass 1: count chunks, cutting by ACCUMULATED WEIGHT so that a
1123         // node with few but heavy children (e.g. two 25k-triangle halves
1124         // of a fractal) still splits into multiple tasks
1125         int taskCount = 0;
1126         int accumulated = 0;
1127         for (int i = 0; i < size; i++) {
1128             accumulated += renderList.get(i).getTransformWeight(context);
1129             if (accumulated >= taskWeight) {
1130                 taskCount++;
1131                 accumulated = 0;
1132             }
1133         }
1134         if (accumulated > 0) {
1135             taskCount++;
1136         }
1137
1138         if (taskCount < 2) {
1139             transformChildrenSerial(transformPipe, aggregator, context);
1140             return;
1141         }
1142
1143         final ParallelTransformCoordinator coordinator = context.transformCoordinator;
1144         if (!coordinator.tryReserveTasks(taskCount)) {
1145             // Frame-wide task budget exhausted: transform inline
1146             transformChildrenSerial(transformPipe, aggregator, context);
1147             return;
1148         }
1149
1150         final TransformStack snapshot = new TransformStack(transformPipe);
1151
1152         // Pass 2: submit chunks (weights are frame-cached, cheap re-walk)
1153         int from = 0;
1154         accumulated = 0;
1155         for (int i = 0; i < size; i++) {
1156             accumulated += renderList.get(i).getTransformWeight(context);
1157             if (accumulated >= taskWeight || i == size - 1) {
1158                 final int chunkFrom = from;
1159                 final int chunkTo = i + 1;
1160                 coordinator.submit(() -> {
1161                     // Pooled scratch: the stack (9.6 KB of arrays) and
1162                     // the chunk aggregator (queue keeps its capacity
1163                     // across frames) come from the coordinator's static
1164                     // pools — previously each chunk allocated both on
1165                     // every frame.
1166                     final TransformStack taskStack =
1167                             coordinator.borrowStack(snapshot);
1168                     try {
1169                         final RenderAggregator taskAggregator =
1170                                 coordinator.borrowAggregator();
1171                         for (int c = chunkFrom; c < chunkTo; c++) {
1172                             renderList.get(c).transform(taskStack, taskAggregator, context);
1173                         }
1174                         return taskAggregator;
1175                     } finally {
1176                         coordinator.returnStack(taskStack);
1177                     }
1178                 });
1179                 from = i + 1;
1180                 accumulated = 0;
1181             }
1182         }
1183     }
1184
1185     /**
1186      * Transforms a point to view space using the current transform stack.
1187      * Helper method for frustum culling that transforms bounding box corners.
1188      *
1189      * @param x             the X coordinate in local space
1190      * @param y             the Y coordinate in local space
1191      * @param z             the Z coordinate in local space
1192      * @param transformPipe the current transform stack
1193      * @return the transformed point in view space
1194      */
1195     private Point3D transformPointToViewSpace(final double x, final double y, final double z,
1196                                               final TransformStack transformPipe) {
1197         final Point3D input = new Point3D(x, y, z);
1198         final Point3D result = new Point3D();
1199         transformPipe.transform(input, result);
1200         return result;
1201     }
1202
1203 }