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