fa0ca38695ee44891f232c9119c68a130dfb0924
[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
550         final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
551         final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
552
553         // Remove from self any polygons that are inside other (interior faces)
554         selfTree.clipTo(otherTree);
555
556         // Remove from other any polygons that are inside self (interior faces)
557         otherTree.clipTo(selfTree);
558
559         // Invert other to convert remaining polygons for the next clip step
560         otherTree.invert();
561
562         // Clip inverted other against self to remove back-facing coplanar polygons
563         otherTree.clipTo(selfTree);
564
565         // Invert back to restore correct polygon orientation
566         otherTree.invert();
567
568         // Merge other's remaining polygons into self's BSP tree
569         selfTree.addPolygons(otherTree.allPolygons());
570
571         replaceSolidPolygons(selfTree.allPolygons());
572         mergeNonPolygonChildrenFrom(other);
573     }
574
575     /**
576      * Performs an in-place subtraction with another composite shape.
577      *
578      * <p>This shape's SolidPolygon children are replaced with the difference result.
579      * The other shape acts as a "cutter" that carves out volume from this shape.</p>
580      *
581      * <p><b>CSG Operation:</b> Subtract removes the volume of the second shape
582      * from the first shape. Useful for creating holes, cavities, and cutouts.</p>
583      *
584      * <p><b>Child handling:</b></p>
585      * <ul>
586      *   <li>SolidPolygon children from this shape → replaced with difference result</li>
587      *   <li>Non-SolidPolygon children from this shape → preserved</li>
588      *   <li>All children from other shape → discarded (other is just a cutter)</li>
589      *   <li>Nested AbstractCompositeShape children → preserved unchanged</li>
590      * </ul>
591      *
592      * @param other the shape to subtract (the cutter)
593      * @see #union(AbstractCompositeShape)
594      * @see #intersect(AbstractCompositeShape)
595      */
596     public void subtract(final AbstractCompositeShape other) {
597
598         final BspTree target = new BspTree(clonePolygons(extractSolidPolygons()));
599         final BspTree cutter = new BspTree(clonePolygons(other.extractSolidPolygons()));
600
601         // Invert target: convert "inside" to "outside" and vice versa
602         // This transforms the problem from "subtract B from A" to "intersect A's complement with B's complement"
603         target.invert();
604
605         // Clip target against cutter: removes parts of target that are INSIDE the cutter
606         // Since target is inverted, this removes parts that were OUTSIDE the original target
607         target.clipTo(cutter);
608
609         // Clip cutter against (inverted) target: removes parts of cutter outside the inverted target
610         // This keeps only cutter polygons that are inside the inverted target = outside original target
611         cutter.clipTo(target);
612
613         // Invert cutter to flip its inside/outside
614         cutter.invert();
615
616         // Clip inverted cutter against target: removes coplanar back-faces
617         cutter.clipTo(target);
618
619         // Invert cutter back to correct orientation
620         cutter.invert();
621
622         // Merge cutter's polygons into target's BSP tree
623         target.addPolygons(cutter.allPolygons());
624
625         // Invert target back to restore correct inside/outside orientation
626         // Result: the carved-out volume (target minus cutter)
627         target.invert();
628
629         replaceSolidPolygons(target.allPolygons());
630     }
631
632     /**
633      * Performs an in-place intersection with another composite shape.
634      *
635      * <p>This shape's SolidPolygon children are replaced with the intersection result.
636      * Only the overlapping volume between the two shapes remains.</p>
637      *
638      * <p><b>CSG Operation:</b> Intersect keeps only the volume where both shapes
639      * overlap. Useful for creating shapes constrained by multiple boundaries.</p>
640      *
641      * <p><b>Child handling:</b></p>
642      * <ul>
643      *   <li>SolidPolygon children from this shape → replaced with intersection result</li>
644      *   <li>Non-SolidPolygon children from this shape → preserved</li>
645      *   <li>All children from other shape → discarded</li>
646      *   <li>Nested AbstractCompositeShape children → preserved unchanged</li>
647      * </ul>
648      *
649      * @param other the shape to intersect with
650      * @see #union(AbstractCompositeShape)
651      * @see #subtract(AbstractCompositeShape)
652      */
653     public void intersect(final AbstractCompositeShape other) {
654
655         final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
656         final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
657
658         // Invert self to convert "inside" to "outside"
659         // This transforms intersection into: keep parts that are "outside both inverted shapes"
660         selfTree.invert();
661
662         // Clip other against inverted self: keeps only parts of other that are INSIDE original self
663         // (because clipTo removes what's "outside" the BSP, and inverted self's "outside" = original self's "inside")
664         otherTree.clipTo(selfTree);
665
666         // Invert other (which now represents the intersection region)
667         otherTree.invert();
668
669         // Clip inverted self against (inverted intersection): removes parts outside the intersection
670         selfTree.clipTo(otherTree);
671
672         // Clip intersection result against inverted self: removes back-facing coplanar polygons
673         otherTree.clipTo(selfTree);
674
675         // Build final BSP tree from the clipped intersection polygons
676         selfTree.addPolygons(otherTree.allPolygons());
677
678         // Invert back to restore correct inside/outside orientation
679         selfTree.invert();
680
681         replaceSolidPolygons(selfTree.allPolygons());
682     }
683
684     /**
685      * Creates deep clones of all polygons in the list.
686      *
687      * <p>CSG operations modify polygons in-place via BSP tree operations.
688      * Cloning ensures the original polygon data is preserved.</p>
689      *
690      * @param polygons the polygons to clone
691      * @return a new list containing deep clones of all polygons
692      */
693     private List<SolidPolygon> clonePolygons(final List<SolidPolygon> polygons) {
694         final List<SolidPolygon> cloned = new ArrayList<>(polygons.size());
695         for (final SolidPolygon p : polygons) {
696             cloned.add(p.deepClone());
697         }
698         return cloned;
699     }
700
701     /**
702      * Replaces this shape's SolidPolygon children with new polygons.
703      *
704      * <p>Preserves all non-SolidPolygon children (Lines, nested composites, etc.).</p>
705      *
706      * @param newPolygons the polygons to replace with
707      */
708     private void replaceSolidPolygons(final List<SolidPolygon> newPolygons) {
709         // Remove all direct SolidPolygon children from this shape
710         final Iterator<SubShape> iterator = subShapesRegistry.iterator();
711         while (iterator.hasNext()) {
712             final SubShape subShape = iterator.next();
713             if (subShape.getShape() instanceof SolidPolygon) {
714                 iterator.remove();
715             }
716         }
717
718         // Add all result polygons as new children
719         for (final SolidPolygon polygon : newPolygons) {
720             addShape(polygon);
721         }
722
723         cacheNeedsRebuild = true;
724     }
725
726     /**
727      * Merges non-SolidPolygon children from another shape into this shape.
728      *
729      * <p>Copies all non-SolidPolygon children (Lines, nested composites, etc.)
730      * from the other shape, preserving their group identifiers.</p>
731      *
732      * @param other the shape to merge non-polygon children from
733      */
734     private void mergeNonPolygonChildrenFrom(final AbstractCompositeShape other) {
735         if (other == null) {
736             return;
737         }
738
739         for (final SubShape otherSubShape : other.subShapesRegistry) {
740             final AbstractShape otherShape = otherSubShape.getShape();
741             if (!(otherShape instanceof SolidPolygon)) {
742                 addShape(otherShape, otherSubShape.getGroupIdentifier());
743             }
744         }
745
746         cacheNeedsRebuild = true;
747     }
748
749     /**
750      * Makes all sub-shapes belonging to the specified group visible.
751      *
752      * @param groupIdentifier the group to show
753      * @see #hideGroup(String)
754      */
755     public void showGroup(final String groupIdentifier) {
756         for (int i = 0; i < subShapesRegistry.size(); i++) {
757             final SubShape subShape = subShapesRegistry.get(i);
758             if (subShape.matchesGroup(groupIdentifier)) {
759                 subShape.setVisible(true);
760                 cacheNeedsRebuild = true;
761             }
762         }
763     }
764
765     /**
766      * Rebuilds the cached render list from the shape registry:
767      * textured triangles pass through as-is (perspective-correct scanline
768      * rendering needs no tessellation), N-vertex solid polygons are
769      * fan-triangulated, everything else passes through.
770      * Logs the operation to the debug log buffer if available.
771      *
772      * @param context the rendering context for logging, may be {@code null}
773      */
774     private void rebuildRenderList(final RenderingContext context) {
775         cacheNeedsRebuild = false;
776
777         final List<AbstractShape> result = new ArrayList<>();
778         int texturedPolygonCount = 0;
779         int solidPolygonCount = 0;
780         int triangulatedPolygonCount = 0;
781         int otherShapeCount = 0;
782
783         for (int i = 0; i < subShapesRegistry.size(); i++) {
784             final SubShape subShape = subShapesRegistry.get(i);
785             if (!subShape.isVisible())
786                 continue;
787
788             final AbstractShape shape = subShape.getShape();
789
790             if (shape instanceof TexturedTriangle) {
791                 result.add(shape);
792                 texturedPolygonCount++;
793             } else if (shape instanceof SolidPolygon polygon) {
794                 final int vertexCount = polygon.getVertexCount();
795
796                 if (vertexCount == 3) {
797                     result.add(polygon);
798                     solidPolygonCount++;
799                 } else {
800                     triangulateSolidPolygon(polygon, result);
801                     triangulatedPolygonCount++;
802                 }
803             } else {
804                 result.add(shape);
805                 otherShapeCount++;
806             }
807         }
808
809         cachedRenderList = postprocessRenderList(result);
810         renderListVersion++;
811         globalRenderListVersion.incrementAndGet();
812
813         if (context != null && context.debugLogBuffer != null) {
814             context.debugLogBuffer.log("rebuildRenderList: " + getClass().getSimpleName()
815                     + " texturedPolygons=" + texturedPolygonCount
816                     + " solidPolygons=" + solidPolygonCount
817                     + " triangulatedPolygons=" + triangulatedPolygonCount
818                     + " otherShapes=" + otherShapeCount);
819         }
820     }
821
822     /**
823      * Returns the global render list version: incremented every time ANY
824      * composite's render list is rebuilt. Used by derived structures
825      * (BSP trees, GI scene snapshots) to detect that they must rebuild.
826      *
827      * @return monotonically increasing global version
828      */
829     public static int getGlobalRenderListVersion() {
830         return globalRenderListVersion.get();
831     }
832
833     /**
834      * Collects the triangles of this composite's current render list,
835      * recursing into nested composites. These are the exact objects that
836      * get transformed and rendered: triangulated render-list polygons, or
837      * lightmapped wrappers for
838      * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}.
839      *
840      * <p>Render lists are built lazily during transform; composites that
841      * have not been transformed yet (or are frustum-culled) contribute
842      * nothing. Vertices are in each composite's local space — callers
843      * combining several composites should require identity transforms.</p>
844      *
845      * @param out list receiving the triangles
846      */
847     public void collectRenderTriangles(final List<AbstractCoordinateShape> out) {
848         if (cachedRenderList == null)
849             return;
850         for (final AbstractShape shape : cachedRenderList) {
851             if (shape instanceof SolidPolygon
852                     || shape instanceof eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle)
853                 out.add((AbstractCoordinateShape) shape);
854             else if (shape instanceof AbstractCompositeShape)
855                 ((AbstractCompositeShape) shape).collectRenderTriangles(out);
856         }
857     }
858
859     /**
860      * Hook: post-processes the freshly rebuilt render list before it becomes
861      * the rendering cache. The default implementation returns the list
862      * unchanged. Subclasses may replace the list — e.g.
863      * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}
864      * wraps its polygons into lightmapped triangles here.
865      *
866      * @param renderList the render list built from the shape registry
867      * @return the render list to cache and render
868      */
869     protected List<AbstractShape> postprocessRenderList(final List<AbstractShape> renderList) {
870         return renderList;
871     }
872
873     /**
874      * Triangulates a convex solid polygon using fan triangulation.
875      *
876      * <p>Fan triangulation creates N-2 triangles from an N-vertex polygon by using
877      * vertex 0 as the anchor and connecting it to each adjacent pair of vertices.</p>
878      *
879      * <p>Properties (color, shading, backface culling, mouse interaction) are
880      * propagated to each resulting triangle to ensure consistent behavior.</p>
881      *
882      * @param polygon the polygon to triangulate (must have at least 4 vertices)
883      * @param result  the list to add the resulting triangles to
884      */
885     private void triangulateSolidPolygon(final SolidPolygon polygon,
886                                          final List<AbstractShape> result) {
887
888         final Color color = polygon.getColor();
889         final boolean shadingEnabled = polygon.isShadingEnabled();
890         final boolean backfaceCulling = polygon.isBackfaceCullingEnabled();
891         final MouseInteractionController mouseController = polygon.mouseInteractionController;
892
893         final List<Vertex> vertices = polygon.vertices;
894         final Vertex v0 = vertices.get(0);
895
896         for (int i = 1; i < vertices.size() - 1; i++) {
897             final Vertex v1 = vertices.get(i);
898             final Vertex v2 = vertices.get(i + 1);
899
900             final SolidPolygon triangle = new SolidPolygon(
901                     v0.coordinate, v1.coordinate, v2.coordinate, color);
902
903             triangle.setShadingEnabled(shadingEnabled);
904             triangle.setBackfaceCulling(backfaceCulling);
905             triangle.setMouseInteractionController(mouseController);
906
907             result.add(triangle);
908         }
909     }
910
911     @Override
912     public void transform(final TransformStack transformPipe,
913                           final RenderAggregator aggregator, final RenderingContext context) {
914
915         // Add the current composite shape transform to the end of the transform
916         // pipeline.
917         transformPipe.addTransform(transform);
918
919         // FRUSTUM CULLING: Check if this composite's bounds are visible
920         // Root composite skips this check (its bounds are always the full scene)
921         // Non-root composites check their aggregated bounds against the frustum
922         if (context.frustum != null && !isRootComposite) {
923             // Count this composite for culling statistics (before frustum test)
924             if (context.cullingStatistics != null) {
925                 context.cullingStatistics.totalComposites.incrementAndGet();
926             }
927
928             final Box localBounds = getBoundingBox();
929
930             // Transform all 8 corners of the bounding box to view space
931             final double minX = localBounds.getMinX();
932             final double maxX = localBounds.getMaxX();
933             final double minY = localBounds.getMinY();
934             final double maxY = localBounds.getMaxY();
935             final double minZ = localBounds.getMinZ();
936             final double maxZ = localBounds.getMaxZ();
937
938             final double[] xs = {minX, maxX};
939             final double[] ys = {minY, maxY};
940             final double[] zs = {minZ, maxZ};
941
942             double viewMinX = Double.MAX_VALUE;
943             double viewMaxX = -Double.MAX_VALUE;
944             double viewMinY = Double.MAX_VALUE;
945             double viewMaxY = -Double.MAX_VALUE;
946             double viewMinZ = Double.MAX_VALUE;
947             double viewMaxZ = -Double.MAX_VALUE;
948
949             for (int i = 0; i < 8; i++) {
950                 final double x = xs[(i & 1)];
951                 final double y = ys[(i >> 1) & 1];
952                 final double z = zs[(i >> 2) & 1];
953
954                 final Point3D corner = transformPointToViewSpace(x, y, z, transformPipe);
955
956                 viewMinX = Math.min(viewMinX, corner.x);
957                 viewMaxX = Math.max(viewMaxX, corner.x);
958                 viewMinY = Math.min(viewMinY, corner.y);
959                 viewMaxY = Math.max(viewMaxY, corner.y);
960                 viewMinZ = Math.min(viewMinZ, corner.z);
961                 viewMaxZ = Math.max(viewMaxZ, corner.z);
962             }
963
964             final Box viewSpaceBounds = new Box(
965                     new Point3D(viewMinX, viewMinY, viewMinZ),
966                     new Point3D(viewMaxX, viewMaxY, viewMaxZ)
967             );
968
969             final Frustum frustum = context.frustum;
970             final boolean visible = frustum.intersectsAABB(viewSpaceBounds);
971
972             if (!visible) {
973                 // Entire composite outside frustum - skip processing all children
974                 if (context.cullingStatistics != null) {
975                     context.cullingStatistics.culledComposites.incrementAndGet();
976                 }
977                 transformPipe.dropTransform();
978                 return;
979             }
980         }
981
982         viewSpaceTracker.analyze(transformPipe, context);
983
984         beforeTransformHook(transformPipe, context);
985
986         rebuildRenderListIfNeeded(context);
987
988         // transform rendered subshapes
989         if (shouldForkTransform(context)) {
990             transformChildrenParallel(transformPipe, aggregator, context);
991         } else {
992             transformChildrenSerial(transformPipe, aggregator, context);
993         }
994
995         transformPipe.dropTransform();
996     }
997
998     /**
999      * Minimum number of children before the parallel fork is considered at
1000      * all. A single child cannot be split; splitting happens in the child.
1001      */
1002     private static final int PARALLEL_TRANSFORM_MIN_SHAPES = 2;
1003
1004     /**
1005      * Minimum total subtree weight (leaf primitives below this composite)
1006      * before forking its transform into parallel chunks. Below this, the
1007      * serial walk is cheaper than the fork overhead. Deliberately above
1008      * one sphere's generated triangle count (~960 at 16 segments):
1009      * measured 2026-09-04, forking those pays task overhead per chunk for
1010      * negligible serial work (35k chunk tasks on a 3000-sphere scene were
1011      * SLOWER than serial).
1012      */
1013     private static final int PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT = 2048;
1014
1015     /**
1016      * Minimum weight per parallel chunk task. Keeps chunk granularity
1017      * coarse enough that task dispatch overhead stays negligible.
1018      */
1019     private static final int PARALLEL_TASK_MIN_WEIGHT = 512;
1020
1021     /**
1022      * Cached subtree weight from {@link #getTransformWeight}, valid for
1023      * {@link #subtreeWeightCycle} only.
1024      */
1025     private int cachedSubtreeWeight;
1026
1027     /**
1028      * Transform cycle id the cached subtree weight was computed on.
1029      * Keyed on the globally unique cycle id, not the per-context frame
1030      * number, so alternating between rendering contexts cannot produce
1031      * stale cache hits.
1032      */
1033     private long subtreeWeightCycle = -1;
1034
1035     /**
1036      * Transform cycle id the cached subtree weight was last RECOMPUTED on.
1037      * Separate from {@link #subtreeWeightCycle} (last read): the refresh
1038      * gate measures the age of the computation, not of the last access.
1039      */
1040     private long subtreeWeightComputeCycle = -1;
1041
1042     /**
1043      * {@link #renderListVersion} at the last weight recomputation.
1044      */
1045     private int weightListVersion = -1;
1046
1047     /**
1048      * Bumped whenever ANY composite rebuilds its render list. Lets the
1049      * weight shortcut react to structural changes anywhere in the tree
1050      * within one cycle, at O(1) per node per cycle — scanning direct
1051      * children's versions instead costs O(leaves) per frame because leaf
1052      * lists dominate (measured 2026-09-04: +3-4 ms/frame on a 400-sphere
1053      * scene).
1054      */
1055     private static final AtomicInteger globalRenderListVersion = new AtomicInteger();
1056
1057     /**
1058      * {@link #globalRenderListVersion} value seen at the last weight
1059      * recomputation.
1060      */
1061     private int weightGlobalVersion = -1;
1062
1063     /**
1064      * Bumped every time {@link #cachedRenderList} is rebuilt. Gates weight
1065      * recomputation: while the list is unchanged, the cached weight is
1066      * reused without re-walking the subtree.
1067      */
1068     private int renderListVersion;
1069
1070     /**
1071      * Full subtree weight re-walks are O(total leaves below this node),
1072      * which costs real milliseconds on big meshes. With an unchanged render
1073      * list the cached weight is refreshed at most every this many cycles;
1074      * structural changes (rebuilds) recompute immediately.
1075      */
1076     private static final long WEIGHT_REFRESH_CYCLES = 16;
1077
1078     /**
1079      * Total transform weight of this composite: the sum of its children's
1080      * weights, i.e. roughly the number of leaf primitives below it.
1081      * Computed lazily; recomputed only when this node's render list was
1082      * rebuilt or the cache is older than {@link #WEIGHT_REFRESH_CYCLES}
1083      * cycles (children's internal rebuilds are picked up by the periodic
1084      * refresh). Used solely for parallel fork load balancing, never for
1085      * correctness, so brief staleness is harmless.
1086      *
1087      * <p>Thread safety: a composite's fork decision runs on exactly one
1088      * thread per cycle. Chunk-thread reads are cycle-stamped cache hits
1089      * published through the executor's happens-before edge.</p>
1090      *
1091      * @param renderingContext the rendering context (cycle identity)
1092      * @return subtree transform weight, at least 1
1093      */
1094     @Override
1095     public int getTransformWeight(final RenderingContext renderingContext) {
1096         final long cycle = renderingContext.transformCycleId;
1097         if (subtreeWeightCycle == cycle) {
1098             return cachedSubtreeWeight;
1099         }
1100         final int globalVersion = globalRenderListVersion.get();
1101         if (weightListVersion == renderListVersion
1102                 && weightGlobalVersion == globalVersion
1103                 && cycle - subtreeWeightComputeCycle < WEIGHT_REFRESH_CYCLES) {
1104             // Nothing rebuilt anywhere and computation fresh: keep the
1105             // value, just re-stamp the read. O(1) per node per cycle.
1106             subtreeWeightCycle = cycle;
1107             return cachedSubtreeWeight;
1108         }
1109         int weight = 0;
1110         for (final AbstractShape child : cachedRenderList) {
1111             weight += child.getTransformWeight(renderingContext);
1112         }
1113         cachedSubtreeWeight = Math.max(1, weight);
1114         subtreeWeightCycle = cycle;
1115         subtreeWeightComputeCycle = cycle;
1116         weightListVersion = renderListVersion;
1117         weightGlobalVersion = globalVersion;
1118         return cachedSubtreeWeight;
1119     }
1120
1121     /**
1122      * Decides whether this composite forks its children's transform into
1123      * parallel chunks: enough children to split, and enough TOTAL weight
1124      * below it to amortize the fork overhead. Weight (not local child
1125      * count) is what matters: a deep narrow tree with two heavy children
1126      * forks just like a flat mesh with thousands of leaves.
1127      *
1128      * @param context the rendering context (provides the coordinator)
1129      * @return true when the parallel fork should be taken
1130      */
1131     private boolean shouldForkTransform(final RenderingContext context) {
1132         if (context.transformCoordinator == null) {
1133             return false;
1134         }
1135         if (cachedRenderList.size() < PARALLEL_TRANSFORM_MIN_SHAPES) {
1136             return false;
1137         }
1138         return getTransformWeight(context) >= PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT;
1139     }
1140
1141     /**
1142      * Target number of task chunks per available processor core.
1143      * More tasks than cores gives the pool load balancing across
1144      * shapes with uneven transform cost.
1145      */
1146     private static final int PARALLEL_TASKS_PER_CORE = 4;
1147
1148     /**
1149      * Transforms all children serially on the calling thread.
1150      *
1151      * @param transformPipe the transform stack (includes this composite's transform)
1152      * @param aggregator    the aggregator to queue visible shapes into
1153      * @param context       the rendering context
1154      */
1155     private void transformChildrenSerial(final TransformStack transformPipe,
1156                                          final RenderAggregator aggregator,
1157                                          final RenderingContext context) {
1158         for (final AbstractShape shape : cachedRenderList) {
1159             shape.transform(transformPipe, aggregator, context);
1160         }
1161     }
1162
1163     /**
1164      * Forks the children's transform into parallel chunk tasks on the
1165      * frame's {@link ParallelTransformCoordinator} and returns immediately
1166      * WITHOUT waiting for them.
1167      *
1168      * <p>Works at any nesting level: a heavy composite reached inside a
1169      * chunk task forks its own children into the same coordinator. This is
1170      * deadlock-safe because chunk tasks never block on other tasks; only
1171      * the orchestrating render thread waits (in the coordinator's drain).</p>
1172      *
1173      * <p>Stack snapshotting: this composite's transform is dropped from
1174      * {@code transformPipe} right after this method returns, long before
1175      * the chunk tasks run, so the pipe is copied HERE on the forking
1176      * thread. Each task then copies the snapshot for its own working stack.
1177      * The snapshot is never mutated after publication, so concurrent
1178      * copying by chunk tasks is safe.</p>
1179      *
1180      * <p>Thread-safety notes: sibling composites are exclusively owned by
1181      * one chunk, so their per-instance caches (render list, bounding
1182      * boxes, own transform's cached matrix) never race. The vertex
1183      * frameNumber cache is a benign race: every thread writes the same
1184      * value.</p>
1185      *
1186      * @param transformPipe the transform stack (includes this composite's transform)
1187      * @param aggregator    unused in the parallel path: per-task aggregators
1188      *                      are merged by the coordinator's drain
1189      * @param context       the rendering context (provides the coordinator)
1190      */
1191     private void transformChildrenParallel(final TransformStack transformPipe,
1192                                            final RenderAggregator aggregator,
1193                                            final RenderingContext context) {
1194         // Snapshot the render list reference. With the pipelined render
1195         // loop, the NEXT pass's tree walk can already be running while
1196         // this pass's chunk tasks are still queued (the drain happens in
1197         // the async continuation, not before the next walk). That walk
1198         // may rebuild this composite's render list, REASSIGNING
1199         // cachedRenderList to a new list of a different size. The chunk
1200         // ranges below are computed against this list instance, so the
1201         // chunk tasks must index this same instance — re-reading the
1202         // field inside the lambda raced with the rebuild and threw
1203         // IndexOutOfBoundsException. (The old list stays alive and valid
1204         // for this pass; each pass transforms into its own vertex slot.)
1205         final List<AbstractShape> renderList = cachedRenderList;
1206         final int size = renderList.size();
1207         final int totalWeight = getTransformWeight(context);
1208         final int processors = Runtime.getRuntime().availableProcessors();
1209         final int targetTasks = processors * PARALLEL_TASKS_PER_CORE;
1210         final int taskWeight = Math.max(PARALLEL_TASK_MIN_WEIGHT,
1211                 (totalWeight + targetTasks - 1) / targetTasks);
1212
1213         // Pass 1: count chunks, cutting by ACCUMULATED WEIGHT so that a
1214         // node with few but heavy children (e.g. two 25k-triangle halves
1215         // of a fractal) still splits into multiple tasks
1216         int taskCount = 0;
1217         int accumulated = 0;
1218         for (int i = 0; i < size; i++) {
1219             accumulated += renderList.get(i).getTransformWeight(context);
1220             if (accumulated >= taskWeight) {
1221                 taskCount++;
1222                 accumulated = 0;
1223             }
1224         }
1225         if (accumulated > 0) {
1226             taskCount++;
1227         }
1228
1229         if (taskCount < 2) {
1230             transformChildrenSerial(transformPipe, aggregator, context);
1231             return;
1232         }
1233
1234         final ParallelTransformCoordinator coordinator = context.transformCoordinator;
1235         if (!coordinator.tryReserveTasks(taskCount)) {
1236             // Frame-wide task budget exhausted: transform inline
1237             transformChildrenSerial(transformPipe, aggregator, context);
1238             return;
1239         }
1240
1241         final TransformStack snapshot = new TransformStack(transformPipe);
1242
1243         // Pass 2: submit chunks (weights are frame-cached, cheap re-walk)
1244         int from = 0;
1245         accumulated = 0;
1246         for (int i = 0; i < size; i++) {
1247             accumulated += renderList.get(i).getTransformWeight(context);
1248             if (accumulated >= taskWeight || i == size - 1) {
1249                 final int chunkFrom = from;
1250                 final int chunkTo = i + 1;
1251                 coordinator.submit(() -> {
1252                     // Pooled scratch: the stack (9.6 KB of arrays) and
1253                     // the chunk aggregator (queue keeps its capacity
1254                     // across frames) come from the coordinator's static
1255                     // pools — previously each chunk allocated both on
1256                     // every frame.
1257                     final TransformStack taskStack =
1258                             coordinator.borrowStack(snapshot);
1259                     try {
1260                         final RenderAggregator taskAggregator =
1261                                 coordinator.borrowAggregator();
1262                         for (int c = chunkFrom; c < chunkTo; c++) {
1263                             renderList.get(c).transform(taskStack, taskAggregator, context);
1264                         }
1265                         return taskAggregator;
1266                     } finally {
1267                         coordinator.returnStack(taskStack);
1268                     }
1269                 });
1270                 from = i + 1;
1271                 accumulated = 0;
1272             }
1273         }
1274     }
1275
1276     /**
1277      * Transforms a point to view space using the current transform stack.
1278      * Helper method for frustum culling that transforms bounding box corners.
1279      *
1280      * @param x             the X coordinate in local space
1281      * @param y             the Y coordinate in local space
1282      * @param z             the Z coordinate in local space
1283      * @param transformPipe the current transform stack
1284      * @return the transformed point in view space
1285      */
1286     private Point3D transformPointToViewSpace(final double x, final double y, final double z,
1287                                               final TransformStack transformPipe) {
1288         final Point3D input = new Point3D(x, y, z);
1289         final Point3D result = new Point3D();
1290         transformPipe.transform(input, result);
1291         return result;
1292     }
1293
1294 }